diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index acad2ab..4558b8e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,13 +28,9 @@ jobs: - name: Migration manifest run: sh scripts/check-migration-manifest.sh - name: Package - run: cargo +1.85.0 package --workspace --allow-dirty --no-verify - - name: Package content audit - run: | - package="$(find target/package -maxdepth 1 -name 'model-routing-*.crate' | head -n 1)" - test -n "$package" - cargo +1.85.0 package --workspace --list --allow-dirty --no-verify > package-files.txt - ! grep -E '(^|/)(\.planr|planr\.sqlite|\.env|receipt|secret|credential)' package-files.txt + run: cargo +1.85.0 package --package model-routing --allow-dirty --no-verify + - name: Assert sole public Cargo inventory + run: cargo +1.85.0 run --quiet -p xtask -- release verify --inventory-only distribution: name: npm distribution @@ -55,3 +51,5 @@ jobs: run: cargo +1.85.0 build --bins - name: Test npm wrapper run: node --test npm/*.test.mjs + - name: Audit npm package inventory + run: cargo +1.85.0 run --quiet -p xtask -- release verify --inventory-only diff --git a/.github/workflows/release-candidate.yml b/.github/workflows/release-candidate.yml index 0cfb287..a45efb0 100644 --- a/.github/workflows/release-candidate.yml +++ b/.github/workflows/release-candidate.yml @@ -50,47 +50,16 @@ jobs: persist-credentials: false - name: Install Rust target run: rustup target add ${{ matrix.rust_target }} - - name: Verify candidate version - run: | - test "$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -n 1)" = "0.2.2" - test "$(jq -r .version package.json)" = "0.2.2" - name: Build native candidate - env: - SWITCHLOOM_TARGET: ${{ matrix.target }} - SWITCHLOOM_CARGO_TARGET: ${{ matrix.rust_target }} - run: scripts/build-release.sh - - name: Stage npm native binary - run: | - mkdir -p "npm/native/${{ matrix.target }}" - install -m 755 "target/${{ matrix.rust_target }}/release/model-routing" \ - "npm/native/${{ matrix.target }}/model-routing" - - name: Verify native version on matching runner - run: | - test "$(./target/${{ matrix.rust_target }}/release/model-routing --version)" = "model-routing 0.2.2" - - name: Write native provenance - env: - TARGET: ${{ matrix.target }} - RUST_TARGET: ${{ matrix.rust_target }} - RUNNER_NAME: ${{ matrix.runner }} run: | - mkdir -p "native-provenance/$TARGET" - sha="$(shasum -a 256 "npm/native/$TARGET/model-routing" | awk '{print $1}')" - version="$(./target/$RUST_TARGET/release/model-routing --version)" - git_sha="$(git rev-parse HEAD)" - built_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)" - jq -n \ - --arg target "$TARGET" \ - --arg rust_target "$RUST_TARGET" \ - --arg runner "$RUNNER_NAME" \ - --arg path "npm/native/$TARGET/model-routing" \ - --arg version "$version" \ - --arg sha256 "$sha" \ - --arg git_sha "$git_sha" \ - --arg built_at "$built_at" \ - '{target:$target,rust_target:$rust_target,runner:$runner,path:$path,version:$version,sha256:$sha256,git_sha:$git_sha,built_at:$built_at}' \ - > "native-provenance/$TARGET/provenance.json" - printf '%s npm/native/%s/model-routing\n' "$sha" "$TARGET" \ - > "native-provenance/$TARGET/SHA256SUMS" + cargo run --quiet -p xtask -- release package \ + --target "${{ matrix.target }}" \ + --cargo-target "${{ matrix.rust_target }}" \ + --stage-npm \ + --provenance-dir native-provenance \ + --runner "${{ matrix.runner }}" \ + --git-sha "$(git rev-parse HEAD)" \ + --built-at github-actions-release-candidate - name: Upload native candidate uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: @@ -125,22 +94,19 @@ jobs: path: rc-artifacts - name: Stage native candidates and provenance run: | - mkdir -p npm/native provenance-parts + mkdir -p npm/native for target in darwin-arm64 darwin-x86_64 linux-arm64 linux-x86_64; do mkdir -p "npm/native/$target" install -m 755 "rc-artifacts/switchloom-native-$target/npm/native/$target/model-routing" \ "npm/native/$target/model-routing" - cp "rc-artifacts/switchloom-native-$target/native-provenance/$target/provenance.json" \ - "provenance-parts/$target.json" done - git_sha="$(git rev-parse HEAD)" - jq -s \ - --arg package_version "$(jq -r .version package.json)" \ - --arg git_sha "$git_sha" \ - '{schema_version:"switchloom.native_provenance.v1",package_version:$package_version,git_sha:$git_sha,generated_by:"github-actions-release-candidate",targets:.}' \ - provenance-parts/*.json > npm/native/provenance.json + cargo run --quiet -p xtask -- release package \ + --assemble-provenance \ + --git-sha "$(git rev-parse HEAD)" \ + --built-at github-actions-release-candidate \ + --generated-by github-actions-release-candidate - name: Validate native provenance and npm package - run: bash scripts/npm-pack-check.sh + run: cargo run --quiet -p xtask -- release verify --inventory-only --require-provenance - name: Upload package provenance uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2a30bbd..f930cb2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -25,12 +25,7 @@ jobs: - name: Verify release contract env: TAG: ${{ github.ref_name }} - run: | - crate_version="$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -n 1)" - package_version="$(jq -r .version package.json)" - test "v$crate_version" = "$TAG" - test "$package_version" = "$crate_version" - grep -q "^## \[$crate_version\]" CHANGELOG.md + run: cargo run --quiet -p xtask -- release verify --contract-only --expected-tag "$TAG" - name: Create draft release env: GH_TOKEN: ${{ github.token }} @@ -75,10 +70,10 @@ jobs: - name: Install Rust target run: rustup target add ${{ matrix.rust_target }} - name: Build release archive - env: - SWITCHLOOM_TARGET: ${{ matrix.target }} - SWITCHLOOM_CARGO_TARGET: ${{ matrix.rust_target }} - run: scripts/build-release.sh + run: | + cargo run --quiet -p xtask -- release package \ + --target "${{ matrix.target }}" \ + --cargo-target "${{ matrix.rust_target }}" - name: Smoke-test native binary if: matrix.target != 'darwin-x86_64' run: ./target/${{ matrix.rust_target }}/release/model-routing --version @@ -99,6 +94,10 @@ jobs: permissions: contents: write steps: + - name: Checkout + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 + with: + persist-credentials: false - name: Download release archives env: GH_TOKEN: ${{ github.token }} @@ -110,10 +109,7 @@ jobs: --pattern 'switchloom-*.tar.gz' \ --dir assets - name: Generate aggregate checksums - run: | - cd assets - sha256sum switchloom-*.tar.gz > SHA256SUMS - cat SHA256SUMS + run: cargo run --quiet -p xtask -- release package --aggregate-checksums-dir assets - name: Publish release env: GH_TOKEN: ${{ github.token }} @@ -163,38 +159,18 @@ jobs: run: | version="${TAG#v}" for target in darwin-arm64 darwin-x86_64 linux-arm64 linux-x86_64; do - case "$target" in - darwin-arm64) rust_target="aarch64-apple-darwin"; runner="macos-14" ;; - darwin-x86_64) rust_target="x86_64-apple-darwin"; runner="macos-14" ;; - linux-arm64) rust_target="aarch64-unknown-linux-gnu"; runner="ubuntu-24.04-arm" ;; - linux-x86_64) rust_target="x86_64-unknown-linux-gnu"; runner="ubuntu-24.04" ;; - esac mkdir -p "extract/$target" "npm/native/$target" tar -xzf "assets/switchloom-$target.tar.gz" -C "extract/$target" install -m 755 "extract/$target/model-routing" "npm/native/$target/model-routing" - sha="$(shasum -a 256 "npm/native/$target/model-routing" | awk '{print $1}')" - version_output="$(if [ "$target" = "linux-x86_64" ]; then "./npm/native/$target/model-routing" --version; else printf 'model-routing %s' "$version"; fi)" - jq -n \ - --arg target "$target" \ - --arg path "npm/native/$target/model-routing" \ - --arg version "$version_output" \ - --arg sha256 "$sha" \ - --arg rust_target "$rust_target" \ - --arg runner "$runner" \ - --arg git_sha "$(git rev-parse HEAD)" \ - --arg built_at "release-$TAG" \ - '{target:$target,rust_target:$rust_target,runner:$runner,path:$path,version:$version,sha256:$sha256,git_sha:$git_sha,built_at:$built_at}' \ - > "assets/$target.native-provenance.json" done - jq -s \ - --arg package_version "$version" \ - --arg git_sha "$(git rev-parse HEAD)" \ - '{schema_version:"switchloom.native_provenance.v1",package_version:$package_version,git_sha:$git_sha,generated_by:"github-actions-release",targets:.}' \ - assets/*.native-provenance.json > npm/native/provenance.json - node scripts/validate-native-provenance.mjs + cargo run --quiet -p xtask -- release package \ + --assemble-provenance \ + --git-sha "$(git rev-parse HEAD)" \ + --built-at "release-$TAG" \ + --generated-by github-actions-release test "$(./npm/native/linux-x86_64/model-routing --version)" = "model-routing $version" SWITCHLOOM_NATIVE_BIN="$PWD/npm/native/linux-x86_64/model-routing" node npm/bin/model-routing.js --version - bash scripts/npm-pack-check.sh + cargo run --quiet -p xtask -- release verify --inventory-only --require-provenance - name: Publish npm package env: TAG: ${{ github.ref_name }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 5562a3b..e365f53 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ All notable changes to Switchloom are recorded here. +## [0.3.0] - 2026-07-21 + +- Hard-cut maintainer-only live verification, catalog, registry, probe, and + evaluation commands from the public CLI. +- Kept one owner for each internal operation: release catalog work in `xtask`, + and deterministic evaluation plus registry signing in the Rust library. +- Made `doctor` the actionable public host availability/version check and + aligned README, migration, package, and website guidance with the v0.3.0 + lifecycle. + ## [0.2.2] - 2026-07-21 - Prepared a superseding release candidate for Codex V2 primary routing and diff --git a/Cargo.lock b/Cargo.lock index 5fee6b5..0d3428c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -110,7 +110,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -174,7 +174,7 @@ checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -302,7 +302,7 @@ checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "model-routing" -version = "0.2.2" +version = "0.3.0" dependencies = [ "anyhow", "clap", @@ -310,6 +310,7 @@ dependencies = [ "serde", "serde_json", "sha2", + "thiserror", "toml", ] @@ -398,7 +399,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -476,6 +477,37 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a207d6d6a2b7fc470b80443726053f18a2481b7e1eee970597051596567987a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.2", +] + [[package]] name = "toml" version = "0.8.23" @@ -571,6 +603,19 @@ dependencies = [ "memchr", ] +[[package]] +name = "xtask" +version = "0.3.0" +dependencies = [ + "anyhow", + "clap", + "model-routing", + "serde", + "serde_json", + "sha2", + "toml", +] + [[package]] name = "zeroize" version = "1.9.0" diff --git a/Cargo.toml b/Cargo.toml index ba13dcd..bc95487 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,13 +1,47 @@ -[package] -name = "model-routing" -version = "0.2.2" +[workspace] +members = [".", "xtask"] +default-members = ["."] +resolver = "3" + +[workspace.package] +version = "0.3.0" edition = "2024" rust-version = "1.85" license = "MIT" -description = "Deterministic model routing for coding agents" repository = "https://github.com/instructa/switchloom" homepage = "https://github.com/instructa/switchloom" documentation = "https://github.com/instructa/switchloom/tree/main/docs" + +[workspace.dependencies] +anyhow = "1" +clap = { version = "4", features = ["derive"] } +ed25519-dalek = "2" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +sha2 = "0.10" +toml = "0.8" +thiserror = "2" + +[workspace.lints.rust] +unsafe_code = "deny" + +[workspace.lints.clippy] +correctness = "deny" +suspicious = "warn" +style = "warn" +complexity = "warn" +perf = "warn" + +[package] +name = "model-routing" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +description = "Deterministic model routing for coding agents" +repository.workspace = true +homepage.workspace = true +documentation.workspace = true readme = "README.md" keywords = ["agents", "routing", "codex", "claude", "cursor"] categories = ["command-line-utilities", "development-tools"] @@ -16,9 +50,12 @@ exclude = [ ".claude/**", ".codex/**", ".cursor/**", + "xtask/**", + "scripts/**", "dist/**", "docs/prepublish-certification-*.md", "reports/**", + "retained-evidence/**", "tmp/**", "npm/native/**", "**/*.sqlite", @@ -37,20 +74,18 @@ name = "switchloom" path = "src/bin/switchloom.rs" [dependencies] -anyhow = "1" -clap = { version = "4", features = ["derive"] } -ed25519-dalek = "2" -serde = { version = "1", features = ["derive"] } -serde_json = "1" -sha2 = "0.10" -toml = "0.8" +anyhow.workspace = true +clap.workspace = true +ed25519-dalek.workspace = true +serde.workspace = true +serde_json.workspace = true +sha2.workspace = true +toml.workspace = true +thiserror.workspace = true -[lints.rust] -unsafe_code = "deny" +[lints] +workspace = true -[lints.clippy] -correctness = "deny" -suspicious = "warn" -style = "warn" -complexity = "warn" -perf = "warn" +[profile.release] +codegen-units = 1 +lto = "thin" diff --git a/README.md b/README.md index 75da9e6..c467ab9 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ brew install instructa/tap/switchloom ### npm ```sh -npm install --global switchloom +npm install --global switchloom@0.3.0 ``` Both channels install the branded `switchloom` command and the compatibility @@ -41,8 +41,8 @@ The generator at [switchloom.ai](https://switchloom.ai) produces only the versio action copies a shell-safe command like: ```sh -npx switchloom@latest preview --recipe 'sw1_...' -npx switchloom@latest apply --recipe 'sw1_...' +npx switchloom@0.3.0 preview --recipe 'sw1_...' +npx switchloom@0.3.0 apply --recipe 'sw1_...' ``` The secondary action downloads the same setup as a readable `.switchloom/config.toml`: @@ -69,21 +69,20 @@ switchloom compile balanced --host codex-openai --output routing-bundle.json switchloom preview routing-bundle.json --repository . switchloom apply routing-bundle.json --repository . switchloom doctor codex -switchloom certify reports/native-host-certification///workdir/dispatch-evidence.json \ - --bundle reports/native-host-certification///workdir/bundle.json ``` `switchloom doctor ` is the install/version check for `codex`, `cursor`, -`claude-code`, `opencode`, `pi`, or a concrete binding id. `switchloom certify` -is the evidence validator alias for `switchloom evidence validate`. +`claude-code`, `opencode`, `pi`, or a concrete binding id. Run it before preview +and apply when setup depends on a locally installed host CLI. ## Current Status -The v0.2.2 release candidate compiles independently, preserves the frozen +The v0.3.0 public CLI compiles independently, preserves the frozen Planr v1.5.0 routing inventory in [docs/migration-baseline.md](docs/migration-baseline.md), -and adds Codex V2 plus Cursor published-byte certification gates. +and hard-cuts maintainer-only evaluation, catalog, registry, and live-verification +operations from the public command surface. -Current Planr handoff rules, runtime classes, and certification gates are in +Current Planr handoff rules, runtime classes, and maintainer verification gates are in [docs/model-routing-policy.md](docs/model-routing-policy.md). Planr consumes Switchloom semantic-role declarations; it must not duplicate the model, effort, host adapter, fork policy, catalog, website compiler, or artifact lifecycle. @@ -95,7 +94,7 @@ cargo fmt --all -- --check cargo clippy --workspace --all-targets --all-features -- -D warnings cargo test --workspace --all-targets --all-features cargo run -- --version -cargo run -- baseline +cargo run -- policy list ``` ## Website Generator @@ -105,14 +104,14 @@ The static Astro website is an above-the-fold team generator built with React an Claude Code model and effort options are derived at build time from the canonical catalog produced by the Rust compiler. Codex mirrors its current desktop picker: `low`, `medium`, `high`, and `xhigh`, while Terra and Sol additionally expose `ultra` as a manual-only mode. Pure `max` is intentionally omitted because the desktop picker does not expose it separately; Ultra sends Max reasoning plus automatic multi-agent delegation. Light, Balanced, and High never select Ultra. Cursor uses a deliberately small, researched frontier allowlist because its full picker changes frequently; the website presents those models in a searchable selector. Generated custom setups are local and unverified until the user reviews them. ```sh -cargo run -- catalog build --output website/data/catalog.json -cargo run -- catalog verify website/data/catalog.json +cargo run -p xtask -- release prepare --allow-dirty +cargo run -p xtask -- release verify --inventory-only pnpm site:check pnpm site:dev ``` The website setup contract is equivalent to the CLI lifecycle: the copied -`npx switchloom@latest apply --recipe 'sw1_...' --repository .` command and the +`npx switchloom@0.3.0 apply --recipe 'sw1_...' --repository .` command and the downloadable `.switchloom/config.toml` both replay through CLI preview/apply before any repository-local artifact is written. @@ -139,11 +138,11 @@ pnpm security:check ## Releases Releases are created only through the repository-owned script. Prepare and -commit a bracketed changelog section such as `## [0.2.2]`, then run: +commit a bracketed changelog section such as `## [0.3.0]`, then run: ```sh -RELEASE_DRY_RUN=1 scripts/release.sh 0.2.2 "Codex V2 routing and published-byte certification" -scripts/release.sh 0.2.2 "Codex V2 routing and published-byte certification" +RELEASE_DRY_RUN=1 scripts/release.sh 0.3.0 "Public CLI hard cut and deterministic setup lifecycle" +scripts/release.sh 0.3.0 "Public CLI hard cut and deterministic setup lifecycle" ``` The script requires a clean, synchronized `main`, runs the complete local diff --git a/astro.config.ts b/astro.config.ts index ed68307..26bc973 100644 --- a/astro.config.ts +++ b/astro.config.ts @@ -3,6 +3,7 @@ import tailwindcss from "@tailwindcss/vite"; import { defineConfig } from "astro/config"; export default defineConfig({ + srcDir: "./website/src", integrations: [react()], outDir: "./dist/website", publicDir: "./website/public", diff --git a/components.json b/components.json index dd1275d..3b0d5b1 100644 --- a/components.json +++ b/components.json @@ -5,7 +5,7 @@ "tsx": true, "tailwind": { "config": "", - "css": "src/styles/global.css", + "css": "website/src/styles/global.css", "baseColor": "neutral", "cssVariables": true, "prefix": "" diff --git a/docs/migration-manifest.tsv b/docs/migration-manifest.tsv index 3ce2400..46d5e93 100644 --- a/docs/migration-manifest.tsv +++ b/docs/migration-manifest.tsv @@ -96,23 +96,24 @@ planr-consumer planr-045 docs/MCP_CONTRACT.md replace Planr Goal B Remove or rew planr-consumer planr-046 apps/docs/content/docs/reference/configuration-and-storage.mdx replace Planr Goal B Replace Routing bundles write-behavior text with Switchloom-produced declaration consumption boundaries. planr-consumer planr-047 apps/docs/redirects.mjs keep Planr docs app Historical routing-bundles redirect-only record may remain if it only preserves old URL navigation. planr-consumer planr-048 docs/documentation/INFORMATION_ARCHITECTURE.md keep Planr docs Historical routing-bundles redirect inventory may remain if it only documents URL migration. +planr-consumer planr-049 apps/docs/wrangler.jsonc keep Planr docs Docs deployment configuration remains Planr-owned; routing references are consumer-only compatibility metadata. cli-command cmd-001 planr-routing policy list move-generalize model-routing policy list List official policies. cli-command cmd-002 planr-routing policy show --host move-generalize model-routing policy show --host Show one policy. cli-command cmd-003 planr-routing compile --host move-generalize model-routing compile --host Compile bundle JSON. -cli-command cmd-004 planr-routing probe move-generalize model-routing probe Probe host capabilities. -cli-command cmd-005 planr-routing evaluate --host move-generalize model-routing evaluate --host Evaluate recommendation evidence. -cli-command cmd-006 planr-routing catalog build move-generalize model-routing catalog build Build catalog JSON. -cli-command cmd-007 planr-routing catalog verify move-generalize model-routing catalog verify Verify catalog JSON. -cli-command cmd-008 planr-routing registry sign --signer --private-key-file --output move-generalize model-routing registry sign --signer --private-key-file --output Sign registry metadata. -cli-command cmd-009 planr-routing registry verify --signature --trusted-signer --trusted-public-key-file move-generalize model-routing registry verify --signature --trusted-signer --trusted-public-key-file Verify registry signature. +cli-command cmd-004 planr-routing probe replace model-routing doctor Public health check; the probe alias is removed. +cli-command cmd-005 planr-routing evaluate --host internalize model-routing library evaluate_policy Offline evaluation has one library owner and no public command. +cli-command cmd-006 planr-routing catalog build internalize xtask release prepare Regenerate catalog JSON through maintainer tooling. +cli-command cmd-007 planr-routing catalog verify internalize xtask release verify Verify catalog JSON through maintainer tooling. +cli-command cmd-008 planr-routing registry sign --signer --private-key-file --output internalize model-routing library sign_registry Sign registry metadata through the library API. +cli-command cmd-009 planr-routing registry verify --signature --trusted-signer --trusted-public-key-file internalize model-routing library verify_registry_signature Verify registry signatures through the library API. generated-artifact art-001 .planr/agents.toml move-generalize .planr/agents.toml Optional Planr integration declaration only. generated-artifact art-002 .planr/policy.toml move-generalize .planr/policy.toml Optional Planr integration policy only. -generated-artifact art-011 .codex/config.toml move-generalize .codex/config.toml Current v0.2.0 repository-local Codex role discovery config. -generated-artifact art-003 .codex/agents/model-routing-luna-xhigh.toml move-generalize .codex/agents/model-routing-luna-xhigh.toml Current v0.2.0 Codex mechanical profile artifact. -generated-artifact art-004 .codex/agents/model-routing-sol-high.toml move-generalize .codex/agents/model-routing-sol-high.toml Current v0.2.0 Codex reviewer profile artifact. -generated-artifact art-005 .codex/agents/model-routing-sol-medium.toml move-generalize .codex/agents/model-routing-sol-medium.toml Current v0.2.0 Codex driver profile artifact. -generated-artifact art-006 .codex/agents/model-routing-sol-ultra.toml move-generalize .codex/agents/model-routing-sol-ultra.toml Current v0.2.0 Codex moonshot profile artifact. -generated-artifact art-007 .codex/agents/model-routing-terra-high.toml move-generalize .codex/agents/model-routing-terra-high.toml Current v0.2.0 Codex implementer profile artifact. -generated-artifact art-008 .codex/agents/model-routing-terra-medium.toml move-generalize .codex/agents/model-routing-terra-medium.toml Current v0.2.0 Codex explorer profile artifact. -generated-artifact art-009 .claude/agents/model-routing-preset-worker.md move-generalize .claude/agents/model-routing-preset-worker.md Current v0.2.0 Claude Code preset worker artifact. -generated-artifact art-010 .cursor/agents/model-routing-preset-worker.md move-generalize .cursor/agents/model-routing-preset-worker.md Current v0.2.0 Cursor preset worker artifact. +generated-artifact art-011 .codex/config.toml move-generalize .codex/config.toml Current v0.3.0 repository-local Codex role discovery config. +generated-artifact art-003 .codex/agents/model-routing-luna-xhigh.toml move-generalize .codex/agents/model-routing-luna-xhigh.toml Current v0.3.0 Codex mechanical profile artifact. +generated-artifact art-004 .codex/agents/model-routing-sol-high.toml move-generalize .codex/agents/model-routing-sol-high.toml Current v0.3.0 Codex reviewer profile artifact. +generated-artifact art-005 .codex/agents/model-routing-sol-medium.toml move-generalize .codex/agents/model-routing-sol-medium.toml Current v0.3.0 Codex driver profile artifact. +generated-artifact art-006 .codex/agents/model-routing-sol-ultra.toml move-generalize .codex/agents/model-routing-sol-ultra.toml Current v0.3.0 Codex moonshot profile artifact. +generated-artifact art-007 .codex/agents/model-routing-terra-high.toml move-generalize .codex/agents/model-routing-terra-high.toml Current v0.3.0 Codex implementer profile artifact. +generated-artifact art-008 .codex/agents/model-routing-terra-medium.toml move-generalize .codex/agents/model-routing-terra-medium.toml Current v0.3.0 Codex explorer profile artifact. +generated-artifact art-009 .claude/agents/model-routing-preset-worker.md move-generalize .claude/agents/model-routing-preset-worker.md Current v0.3.0 Claude Code preset worker artifact. +generated-artifact art-010 .cursor/agents/model-routing-preset-worker.md move-generalize .cursor/agents/model-routing-preset-worker.md Current v0.3.0 Cursor preset worker artifact. diff --git a/docs/model-routing-policy.md b/docs/model-routing-policy.md index 006b1b6..edf3e05 100644 --- a/docs/model-routing-policy.md +++ b/docs/model-routing-policy.md @@ -83,57 +83,33 @@ recipes and config files from the same `setupContract` embedded in the generated catalog; the CLI is the only writer of repository-local artifacts. ```sh -model-routing catalog build --output website/data/catalog.json -model-routing catalog verify website/data/catalog.json +cargo run -p xtask -- release prepare --allow-dirty +cargo run -p xtask -- release verify --inventory-only model-routing compile balanced --host codex-openai --output routing-bundle.json model-routing preview routing-bundle.json --repository . model-routing apply routing-bundle.json --repository . -npx switchloom@latest preview --recipe 'sw1_...' --repository . -npx switchloom@latest apply --recipe 'sw1_...' --repository . +npx switchloom@0.3.0 preview --recipe 'sw1_...' --repository . +npx switchloom@0.3.0 apply --recipe 'sw1_...' --repository . switchloom status --repository . switchloom update --repository . switchloom rollback --repository . switchloom uninstall --repository . switchloom doctor codex -switchloom certify reports/native-host-certification///workdir/dispatch-evidence.json \ - --bundle reports/native-host-certification///workdir/bundle.json ``` Use `--integration planr` only when the repository should receive optional `.planr/agents.toml` and `.planr/policy.toml` declarations. Standalone setup must not emit `.planr` files. -## Certification Flow +## Maintainer Verification Boundary -Certification requires a package digest, generated bundle, applied artifacts, -requested invocation, nonce-bearing child output, `DispatchEvidenceV1`, and the -bundle validator: - -```sh -scripts/codex-standalone-oracle.sh -scripts/native-host-certification-oracle.sh cursor-openai target/debug/model-routing -scripts/native-host-certification-oracle.sh cursor-fable-grok target/debug/model-routing -scripts/native-host-certification-oracle.sh claude-native target/debug/model-routing -scripts/opencode-native-oracle.sh target/debug/model-routing -scripts/pi-external-oracle.sh target/debug/model-routing - -switchloom certify reports/native-host-certification///workdir/dispatch-evidence.json \ - --bundle reports/native-host-certification///workdir/bundle.json -``` - -`switchloom doctor ` probes the selected host CLI and reports the detected -version without claiming authentication. `switchloom certify` is the -user-facing alias for the strict `evidence validate` receipt check. - -For the active available-host release gate, Codex is the deterministic -effective-routing certification target. The two Cursor profiles are live -nonce-correlated requested-routing targets; their receipts remain `advisory` -unless Cursor exposes host-authenticated effective role/model telemetry. Claude -Code, OpenCode, and Pi remain unavailable or unverified unless their current -report directories contain authentic nonce-bearing child evidence and passing -validator output. +Live host checks, receipt validation, catalog generation, and catalog verification +are unpublished maintainer operations owned by `xtask`. They are exercised by the +repository release workflow and are not part of the v0.3.0 public CLI contract. +Public users should run `switchloom doctor ` to check host availability and +version, then review `preview` output before `apply`. ## Planr Consumer Handoff diff --git a/docs/package-policy.md b/docs/package-policy.md index 994c4b5..afd2205 100644 --- a/docs/package-policy.md +++ b/docs/package-policy.md @@ -14,3 +14,10 @@ The policy is enforced by `.gitignore`, `Cargo.toml` `exclude`, and the CI packa ## Publishable Inputs Only source, fixtures, docs, CI metadata, and deterministic generator inputs should enter the package. Live verification receipts and authenticated-host evidence belong in release notes or reviewed handoff docs after secret scrubbing, not in the crate payload. + +## v0.3.0 Public Boundary + +The published CLI owns `policy`, `compile`, `inspect`, `preview`, `apply`, +`update`, `status`, `rollback`, `uninstall`, and `doctor`. Maintainer verification, +catalog generation, and release packaging remain in the unpublished `xtask` +crate. Offline evaluation and registry signing remain library APIs. diff --git a/docs/preset-evaluation.md b/docs/preset-evaluation.md index 5170be7..2068bec 100644 --- a/docs/preset-evaluation.md +++ b/docs/preset-evaluation.md @@ -1,10 +1,8 @@ # Preset Evaluation -Evaluation commands are reproducible and offline: - -```sh -model-routing evaluate balanced --host codex-openai -``` +Evaluation is a deterministic, offline library API owned by the `model-routing` +crate. It is exercised by Rust tests and maintainer release verification; v0.3.0 +does not expose a standalone evaluation command. The report records the evaluation suite id, suite hash, bundle hash, scenario count, status, and recommendation state. Offline evaluation cannot claim live verification or recommendation status. diff --git a/docs/preset-registry.md b/docs/preset-registry.md index d085fc2..5ad4c58 100644 --- a/docs/preset-registry.md +++ b/docs/preset-registry.md @@ -1,18 +1,15 @@ # Preset Registry -Switchloom can generate and verify catalog data for the CLI and public website from the same package-owned compiler path: +Maintainers generate and verify catalog data for the CLI and public website from +the same package-owned compiler path: ```sh -model-routing catalog build --output website/data/catalog.json -model-routing catalog verify website/data/catalog.json +cargo run -p xtask -- release prepare --allow-dirty +cargo run -p xtask -- release verify --inventory-only ``` -Detached Ed25519 signatures bind catalog content to a trusted signer: - -```sh -model-routing registry sign website/data/catalog.json --signer maintainers --private-key-file key.hex --output catalog.sig.json -model-routing registry verify website/data/catalog.json --signature catalog.sig.json --trusted-signer maintainers --trusted-public-key-file public.hex -``` +Detached Ed25519 signing and verification are owned by the `model-routing` +library API. They are not standalone public CLI operations in v0.3.0. Unsigned catalog entries remain experimental. diff --git a/docs/v0.3.0-migration-characterization.md b/docs/v0.3.0-migration-characterization.md new file mode 100644 index 0000000..ca5d8a3 --- /dev/null +++ b/docs/v0.3.0-migration-characterization.md @@ -0,0 +1,116 @@ +# v0.3.0 Migration Characterization Baseline + +Captured on 2026-07-21 before any v0.3.0 migration write. This is a +characterization receipt, not a claim that every pre-existing check was green. +Existing repository files, retained evidence, and external Planr state were +treated as user-owned and were not reset, stashed, cleaned, or modified. + +## Fail-closed release gate + +Run the executable gate from a clean candidate tree: + +```sh +bash scripts/check-v0.3.0-migration-gate.sh +``` + +The gate stops before network or release checks when `git status +--porcelain=v1 --untracked-files=all` is non-empty. It then requires release +plan `pln-68a087d8` to audit `holds: true`, verifies the signed-in tools can +resolve the final v0.2.2 npm/GitHub/Homebrew/site publication, and compares the +published bytes against these frozen identities: + +- Tag `v0.2.2`: `206ab2ba15724e438486ef5dfa1ee31888858c19`. +- npm tarball SHA-256: + `ace8594db2f972a754dbfd26590f3196fbd8abaf58648aef1407bc3c463eddc6`. +- npm and GitHub macOS ARM native binary SHA-256: + `363c8e146988f315fb46e30083523b82c3f61486631188ea30d218549931b8b6`. +- GitHub archive and Homebrew formula SHA-256: + `74743a04809a0f625a791a1dc10f72e5f21a7ef5d6d99ad872d5740ebccbd6a9`. +- Deployed and local catalog SHA-256: + `1e86d746a362de3de82dbd74579eef241fca0ca2a2f3278fa546d2e89f21ab5c`. + +Direct pre-write verification used `planr plan audit pln-68a087d8 --json`, +`npm view switchloom@0.2.2 ... --json`, `gh release view v0.2.2 --repo +instructa/switchloom ...`, `brew info --json=v2 instructa/tap/switchloom`, and +`curl -fsSL https://switchloom.ai/data/catalog.json`. The plan held; GitHub was +published, non-draft, and non-prerelease; npm and Homebrew resolved 0.2.2; and +all byte comparisons above agreed. + +## Repository and protected-state freeze + +The pre-write repository snapshot was captured with: + +```sh +git rev-parse HEAD +git status --porcelain=v1 -uall +git status --porcelain=v1 -uall | shasum -a 256 +git describe --tags --always --dirty +git -C /Users/kregenrek/projects/planr rev-parse HEAD +git -C /Users/kregenrek/projects/planr status --porcelain=v1 -uall | shasum -a 256 +git -C /Users/kregenrek/projects/planr status --short --branch +``` + +- Switchloom HEAD: `5c68fca30266b71a7694461c293d9565ea280cb2`. +- Switchloom description: `v0.2.2-2-g5c68fca`. +- Switchloom porcelain status: empty; SHA-256 of empty status: + `e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855`. +- Protected Planr HEAD: `bbc877d40191b2cbb289ed26df5e6fee25e4326d`. +- Protected Planr branch: + `agent/disable-dependabot-version-updates...origin/agent/disable-dependabot-version-updates`. +- Protected Planr porcelain status: empty; SHA-256 of empty status: + `e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855`. + +## Public CLI characterization + +The public GitHub `switchloom-darwin-arm64.tar.gz` was downloaded, its +`model-routing` binary was hashed, and these commands were run against those +published bytes: + +```sh +model-routing --version +model-routing --help +``` + +Version output was `model-routing 0.2.2`. Top-level commands were `policy`, +`compile`, `inspect`, `preview`, `apply`, `update`, `status`, `uninstall`, +`rollback`, `doctor`, `probe`, `certify`, `evaluate`, `evidence`, `catalog`, +`registry`, and `help`. This intentionally records the old public surface so +later removal of internal certification commands can be distinguished from +accidental drift. + +## Frozen local content hashes + +Exact commands: + +```sh +find fixtures -type f -print0 | LC_ALL=C sort -z | xargs -0 shasum -a 256 | shasum -a 256 +find website/data/bundles -type f -print0 | LC_ALL=C sort -z | xargs -0 shasum -a 256 | shasum -a 256 +shasum -a 256 website/data/catalog.json Cargo.toml Cargo.lock package.json pnpm-lock.yaml npm/bin/model-routing.js +``` + +- Fixture inventory digest: `af54f61fd51bdeb76855074ddac3ab19b1f2fb8557b4efe18487ade9cb28adb3`. +- Website bundle inventory digest: `ed5604749325ffde6d39f576abed6ac578dc79a9f1ee044a4b523ed06ad66cf5`. +- `website/data/catalog.json`: `1e86d746a362de3de82dbd74579eef241fca0ca2a2f3278fa546d2e89f21ab5c`. +- `Cargo.toml`: `9ea362f1b3078a6f057cae5dd36288ce41cf9d07697b1a94403fcb0fce3c4b58`. +- `Cargo.lock`: `505daeb8fc32dc5ca7ca855353dfcb9c838ee5861b039f521ac0a09664b2321b`. +- `package.json`: `9121f959d3757846f916f06fd83ed4009aaaa998a300533ad140136fe69860b4`. +- `pnpm-lock.yaml`: `82c439e4d987689fff78a8f724adbd493144b7c17d0214b96362922df22e68eb`. +- `npm/bin/model-routing.js`: `cedc5d44984cd83eeab6677f7930213b1ad8d47fa3bc41bb69a590a1508999d2`. + +## Existing test baseline + +The following results predate migration changes: + +- `cargo fmt --all -- --check && cargo clippy --workspace --all-targets + --all-features -- -D warnings`: passed. +- `cargo test --workspace --all-targets --all-features`: passed, 63 library + tests and both zero-test binary targets. +- `CI=true pnpm site:check`: passed, 4 Vitest files / 27 tests, 5 Node tests, + zero Astro diagnostics, and one static page built. +- `pnpm distribution:test`: passed, 3 Node tests, native provenance validation, + and npm pack validation for 19 files. +- `sh scripts/check-migration-manifest.sh`: **pre-existing failure**: + `migration manifest missing planr-consumer entry: apps/docs/wrangler.jsonc`. + +That manifest failure is part of the frozen baseline. It must not be attributed +to the v0.3.0 migration unless later work changes its behavior or ownership. diff --git a/fixtures/routing-bundle-v1/valid-balanced-codex.json b/fixtures/routing-bundle-v1/valid-balanced-codex.json index 015ed0d..6d46a00 100644 --- a/fixtures/routing-bundle-v1/valid-balanced-codex.json +++ b/fixtures/routing-bundle-v1/valid-balanced-codex.json @@ -6,7 +6,7 @@ "generated_at": "2026-07-16T00:00:00Z", "source": { "package": "model-routing", - "package_version": "0.2.2", + "package_version": "0.3.0", "integration": "planr" }, "policy": { @@ -463,7 +463,7 @@ }, "planr_handoff": { "schema_version": 1, - "switchloom_package": "switchloom@0.2.2", + "switchloom_package": "switchloom@0.3.0", "semantic_role_contract": "Planr supplies usage policy `balanced`, work_type routes, and semantic roles; Switchloom owns the selected host binding, model, effort, fork/context policy, and generated dispatch artifacts.", "required_consumer_behavior": [ "Consume RoutingIntentV1 as the only source for semantic_role, work_type, selected profile, model, effort, agent_type, and fork_turns inputs.", diff --git a/fixtures/routing-bundle-v1/valid-balanced-mixed.json b/fixtures/routing-bundle-v1/valid-balanced-mixed.json index af91749..0c391a4 100644 --- a/fixtures/routing-bundle-v1/valid-balanced-mixed.json +++ b/fixtures/routing-bundle-v1/valid-balanced-mixed.json @@ -6,7 +6,7 @@ "generated_at": "2026-07-16T00:00:00Z", "source": { "package": "model-routing", - "package_version": "0.2.2", + "package_version": "0.3.0", "integration": "planr" }, "policy": { @@ -394,7 +394,7 @@ }, "planr_handoff": { "schema_version": 1, - "switchloom_package": "switchloom@0.2.2", + "switchloom_package": "switchloom@0.3.0", "semantic_role_contract": "Planr supplies usage policy `balanced`, work_type routes, and semantic roles; Switchloom owns the selected host binding, model, effort, fork/context policy, and generated dispatch artifacts.", "required_consumer_behavior": [ "Consume RoutingIntentV1 as the only source for semantic_role, work_type, selected profile, model, effort, agent_type, and fork_turns inputs.", diff --git a/package.json b/package.json index 6146265..9ed86db 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "switchloom", - "version": "0.2.2", + "version": "0.3.0", "description": "Deterministic model routing for coding agents.", "license": "MIT", "repository": { diff --git a/retained-evidence/astro-source-migration/root-src/components/Generator.tsx b/retained-evidence/astro-source-migration/root-src/components/Generator.tsx new file mode 100644 index 0000000..19b15e4 --- /dev/null +++ b/retained-evidence/astro-source-migration/root-src/components/Generator.tsx @@ -0,0 +1 @@ +export { default } from "../../website/src/components/Generator"; diff --git a/retained-evidence/astro-source-migration/root-src/components/ui/alert.tsx b/retained-evidence/astro-source-migration/root-src/components/ui/alert.tsx new file mode 100644 index 0000000..e319290 --- /dev/null +++ b/retained-evidence/astro-source-migration/root-src/components/ui/alert.tsx @@ -0,0 +1 @@ +export * from "../../../website/src/components/ui/alert"; diff --git a/retained-evidence/astro-source-migration/root-src/components/ui/badge.tsx b/retained-evidence/astro-source-migration/root-src/components/ui/badge.tsx new file mode 100644 index 0000000..fd82a23 --- /dev/null +++ b/retained-evidence/astro-source-migration/root-src/components/ui/badge.tsx @@ -0,0 +1 @@ +export * from "../../../website/src/components/ui/badge"; diff --git a/retained-evidence/astro-source-migration/root-src/components/ui/button.tsx b/retained-evidence/astro-source-migration/root-src/components/ui/button.tsx new file mode 100644 index 0000000..5651418 --- /dev/null +++ b/retained-evidence/astro-source-migration/root-src/components/ui/button.tsx @@ -0,0 +1 @@ +export * from "../../../website/src/components/ui/button"; diff --git a/retained-evidence/astro-source-migration/root-src/components/ui/card.tsx b/retained-evidence/astro-source-migration/root-src/components/ui/card.tsx new file mode 100644 index 0000000..8f9a82d --- /dev/null +++ b/retained-evidence/astro-source-migration/root-src/components/ui/card.tsx @@ -0,0 +1 @@ +export * from "../../../website/src/components/ui/card"; diff --git a/retained-evidence/astro-source-migration/root-src/components/ui/combobox.tsx b/retained-evidence/astro-source-migration/root-src/components/ui/combobox.tsx new file mode 100644 index 0000000..4f059dc --- /dev/null +++ b/retained-evidence/astro-source-migration/root-src/components/ui/combobox.tsx @@ -0,0 +1 @@ +export * from "../../../website/src/components/ui/combobox"; diff --git a/retained-evidence/astro-source-migration/root-src/components/ui/field.tsx b/retained-evidence/astro-source-migration/root-src/components/ui/field.tsx new file mode 100644 index 0000000..ae473fe --- /dev/null +++ b/retained-evidence/astro-source-migration/root-src/components/ui/field.tsx @@ -0,0 +1 @@ +export * from "../../../website/src/components/ui/field"; diff --git a/retained-evidence/astro-source-migration/root-src/components/ui/input-group.tsx b/retained-evidence/astro-source-migration/root-src/components/ui/input-group.tsx new file mode 100644 index 0000000..f5d79ca --- /dev/null +++ b/retained-evidence/astro-source-migration/root-src/components/ui/input-group.tsx @@ -0,0 +1 @@ +export * from "../../../website/src/components/ui/input-group"; diff --git a/retained-evidence/astro-source-migration/root-src/components/ui/input.tsx b/retained-evidence/astro-source-migration/root-src/components/ui/input.tsx new file mode 100644 index 0000000..9be075a --- /dev/null +++ b/retained-evidence/astro-source-migration/root-src/components/ui/input.tsx @@ -0,0 +1 @@ +export * from "../../../website/src/components/ui/input"; diff --git a/retained-evidence/astro-source-migration/root-src/components/ui/label.tsx b/retained-evidence/astro-source-migration/root-src/components/ui/label.tsx new file mode 100644 index 0000000..f9a2d16 --- /dev/null +++ b/retained-evidence/astro-source-migration/root-src/components/ui/label.tsx @@ -0,0 +1 @@ +export * from "../../../website/src/components/ui/label"; diff --git a/retained-evidence/astro-source-migration/root-src/components/ui/separator.tsx b/retained-evidence/astro-source-migration/root-src/components/ui/separator.tsx new file mode 100644 index 0000000..5b0ed0e --- /dev/null +++ b/retained-evidence/astro-source-migration/root-src/components/ui/separator.tsx @@ -0,0 +1 @@ +export * from "../../../website/src/components/ui/separator"; diff --git a/retained-evidence/astro-source-migration/root-src/components/ui/tabs.tsx b/retained-evidence/astro-source-migration/root-src/components/ui/tabs.tsx new file mode 100644 index 0000000..bda7648 --- /dev/null +++ b/retained-evidence/astro-source-migration/root-src/components/ui/tabs.tsx @@ -0,0 +1 @@ +export * from "../../../website/src/components/ui/tabs"; diff --git a/retained-evidence/astro-source-migration/root-src/components/ui/textarea.tsx b/retained-evidence/astro-source-migration/root-src/components/ui/textarea.tsx new file mode 100644 index 0000000..7ffec25 --- /dev/null +++ b/retained-evidence/astro-source-migration/root-src/components/ui/textarea.tsx @@ -0,0 +1 @@ +export * from "../../../website/src/components/ui/textarea"; diff --git a/retained-evidence/astro-source-migration/root-src/components/ui/toggle-group.tsx b/retained-evidence/astro-source-migration/root-src/components/ui/toggle-group.tsx new file mode 100644 index 0000000..4eb19c0 --- /dev/null +++ b/retained-evidence/astro-source-migration/root-src/components/ui/toggle-group.tsx @@ -0,0 +1 @@ +export * from "../../../website/src/components/ui/toggle-group"; diff --git a/retained-evidence/astro-source-migration/root-src/components/ui/toggle.tsx b/retained-evidence/astro-source-migration/root-src/components/ui/toggle.tsx new file mode 100644 index 0000000..5dd5d62 --- /dev/null +++ b/retained-evidence/astro-source-migration/root-src/components/ui/toggle.tsx @@ -0,0 +1 @@ +export * from "../../../website/src/components/ui/toggle"; diff --git a/retained-evidence/astro-source-migration/root-src/env.d.ts b/retained-evidence/astro-source-migration/root-src/env.d.ts new file mode 100644 index 0000000..0a28b05 --- /dev/null +++ b/retained-evidence/astro-source-migration/root-src/env.d.ts @@ -0,0 +1 @@ +// Canonical Astro environment declarations live in website/src/env.d.ts. diff --git a/retained-evidence/astro-source-migration/root-src/lib/build-site.test.ts b/retained-evidence/astro-source-migration/root-src/lib/build-site.test.ts new file mode 100644 index 0000000..7883be5 --- /dev/null +++ b/retained-evidence/astro-source-migration/root-src/lib/build-site.test.ts @@ -0,0 +1 @@ +// Canonical test suite moved to website/src/lib/build-site.test.ts. diff --git a/retained-evidence/astro-source-migration/root-src/lib/generator.test.ts b/retained-evidence/astro-source-migration/root-src/lib/generator.test.ts new file mode 100644 index 0000000..326ace0 --- /dev/null +++ b/retained-evidence/astro-source-migration/root-src/lib/generator.test.ts @@ -0,0 +1 @@ +// Canonical test suite moved to website/src/lib/generator.test.ts. diff --git a/retained-evidence/astro-source-migration/root-src/lib/generator.ts b/retained-evidence/astro-source-migration/root-src/lib/generator.ts new file mode 100644 index 0000000..440a0a7 --- /dev/null +++ b/retained-evidence/astro-source-migration/root-src/lib/generator.ts @@ -0,0 +1 @@ +export * from "../../website/src/lib/generator"; diff --git a/retained-evidence/astro-source-migration/root-src/lib/security-headers.test.ts b/retained-evidence/astro-source-migration/root-src/lib/security-headers.test.ts new file mode 100644 index 0000000..43059af --- /dev/null +++ b/retained-evidence/astro-source-migration/root-src/lib/security-headers.test.ts @@ -0,0 +1 @@ +// Canonical test suite moved to website/src/lib/security-headers.test.ts. diff --git a/retained-evidence/astro-source-migration/root-src/lib/utils.ts b/retained-evidence/astro-source-migration/root-src/lib/utils.ts new file mode 100644 index 0000000..eb608da --- /dev/null +++ b/retained-evidence/astro-source-migration/root-src/lib/utils.ts @@ -0,0 +1 @@ +export * from "../../website/src/lib/utils"; diff --git a/retained-evidence/astro-source-migration/root-src/lib/website-cli-parity.test.ts b/retained-evidence/astro-source-migration/root-src/lib/website-cli-parity.test.ts new file mode 100644 index 0000000..bd54cac --- /dev/null +++ b/retained-evidence/astro-source-migration/root-src/lib/website-cli-parity.test.ts @@ -0,0 +1 @@ +// Canonical test suite moved to website/src/lib/website-cli-parity.test.ts. diff --git a/retained-evidence/astro-source-migration/root-src/pages/index.astro b/retained-evidence/astro-source-migration/root-src/pages/index.astro new file mode 100644 index 0000000..081d579 --- /dev/null +++ b/retained-evidence/astro-source-migration/root-src/pages/index.astro @@ -0,0 +1 @@ + diff --git a/retained-evidence/astro-source-migration/root-src/styles/global.css b/retained-evidence/astro-source-migration/root-src/styles/global.css new file mode 100644 index 0000000..845d81c --- /dev/null +++ b/retained-evidence/astro-source-migration/root-src/styles/global.css @@ -0,0 +1 @@ +@import "../../website/src/styles/global.css"; diff --git a/retained-evidence/evidence-validator-parity/legacy/validate-codex-runtime-evidence.mjs b/retained-evidence/evidence-validator-parity/legacy/validate-codex-runtime-evidence.mjs new file mode 100644 index 0000000..9f953ee --- /dev/null +++ b/retained-evidence/evidence-validator-parity/legacy/validate-codex-runtime-evidence.mjs @@ -0,0 +1,206 @@ +#!/usr/bin/env node +import { readFile } from "node:fs/promises"; + +const positional = []; +const options = new Map(); +for (let index = 2; index < process.argv.length; index += 1) { + const arg = process.argv[index]; + if (arg === "--expect") { + options.set("expect", process.argv[index + 1]); + index += 1; + } else { + positional.push(arg); + } +} + +const [receiptPath] = positional; +if (!receiptPath || positional.length > 1 || (options.has("expect") && !options.get("expect"))) { + throw new Error("usage: node scripts/validate-codex-runtime-evidence.mjs [--expect ]"); +} + +const schemaVersion = "switchloom.codex_runtime_evidence.v1"; +const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; +const sha256DigestPattern = /^sha256:[0-9a-f]{64}$/; +const sha256HexPattern = /^[0-9a-f]{64}$/; +const codexVersionPattern = /^codex(?:-cli)?\s+\d+\.\d+\.\d+(?:\b|[-+])/; + +function fail(message) { + throw new Error(`codex runtime evidence validation failed: ${message}`); +} + +function assert(condition, message) { + if (!condition) fail(message); +} + +function assertUuid(value, label) { + assert(typeof value === "string" && uuidPattern.test(value), `${label} must be a UUID`); +} + +function assertString(value, label) { + assert(typeof value === "string" && value.trim().length > 0, `${label} must not be blank`); +} + +function expectedChildrenFrom(receipt, expected) { + const children = expected?.children ?? receipt.children; + assert(Array.isArray(children), "expected children must be an array"); + return new Map(children.map((child) => { + const kind = child.semantic_role ?? child.kind; + const model = child.model ?? child.state?.model; + const effort = child.effort ?? child.state?.reasoning_effort; + assertString(kind, "expected child semantic_role"); + assertString(child.agent_type, `${kind} expected agent_type`); + assertString(child.profile, `${kind} expected profile`); + assertString(child.task_name, `${kind} expected task_name`); + assertString(child.message_sha256 ?? child.message_ciphertext_sha256 ?? child.input?.message_sha256, `${kind} expected message hash`); + assertString(model, `${kind} expected model`); + assertString(effort, `${kind} expected effort`); + return [kind, { ...child, model, effort }]; + })); +} + +const receipt = JSON.parse(await readFile(receiptPath, "utf8")); +const expectedReceipt = options.has("expect") + ? JSON.parse(await readFile(options.get("expect"), "utf8")) + : undefined; +const requiredChildren = expectedChildrenFrom(receipt, expectedReceipt); +assert(receipt.schema_version === schemaVersion, `schema_version must be ${schemaVersion}`); +assert(receipt.run?.status === "complete", "run.status must be complete"); +assert(receipt.run?.complete_marker === "SWITCHLOOM_CODEX_RUNTIME_EVIDENCE_COMPLETE", "run complete marker missing"); +assert(receipt.run?.evidence_source === "codex_persisted_spawn_state", "run.evidence_source must be codex_persisted_spawn_state"); +assertUuid(receipt.run?.parent_thread_id, "run.parent_thread_id"); +assert(typeof receipt.run?.parent_session === "string" && receipt.run.parent_session.endsWith(".jsonl"), "run.parent_session must name a persisted session jsonl"); +assert(typeof receipt.run?.workdir === "string" && receipt.run.workdir.startsWith("/"), "run.workdir must be absolute"); +assert(Array.isArray(receipt.children), "children must be an array"); +assert(receipt.children.length === requiredChildren.size, "children must contain worker and reviewer only"); +assert(Array.isArray(receipt.dispatch_evidence), "dispatch_evidence must be an array"); +assert(receipt.dispatch_evidence.length === requiredChildren.size, "dispatch_evidence must contain worker and reviewer receipts only"); + +const seenKinds = new Set(); +for (const child of receipt.children) { + const expected = requiredChildren.get(child.kind); + assert(expected, `unexpected child kind ${child.kind}`); + assert(!seenKinds.has(child.kind), `duplicate child kind ${child.kind}`); + seenKinds.add(child.kind); + assert(child.agent_type === expected.agent_type, `${child.kind} agent_type mismatch`); + assert(child.task_name === expected.task_name, `${child.kind} task_name mismatch`); + assert(/^[a-z][a-z0-9_]*$/.test(child.task_name), `${child.kind} task_name is invalid`); + assert(child.canonical_task === `/root/${child.task_name}`, `${child.kind} canonical_task mismatch`); + assert(child.parent_thread_id === receipt.run.parent_thread_id, `${child.kind} parent_thread_id mismatch`); + assertUuid(child.child_thread_id, `${child.kind} child_thread_id`); + assert(child.child_thread_id !== receipt.run.parent_thread_id, `${child.kind} child_thread_id must differ from parent`); + assert(child.spawn?.surface === "collaboration.spawn_agent", `${child.kind} spawn surface must be Codex V2 collaboration.spawn_agent`); + assert(child.spawn?.agent_type === child.agent_type, `${child.kind} spawn agent_type mismatch`); + assert(child.spawn?.task_name === child.task_name, `${child.kind} spawn task_name mismatch`); + assert(child.spawn?.fork_turns === "none", `${child.kind} fork_turns must be none`); + assertString(child.spawn?.call_id, `${child.kind} spawn call_id`); + assert(!("model" in child.spawn), `${child.kind} spawn must not manually override model`); + assert(!("reasoning_effort" in child.spawn), `${child.kind} spawn must not manually override effort`); + const expectedMessageSha256 = expected.message_sha256 ?? expected.input?.message_sha256; + const expectedCiphertextSha256 = expected.message_ciphertext_sha256; + const expectedMaxMessageBytes = expected.max_message_bytes ?? expected.input?.max_message_bytes; + if (expectedMessageSha256 !== undefined) { + assert(sha256HexPattern.test(expectedMessageSha256), `${child.kind} expected message_sha256 must be lowercase sha256 hex`); + } + if (expectedCiphertextSha256 !== undefined) { + assert(sha256HexPattern.test(expectedCiphertextSha256), `${child.kind} expected message_ciphertext_sha256 must be lowercase sha256 hex`); + } + assert(Number.isInteger(expectedMaxMessageBytes) && expectedMaxMessageBytes > 0, `${child.kind} expected max_message_bytes must be positive`); + assert(sha256HexPattern.test(child.input?.message_sha256), `${child.kind} input message_sha256 must be lowercase sha256 hex`); + const messageEncoding = child.input?.message_encoding ?? "plaintext"; + if (messageEncoding === "plaintext") { + assert(expectedMessageSha256 !== undefined, `${child.kind} plaintext input requires expected message_sha256`); + assert(child.input.message_sha256 === expectedMessageSha256, `${child.kind} input message_sha256 mismatch`); + assert(child.input.message_plaintext_verdict === "deterministic", `${child.kind} input message_plaintext_verdict mismatch`); + } else if (messageEncoding === "codex-encrypted") { + if (expectedCiphertextSha256 !== undefined) { + assert(child.input.message_sha256 === expectedCiphertextSha256, `${child.kind} input message_ciphertext_sha256 mismatch`); + } + assert(child.input.message_plaintext_verdict === "unsupported", `${child.kind} encrypted input cannot claim deterministic plaintext`); + if (child.input.message_plaintext_intent_sha256 !== undefined) { + assert(expectedMessageSha256 !== undefined, `${child.kind} encrypted input intent hash has no expected plaintext hash`); + assert(child.input.message_plaintext_intent_sha256 === expectedMessageSha256, `${child.kind} input message_plaintext_intent_sha256 mismatch`); + } + } else { + fail(`${child.kind} unsupported input message_encoding ${messageEncoding}`); + } + assert(Number.isInteger(child.input?.message_bytes) && child.input.message_bytes > 0, `${child.kind} input message_bytes must be positive`); + assert(child.input.message_bytes <= expectedMaxMessageBytes, `${child.kind} input message_bytes exceeds max_message_bytes`); + assert(child.input?.max_message_bytes === expectedMaxMessageBytes, `${child.kind} input max_message_bytes mismatch`); + assert( + child.spawn_output?.task_name === child.canonical_task || child.spawn_output?.agent_id === child.child_thread_id, + `${child.kind} spawn output task mismatch`, + ); + assert(child.session?.agent_role === child.agent_type, `${child.kind} session agent_role mismatch`); + assert( + child.session?.agent_path === child.canonical_task || child.session?.agent_path === null, + `${child.kind} session agent_path mismatch`, + ); + assert(child.session?.thread_source === "subagent", `${child.kind} session thread_source mismatch`); + assert(child.session?.parent_thread_id === receipt.run.parent_thread_id, `${child.kind} session parent mismatch`); + assert(typeof child.session?.session_file === "string" && child.session.session_file.endsWith(".jsonl"), `${child.kind} session file missing`); + assert(child.state?.agent_role === child.agent_type, `${child.kind} state agent_role mismatch`); + assert( + child.state?.agent_path === child.canonical_task || child.state?.agent_path === null, + `${child.kind} state agent_path mismatch`, + ); + assert(child.state?.model === expected.model, `${child.kind} effective model mismatch`); + assert(child.state?.reasoning_effort === expected.effort, `${child.kind} effective effort mismatch`); + assert(child.state?.thread_source === "subagent", `${child.kind} state thread_source mismatch`); + assert(child.state?.cwd === receipt.run.workdir, `${child.kind} state cwd mismatch`); + assert(child.state.model !== "gpt-5.6-sol" || child.state.reasoning_effort !== "medium", `${child.kind} inherited Sol Medium evidence is forbidden`); + assert(child.final_answer?.message_type === "FINAL_ANSWER", `${child.kind} final answer marker missing`); + + const dispatchEvidence = receipt.dispatch_evidence.find((evidence) => { + return evidence?.requested_dispatch?.semantic_role === child.kind + && evidence?.requested_dispatch?.agent_type === child.agent_type + && evidence?.child_identity?.task_name === child.task_name; + }); + assert(dispatchEvidence, `${child.kind} dispatch_evidence receipt missing`); + assert(dispatchEvidence.schema_version === 1, `${child.kind} dispatch_evidence schema_version mismatch`); + assertString(dispatchEvidence.package_digest, `${child.kind} dispatch_evidence package_digest`); + assertString(dispatchEvidence.host_version, `${child.kind} dispatch_evidence host_version`); + assert(sha256DigestPattern.test(dispatchEvidence.package_digest), `${child.kind} dispatch_evidence package_digest must be sha256:<64 lowercase hex>`); + assert(codexVersionPattern.test(dispatchEvidence.host_version), `${child.kind} dispatch_evidence host_version must come from codex --version`); + if (expectedReceipt) { + assert(dispatchEvidence.package_digest === expectedReceipt.package_digest, `${child.kind} package_digest mismatch`); + assert(dispatchEvidence.host_version === expectedReceipt.host_version, `${child.kind} host_version mismatch`); + } + assert(dispatchEvidence.requested_dispatch.profile === expected.profile, `${child.kind} requested profile mismatch`); + assert(dispatchEvidence.requested_dispatch.model === expected.model, `${child.kind} requested model mismatch`); + assert(dispatchEvidence.requested_dispatch.effort === expected.effort, `${child.kind} requested effort mismatch`); + assert(dispatchEvidence.requested_dispatch.agent_type === child.agent_type, `${child.kind} requested agent_type mismatch`); + assert(dispatchEvidence.requested_dispatch.fork_turns?.mode === "none", `${child.kind} requested fork_turns must be none`); + assert(!("turns" in dispatchEvidence.requested_dispatch.fork_turns), `${child.kind} fork_turns none must not include turns`); + assert(dispatchEvidence.requested_dispatch.message_sha256 === child.input.message_sha256, `${child.kind} requested message_sha256 mismatch`); + assert(dispatchEvidence.requested_dispatch.message_encoding === messageEncoding, `${child.kind} requested message_encoding mismatch`); + assert(dispatchEvidence.requested_dispatch.message_plaintext_verdict === child.input.message_plaintext_verdict, `${child.kind} requested message_plaintext_verdict mismatch`); + assert(dispatchEvidence.requested_dispatch.message_plaintext_intent_sha256 === child.input.message_plaintext_intent_sha256, `${child.kind} requested message_plaintext_intent_sha256 mismatch`); + assert(dispatchEvidence.requested_dispatch.message_bytes === child.input.message_bytes, `${child.kind} requested message_bytes mismatch`); + assert(dispatchEvidence.requested_dispatch.max_message_bytes === expectedMaxMessageBytes, `${child.kind} requested max_message_bytes mismatch`); + assert(dispatchEvidence.child_identity.host === "codex", `${child.kind} child host mismatch`); + assert(dispatchEvidence.child_identity.role === child.kind, `${child.kind} child role mismatch`); + assert(dispatchEvidence.child_identity.agent_role === child.agent_type, `${child.kind} child agent_role mismatch`); + assert(dispatchEvidence.child_identity.agent_type === child.agent_type, `${child.kind} child agent_type mismatch`); + assert(dispatchEvidence.effective_model === child.state.model, `${child.kind} effective model receipt mismatch`); + assert(dispatchEvidence.effective_effort === child.state.reasoning_effort, `${child.kind} effective effort receipt mismatch`); + assert(dispatchEvidence.nonce === `${receipt.run.parent_thread_id}:${child.child_thread_id}:${child.spawn.call_id}`, `${child.kind} dispatch_evidence nonce must bind parent thread, child thread, and spawn call`); + assert( + !dispatchEvidence.nonce.includes("nonce-") && !dispatchEvidence.nonce.includes("placeholder"), + `${child.kind} dispatch_evidence nonce must be runtime-derived, not a placeholder`, + ); + assert(Array.isArray(dispatchEvidence.raw_evidence_refs) && dispatchEvidence.raw_evidence_refs.length > 0, `${child.kind} raw evidence refs missing`); + assert( + dispatchEvidence.raw_evidence_refs.includes(`codex-session:${receipt.run.parent_session}`) + && dispatchEvidence.raw_evidence_refs.includes(`codex-session:${child.session.session_file}`) + && dispatchEvidence.raw_evidence_refs.includes(`state_5.sqlite:thread_spawn_edges:${receipt.run.parent_thread_id}:${child.child_thread_id}`) + && dispatchEvidence.raw_evidence_refs.includes(`spawn_call:${child.spawn.call_id}`), + `${child.kind} raw evidence refs must bind parent session, child session, spawn edge, and spawn call`, + ); + assert(dispatchEvidence.verdict === "deterministic", `${child.kind} dispatch_evidence verdict mismatch`); +} + +for (const kind of requiredChildren.keys()) { + assert(seenKinds.has(kind), `missing ${kind} child evidence`); +} + +console.log("codex runtime evidence validation passed"); diff --git a/retained-evidence/evidence-validator-parity/legacy/validate-codex-runtime-evidence.test.mjs b/retained-evidence/evidence-validator-parity/legacy/validate-codex-runtime-evidence.test.mjs new file mode 100644 index 0000000..03ab87a --- /dev/null +++ b/retained-evidence/evidence-validator-parity/legacy/validate-codex-runtime-evidence.test.mjs @@ -0,0 +1,416 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; + +const parent = "11111111-1111-4111-8111-111111111111"; +const worker = "22222222-2222-4222-8222-222222222222"; +const reviewer = "33333333-3333-4333-8333-333333333333"; +const workdir = "/tmp/switchloom-fresh-repo"; +const packageDigest = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +const hostVersion = "codex 0.144.0"; + +function sha256(value) { + return createHash("sha256").update(value).digest("hex"); +} + +function profileFor(kind) { + return kind === "worker" ? "codex-terra-high" : "codex-sol-high"; +} + +function child(kind, agentType, taskName, childThreadId, model, effort, profile = profileFor(kind)) { + const callId = `call-${kind}`; + const message = `${kind} bounded task input`; + return { + kind, + profile, + agent_type: agentType, + task_name: taskName, + canonical_task: `/root/${taskName}`, + parent_thread_id: parent, + child_thread_id: childThreadId, + spawn: { + surface: "collaboration.spawn_agent", + agent_type: agentType, + task_name: taskName, + fork_turns: "none", + call_id: callId, + }, + input: { + message_sha256: sha256(message), + message_bytes: Buffer.byteLength(message, "utf8"), + max_message_bytes: 512, + message_encoding: "plaintext", + message_plaintext_verdict: "deterministic", + }, + spawn_output: { + task_name: `/root/${taskName}`, + }, + session: { + agent_role: agentType, + agent_path: `/root/${taskName}`, + thread_source: "subagent", + parent_thread_id: parent, + session_file: `${childThreadId}.jsonl`, + }, + state: { + agent_role: agentType, + agent_path: `/root/${taskName}`, + model, + reasoning_effort: effort, + thread_source: "subagent", + cwd: workdir, + }, + final_answer: { + message_type: "FINAL_ANSWER", + }, + }; +} + +function validReceipt() { + const children = [ + child("worker", "model_routing_terra_high", "worker", worker, "gpt-5.6-terra", "high"), + child("reviewer", "model_routing_sol_high", "reviewer", reviewer, "gpt-5.6-sol", "high"), + ]; + return { + schema_version: "switchloom.codex_runtime_evidence.v1", + run: { + status: "complete", + complete_marker: "SWITCHLOOM_CODEX_RUNTIME_EVIDENCE_COMPLETE", + evidence_source: "codex_persisted_spawn_state", + parent_thread_id: parent, + parent_session: `${parent}.jsonl`, + workdir, + }, + children, + dispatch_evidence: children.map((entry) => ({ + schema_version: 1, + package_digest: packageDigest, + host_version: hostVersion, + requested_dispatch: { + semantic_role: entry.kind, + profile: entry.profile, + model: entry.state.model, + effort: entry.state.reasoning_effort, + agent_type: entry.agent_type, + fork_turns: { + mode: "none", + }, + message_sha256: entry.input.message_sha256, + message_encoding: entry.input.message_encoding ?? "plaintext", + message_plaintext_verdict: entry.input.message_plaintext_verdict ?? "deterministic", + message_plaintext_intent_sha256: entry.input.message_plaintext_intent_sha256, + message_bytes: entry.input.message_bytes, + max_message_bytes: entry.input.max_message_bytes, + }, + child_identity: { + host: "codex", + role: entry.kind, + agent_role: entry.agent_type, + agent_type: entry.agent_type, + task_name: entry.task_name, + }, + effective_model: entry.state.model, + effective_effort: entry.state.reasoning_effort, + nonce: `${parent}:${entry.child_thread_id}:${entry.spawn.call_id}`, + raw_evidence_refs: [ + `codex-session:${parent}.jsonl`, + `codex-session:${entry.child_thread_id}.jsonl`, + `state_5.sqlite:thread_spawn_edges:${parent}:${entry.child_thread_id}`, + `spawn_call:${entry.spawn.call_id}`, + ], + verdict: "deterministic", + })), + }; +} + +async function withReceipt(receipt, callback) { + const root = await mkdtemp(join(tmpdir(), "switchloom-codex-evidence-")); + try { + const path = join(root, "receipt.json"); + await writeFile(path, typeof receipt === "string" ? receipt : JSON.stringify(receipt, null, 2)); + return await callback(path); + } finally { + await rm(root, { recursive: true, force: true }); + } +} + +function run(path) { + const validator = process.env.VALIDATOR_MODE === "rust" + ? new URL("../../../scripts/validate-codex-runtime-evidence.mjs", import.meta.url).pathname + : new URL("./validate-codex-runtime-evidence.mjs", import.meta.url).pathname; + return spawnSync(process.execPath, [validator, path], { + cwd: new URL("..", import.meta.url), + encoding: "utf8", + }); +} + +function runWithExpect(path, expectPath) { + const validator = process.env.VALIDATOR_MODE === "rust" + ? new URL("../../../scripts/validate-codex-runtime-evidence.mjs", import.meta.url).pathname + : new URL("./validate-codex-runtime-evidence.mjs", import.meta.url).pathname; + return spawnSync(process.execPath, [validator, path, "--expect", expectPath], { + cwd: new URL("..", import.meta.url), + encoding: "utf8", + }); +} + +test("accepts complete correlated Codex runtime evidence", async () => { + await withReceipt(validReceipt(), (path) => { + const result = run(path); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /runtime evidence validation passed/); + }); +}); + +test("rejects prose-only and incomplete evidence", async () => { + await withReceipt("The worker used Terra High and the reviewer used Sol High.", (path) => { + const result = run(path); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /JSON|Unexpected token|not valid/i); + }); + + const incomplete = validReceipt(); + delete incomplete.run.complete_marker; + await withReceipt(incomplete, (path) => { + const result = run(path); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /complete marker missing/); + }); +}); + +test("rejects missing persisted source and uncorrelated child state", async () => { + const synthetic = validReceipt(); + delete synthetic.run.evidence_source; + await withReceipt(synthetic, (path) => { + const result = run(path); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /evidence_source/); + }); + + const uncorrelated = validReceipt(); + uncorrelated.children[0].session.parent_thread_id = reviewer; + await withReceipt(uncorrelated, (path) => { + const result = run(path); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /session parent mismatch/); + }); +}); + +test("rejects inherited Sol Medium behavior", async () => { + const inherited = validReceipt(); + inherited.children[1].state.model = "gpt-5.6-sol"; + inherited.children[1].state.reasoning_effort = "medium"; + await withReceipt(inherited, (path) => { + const result = run(path); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /effective effort mismatch|inherited Sol Medium/); + }); +}); + +test("rejects receipts without correlated dispatch evidence", async () => { + const missing = validReceipt(); + delete missing.dispatch_evidence; + await withReceipt(missing, (path) => { + const result = run(path); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /dispatch_evidence/); + }); + + const uncorrelated = validReceipt(); + uncorrelated.dispatch_evidence[0].child_identity.agent_role = "model_routing_sol_medium"; + await withReceipt(uncorrelated, (path) => { + const result = run(path); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /agent_role mismatch/); + }); + + const missingNonce = validReceipt(); + missingNonce.dispatch_evidence[0].nonce = ""; + await withReceipt(missingNonce, (path) => { + const result = run(path); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /nonce/); + }); + + const staleNonce = validReceipt(); + staleNonce.dispatch_evidence[0].nonce = `${parent}:${worker}:stale-call`; + await withReceipt(staleNonce, (path) => { + const result = run(path); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /nonce must bind parent thread, child thread, and spawn call/); + }); + + const echoedNonce = validReceipt(); + echoedNonce.dispatch_evidence[0].nonce = "nonce-123"; + await withReceipt(echoedNonce, (path) => { + const result = run(path); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /nonce must bind parent thread, child thread, and spawn call/); + }); +}); + +test("rejects placeholder package and host provenance", async () => { + const placeholder = validReceipt(); + placeholder.dispatch_evidence[0].package_digest = `codex-runtime:${parent}`; + await withReceipt(placeholder, (path) => { + const result = run(path); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /package_digest/); + }); + + const proseVersion = validReceipt(); + proseVersion.dispatch_evidence[0].host_version = "codex native persisted spawn state"; + await withReceipt(proseVersion, (path) => { + const result = run(path); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /host_version/); + }); +}); + +test("rejects non-V2 spawn surface, direct override, and mismatched task identity", async () => { + const external = validReceipt(); + external.children[0].spawn.surface = "multi_agent_v1__spawn_agent"; + await withReceipt(external, (path) => { + const result = run(path); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /spawn surface/); + }); + + const directOverride = validReceipt(); + directOverride.children[0].spawn.model = "gpt-5.6-sol"; + await withReceipt(directOverride, (path) => { + const result = run(path); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /manually override model/); + }); + + const mismatchedTask = validReceipt(); + mismatchedTask.children[0].task_name = "unrelated_worker"; + await withReceipt(mismatchedTask, (path) => { + const result = run(path); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /canonical_task mismatch|task_name mismatch/); + }); +}); + +test("rejects missing, changed, and oversized bounded task input evidence", async () => { + const missing = validReceipt(); + delete missing.children[0].input; + await withReceipt(missing, (path) => { + const result = run(path); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /expected message hash|expected message_sha256|input message_sha256/); + }); + + const changed = validReceipt(); + changed.children[0].input.message_sha256 = sha256("different message"); + await withReceipt(changed, (path) => { + const result = run(path); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /message_sha256 mismatch/); + }); + + const oversized = validReceipt(); + oversized.children[0].input.message_bytes = oversized.children[0].input.max_message_bytes + 1; + oversized.dispatch_evidence[0].requested_dispatch.message_bytes = oversized.children[0].input.message_bytes; + await withReceipt(oversized, (path) => { + const result = run(path); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /exceeds max_message_bytes/); + }); +}); + +test("rejects raw refs that do not bind parent and child Codex sessions", async () => { + const missingChildSession = validReceipt(); + missingChildSession.dispatch_evidence[0].raw_evidence_refs = [ + `codex-session:${parent}.jsonl`, + `state_5.sqlite:thread_spawn_edges:${parent}:${worker}`, + "spawn_call:call-worker", + ]; + await withReceipt(missingChildSession, (path) => { + const result = run(path); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /raw evidence refs must bind/); + }); +}); + +test("accepts custom role receipts with explicit expected semantic roles and profiles", async () => { + const implementer = child("implementer", "switchloom_implementer", "implementer", worker, "gpt-5.6-terra", "high", "switchloom_implementer"); + const customReviewer = child("reviewer", "switchloom_reviewer", "reviewer", reviewer, "gpt-5.6-sol", "high", "switchloom_reviewer"); + const custom = validReceipt(); + custom.children = [implementer, customReviewer]; + custom.dispatch_evidence = custom.children.map((entry) => ({ + schema_version: 1, + package_digest: packageDigest, + host_version: hostVersion, + requested_dispatch: { + semantic_role: entry.kind, + profile: entry.profile, + model: entry.state.model, + effort: entry.state.reasoning_effort, + agent_type: entry.agent_type, + fork_turns: { mode: "none" }, + message_sha256: entry.input.message_sha256, + message_encoding: entry.input.message_encoding ?? "plaintext", + message_plaintext_verdict: entry.input.message_plaintext_verdict ?? "deterministic", + message_plaintext_intent_sha256: entry.input.message_plaintext_intent_sha256, + message_bytes: entry.input.message_bytes, + max_message_bytes: entry.input.max_message_bytes, + }, + child_identity: { + host: "codex", + role: entry.kind, + agent_role: entry.agent_type, + agent_type: entry.agent_type, + task_name: entry.task_name, + }, + effective_model: entry.state.model, + effective_effort: entry.state.reasoning_effort, + nonce: `${parent}:${entry.child_thread_id}:${entry.spawn.call_id}`, + raw_evidence_refs: [ + `codex-session:${parent}.jsonl`, + `codex-session:${entry.child_thread_id}.jsonl`, + `state_5.sqlite:thread_spawn_edges:${parent}:${entry.child_thread_id}`, + `spawn_call:${entry.spawn.call_id}`, + ], + verdict: "deterministic", + })); + + const expected = { + package_digest: packageDigest, + host_version: hostVersion, + children: [ + { + semantic_role: "implementer", + profile: "switchloom_implementer", + agent_type: "switchloom_implementer", + task_name: "implementer", + message_sha256: implementer.input.message_sha256, + max_message_bytes: implementer.input.max_message_bytes, + model: "gpt-5.6-terra", + effort: "high", + }, + { + semantic_role: "reviewer", + profile: "switchloom_reviewer", + agent_type: "switchloom_reviewer", + task_name: "reviewer", + message_sha256: customReviewer.input.message_sha256, + max_message_bytes: customReviewer.input.max_message_bytes, + model: "gpt-5.6-sol", + effort: "high", + }, + ], + }; + await withReceipt(custom, async (path) => { + await withReceipt(expected, (expectPath) => { + const result = runWithExpect(path, expectPath); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /runtime evidence validation passed/); + }); + }); +}); diff --git a/retained-evidence/evidence-validator-parity/legacy/validate-opencode-runtime-evidence.mjs b/retained-evidence/evidence-validator-parity/legacy/validate-opencode-runtime-evidence.mjs new file mode 100644 index 0000000..714b18a --- /dev/null +++ b/retained-evidence/evidence-validator-parity/legacy/validate-opencode-runtime-evidence.mjs @@ -0,0 +1,186 @@ +#!/usr/bin/env node +import { readFileSync, writeFileSync } from "node:fs"; + +function usage() { + console.error("usage: validate-opencode-runtime-evidence.mjs --jsonl --invocation --receipt --package-digest --host-version --profile --model --variant --worker "); + process.exit(2); +} + +function arg(name) { + const index = process.argv.indexOf(`--${name}`); + if (index === -1 || index + 1 >= process.argv.length) usage(); + return process.argv[index + 1]; +} + +function parseJsonl(path) { + return readFileSync(path, "utf8") + .split(/\n+/) + .filter((line) => line.trim()) + .map((line, index) => { + try { + return JSON.parse(line); + } catch (error) { + throw new Error(`host output line ${index + 1} is not JSON: ${error.message}`); + } + }); +} + +function visit(value, fn, path = []) { + if (Array.isArray(value)) { + value.forEach((entry, index) => visit(entry, fn, path.concat(String(index)))); + return; + } + if (value && typeof value === "object") { + for (const [key, entry] of Object.entries(value)) { + fn(key, entry, path.concat(key), value); + visit(entry, fn, path.concat(key)); + } + } +} + +function stringValueForKeys(value, keys) { + let found = null; + visit(value, (key, entry) => { + if (found === null && keys.includes(key) && typeof entry === "string") found = entry; + }); + return found; +} + +function idValue(value) { + return stringValueForKeys(value, ["id", "toolCallID", "toolCallId", "call_id", "callId", "taskID", "taskId"]); +} + +function eventContains(value, needle) { + return JSON.stringify(value).includes(needle); +} + +function eventMentionsTask(value) { + let structured = false; + visit(value, (key, entry) => { + const keyLower = key.toLowerCase(); + if (typeof entry === "string") { + const valueLower = entry.toLowerCase(); + if ((keyLower.includes("tool") || keyLower.includes("type") || keyLower.includes("name")) && valueLower.includes("task")) { + structured = true; + } + } + }); + return structured; +} + +function eventAgent(value) { + return stringValueForKeys(value, ["agent", "agentName", "agent_name", "subagent", "subagentName", "taskAgent", "task_agent"]); +} + +function eventIsResult(value) { + let result = false; + visit(value, (key, entry) => { + const keyLower = key.toLowerCase(); + if (typeof entry === "string") { + const valueLower = entry.toLowerCase(); + if ((keyLower.includes("type") || keyLower.includes("event") || keyLower.includes("kind")) && valueLower.includes("result")) { + result = true; + } + if ((keyLower.includes("tool") || keyLower.includes("name")) && valueLower.includes("result")) { + result = true; + } + } + }); + return result; +} + +function firstModel(value) { + return stringValueForKeys(value, ["model", "modelID", "modelId", "providerModel", "provider_model"]); +} + +function firstVariant(value) { + return stringValueForKeys(value, ["variant", "effort", "reasoningEffort", "reasoning_effort"]); +} + +const jsonlPath = arg("jsonl"); +const invocationPath = arg("invocation"); +const receiptPath = arg("receipt"); +const packageDigest = arg("package-digest"); +const hostVersion = arg("host-version"); +const profile = arg("profile"); +const requestedModel = arg("model"); +const requestedVariant = arg("variant"); +const worker = arg("worker"); + +const invocation = JSON.parse(readFileSync(invocationPath, "utf8")); +const nonce = invocation.nonce; +if (!nonce) throw new Error("requested invocation must include nonce"); + +const events = parseJsonl(jsonlPath); +if (events.length === 0) throw new Error("host output has no JSON events"); + +const taskInvocations = events + .map((event, index) => ({ event, index, id: idValue(event) })) + .filter(({ event, id }) => id && eventContains(event, worker) && eventMentionsTask(event)); +if (taskInvocations.length === 0) { + throw new Error(`no structured Task invocation with non-null call ID targeted ${worker}`); +} + +const taskIds = new Set(taskInvocations.map(({ id }) => id).filter(Boolean)); +const mismatchedResult = events + .map((event) => ({ event, id: idValue(event), agent: eventAgent(event), result: eventIsResult(event) })) + .find(({ event, id, agent, result }) => eventContains(event, nonce) && result && id && taskIds.has(id) && agent && agent !== worker); +if (mismatchedResult) { + throw new Error(`worker result came from ${mismatchedResult.agent}, expected ${worker}`); +} +const workerResults = events + .map((event, index) => ({ event, index, id: idValue(event), agent: eventAgent(event), result: eventIsResult(event) })) + .filter(({ event, id, agent, result }) => { + if (!eventContains(event, nonce)) return false; + if (!result) return false; + if (!id || !taskIds.has(id)) return false; + if (agent !== worker) return false; + return true; + }); +if (workerResults.length === 0) { + throw new Error(`nonce ${nonce} was not returned by an explicit ${worker} Task result with matching call ID`); +} + +const workerEvent = workerResults[0].event; +const effectiveModel = firstModel(workerEvent) ?? taskInvocations.map(({ event }) => firstModel(event)).find(Boolean) ?? null; +const effectiveVariant = firstVariant(workerEvent) ?? taskInvocations.map(({ event }) => firstVariant(event)).find(Boolean) ?? null; +const observedAgent = eventAgent(workerEvent); +if (!observedAgent) { + throw new Error(`worker result is missing explicit child identity for ${worker}`); +} +if (observedAgent !== worker) { + throw new Error(`worker result came from ${observedAgent}, expected ${worker}`); +} + +const receipt = { + schema_version: 1, + package_digest: packageDigest, + host_version: hostVersion, + requested_dispatch: { + semantic_role: "worker", + profile, + model: requestedModel, + effort: requestedVariant, + agent_type: worker, + fork_turns: { mode: "none" }, + }, + child_identity: { + host: "opencode", + role: "worker", + agent_role: observedAgent, + agent_type: observedAgent, + task_name: observedAgent, + }, + nonce, + raw_evidence_refs: [ + "requested-invocation:requested-invocation.json#argv", + "host-output:host-output.jsonl#task", + "host-stderr:host-output.stderr", + ], + verdict: "advisory", +}; +if (effectiveModel) receipt.effective_model = effectiveModel; +if (effectiveVariant) receipt.effective_effort = effectiveVariant; + +writeFileSync(receiptPath, `${JSON.stringify(receipt, null, 2)}\n`); +console.log("opencode runtime evidence validated"); diff --git a/retained-evidence/evidence-validator-parity/legacy/validate-opencode-runtime-evidence.test.mjs b/retained-evidence/evidence-validator-parity/legacy/validate-opencode-runtime-evidence.test.mjs new file mode 100644 index 0000000..c7a7ddf --- /dev/null +++ b/retained-evidence/evidence-validator-parity/legacy/validate-opencode-runtime-evidence.test.mjs @@ -0,0 +1,79 @@ +import { mkdtemp, writeFile, readFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { spawnSync } from "node:child_process"; +import { test } from "node:test"; +import assert from "node:assert/strict"; + +const script = process.env.VALIDATOR_MODE === "rust" + ? new URL("../../../scripts/validate-opencode-runtime-evidence.mjs", import.meta.url).pathname + : new URL("./validate-opencode-runtime-evidence.mjs", import.meta.url).pathname; + +async function runFixture(name, events) { + const dir = await mkdtemp(join(tmpdir(), `opencode-evidence-${name}-`)); + const jsonl = join(dir, "host-output.jsonl"); + const invocation = join(dir, "requested-invocation.json"); + const receipt = join(dir, "dispatch-evidence.json"); + await writeFile(jsonl, events.map((event) => JSON.stringify(event)).join("\n")); + await writeFile(invocation, JSON.stringify({ nonce: "nonce-123" })); + const result = spawnSync(process.execPath, [ + script, + "--jsonl", jsonl, + "--invocation", invocation, + "--receipt", receipt, + "--package-digest", "sha256:abc", + "--host-version", "1.14.17", + "--profile", "opencode-worker", + "--model", "opencode/gpt-5-nano", + "--variant", "low", + "--worker", "model-routing-preset-worker", + ], { cwd: process.cwd(), encoding: "utf8" }); + return { ...result, receipt }; +} + +test("accepts correlated OpenCode Task evidence for the worker", async () => { + const result = await runFixture("valid", [ + { type: "tool_call", tool: "Task", id: "call-1", agent: "model-routing-preset-worker", model: "opencode/gpt-5-nano", variant: "low" }, + { type: "tool_result", toolCallID: "call-1", agent: "model-routing-preset-worker", result: "nonce-123", model: "opencode/gpt-5-nano", variant: "low" }, + ]); + assert.equal(result.status, 0, result.stderr); + const receipt = JSON.parse(await readFile(result.receipt, "utf8")); + assert.equal(receipt.child_identity.agent_role, "model-routing-preset-worker"); + assert.equal(receipt.effective_model, "opencode/gpt-5-nano"); + assert.equal(receipt.effective_effort, "low"); +}); + +test("rejects a driver-only echoed nonce", async () => { + const result = await runFixture("driver-echo", [ + { type: "message", agent: "model-routing-preset-driver", result: "nonce-123", model: "opencode/gpt-5-nano" }, + ]); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /no structured Task invocation/); +}); + +test("rejects a task invocation whose result does not come from the worker", async () => { + const result = await runFixture("mismatched-child", [ + { type: "tool_call", tool: "Task", id: "call-1", agent: "model-routing-preset-worker", model: "opencode/gpt-5-nano" }, + { type: "tool_result", toolCallID: "call-1", agent: "other-worker", result: "nonce-123" }, + ]); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /worker result came from other-worker/); +}); + +test("rejects an echoed nonce in the requested input event", async () => { + const result = await runFixture("input-echo", [ + { type: "input", prompt: "Return nonce-123 using model-routing-preset-worker" }, + { type: "tool_call", tool: "Task", id: "call-1", agent: "model-routing-preset-worker" }, + ]); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /was not returned by an explicit/); +}); + +test("rejects a later driver message that mentions the worker and nonce", async () => { + const result = await runFixture("later-driver-echo", [ + { type: "tool_call", tool: "Task", id: "call-1", agent: "model-routing-preset-worker", model: "opencode/gpt-5-nano" }, + { type: "message", agent: "model-routing-preset-driver", text: "model-routing-preset-worker returned nonce-123" }, + ]); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /was not returned by an explicit/); +}); diff --git a/retained-evidence/evidence-validator-parity/legacy/validate-pi-runtime-evidence.mjs b/retained-evidence/evidence-validator-parity/legacy/validate-pi-runtime-evidence.mjs new file mode 100644 index 0000000..6a46c6a --- /dev/null +++ b/retained-evidence/evidence-validator-parity/legacy/validate-pi-runtime-evidence.mjs @@ -0,0 +1,181 @@ +#!/usr/bin/env node +import { createHash } from "node:crypto"; +import { readFileSync, writeFileSync } from "node:fs"; + +function usage() { + console.error("usage: validate-pi-runtime-evidence.mjs --workflow --invocation --stdout --stderr --workflow-receipt --dispatch-receipt --package-digest --host-version --profile --model --thinking --agent-type "); + process.exit(2); +} + +function arg(name) { + const index = process.argv.indexOf(`--${name}`); + if (index === -1 || index + 1 >= process.argv.length) usage(); + return process.argv[index + 1]; +} + +function readJson(path, label) { + try { + return JSON.parse(readFileSync(path, "utf8")); + } catch (error) { + throw new Error(`${label} is not valid JSON: ${error.message}`); + } +} + +function assert(condition, message) { + if (!condition) throw new Error(message); +} + +function arraysEqual(left, right) { + return Array.isArray(left) && Array.isArray(right) && left.length === right.length && left.every((value, index) => value === right[index]); +} + +function expectedInvocationArgv(workflowArgv) { + return ["env", "PI_CODING_AGENT_DIR=.pi-agent", "PI_OFFLINE=1", ...workflowArgv]; +} + +function optionValue(argv, option) { + const index = argv.indexOf(option); + if (index === -1 || index + 1 >= argv.length) return null; + return argv[index + 1]; +} + +function sha256Text(value) { + return `sha256:${createHash("sha256").update(value).digest("hex")}`; +} + +const workflowPath = arg("workflow"); +const invocationPath = arg("invocation"); +const stdoutPath = arg("stdout"); +const stderrPath = arg("stderr"); +const workflowReceiptPath = arg("workflow-receipt"); +const dispatchReceiptPath = arg("dispatch-receipt"); +const packageDigest = arg("package-digest"); +const hostVersion = arg("host-version"); +const profile = arg("profile"); +const requestedModel = arg("model"); +const requestedThinking = arg("thinking"); +const agentType = arg("agent-type"); + +const workflow = readJson(workflowPath, "workflow"); +const invocation = readJson(invocationPath, "requested invocation"); +const stdout = readFileSync(stdoutPath, "utf8").trim(); +readFileSync(stderrPath, "utf8"); + +const nonce = invocation.nonce; +assert(typeof nonce === "string" && nonce.length > 0, "requested invocation must include nonce"); +assert(workflow.schema_version === 1, "workflow schema_version must be 1"); +assert(workflow.runner === "pi", "workflow runner must be pi"); +assert(workflow.runtime_class === "external-runner", "workflow runtime_class must be external-runner"); + +const args = workflow.arguments; +assert(args && typeof args === "object", "workflow must include typed arguments"); +assert(args.agent_type === agentType, `workflow agent_type ${args.agent_type} does not match ${agentType}`); +assert(args.provider_model === requestedModel, `workflow provider_model ${args.provider_model} does not match ${requestedModel}`); +assert(args.thinking === requestedThinking, `workflow thinking ${args.thinking} does not match ${requestedThinking}`); +assert(args.task?.semantic_role === "worker", "workflow task semantic_role must be worker"); +assert(args.task?.returns === "nonce-only", "workflow task must require nonce-only return"); +assert(args.isolation?.session === "none", "workflow isolation must disable session persistence"); +assert(args.isolation?.tools === "none", "workflow isolation must disable tools"); +assert(args.isolation?.extensions === "none", "workflow isolation must disable extensions"); +assert(args.isolation?.skills === "none", "workflow isolation must disable skills"); +assert(Array.isArray(workflow.process?.argv), "workflow process argv must be recorded"); +for (const required of ["--print", "--no-session", "--no-tools", "--no-extensions", "--no-skills", "--provider", "--model", "--thinking"]) { + assert(workflow.process.argv.includes(required), `workflow process argv must include ${required}`); +} +assert(Array.isArray(invocation.argv), "requested invocation must include argv"); +assert(invocation.env && typeof invocation.env === "object", "requested invocation must include env"); + +const expectedArgv = expectedInvocationArgv(workflow.process.argv); +assert( + arraysEqual(invocation.argv, expectedArgv), + `requested invocation argv does not match workflow process argv with report-local env boundary`, +); +assert(invocation.env.PI_CODING_AGENT_DIR === ".pi-agent", "requested invocation must set report-local PI_CODING_AGENT_DIR=.pi-agent"); +assert(invocation.env.PI_OFFLINE === "1", "requested invocation must set PI_OFFLINE=1"); +assert(invocation.argv[1] === "PI_CODING_AGENT_DIR=.pi-agent", "requested invocation argv must set report-local PI_CODING_AGENT_DIR=.pi-agent"); +assert(invocation.argv[2] === "PI_OFFLINE=1", "requested invocation argv must set PI_OFFLINE=1"); + +const executedArgv = invocation.argv.slice(3); +assert(executedArgv[0] === "pi", "requested invocation must execute pi"); +assert(executedArgv.includes("--print"), "requested invocation must use Pi print mode"); +assert(executedArgv.includes("--no-session"), "requested invocation must disable session persistence"); +assert(executedArgv.includes("--no-tools"), "requested invocation must disable tools"); +assert(executedArgv.includes("--no-extensions"), "requested invocation must disable extensions"); +assert(executedArgv.includes("--no-skills"), "requested invocation must disable skills"); +const executedProvider = optionValue(executedArgv, "--provider"); +const executedModel = optionValue(executedArgv, "--model"); +const executedThinking = optionValue(executedArgv, "--thinking"); +assert(executedProvider && executedModel, "requested invocation must include provider and model"); +assert(executedThinking, "requested invocation must include thinking"); +assert(`${executedProvider}/${executedModel}` === requestedModel, "requested invocation provider/model does not match requested model"); +assert(executedThinking === requestedThinking, "requested invocation thinking does not match requested thinking"); + +const expectedPromptHash = sha256Text(`Return only this nonce and no other text: ${nonce}`); +assert(invocation.prompt_sha256 === expectedPromptHash, "requested invocation prompt hash does not match nonce task"); + +const normalizedStdout = stdout.replace(/\s+/g, " ").trim(); +assert(normalizedStdout === nonce, `Pi child output did not exactly return nonce ${nonce}`); + +const workflowReceipt = { + schema_version: 1, + runner: "pi", + workflow: workflow.workflow, + runtime_class: "external-runner", + package_digest: packageDigest, + host_version: hostVersion, + invocation: { + argv: invocation.argv, + env: invocation.env, + prompt_sha256: invocation.prompt_sha256, + }, + requested: { + semantic_role: "worker", + profile, + agent_type: agentType, + provider_model: requestedModel, + thinking: requestedThinking, + isolation: args.isolation, + }, + observed: { + stdout_ref: "host-output:host-output.txt", + stderr_ref: "host-stderr:host-output.stderr", + nonce, + }, + verdict: "advisory", +}; + +const dispatchReceipt = { + schema_version: 1, + package_digest: packageDigest, + host_version: hostVersion, + requested_dispatch: { + semantic_role: "worker", + profile, + model: requestedModel, + effort: requestedThinking, + agent_type: agentType, + fork_turns: { mode: "none" }, + }, + child_identity: { + host: "pi", + role: "worker", + agent_role: agentType, + agent_type: agentType, + task_name: "model-routing-preset-runner", + }, + effective_model: requestedModel, + effective_effort: requestedThinking, + nonce, + raw_evidence_refs: [ + "workflow:workflow.json#arguments", + "requested-invocation:requested-invocation.json#argv", + "workflow-receipt:workflow-receipt.json", + "host-output:host-output.txt", + "host-stderr:host-output.stderr", + ], + verdict: "advisory", +}; + +writeFileSync(workflowReceiptPath, `${JSON.stringify(workflowReceipt, null, 2)}\n`); +writeFileSync(dispatchReceiptPath, `${JSON.stringify(dispatchReceipt, null, 2)}\n`); +console.log("pi runtime evidence validated"); diff --git a/retained-evidence/evidence-validator-parity/legacy/validate-pi-runtime-evidence.test.mjs b/retained-evidence/evidence-validator-parity/legacy/validate-pi-runtime-evidence.test.mjs new file mode 100644 index 0000000..0025bed --- /dev/null +++ b/retained-evidence/evidence-validator-parity/legacy/validate-pi-runtime-evidence.test.mjs @@ -0,0 +1,172 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { test } from "node:test"; + +const script = process.env.VALIDATOR_MODE === "rust" + ? new URL("../../../scripts/validate-pi-runtime-evidence.mjs", import.meta.url).pathname + : new URL("./validate-pi-runtime-evidence.mjs", import.meta.url).pathname; + +function promptHash(nonce) { + return `sha256:${createHash("sha256").update(`Return only this nonce and no other text: ${nonce}`).digest("hex")}`; +} + +function fixture(name, overrides = {}) { + const dir = mkdtempSync(join(tmpdir(), `pi-evidence-${name}-`)); + mkdirSync(dir, { recursive: true }); + const workflow = { + schema_version: 1, + workflow: "model-routing-preset-runner", + runner: "pi", + runtime_class: "external-runner", + arguments: { + agent_type: overrides.agentType ?? "switchloom-pi-worker", + provider_model: overrides.model ?? "openai/gpt-4o-mini", + thinking: overrides.thinking ?? "low", + isolation: { + session: overrides.session ?? "none", + tools: overrides.tools ?? "none", + extensions: "none", + skills: "none", + agent_dir: "report-workdir/.pi-agent", + }, + task: { + semantic_role: "worker", + profile: "pi-worker", + returns: overrides.returns ?? "nonce-only", + }, + }, + process: { + argv: ["pi", "--print", "--no-session", "--no-tools", "--no-extensions", "--no-skills", "--provider", "openai", "--model", "gpt-4o-mini", "--thinking", "low"], + state_boundary: "PI_CODING_AGENT_DIR is set to a report-local directory for every certification run", + }, + security: { + filesystem_tools: "disabled", + session_persistence: "disabled", + native_subagents: "not used", + }, + }; + const invocation = { + nonce: "nonce-123", + argv: overrides.invocationArgv ?? ["env", "PI_CODING_AGENT_DIR=.pi-agent", "PI_OFFLINE=1", ...workflow.process.argv], + env: overrides.invocationEnv ?? { PI_CODING_AGENT_DIR: ".pi-agent", PI_OFFLINE: "1" }, + prompt_sha256: overrides.promptSha256 ?? promptHash("nonce-123"), + }; + writeFileSync(join(dir, "workflow.json"), JSON.stringify(workflow, null, 2)); + writeFileSync(join(dir, "requested-invocation.json"), JSON.stringify(invocation, null, 2)); + writeFileSync(join(dir, "host-output.txt"), `${overrides.stdout ?? "nonce-123"}\n`); + writeFileSync(join(dir, "host-output.stderr"), overrides.stderr ?? ""); + return dir; +} + +function run(dir, extraArgs = []) { + return spawnSync(process.execPath, [ + script, + "--workflow", join(dir, "workflow.json"), + "--invocation", join(dir, "requested-invocation.json"), + "--stdout", join(dir, "host-output.txt"), + "--stderr", join(dir, "host-output.stderr"), + "--workflow-receipt", join(dir, "workflow-receipt.json"), + "--dispatch-receipt", join(dir, "dispatch-evidence.json"), + "--package-digest", "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "--host-version", "0.66.1", + "--profile", "pi-worker", + "--model", "openai/gpt-4o-mini", + "--thinking", "low", + "--agent-type", "switchloom-pi-worker", + ...extraArgs, + ], { encoding: "utf8" }); +} + +test("accepts a nonce-only Pi workflow receipt", () => { + const dir = fixture("valid"); + const result = run(dir); + assert.equal(result.status, 0, result.stderr); + const workflowReceipt = JSON.parse(readFileSync(join(dir, "workflow-receipt.json"), "utf8")); + const dispatchEvidence = JSON.parse(readFileSync(join(dir, "dispatch-evidence.json"), "utf8")); + assert.equal(workflowReceipt.observed.nonce, "nonce-123"); + assert.equal(workflowReceipt.requested.isolation.tools, "none"); + assert.equal(dispatchEvidence.child_identity.host, "pi"); + assert.equal(dispatchEvidence.requested_dispatch.agent_type, "switchloom-pi-worker"); + assert.equal(dispatchEvidence.verdict, "advisory"); +}); + +test("rejects non-nonce Pi output", () => { + const dir = fixture("bad-output", { stdout: "nonce-123 plus explanation" }); + const result = run(dir); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /did not exactly return nonce/); +}); + +test("rejects workflows that enable tools", () => { + const dir = fixture("tools", { tools: "default" }); + const result = run(dir); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /disable tools/); +}); + +test("rejects mismatched agent type", () => { + const dir = fixture("agent", { agentType: "switchloom-pi-driver" }); + const result = run(dir); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /agent_type/); +}); + +test("rejects workflows without nonce-only task contract", () => { + const dir = fixture("task", { returns: "summary" }); + const result = run(dir); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /nonce-only/); +}); + +test("rejects replayed invocation argv that changes provider, model, and isolation", () => { + const dir = fixture("replay", { + invocationArgv: [ + "env", + "PI_CODING_AGENT_DIR=/tmp/shared-agent", + "PI_OFFLINE=1", + "pi", + "--print", + "--provider", + "anthropic", + "--model", + "claude-opus-4-5", + ], + invocationEnv: { PI_CODING_AGENT_DIR: "/tmp/shared-agent", PI_OFFLINE: "1" }, + }); + const result = run(dir); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /argv does not match workflow process argv|PI_CODING_AGENT_DIR/); +}); + +test("rejects invocation argv missing thinking", () => { + const dir = fixture("missing-thinking", { + invocationArgv: ["env", "PI_CODING_AGENT_DIR=.pi-agent", "PI_OFFLINE=1", "pi", "--print", "--no-session", "--no-tools", "--no-extensions", "--no-skills", "--provider", "openai", "--model", "gpt-4o-mini"], + }); + const result = run(dir); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /argv does not match workflow process argv|thinking/); +}); + +test("rejects invocation env outside the report-local Pi boundary", () => { + const workflowArgv = ["pi", "--print", "--no-session", "--no-tools", "--no-extensions", "--no-skills", "--provider", "openai", "--model", "gpt-4o-mini", "--thinking", "low"]; + const dir = fixture("bad-env", { + invocationArgv: ["env", "PI_CODING_AGENT_DIR=.pi-agent", "PI_OFFLINE=1", ...workflowArgv], + invocationEnv: { PI_CODING_AGENT_DIR: "/tmp/shared-agent", PI_OFFLINE: "1" }, + }); + const result = run(dir); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /PI_CODING_AGENT_DIR/); +}); + +test("rejects prompt hashes that do not bind to the nonce task", () => { + const dir = fixture("bad-prompt", { + promptSha256: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + }); + const result = run(dir); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /prompt hash/); +}); diff --git a/retained-evidence/evidence-validator-parity/report.json b/retained-evidence/evidence-validator-parity/report.json new file mode 100644 index 0000000..573a333 --- /dev/null +++ b/retained-evidence/evidence-validator-parity/report.json @@ -0,0 +1,50 @@ +{ + "schema_version": 1, + "generated_at": "2026-07-21T15:55:00Z", + "claim": "The same deterministic fixture generators and input bytes were evaluated by the frozen legacy validators and the canonical Rust validators.", + "implementations": [ + { "path": "retained-evidence/evidence-validator-parity/legacy/validate-codex-runtime-evidence.mjs", "sha256": "6120612503d08505984c6177185b373008db23ff52a4e55e1560ff6f0f9dc554", "owner": "frozen-legacy" }, + { "path": "retained-evidence/evidence-validator-parity/legacy/validate-opencode-runtime-evidence.mjs", "sha256": "a89826177bcfd97c595f2a08dc32aa0664629c48df9156398954026202333d14", "owner": "frozen-legacy" }, + { "path": "retained-evidence/evidence-validator-parity/legacy/validate-pi-runtime-evidence.mjs", "sha256": "ff42e19fe139bee3c329aaaa00dfbd2a92eef69bc985a758cc604eef63d6e909", "owner": "frozen-legacy" }, + { "path": "xtask/src/certify/codex.rs", "sha256": "11c89f32acdbec66c2a428b76c1de552216286e7c3eabd57a1fe72ba900c9ddf", "owner": "canonical-rust" }, + { "path": "xtask/src/certify/codex_spawn.rs", "sha256": "e1e31b0b1413a1520938a226cf8c658f474875d78d3d51c0a5e9c0786a13278c", "owner": "canonical-rust" }, + { "path": "xtask/src/certify/opencode.rs", "sha256": "c08b2531418bea9610a47043f04ccbc10c4a3a60444095a4613bd9b7185a0c62", "owner": "canonical-rust" }, + { "path": "xtask/src/certify/pi.rs", "sha256": "000b152e90377a7ef6f5074f1362ae1d8131429084d990b1e3e922d493b38e58", "owner": "canonical-rust" } + ], + "corpus": [ + { "path": "retained-evidence/evidence-validator-parity/legacy/validate-codex-runtime-evidence.test.mjs", "sha256": "c770295b6dda3c9581fa78120e73631d0debea91a062832f68b5b63f24164ea0" }, + { "path": "retained-evidence/evidence-validator-parity/legacy/validate-opencode-runtime-evidence.test.mjs", "sha256": "23bf2cbfd2ab166fbf764a7451c80fa89ddf4ee9bc1558d8dcf7fd712a5fd25e" }, + { "path": "retained-evidence/evidence-validator-parity/legacy/validate-pi-runtime-evidence.test.mjs", "sha256": "0bfd07e8bffec000accdbd2f67233d6082e6bb5663994ef9d7d40250e69971b2" } + ], + "cases": [ + { "name": "codex-valid-correlated", "expected": "accept", "error_class": "none" }, + { "name": "codex-prose-or-incomplete", "expected": "reject", "error_class": "json-or-completion" }, + { "name": "codex-missing-source-or-uncorrelated-state", "expected": "reject", "error_class": "source-correlation" }, + { "name": "codex-inherited-sol-medium", "expected": "reject", "error_class": "effective-routing" }, + { "name": "codex-missing-or-uncorrelated-dispatch", "expected": "reject", "error_class": "dispatch-correlation" }, + { "name": "codex-placeholder-provenance", "expected": "reject", "error_class": "package-or-host-provenance" }, + { "name": "codex-non-v2-override-or-task-mismatch", "expected": "reject", "error_class": "spawn-contract" }, + { "name": "codex-missing-changed-or-oversized-input", "expected": "reject", "error_class": "bounded-input" }, + { "name": "codex-uncorrelated-raw-refs", "expected": "reject", "error_class": "raw-reference-correlation" }, + { "name": "codex-custom-explicit-roles", "expected": "accept", "error_class": "none" }, + { "name": "opencode-valid-correlated-task", "expected": "accept", "error_class": "none" }, + { "name": "opencode-driver-only-echo", "expected": "reject", "error_class": "missing-structured-task" }, + { "name": "opencode-mismatched-child", "expected": "reject", "error_class": "child-identity" }, + { "name": "opencode-input-echo", "expected": "reject", "error_class": "missing-correlated-result" }, + { "name": "opencode-later-driver-echo", "expected": "reject", "error_class": "missing-correlated-result" }, + { "name": "pi-valid-nonce-only", "expected": "accept", "error_class": "none" }, + { "name": "pi-prose-output", "expected": "reject", "error_class": "nonce-only-output" }, + { "name": "pi-tools-enabled", "expected": "reject", "error_class": "isolation" }, + { "name": "pi-agent-type-mismatch", "expected": "reject", "error_class": "child-identity" }, + { "name": "pi-non-nonce-task", "expected": "reject", "error_class": "task-contract" }, + { "name": "pi-replayed-argv", "expected": "reject", "error_class": "invocation-correlation" }, + { "name": "pi-missing-thinking", "expected": "reject", "error_class": "invocation-thinking" }, + { "name": "pi-shared-agent-dir", "expected": "reject", "error_class": "state-boundary" }, + { "name": "pi-unbound-prompt-hash", "expected": "reject", "error_class": "prompt-correlation" } + ], + "results": { + "legacy": { "harness_exit": 0, "passed": 24, "failed": 0 }, + "rust": { "harness_exit": 0, "passed": 24, "failed": 0 }, + "semantic_agreement": "24/24" + } +} diff --git a/scripts/build-release.sh b/scripts/build-release.sh index b9f63c7..50bfaf9 100755 --- a/scripts/build-release.sh +++ b/scripts/build-release.sh @@ -2,72 +2,11 @@ set -eu cd "$(dirname "$0")/.." - -version="$(sed -n 's/^version = "\([^"]*\)"/\1/p' Cargo.toml | head -n 1)" - -detect_target() { - os="$(uname -s | tr '[:upper:]' '[:lower:]')" - arch="$(uname -m)" - - case "$os" in - darwin) os="darwin" ;; - linux) os="linux" ;; - *) - echo "unsupported OS: $os" >&2 - exit 1 - ;; - esac - - case "$arch" in - arm64 | aarch64) arch="arm64" ;; - x86_64 | amd64) arch="x86_64" ;; - *) - echo "unsupported architecture: $arch" >&2 - exit 1 - ;; - esac - - echo "$os-$arch" -} - -sha256_tool() { - if command -v shasum >/dev/null 2>&1; then - shasum -a 256 "$@" - elif command -v sha256sum >/dev/null 2>&1; then - sha256sum "$@" - else - echo "shasum or sha256sum is required" >&2 - exit 1 - fi -} - -target="${SWITCHLOOM_TARGET:-$(detect_target)}" -cargo_target="${SWITCHLOOM_CARGO_TARGET:-}" -target_dir="dist/switchloom-$version" -asset="switchloom-$target.tar.gz" - -rm -rf "$target_dir" "dist/$asset" -mkdir -p "$target_dir" - -if [ -n "$cargo_target" ]; then - cargo build --release --locked --target "$cargo_target" - built_bin="target/$cargo_target/release/model-routing" -else - cargo build --release --locked - built_bin="target/release/model-routing" +set -- release package +if [ -n "${SWITCHLOOM_TARGET:-}" ]; then + set -- "$@" --target "$SWITCHLOOM_TARGET" fi - -cp "$built_bin" "$target_dir/model-routing" -cp README.md LICENSE "$target_dir/" - -( - cd "$target_dir" - sha256_tool model-routing README.md LICENSE > SHA256SUMS -) - -( - cd "$target_dir" - tar -czf "../$asset" model-routing README.md LICENSE SHA256SUMS -) - -echo "release artifact: dist/$asset" +if [ -n "${SWITCHLOOM_CARGO_TARGET:-}" ]; then + set -- "$@" --cargo-target "$SWITCHLOOM_CARGO_TARGET" +fi +exec cargo run --quiet -p xtask -- "$@" diff --git a/scripts/check-evidence-validator-parity.mjs b/scripts/check-evidence-validator-parity.mjs new file mode 100644 index 0000000..e9a8e8c --- /dev/null +++ b/scripts/check-evidence-validator-parity.mjs @@ -0,0 +1,38 @@ +#!/usr/bin/env node +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { readFileSync } from "node:fs"; + +process.chdir(new URL("../", import.meta.url).pathname); +const report = JSON.parse(readFileSync("retained-evidence/evidence-validator-parity/report.json", "utf8")); +assert.equal(report.schema_version, 1); +assert.equal(report.cases.length, 24, "parity matrix must name exactly 24 cases"); +assert.equal(new Set(report.cases.map(({ name }) => name)).size, 24, "parity case names must be unique"); +assert.deepEqual(report.results.legacy, { harness_exit: 0, passed: 24, failed: 0 }); +assert.deepEqual(report.results.rust, { harness_exit: 0, passed: 24, failed: 0 }); +assert.equal(report.results.semantic_agreement, "24/24"); + +for (const artifact of [...report.implementations, ...report.corpus]) { + const digest = createHash("sha256").update(readFileSync(artifact.path)).digest("hex"); + assert.equal(digest, artifact.sha256, `digest changed for ${artifact.path}`); +} + +const tests = report.corpus.map(({ path }) => path); +for (const mode of ["legacy", "rust"]) { + const result = spawnSync(process.execPath, ["--test", ...tests], { + cwd: process.cwd(), + encoding: "utf8", + env: { ...process.env, VALIDATOR_MODE: mode }, + }); + if (result.status !== 0) { + process.stderr.write(result.stdout); + process.stderr.write(result.stderr); + } + assert.equal(result.status, 0, `${mode} validator corpus failed`); + assert.match(result.stdout, /tests 24/); + assert.match(result.stdout, /pass 24/); + assert.match(result.stdout, /fail 0/); +} + +console.log("evidence validator differential parity passed: 24/24 named cases, identical corpus generators, pinned implementation digests"); diff --git a/scripts/check-v0.3.0-migration-gate.sh b/scripts/check-v0.3.0-migration-gate.sh new file mode 100755 index 0000000..ce1ea9b --- /dev/null +++ b/scripts/check-v0.3.0-migration-gate.sh @@ -0,0 +1,144 @@ +#!/usr/bin/env bash +set -euo pipefail + +readonly expected_version="0.2.2" +readonly expected_tag="v0.2.2" +readonly expected_tag_commit="206ab2ba15724e438486ef5dfa1ee31888858c19" +readonly expected_npm_sha256="ace8594db2f972a754dbfd26590f3196fbd8abaf58648aef1407bc3c463eddc6" +readonly expected_native_sha256="363c8e146988f315fb46e30083523b82c3f61486631188ea30d218549931b8b6" +readonly expected_release_archive_sha256="74743a04809a0f625a791a1dc10f72e5f21a7ef5d6d99ad872d5740ebccbd6a9" +readonly expected_catalog_sha256="1e86d746a362de3de82dbd74579eef241fca0ca2a2f3278fa546d2e89f21ab5c" +readonly release_plan="pln-68a087d8" + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repo_root="$(git -C "$script_dir/.." rev-parse --show-toplevel)" +planr_root="${SWITCHLOOM_PLANR_ROOT:-$repo_root}" +cd "$repo_root" + +for command_name in git planr node npm gh brew curl tar shasum; do + if ! command -v "$command_name" >/dev/null 2>&1; then + printf 'migration gate: required command is unavailable: %s\n' "$command_name" >&2 + exit 1 + fi +done + +dirty_state="$(git status --porcelain=v1 --untracked-files=all)" +if [[ -n "$dirty_state" ]]; then + printf '%s\n' 'migration gate: candidate tree is dirty; refusing migration' >&2 + printf '%s\n' "$dirty_state" >&2 + exit 1 +fi + +resolved_tag_commit="$(git rev-parse "${expected_tag}^{commit}")" +if [[ "$resolved_tag_commit" != "$expected_tag_commit" ]]; then + printf 'migration gate: %s resolves to %s, expected %s\n' \ + "$expected_tag" "$resolved_tag_commit" "$expected_tag_commit" >&2 + exit 1 +fi +if ! git merge-base --is-ancestor "$expected_tag_commit" HEAD; then + printf 'migration gate: released commit %s is not an ancestor of HEAD\n' \ + "$expected_tag_commit" >&2 + exit 1 +fi + +package_version="$(node -p "JSON.parse(require('fs').readFileSync('package.json', 'utf8')).version")" +cargo_version="$(sed -nE 's/^version = "([^"]+)"$/\1/p' Cargo.toml | head -n 1)" +if [[ "$package_version" != "$expected_version" || "$cargo_version" != "$expected_version" ]]; then + printf 'migration gate: local versions disagree (npm=%s cargo=%s expected=%s)\n' \ + "$package_version" "$cargo_version" "$expected_version" >&2 + exit 1 +fi + +audit_json="$(cd "$planr_root" && planr plan audit "$release_plan" --json)" +AUDIT_JSON="$audit_json" node <<'NODE' +const audit = JSON.parse(process.env.AUDIT_JSON); +if (audit.holds !== true) { + console.error("migration gate: release plan does not hold"); + process.exit(1); +} +NODE + +npm_json="$(npm view "switchloom@$expected_version" version dist.tarball dist.shasum dist.integrity --json)" +npm_tarball_url="$(NPM_JSON="$npm_json" node <<'NODE' +const metadata = JSON.parse(process.env.NPM_JSON); +if (metadata.version !== "0.2.2") { + console.error(`migration gate: npm resolved ${metadata.version}, expected 0.2.2`); + process.exit(1); +} +if (metadata["dist.shasum"] !== "928fd2a847acc9f30a94ed96bf71500e41ea443d") { + console.error("migration gate: npm shasum changed"); + process.exit(1); +} +if (metadata["dist.integrity"] !== "sha512-+N8UCvDIIAkWsxWSfE9nerP6u9S9hl6vb3hH6CWkx2mUDGmqmdAm8tmU8rL9csCn5RITox3Ite8320OndB4R7A==") { + console.error("migration gate: npm integrity changed"); + process.exit(1); +} +process.stdout.write(metadata["dist.tarball"]); +NODE +)" + +npm_sha256="$(curl -fsSL "$npm_tarball_url" | shasum -a 256 | awk '{print $1}')" +npm_native_sha256="$(curl -fsSL "$npm_tarball_url" | \ + tar -xzOf - package/npm/native/darwin-arm64/model-routing | shasum -a 256 | awk '{print $1}')" +if [[ "$npm_sha256" != "$expected_npm_sha256" || "$npm_native_sha256" != "$expected_native_sha256" ]]; then + printf 'migration gate: npm bytes changed (tarball=%s native=%s)\n' \ + "$npm_sha256" "$npm_native_sha256" >&2 + exit 1 +fi + +release_json="$(gh release view "$expected_tag" --repo instructa/switchloom \ + --json tagName,isDraft,isPrerelease,assets)" +RELEASE_JSON="$release_json" node <<'NODE' +const release = JSON.parse(process.env.RELEASE_JSON); +const asset = release.assets.find(({name}) => name === "switchloom-darwin-arm64.tar.gz"); +if (release.tagName !== "v0.2.2" || release.isDraft || release.isPrerelease) { + console.error("migration gate: GitHub release is unresolved or not final"); + process.exit(1); +} +if (asset?.digest !== "sha256:74743a04809a0f625a791a1dc10f72e5f21a7ef5d6d99ad872d5740ebccbd6a9") { + console.error("migration gate: GitHub release asset digest changed"); + process.exit(1); +} +NODE + +release_archive_url="https://github.com/instructa/switchloom/releases/download/$expected_tag/switchloom-darwin-arm64.tar.gz" +release_archive_sha256="$(curl -fsSL "$release_archive_url" | shasum -a 256 | awk '{print $1}')" +release_native_sha256="$(curl -fsSL "$release_archive_url" | \ + tar -xzOf - model-routing | shasum -a 256 | awk '{print $1}')" +if [[ "$release_archive_sha256" != "$expected_release_archive_sha256" || \ + "$release_native_sha256" != "$expected_native_sha256" ]]; then + printf 'migration gate: GitHub bytes changed (archive=%s native=%s)\n' \ + "$release_archive_sha256" "$release_native_sha256" >&2 + exit 1 +fi + +brew_json="$(brew info --json=v2 instructa/tap/switchloom)" +BREW_JSON="$brew_json" node <<'NODE' +const formula = JSON.parse(process.env.BREW_JSON).formulae[0]; +if (formula?.versions?.stable !== "0.2.2") { + console.error("migration gate: Homebrew stable version is not 0.2.2"); + process.exit(1); +} +if (formula?.urls?.stable?.checksum !== "74743a04809a0f625a791a1dc10f72e5f21a7ef5d6d99ad872d5740ebccbd6a9") { + console.error("migration gate: Homebrew checksum disagrees with GitHub release"); + process.exit(1); +} +NODE + +website_catalog_sha256="$(curl -fsSL https://switchloom.ai/data/catalog.json | \ + shasum -a 256 | awk '{print $1}')" +local_catalog_sha256="$(shasum -a 256 website/data/catalog.json | awk '{print $1}')" +if [[ "$website_catalog_sha256" != "$expected_catalog_sha256" || \ + "$local_catalog_sha256" != "$expected_catalog_sha256" ]]; then + printf 'migration gate: website/local catalog bytes disagree (website=%s local=%s)\n' \ + "$website_catalog_sha256" "$local_catalog_sha256" >&2 + exit 1 +fi + +printf '%s\n' \ + "migration gate passed: release plan holds" \ + "tag commit: $expected_tag_commit" \ + "npm tarball sha256: $npm_sha256" \ + "npm/GitHub native sha256: $expected_native_sha256" \ + "GitHub/Homebrew archive sha256: $release_archive_sha256" \ + "website/local catalog sha256: $website_catalog_sha256" diff --git a/scripts/codex-standalone-oracle.sh b/scripts/codex-standalone-oracle.sh index b7b6189..29bcbe3 100755 --- a/scripts/codex-standalone-oracle.sh +++ b/scripts/codex-standalone-oracle.sh @@ -1,619 +1,5 @@ #!/usr/bin/env bash set -euo pipefail - repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -workdir="$(mktemp -d /private/tmp/model-routing-codex-standalone.XXXXXX)" -git -C "$workdir" init -q -codex_home_config_before_hash="" -codex_home_config_before_path="" -codex_auth_mode="${SWITCHLOOM_CODEX_AUTH_MODE:-current}" -if [ "$codex_auth_mode" = "current" ]; then - codex_home="${CODEX_HOME:-$HOME/.codex}" -elif [ "$codex_auth_mode" = "isolated" ]; then - codex_home="${SWITCHLOOM_CODEX_HOME:-/private/tmp/switchloom-codex-auth-home}" -else - printf 'unsupported SWITCHLOOM_CODEX_AUTH_MODE: %s\n' "$codex_auth_mode" >&2 - exit 1 -fi -codex_auth_wait_seconds="${SWITCHLOOM_CODEX_AUTH_WAIT_SECONDS:-0}" -package_target="$workdir/package-target" -package_src="$workdir/package-src" -install_root="$workdir/package-install" -external_routing_bin="${SWITCHLOOM_CODEX_ROUTING_BIN:-}" -external_package_digest="${SWITCHLOOM_CODEX_PACKAGE_DIGEST:-}" - -require_contains() { - local pattern="$1" - shift - if ! rg -F -q "$pattern" "$@"; then - printf 'missing expected pattern %s in %s\n' "$pattern" "$*" >&2 - exit 1 - fi -} - -require_absent() { - local pattern="$1" - shift - if rg -F -q "$pattern" "$@"; then - printf 'unexpected pattern %s in %s\n' "$pattern" "$*" >&2 - exit 1 - fi -} - -assert_isolated_codex_home() { - mkdir -p "$codex_home" - if [ -d "$codex_home/agents" ]; then - printf 'isolated CODEX_HOME must not contain agent registrations: %s/agents\n' "$codex_home" >&2 - exit 1 - fi - if find "$codex_home" -maxdepth 1 -name '*.config.toml' -type f | rg -q .; then - printf 'isolated CODEX_HOME must not contain profile config registrations: %s\n' "$codex_home" >&2 - find "$codex_home" -maxdepth 1 -name '*.config.toml' -type f >&2 - exit 1 - fi - if [ -f "$codex_home/config.toml" ]; then - require_absent '[agents.' "$codex_home/config.toml" - require_absent 'config_file = "./agents/' "$codex_home/config.toml" - require_absent 'config_file = "' "$codex_home/config.toml" - require_absent '[profiles.' "$codex_home/config.toml" - require_absent 'profile = ' "$codex_home/config.toml" - fi -} - -assert_current_codex_home_read_only_auth_shape() { - local search_paths=() - test -d "$codex_home" - if [ -f "$codex_home/config.toml" ]; then - search_paths+=("$codex_home/config.toml") - fi - if [ -d "$codex_home/agents" ]; then - search_paths+=("$codex_home/agents") - fi - while IFS= read -r -d '' profile_config; do - search_paths+=("$profile_config") - done < <(find "$codex_home" -maxdepth 1 -name '*.config.toml' -type f -print0) - - if [ "${#search_paths[@]}" -gt 0 ]; then - require_absent 'model_routing_terra_high' "${search_paths[@]}" - require_absent 'model_routing_sol_high' "${search_paths[@]}" - require_absent 'model-routing-terra-high.toml' "${search_paths[@]}" - require_absent 'model-routing-sol-high.toml' "${search_paths[@]}" - fi -} - -hash_file() { - shasum -a 256 "$1" | awk '{print $1}' -} - -hash_optional_file() { - if [ -f "$1" ]; then - hash_file "$1" - else - printf 'missing\n' - fi -} - -strip_codex_project_trust_entry() { - local input="$1" - local output="$2" - local header="[projects.\"$workdir\"]" - awk -v header="$header" ' - $0 == header { - skip = 1 - next - } - skip && $0 ~ /^\[/ { - skip = 0 - } - !skip { - print - } - ' "$input" > "$output" -} - -restore_current_codex_home_config() { - if [ "$codex_auth_mode" != "current" ]; then - return - fi - if [ ! -f "$codex_home_config_before_path" ]; then - return - fi - if [ ! -f "$codex_home/config.toml" ]; then - printf 'current CODEX_HOME config disappeared after authenticated Codex run: %s\n' "$codex_home/config.toml" \ - > "$workdir/codex-home-config-restore.txt" - return 1 - fi - - local stripped="$workdir/codex-home-config-after-stripped.toml" - strip_codex_project_trust_entry "$codex_home/config.toml" "$stripped" - if cmp -s "$codex_home_config_before_path" "$stripped"; then - cp "$codex_home_config_before_path" "$codex_home/config.toml" - if ! cmp -s "$codex_home_config_before_path" "$codex_home/config.toml"; then - printf 'failed to restore current CODEX_HOME config snapshot: %s\n' "$codex_home/config.toml" \ - > "$workdir/codex-home-config-restore.txt" - return 1 - fi - printf 'removed transient Codex trust entry for %s\n' "$workdir" \ - > "$workdir/codex-home-config-restore.txt" - else - { - printf 'current CODEX_HOME config changed beyond the transient trust entry for %s\n' "$workdir" - printf 'before: %s\n' "$codex_home_config_before_path" - printf 'after stripped: %s\n' "$stripped" - } > "$workdir/codex-home-config-restore.txt" - return 1 - fi -} - -cleanup_current_codex_home_config_on_exit() { - local status=$? - if ! restore_current_codex_home_config; then - if [ "$status" -eq 0 ]; then - status=1 - fi - fi - exit "$status" -} - -run_restore_current_codex_home_config_trap_child() { - codex_auth_mode="current" - codex_home="${SWITCHLOOM_CODEX_ORACLE_TEST_CHILD_HOME:?}" - codex_home_config_before_path="${SWITCHLOOM_CODEX_ORACLE_TEST_CHILD_BEFORE:?}" - trap cleanup_current_codex_home_config_on_exit EXIT - { - printf '[projects."%s"]\n' "$workdir" - printf 'trust_level = "trusted"\n' - } >> "$codex_home/config.toml" - exit 7 -} - -run_restore_current_codex_home_config_regression() { - local test_root="$workdir/restore-regression" - local trust_config="$test_root/trust-only/config.toml" - local mutation_config="$test_root/non-trust-mutation/config.toml" - local trap_config="$test_root/trap-failure/config.toml" - local trap_child_status - mkdir -p "$test_root/trust-only" "$test_root/non-trust-mutation" "$test_root/trap-failure" - - codex_auth_mode="current" - codex_home="$test_root/trust-only" - codex_home_config_before_path="$test_root/trust-only-before.toml" - printf '[features]\nlocal = true\n' > "$codex_home_config_before_path" - cp "$codex_home_config_before_path" "$trust_config" - { - printf '[projects."%s"]\n' "$workdir" - printf 'trust_level = "trusted"\n' - } >> "$trust_config" - restore_current_codex_home_config - cmp -s "$codex_home_config_before_path" "$trust_config" - - codex_home="$test_root/trap-failure" - codex_home_config_before_path="$test_root/trap-failure-before.toml" - printf '[features]\nlocal = true\n' > "$codex_home_config_before_path" - cp "$codex_home_config_before_path" "$trap_config" - set +e - SWITCHLOOM_CODEX_ORACLE_TEST_RESTORE_CHILD=1 \ - SWITCHLOOM_CODEX_ORACLE_TEST_CHILD_HOME="$codex_home" \ - SWITCHLOOM_CODEX_ORACLE_TEST_CHILD_BEFORE="$codex_home_config_before_path" \ - bash "$repo_root/scripts/codex-standalone-oracle.sh" \ - > "$test_root/trap-child.stdout" \ - 2> "$test_root/trap-child.stderr" - trap_child_status=$? - set -e - if [ "$trap_child_status" -ne 7 ]; then - printf 'restore trap regression preserved status %s, expected 7\n' "$trap_child_status" >&2 - exit 1 - fi - cmp -s "$codex_home_config_before_path" "$trap_config" - - codex_home="$test_root/non-trust-mutation" - codex_home_config_before_path="$test_root/non-trust-mutation-before.toml" - printf '[features]\nlocal = true\n' > "$codex_home_config_before_path" - cp "$codex_home_config_before_path" "$mutation_config" - { - printf '[projects."%s"]\n' "$workdir" - printf 'trust_level = "trusted"\n' - printf '[profiles.default]\n' - printf 'model = "gpt-5.6-sol"\n' - } >> "$mutation_config" - if restore_current_codex_home_config; then - printf 'restore regression failed to reject non-trust mutation\n' >&2 - exit 1 - fi - - printf 'restore regression passed\n' -} - -if [ "${SWITCHLOOM_CODEX_ORACLE_TEST_RESTORE_CHILD:-0}" = "1" ]; then - run_restore_current_codex_home_config_trap_child -fi - -if [ "${SWITCHLOOM_CODEX_ORACLE_TEST_RESTORE:-0}" = "1" ]; then - run_restore_current_codex_home_config_regression - exit 0 -fi - -record_lifecycle_codex_home_hash() { - local phase="$1" - local hash - hash="$(hash_optional_file "$lifecycle_codex_home/config.toml")" - printf '%s %s\n' "$phase" "$hash" >> "$workdir/lifecycle-codex-home-hashes.txt" - if [ "$hash" != "$lifecycle_codex_home_config_before_hash" ]; then - printf 'isolated lifecycle CODEX_HOME config changed during %s: %s\n' "$phase" "$lifecycle_codex_home/config.toml" >&2 - exit 1 - fi -} - -assert_lifecycle_project_preserved() { - local phase="$1" - require_contains '[agents.local_reviewer]' "$lifecycle_workdir/.codex/config.toml" - require_contains 'config_file = "./agents/local-reviewer.toml"' "$lifecycle_workdir/.codex/config.toml" - require_contains '[features]' "$lifecycle_workdir/.codex/config.toml" - require_contains 'local = true' "$lifecycle_workdir/.codex/config.toml" - require_contains 'name = "local_reviewer"' "$lifecycle_workdir/.codex/agents/local-reviewer.toml" - printf '%s project-local unrelated Codex config and role preserved\n' "$phase" >> "$workdir/lifecycle-preservation.txt" -} - -if [ -n "$external_routing_bin" ]; then - routing_bin="$external_routing_bin" - test -x "$routing_bin" - routing_bin_sha256="$(hash_file "$routing_bin")" - if [ -n "$external_package_digest" ]; then - case "$external_package_digest" in - sha256:*) crate_sha256="${external_package_digest#sha256:}" ;; - *) - printf 'SWITCHLOOM_CODEX_PACKAGE_DIGEST must use sha256:\n' >&2 - exit 1 - ;; - esac - else - crate_sha256="$routing_bin_sha256" - fi - crate_path="$routing_bin" - printf '%s %s\n' "$crate_sha256" "$crate_path" > "$workdir/package-external.sha256" - printf '%s %s\n' "$routing_bin_sha256" "$routing_bin" > "$workdir/package-installed-binary.sha256" -else - cargo package --manifest-path "$repo_root/Cargo.toml" \ - --allow-dirty \ - --no-verify \ - --offline \ - --target-dir "$package_target" \ - > "$workdir/package.stdout" \ - 2> "$workdir/package.stderr" - - crate_version="$(sed -n 's/^version = "\([^"]*\)"/\1/p' "$repo_root/Cargo.toml" | head -n 1)" - crate_path="$package_target/package/model-routing-$crate_version.crate" - test -f "$crate_path" - crate_sha256="$(hash_file "$crate_path")" - printf '%s %s\n' "$crate_sha256" "$crate_path" > "$workdir/package-crate.sha256" - - mkdir -p "$package_src" - tar -xf "$crate_path" -C "$package_src" - crate_stem="$(basename "$crate_path" .crate)" - packaged_repo="$package_src/$crate_stem" - test -f "$packaged_repo/Cargo.toml" - - cargo install --path "$packaged_repo" \ - --bin model-routing \ - --root "$install_root" \ - --locked \ - --offline \ - > "$workdir/package-install.stdout" \ - 2> "$workdir/package-install.stderr" - - routing_bin="$install_root/bin/model-routing" - test -x "$routing_bin" - routing_bin_sha256="$(hash_file "$routing_bin")" - printf '%s %s\n' "$routing_bin_sha256" "$routing_bin" > "$workdir/package-installed-binary.sha256" -fi -codex_version="$(codex --version 2>&1 | rg '^codex(-cli)? ' | tail -n 1)" - -"$routing_bin" compile balanced \ - --host codex-openai \ - --output "$workdir/standalone.json" \ - > "$workdir/compile.stdout" \ - 2> "$workdir/compile.stderr" - -"$routing_bin" inspect "$workdir/standalone.json" \ - > "$workdir/inspect.json" \ - 2> "$workdir/inspect.stderr" - -"$routing_bin" preview "$workdir/standalone.json" \ - --repository "$workdir" \ - > "$workdir/preview.json" \ - 2> "$workdir/preview.stderr" - -"$routing_bin" apply "$workdir/standalone.json" \ - --repository "$workdir" \ - > "$workdir/apply.json" \ - 2> "$workdir/apply.stderr" - -test -d "$workdir/.codex/agents" -test ! -e "$workdir/.planr" -require_contains 'name = "model_routing_terra_high"' "$workdir/.codex/agents/model-routing-terra-high.toml" -require_contains 'model = "gpt-5.6-terra"' "$workdir/.codex/agents/model-routing-terra-high.toml" -require_contains 'model_reasoning_effort = "high"' "$workdir/.codex/agents/model-routing-terra-high.toml" -require_contains 'name = "model_routing_sol_high"' "$workdir/.codex/agents/model-routing-sol-high.toml" -require_contains 'model = "gpt-5.6-sol"' "$workdir/.codex/agents/model-routing-sol-high.toml" -require_contains 'model_reasoning_effort = "high"' "$workdir/.codex/agents/model-routing-sol-high.toml" -require_absent 'fork_turns = "all"' "$workdir/.codex/agents/model-routing-terra-high.toml" "$workdir/.codex/agents/model-routing-sol-high.toml" -require_contains '[agents.model_routing_terra_high]' "$workdir/.codex/config.toml" -require_contains 'config_file = "./agents/model-routing-terra-high.toml"' "$workdir/.codex/config.toml" -require_contains '[agents.model_routing_sol_high]' "$workdir/.codex/config.toml" -require_contains 'config_file = "./agents/model-routing-sol-high.toml"' "$workdir/.codex/config.toml" -require_contains '"mode": "none"' "$workdir/standalone.json" - -lifecycle_workdir="$(mktemp -d /private/tmp/model-routing-codex-lifecycle.XXXXXX)" -git -C "$lifecycle_workdir" init -q -mkdir -p "$lifecycle_workdir/.codex/agents" -cat > "$lifecycle_workdir/.codex/config.toml" <<'TOML' -[agents.local_reviewer] -config_file = "./agents/local-reviewer.toml" - -[features] -local = true -TOML -cat > "$lifecycle_workdir/.codex/agents/local-reviewer.toml" <<'TOML' -name = "local_reviewer" -TOML -lifecycle_codex_home="$workdir/lifecycle-codex-home" -mkdir -p "$lifecycle_codex_home" -cat > "$lifecycle_codex_home/config.toml" < "$workdir/lifecycle-codex-home-hashes.txt" -assert_lifecycle_project_preserved "before" - -env CODEX_HOME="$lifecycle_codex_home" "$routing_bin" preview "$workdir/standalone.json" \ - --repository "$lifecycle_workdir" \ - > "$workdir/lifecycle-preview.json" \ - 2> "$workdir/lifecycle-preview.stderr" -record_lifecycle_codex_home_hash "after-preview" -assert_lifecycle_project_preserved "after-preview" - -env CODEX_HOME="$lifecycle_codex_home" "$routing_bin" apply "$workdir/standalone.json" \ - --repository "$lifecycle_workdir" \ - > "$workdir/lifecycle-apply.json" \ - 2> "$workdir/lifecycle-apply.stderr" -record_lifecycle_codex_home_hash "after-apply" -assert_lifecycle_project_preserved "after-apply" - -"$routing_bin" compile read-only-audit \ - --host codex-openai \ - --output "$workdir/read-only-codex.json" \ - > "$workdir/lifecycle-update-compile.stdout" \ - 2> "$workdir/lifecycle-update-compile.stderr" -env CODEX_HOME="$lifecycle_codex_home" "$routing_bin" update "$workdir/read-only-codex.json" \ - --repository "$lifecycle_workdir" \ - > "$workdir/lifecycle-update.json" \ - 2> "$workdir/lifecycle-update.stderr" -record_lifecycle_codex_home_hash "after-update" -assert_lifecycle_project_preserved "after-update" - -env CODEX_HOME="$lifecycle_codex_home" "$routing_bin" rollback \ - --repository "$lifecycle_workdir" \ - > "$workdir/lifecycle-rollback.json" \ - 2> "$workdir/lifecycle-rollback.stderr" -record_lifecycle_codex_home_hash "after-rollback" -assert_lifecycle_project_preserved "after-rollback" - -env CODEX_HOME="$lifecycle_codex_home" "$routing_bin" uninstall \ - --repository "$lifecycle_workdir" \ - > "$workdir/lifecycle-uninstall.json" \ - 2> "$workdir/lifecycle-uninstall.stderr" -record_lifecycle_codex_home_hash "after-uninstall" -assert_lifecycle_project_preserved "after-uninstall" -require_absent 'model_routing_terra_high' "$lifecycle_workdir/.codex/config.toml" -require_absent 'model_routing_sol_high' "$lifecycle_workdir/.codex/config.toml" - -if [ "$codex_auth_mode" = "isolated" ]; then - assert_isolated_codex_home - { - printf 'model = "gpt-5.6-sol"\n' - printf 'model_reasoning_effort = "high"\n' - printf 'service_tier = "priority"\n' - printf 'cli_auth_credentials_store = "auto"\n' - printf 'mcp_oauth_credentials_store = "auto"\n\n' - printf '[features]\n' - printf 'multi_agent = true\n\n' - printf '[projects."%s"]\n' "$workdir" - printf 'trust_level = "trusted"\n' - } > "$codex_home/config.toml" - assert_isolated_codex_home -else - assert_current_codex_home_read_only_auth_shape -fi -codex_home_config_before_hash="$(hash_optional_file "$codex_home/config.toml")" -codex_home_config_before_path="$workdir/codex-home-config-before.toml" -if [ "$codex_auth_mode" = "current" ] && [ -f "$codex_home/config.toml" ]; then - cp "$codex_home/config.toml" "$codex_home_config_before_path" - trap cleanup_current_codex_home_config_on_exit EXIT -fi -{ - printf 'repository .codex/config.toml\n' - printf 'isolated CODEX_HOME: %s\n' "$codex_home" - printf 'CODEX_HOME auth mode: %s\n' "$codex_auth_mode" - if [ "$codex_auth_mode" = "isolated" ]; then - printf 'isolated CODEX_HOME config contains trust/auth prerequisites only\n' - else - printf 'current CODEX_HOME used for auth only; project trust supplied by runtime override\n' - printf 'global config before sha256: %s\n' "$codex_home_config_before_hash" - fi - printf 'no CODEX_HOME agent registrations and no profile supplied to codex exec\n' - printf 'reload boundary: fresh codex exec process starts after repository apply\n' - printf 'lifecycle isolated CODEX_HOME: %s\n' "$lifecycle_codex_home" - printf 'lifecycle isolated CODEX_HOME before sha256: %s\n' "$lifecycle_codex_home_config_before_hash" -} > "$workdir/agent-role-source.txt" - -auth_deadline=$((SECONDS + codex_auth_wait_seconds)) -until env CODEX_HOME="$codex_home" codex login status \ - > "$workdir/codex-login-status.stdout" \ - 2> "$workdir/codex-login-status.stderr"; do - if [ "$SECONDS" -ge "$auth_deadline" ]; then - printf 'isolated CODEX_HOME is missing Codex auth prerequisites after %s seconds: %s\n' "$codex_auth_wait_seconds" "$codex_home" >&2 - printf 'receipt: %s\n' "$workdir" >&2 - exit 1 - fi - sleep 5 -done -if [ "$codex_auth_mode" = "isolated" ]; then - assert_isolated_codex_home -else - assert_current_codex_home_read_only_auth_shape - codex_home_config_after_login_hash="$(hash_optional_file "$codex_home/config.toml")" - if [ "$codex_home_config_after_login_hash" != "$codex_home_config_before_hash" ]; then - printf 'current CODEX_HOME config changed during login preflight: %s\n' "$codex_home/config.toml" >&2 - exit 1 - fi -fi - -implementer_message="Inspect the generated repository without editing files. End your final answer with SWITCHLOOM_STANDALONE_IMPLEMENTER_DONE." -reviewer_message="Independently inspect the generated repository without editing files. End your final answer with SWITCHLOOM_STANDALONE_REVIEWER_DONE." -implementer_message_sha256="$(printf '%s' "$implementer_message" | shasum -a 256 | awk '{print $1}')" -reviewer_message_sha256="$(printf '%s' "$reviewer_message" | shasum -a 256 | awk '{print $1}')" -max_spawn_message_bytes=512 - -cat > "$workdir/expected-spawn-receipts.json" < "$workdir/oracle-prompt.md" <<'PROMPT' -Use the native collaboration spawn_agent tool exactly twice, then wait for both child agents to finish. - -Your first tool call must be spawn_agent with exactly these fields: -- agent_type: model_routing_terra_high -- task_name: standalone_implementer -- fork_turns: none -- message: Inspect the generated repository without editing files. End your final answer with SWITCHLOOM_STANDALONE_IMPLEMENTER_DONE. - -Your second tool call must be spawn_agent with exactly these fields: -- agent_type: model_routing_sol_high -- task_name: standalone_reviewer -- fork_turns: none -- message: Independently inspect the generated repository without editing files. End your final answer with SWITCHLOOM_STANDALONE_REVIEWER_DONE. - -Do not omit agent_type. Do not change either message. Do not pass model or reasoning_effort in either spawn call. Do not call wait_agent or answer before both spawn_agent calls have succeeded. - -After both children finish, return a short final answer containing: -SWITCHLOOM_CODEX_RUNTIME_EVIDENCE_COMPLETE -PROMPT - -codex_exec_args=( - --json - --ignore-user-config - -C "$workdir" - -s workspace-write - -c 'approval_policy="never"' - -c "projects.\"$workdir\".trust_level=\"trusted\"" - -c "agents.model_routing_terra_high.config_file=\"$workdir/.codex/agents/model-routing-terra-high.toml\"" - -c "agents.model_routing_sol_high.config_file=\"$workdir/.codex/agents/model-routing-sol-high.toml\"" - -c multi_agent_v2.hide_spawn_agent_metadata=false - -o "$workdir/codex-last-message.txt" -) -if [ "$codex_auth_mode" = "current" ]; then - codex_exec_args+=( - -c 'cli_auth_credentials_store="auto"' - -c 'mcp_oauth_credentials_store="auto"' - ) -fi - -env CODEX_HOME="$codex_home" codex exec "${codex_exec_args[@]}" "$(cat "$workdir/oracle-prompt.md")" \ - < /dev/null \ - > "$workdir/codex-events.jsonl" \ - 2> "$workdir/codex.stderr" -restore_current_codex_home_config - -test -s "$workdir/codex-events.jsonl" -node "$repo_root/scripts/validate-codex-spawn-state.mjs" \ - --events "$workdir/codex-events.jsonl" \ - --workdir "$workdir" \ - --expect "$workdir/expected-spawn-receipts.json" \ - --state-db "$codex_home/state_5.sqlite" \ - --sessions-dir "$codex_home/sessions" \ - > "$workdir/codex-runtime-evidence.json" -node "$repo_root/scripts/validate-codex-runtime-evidence.mjs" \ - "$workdir/codex-runtime-evidence.json" \ - --expect "$workdir/expected-spawn-receipts.json" \ - > "$workdir/validate-runtime-evidence.stdout" \ - 2> "$workdir/validate-runtime-evidence.stderr" -require_contains 'SWITCHLOOM_CODEX_RUNTIME_EVIDENCE_COMPLETE' "$workdir/codex-last-message.txt" -require_contains '"kind": "implementer"' "$workdir/codex-runtime-evidence.json" -require_contains '"agent_type": "model_routing_terra_high"' "$workdir/codex-runtime-evidence.json" -require_contains '"agent_type": "model_routing_sol_high"' "$workdir/codex-runtime-evidence.json" -require_contains '"message_sha256":' "$workdir/codex-runtime-evidence.json" -require_contains '"fork_turns": "none"' "$workdir/codex-runtime-evidence.json" -require_contains '"agent_role": "model_routing_terra_high"' "$workdir/codex-runtime-evidence.json" -require_contains '"agent_role": "model_routing_sol_high"' "$workdir/codex-runtime-evidence.json" -require_contains '"model": "gpt-5.6-terra"' "$workdir/codex-runtime-evidence.json" -require_contains '"reasoning_effort": "high"' "$workdir/codex-runtime-evidence.json" -require_contains 'codex runtime evidence validation passed' "$workdir/validate-runtime-evidence.stdout" -require_contains 'repository .codex/config.toml' "$workdir/agent-role-source.txt" -require_contains 'reload boundary: fresh codex exec process starts after repository apply' "$workdir/agent-role-source.txt" -require_contains 'after-uninstall' "$workdir/lifecycle-codex-home-hashes.txt" -require_contains 'after-uninstall project-local unrelated Codex config and role preserved' "$workdir/lifecycle-preservation.txt" -if [ "$codex_auth_mode" = "isolated" ]; then - require_absent '[agents.' "$codex_home/config.toml" -else - assert_current_codex_home_read_only_auth_shape - codex_home_config_after_hash="$(hash_optional_file "$codex_home/config.toml")" - printf 'global config after sha256: %s\n' "$codex_home_config_after_hash" >> "$workdir/agent-role-source.txt" - if [ "$codex_home_config_after_hash" != "$codex_home_config_before_hash" ]; then - printf 'global config sha256 changed during authenticated Codex run; no global Switchloom agent registrations were present before or after: %s -> %s\n' "$codex_home_config_before_hash" "$codex_home_config_after_hash" >> "$workdir/agent-role-source.txt" - exit 1 - else - printf 'global config sha256 unchanged: %s\n' "$codex_home_config_after_hash" >> "$workdir/agent-role-source.txt" - fi -fi - -printf 'codex standalone oracle passed\n' -printf 'receipts: %s\n' "$workdir" -printf 'package crate: %s\n' "$crate_path" -printf 'package crate sha256: %s\n' "$crate_sha256" -printf 'installed binary: %s\n' "$routing_bin" -printf 'installed binary sha256: %s\n' "$routing_bin_sha256" -printf 'runtime evidence: %s\n' "$workdir/codex-runtime-evidence.json" -printf 'agent role source proof: %s\n' "$workdir/agent-role-source.txt" -printf 'lifecycle CODEX_HOME hash proof: %s\n' "$workdir/lifecycle-codex-home-hashes.txt" -printf 'lifecycle preservation proof: %s\n' "$workdir/lifecycle-preservation.txt" -printf 'implementer effective route: model_routing_terra_high gpt-5.6-terra high fork none\n' -printf 'review effective route: model_routing_sol_high gpt-5.6-sol high fork none\n' -printf 'planr integration artifacts: absent\n' -printf 'preserved generated repository and retained auth evidence; uninstall not run\n' +routing_bin="${SWITCHLOOM_CODEX_ROUTING_BIN:-$repo_root/target/debug/model-routing}" +exec cargo run --quiet --manifest-path "$repo_root/Cargo.toml" -p xtask -- certify codex --routing-bin "$routing_bin" diff --git a/scripts/native-host-certification-oracle.sh b/scripts/native-host-certification-oracle.sh index a106802..025300a 100755 --- a/scripts/native-host-certification-oracle.sh +++ b/scripts/native-host-certification-oracle.sh @@ -1,223 +1,6 @@ #!/usr/bin/env bash set -euo pipefail - -usage() { - printf 'usage: %s [routing-bin]\n' "$0" >&2 - printf 'live host execution is the default and only certification mode\n' >&2 - exit 2 -} - -host="${1:-}" -test -n "$host" || usage -routing_bin="${2:-target/debug/model-routing}" -mode="${SWITCHLOOM_NATIVE_HOST_ORACLE_MODE:-live}" -if [ "$mode" != "live" ]; then - printf 'unsupported SWITCHLOOM_NATIVE_HOST_ORACLE_MODE=%s; certification requires live\n' "$mode" >&2 - exit 2 -fi -report_root="${SWITCHLOOM_NATIVE_HOST_REPORT_ROOT:-reports/native-host-certification}" -timestamp="$(date -u +%Y%m%dT%H%M%SZ)" -report_dir="$report_root/$host/$timestamp" -workdir="$report_dir/workdir" -mkdir -p "$workdir" - -hash_file() { - shasum -a 256 "$1" | awk '{print $1}' -} - -run_with_timeout() { - local timeout_seconds="$1" - shift - "$@" & - local child_pid="$!" - local deadline=$((SECONDS + timeout_seconds)) - while kill -0 "$child_pid" 2>/dev/null; do - if [ "$SECONDS" -ge "$deadline" ]; then - kill "$child_pid" 2>/dev/null || true - wait "$child_pid" 2>/dev/null || true - printf 'live host command timed out after %s seconds; report: %s\n' "$timeout_seconds" "$report_dir" >&2 - return 124 - fi - sleep 1 - done - wait "$child_pid" -} - -case "$host" in - claude-native) - runtime_host="claude-code" - cli_bin="claude" - profile="claude-native-worker" - semantic_role="worker" - agent_role="model-routing-preset-worker" - requested_model="sonnet" - requested_effort="medium" - artifact_path=".claude/agents/model-routing-preset-worker.md" - host_version="$(claude --version 2>&1 | tail -n 1)" - invocation_json='["claude","--print","--output-format","json","--agent","model-routing-preset-worker","--model","sonnet","--effort","medium"]' - ;; - cursor-openai) - runtime_host="cursor" - cli_bin="cursor-agent" - profile="cursor-openai-worker" - semantic_role="worker" - agent_role="model-routing-preset-worker" - requested_model="gpt-5.4-mini" - requested_effort="" - artifact_path=".cursor/agents/model-routing-preset-worker.md" - host_version="$(cursor-agent --version 2>&1 | tail -n 1)" - invocation_json='["cursor-agent","--print","--output-format","json","--trust","--model","gpt-5.4-mini"]' - ;; - cursor-fable-grok) - runtime_host="cursor" - cli_bin="cursor-agent" - profile="cursor-grok-worker" - semantic_role="worker" - agent_role="model-routing-preset-worker" - requested_model="cursor-grok-4.5-medium" - requested_effort="" - artifact_path=".cursor/agents/model-routing-preset-worker.md" - host_version="$(cursor-agent --version 2>&1 | tail -n 1)" - invocation_json='["cursor-agent","--print","--output-format","json","--trust","--model","cursor-grok-4.5-medium"]' - ;; - *) - usage - ;; -esac - -test -x "$routing_bin" -command -v "$cli_bin" >/dev/null -package_digest="sha256:$(hash_file "$routing_bin")" -nonce="$(uuidgen | tr '[:upper:]' '[:lower:]')" - -"$routing_bin" compile balanced --host "$host" --output "$workdir/bundle.json" -"$routing_bin" apply "$workdir/bundle.json" --repository "$workdir" > "$workdir/apply.json" -test -f "$workdir/$artifact_path" - -# shellcheck disable=SC2016 -env \ - RUNTIME_HOST="$runtime_host" \ - MODE="$mode" \ - NONCE="$nonce" \ - INVOCATION_JSON="$invocation_json" \ - ARTIFACT_PATH="$artifact_path" \ - node -e ' -const fs = require("node:fs"); -const payload = { - host: process.env.RUNTIME_HOST, - mode: process.env.MODE, - nonce: process.env.NONCE, - argv: JSON.parse(process.env.INVOCATION_JSON), - prompt: `Return only this nonce and do not edit files: ${process.env.NONCE}`, - artifact_path: process.env.ARTIFACT_PATH -}; -fs.writeFileSync(process.argv[1], JSON.stringify(payload, null, 2)); -' "$workdir/requested-invocation.json" - -prompt="Return only this nonce and do not edit files: $nonce" -host_timeout_seconds="${SWITCHLOOM_NATIVE_HOST_TIMEOUT_SECONDS:-180}" -run_claude_host() { - ( - cd "$workdir" - claude --print --output-format json --agent model-routing-preset-worker --model "$requested_model" --effort "$requested_effort" "$prompt" - ) -} - -run_cursor_host() { - ( - cd "$workdir" - cursor-agent --print --output-format json --trust --model "$requested_model" "$prompt" - ) -} - -if [ "$host" = "claude-native" ]; then - run_with_timeout "$host_timeout_seconds" run_claude_host > "$workdir/host-output.json" 2> "$workdir/host-output.stderr" -else - run_with_timeout "$host_timeout_seconds" run_cursor_host > "$workdir/host-output.json" 2> "$workdir/host-output.stderr" -fi -if ! rg -F -q "$nonce" "$workdir/host-output.json" "$workdir/host-output.stderr"; then - printf 'live host output did not return nonce %s; report: %s\n' "$nonce" "$report_dir" >&2 - exit 1 -fi - -# shellcheck disable=SC2016 -env \ - PACKAGE_DIGEST="$package_digest" \ - HOST_VERSION="$host_version" \ - RUNTIME_HOST="$runtime_host" \ - HOST="$host" \ - PROFILE="$profile" \ - SEMANTIC_ROLE="$semantic_role" \ - AGENT_ROLE="$agent_role" \ - REQUESTED_MODEL="$requested_model" \ - REQUESTED_EFFORT="$requested_effort" \ - NONCE="$nonce" \ - MODE="$mode" \ - WORKDIR="$workdir" \ - node -e ' -const fs = require("node:fs"); -const outputPath = `${process.env.WORKDIR}/host-output.json`; -let output = {}; -try { - output = JSON.parse(fs.readFileSync(outputPath, "utf8")); -} catch { - output = {}; -} -const effectiveModel = output.effective_model ?? output.model ?? output.result?.model ?? null; -const effectiveEffort = output.effective_effort ?? output.effort ?? output.result?.effort ?? null; -const requestedEffort = process.env.REQUESTED_EFFORT || null; -const deterministic = process.env.MODE === "live" - && effectiveModel === process.env.REQUESTED_MODEL - && (!requestedEffort || effectiveEffort === requestedEffort); -const rawRefs = [ - "requested-invocation:requested-invocation.json#argv", - "host-output:host-output.json", - "host-stderr:host-output.stderr" -]; -if (deterministic) { - rawRefs.push(`host-authenticated-effective-model:${process.env.RUNTIME_HOST}:host-output.json#model`); - if (requestedEffort) { - rawRefs.push(`host-authenticated-effective-effort:${process.env.RUNTIME_HOST}:host-output.json#effort`); - } -} -const receipt = { - schema_version: 1, - package_digest: process.env.PACKAGE_DIGEST, - host_version: process.env.HOST_VERSION, - requested_dispatch: { - semantic_role: process.env.SEMANTIC_ROLE, - profile: process.env.PROFILE, - model: process.env.REQUESTED_MODEL, - agent_type: process.env.AGENT_ROLE, - fork_turns: { mode: "none" } - }, - child_identity: { - host: process.env.RUNTIME_HOST, - role: process.env.SEMANTIC_ROLE, - agent_role: process.env.AGENT_ROLE, - agent_type: process.env.AGENT_ROLE, - task_name: process.env.AGENT_ROLE - }, - nonce: process.env.NONCE, - raw_evidence_refs: rawRefs, - verdict: deterministic ? "deterministic" : "advisory" -}; -if (requestedEffort) receipt.requested_dispatch.effort = requestedEffort; -if (effectiveModel) receipt.effective_model = effectiveModel; -if (effectiveEffort) receipt.effective_effort = effectiveEffort; -fs.writeFileSync(`${process.env.WORKDIR}/dispatch-evidence.json`, JSON.stringify(receipt, null, 2)); -' - -"$routing_bin" evidence validate "$workdir/dispatch-evidence.json" --bundle "$workdir/bundle.json" \ - > "$workdir/evidence-validate.stdout" \ - 2> "$workdir/evidence-validate.stderr" - -printf 'native host certification oracle passed\n' -printf 'host: %s\n' "$host" -printf 'mode: %s\n' "$mode" -printf 'receipt: %s\n' "$workdir/dispatch-evidence.json" -printf 'bundle: %s\n' "$workdir/bundle.json" -printf 'invocation: %s\n' "$workdir/requested-invocation.json" -printf 'host output: %s\n' "$workdir/host-output.json" -printf 'validation: %s\n' "$workdir/evidence-validate.stdout" -printf 'report: %s\n' "$report_dir" +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +host="${1:?usage: native-host-certification-oracle.sh [routing-bin]}" +routing_bin="${2:-$repo_root/target/debug/model-routing}" +exec cargo run --quiet --manifest-path "$repo_root/Cargo.toml" -p xtask -- certify cursor --host "$host" --routing-bin "$routing_bin" diff --git a/scripts/npm-pack-check.sh b/scripts/npm-pack-check.sh index 5500f10..1151099 100755 --- a/scripts/npm-pack-check.sh +++ b/scripts/npm-pack-check.sh @@ -1,41 +1,5 @@ -#!/usr/bin/env bash -set -euo pipefail +#!/usr/bin/env sh +set -eu -repo_root="$(git rev-parse --show-toplevel)" -cache_dir="${TMPDIR:-/tmp}/switchloom-npm-cache" - -cd "$repo_root" -package_version="$(node -p 'JSON.parse(require("fs").readFileSync("package.json", "utf8")).version')" -current_target="$(node -e 'const os=require("os"); const osMap={darwin:"darwin",linux:"linux"}; const archMap={arm64:"arm64",x64:"x86_64"}; const platform=osMap[os.platform()]; const arch=archMap[os.arch()]; process.stdout.write(platform&&arch ? platform+"-"+arch : "");')" - -if [ -z "$current_target" ]; then - printf 'unsupported npm native target for this platform\n' >&2 - exit 1 -fi - -for native in npm/native/*/model-routing; do - if grep -aFq '0.1.1' "$native"; then - printf 'stale native version string found in %s\n' "$native" >&2 - exit 1 - fi - if ! grep -aFq "$package_version" "$native"; then - printf 'package version %s not found in %s\n' "$package_version" "$native" >&2 - exit 1 - fi -done - -node scripts/validate-native-provenance.mjs - -native_version="$(./npm/native/"$current_target"/model-routing --version)" -wrapper_version="$(node npm/bin/model-routing.js --version)" -expected_version="model-routing $package_version" -if [ "$native_version" != "$expected_version" ]; then - printf 'selected native version mismatch: expected %s, got %s\n' "$expected_version" "$native_version" >&2 - exit 1 -fi -if [ "$wrapper_version" != "$expected_version" ]; then - printf 'wrapper version mismatch: expected %s, got %s\n' "$expected_version" "$wrapper_version" >&2 - exit 1 -fi - -npm_config_cache="$cache_dir" npm pack --dry-run +cd "$(dirname "$0")/.." +exec cargo run --quiet -p xtask -- release verify --inventory-only --require-provenance diff --git a/scripts/offline-verification-suite.sh b/scripts/offline-verification-suite.sh index 3abc98e..22340d1 100755 --- a/scripts/offline-verification-suite.sh +++ b/scripts/offline-verification-suite.sh @@ -3,18 +3,7 @@ set -euo pipefail repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" workdir="$(mktemp -d /private/tmp/model-routing-offline-suite.XXXXXX)" -package_files="$workdir/package-files.txt" metadata="$workdir/cargo-metadata.json" -package_repo="$workdir/package-repo" - -require_contains() { - local pattern="$1" - shift - if ! rg -F -q "$pattern" "$@"; then - printf 'missing expected pattern %s in %s\n' "$pattern" "$*" >&2 - exit 1 - fi -} require_absent() { local pattern="$1" @@ -29,40 +18,13 @@ cargo fmt --manifest-path "$repo_root/Cargo.toml" --all -- --check cargo clippy --manifest-path "$repo_root/Cargo.toml" --workspace --all-targets --all-features -- -D warnings cargo test --manifest-path "$repo_root/Cargo.toml" --workspace --all-targets --all-features sh "$repo_root/scripts/check-migration-manifest.sh" +node "$repo_root/scripts/check-evidence-validator-parity.mjs" node --test "$repo_root"/scripts/*.test.mjs "$repo_root"/website/*.test.mjs -node "$repo_root/scripts/regenerate-preset-catalog.mjs" --routing-bin "$repo_root/target/debug/model-routing" -cargo run --manifest-path "$repo_root/Cargo.toml" --bin model-routing -- catalog verify "$repo_root/website/data/catalog.json" +cargo run --manifest-path "$repo_root/Cargo.toml" -p xtask -- release prepare --allow-dirty node "$repo_root/scripts/build-site.mjs" -cargo run --manifest-path "$repo_root/Cargo.toml" --bin model-routing -- evaluate balanced --host codex-openai > "$workdir/evaluate-codex.json" -require_contains '"status": "experimental"' "$workdir/evaluate-codex.json" -require_contains '"recommended": false' "$workdir/evaluate-codex.json" -require_contains '"offline_reproducible": true' "$workdir/evaluate-codex.json" -require_contains '"live_evidence": null' "$workdir/evaluate-codex.json" cargo metadata --manifest-path "$repo_root/Cargo.toml" --format-version 1 --no-deps > "$metadata" require_absent '"name":"planr"' "$metadata" -if git -C "$repo_root" rev-parse --verify HEAD >/dev/null 2>&1; then - package_manifest="$repo_root/Cargo.toml" -else - rsync -a \ - --exclude .git \ - --exclude .planr \ - --exclude target \ - --exclude dist \ - "$repo_root/" \ - "$package_repo/" - git -C "$package_repo" init --quiet - git -C "$package_repo" config user.email model-routing-oracle@example.invalid - git -C "$package_repo" config user.name "Model Routing Oracle" - git -C "$package_repo" add . - git -C "$package_repo" commit --quiet -m "package oracle" - package_manifest="$package_repo/Cargo.toml" -fi -cargo package --manifest-path "$package_manifest" --workspace --allow-dirty --no-verify --offline -cargo package --manifest-path "$package_manifest" --workspace --list --allow-dirty --no-verify --offline > "$package_files" -require_absent '.planr' "$package_files" -require_absent 'receipt' "$package_files" -require_absent 'credential' "$package_files" -require_absent 'secret' "$package_files" +cargo run --manifest-path "$repo_root/Cargo.toml" -p xtask -- release verify --inventory-only betterleaks dir "$repo_root" trivy fs \ --skip-db-update \ @@ -78,5 +40,5 @@ zizmor "$repo_root/.github/workflows" printf 'offline verification suite passed\n' printf 'receipts: %s\n' "$workdir" printf 'format/lint/unit/package/website/catalog/security/workflow gates passed\n' -printf 'offline evaluation remained experimental with no live evidence claim\n' +printf 'offline evaluation contract passed through Rust tests\n' printf 'cargo metadata has no Planr dependency\n' diff --git a/scripts/opencode-native-oracle.sh b/scripts/opencode-native-oracle.sh index d9866e4..485f359 100755 --- a/scripts/opencode-native-oracle.sh +++ b/scripts/opencode-native-oracle.sh @@ -1,141 +1,5 @@ #!/usr/bin/env bash set -euo pipefail - -usage() { - printf 'usage: %s [routing-bin]\n' "$0" >&2 - printf 'live OpenCode execution is required for certification\n' >&2 - exit 2 -} - -routing_bin="${1:-target/debug/model-routing}" -mode="${SWITCHLOOM_OPENCODE_ORACLE_MODE:-live}" -if [ "$mode" != "live" ]; then - printf 'unsupported SWITCHLOOM_OPENCODE_ORACLE_MODE=%s; certification requires live\n' "$mode" >&2 - exit 2 -fi - -host="opencode-native" -runtime_host="opencode" -profile="opencode-worker" -agent_role="model-routing-preset-worker" -driver_agent_role="model-routing-preset-driver" -requested_model="opencode/gpt-5-nano" -requested_effort="low" -artifact_path=".opencode/agents/model-routing-preset-worker.md" -report_root="${SWITCHLOOM_OPENCODE_REPORT_ROOT:-reports/native-host-certification}" -timestamp="$(date -u +%Y%m%dT%H%M%SZ)" -report_dir="$report_root/$host/$timestamp" -workdir="$report_dir/workdir" -mkdir -p "$workdir" - -hash_file() { - shasum -a 256 "$1" | awk '{print $1}' -} - -run_with_timeout() { - local timeout_seconds="$1" - shift - "$@" & - local child_pid="$!" - local deadline=$((SECONDS + timeout_seconds)) - while kill -0 "$child_pid" 2>/dev/null; do - if [ "$SECONDS" -ge "$deadline" ]; then - kill "$child_pid" 2>/dev/null || true - wait "$child_pid" 2>/dev/null || true - printf 'live OpenCode command timed out after %s seconds; report: %s\n' "$timeout_seconds" "$report_dir" >&2 - return 124 - fi - sleep 1 - done - wait "$child_pid" -} - -test -x "$routing_bin" -command -v opencode >/dev/null -package_digest="sha256:$(hash_file "$routing_bin")" -host_version="$(opencode --version 2>&1 | tail -n 1)" -nonce="$(uuidgen | tr '[:upper:]' '[:lower:]')" - -"$routing_bin" compile balanced --host "$host" --output "$workdir/bundle.json" -"$routing_bin" apply "$workdir/bundle.json" --repository "$workdir" > "$workdir/apply.json" -test -f "$workdir/$artifact_path" - -# shellcheck disable=SC2016 -env \ - RUNTIME_HOST="$runtime_host" \ - MODE="$mode" \ - NONCE="$nonce" \ - ARTIFACT_PATH="$artifact_path" \ - REQUESTED_MODEL="$requested_model" \ - REQUESTED_EFFORT="$requested_effort" \ - node -e ' -const fs = require("node:fs"); -const payload = { - host: process.env.RUNTIME_HOST, - mode: process.env.MODE, - nonce: process.env.NONCE, - argv: [ - "env", - "XDG_DATA_HOME=.opencode-xdg/data", - "XDG_STATE_HOME=.opencode-xdg/state", - "XDG_CACHE_HOME=.opencode-xdg/cache", - "opencode", - "run", - "--dir", - ".", - "--agent", - "model-routing-preset-driver", - "--model", - process.env.REQUESTED_MODEL, - "--variant", - process.env.REQUESTED_EFFORT, - "--format", - "json" - ], - prompt: `Use the Task tool to invoke model-routing-preset-worker. The worker must return only this nonce and must not edit files: ${process.env.NONCE}. After the worker returns, return only the same nonce.`, - artifact_path: process.env.ARTIFACT_PATH -}; -fs.writeFileSync(process.argv[1], JSON.stringify(payload, null, 2)); -' "$workdir/requested-invocation.json" - -prompt="Use the Task tool to invoke model-routing-preset-worker. The worker must return only this nonce and must not edit files: $nonce. After the worker returns, return only the same nonce." -host_timeout_seconds="${SWITCHLOOM_OPENCODE_TIMEOUT_SECONDS:-180}" -run_opencode_host() { - ( - cd "$workdir" - mkdir -p .opencode-xdg/data .opencode-xdg/state .opencode-xdg/cache - XDG_DATA_HOME="$PWD/.opencode-xdg/data" \ - XDG_STATE_HOME="$PWD/.opencode-xdg/state" \ - XDG_CACHE_HOME="$PWD/.opencode-xdg/cache" \ - opencode run --dir . --agent "$driver_agent_role" --model "$requested_model" --variant "$requested_effort" --format json "$prompt" - ) -} - -run_with_timeout "$host_timeout_seconds" run_opencode_host > "$workdir/host-output.jsonl" 2> "$workdir/host-output.stderr" - -node scripts/validate-opencode-runtime-evidence.mjs \ - --jsonl "$workdir/host-output.jsonl" \ - --invocation "$workdir/requested-invocation.json" \ - --receipt "$workdir/dispatch-evidence.json" \ - --package-digest "$package_digest" \ - --host-version "$host_version" \ - --profile "$profile" \ - --model "$requested_model" \ - --variant "$requested_effort" \ - --worker "$agent_role" \ - > "$workdir/runtime-evidence-validate.stdout" \ - 2> "$workdir/runtime-evidence-validate.stderr" - -"$routing_bin" evidence validate "$workdir/dispatch-evidence.json" --bundle "$workdir/bundle.json" \ - > "$workdir/evidence-validate.stdout" \ - 2> "$workdir/evidence-validate.stderr" - -printf 'OpenCode native certification oracle passed\n' -printf 'host: %s\n' "$host" -printf 'mode: %s\n' "$mode" -printf 'receipt: %s\n' "$workdir/dispatch-evidence.json" -printf 'bundle: %s\n' "$workdir/bundle.json" -printf 'invocation: %s\n' "$workdir/requested-invocation.json" -printf 'host output: %s\n' "$workdir/host-output.jsonl" -printf 'validation: %s\n' "$workdir/evidence-validate.stdout" -printf 'report: %s\n' "$report_dir" +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +routing_bin="${1:-$repo_root/target/debug/model-routing}" +exec cargo run --quiet --manifest-path "$repo_root/Cargo.toml" -p xtask -- certify opencode --routing-bin "$routing_bin" diff --git a/scripts/pi-external-oracle.sh b/scripts/pi-external-oracle.sh index 01f270b..09147ed 100755 --- a/scripts/pi-external-oracle.sh +++ b/scripts/pi-external-oracle.sh @@ -1,166 +1,5 @@ #!/usr/bin/env bash set -euo pipefail - -usage() { - printf 'usage: %s [routing-bin]\n' "$0" >&2 - printf 'live Pi external runner execution is required for certification\n' >&2 - exit 2 -} - -routing_bin="${1:-target/debug/model-routing}" -host="pi-external" -runtime_host="pi" -profile="pi-worker" -agent_type="switchloom-pi-worker" -requested_provider="openai" -requested_model_id="gpt-4o-mini" -requested_model="$requested_provider/$requested_model_id" -requested_thinking="low" -artifact_path=".pi/workflows/model-routing-preset-runner.json" -report_root="${SWITCHLOOM_PI_REPORT_ROOT:-reports/native-host-certification}" -timestamp="$(date -u +%Y%m%dT%H%M%SZ)" -report_dir="$report_root/$host/$timestamp" -workdir="$report_dir/workdir" -mkdir -p "$workdir" - -hash_file() { - shasum -a 256 "$1" | awk '{print $1}' -} - -hash_text() { - shasum -a 256 | awk '{print "sha256:" $1}' -} - -run_with_timeout() { - local timeout_seconds="$1" - shift - "$@" & - local child_pid="$!" - local deadline=$((SECONDS + timeout_seconds)) - while kill -0 "$child_pid" 2>/dev/null; do - if [ "$SECONDS" -ge "$deadline" ]; then - kill "$child_pid" 2>/dev/null || true - wait "$child_pid" 2>/dev/null || true - printf 'live Pi command timed out after %s seconds; report: %s\n' "$timeout_seconds" "$report_dir" >&2 - return 124 - fi - sleep 1 - done - wait "$child_pid" -} - -test -x "$routing_bin" -command -v pi >/dev/null -package_digest="sha256:$(hash_file "$routing_bin")" -host_version="$(pi --version 2>&1 | tail -n 1)" -nonce="$(uuidgen | tr '[:upper:]' '[:lower:]')" - -"$routing_bin" compile balanced --host "$host" --output "$workdir/bundle.json" -"$routing_bin" apply "$workdir/bundle.json" --repository "$workdir" > "$workdir/apply.json" -test -f "$workdir/$artifact_path" -cp "$workdir/$artifact_path" "$workdir/workflow.json" - -prompt="Return only this nonce and no other text: $nonce" -prompt_sha256="$(printf '%s' "$prompt" | hash_text)" - -env \ - RUNTIME_HOST="$runtime_host" \ - NONCE="$nonce" \ - ARTIFACT_PATH="$artifact_path" \ - REQUESTED_PROVIDER="$requested_provider" \ - REQUESTED_MODEL_ID="$requested_model_id" \ - REQUESTED_MODEL="$requested_model" \ - REQUESTED_THINKING="$requested_thinking" \ - AGENT_TYPE="$agent_type" \ - PROMPT_SHA256="$prompt_sha256" \ - node -e ' -const fs = require("node:fs"); -const payload = { - host: process.env.RUNTIME_HOST, - nonce: process.env.NONCE, - argv: [ - "env", - "PI_CODING_AGENT_DIR=.pi-agent", - "PI_OFFLINE=1", - "pi", - "--print", - "--no-session", - "--no-tools", - "--no-extensions", - "--no-skills", - "--provider", - process.env.REQUESTED_PROVIDER, - "--model", - process.env.REQUESTED_MODEL_ID, - "--thinking", - process.env.REQUESTED_THINKING - ], - env: { - PI_CODING_AGENT_DIR: ".pi-agent", - PI_OFFLINE: "1" - }, - requested: { - profile: "pi-worker", - agent_type: process.env.AGENT_TYPE, - provider_model: process.env.REQUESTED_MODEL, - thinking: process.env.REQUESTED_THINKING, - isolation: { - session: "none", - tools: "none", - extensions: "none", - skills: "none" - } - }, - prompt_sha256: process.env.PROMPT_SHA256, - artifact_path: process.env.ARTIFACT_PATH -}; -fs.writeFileSync(process.argv[1], JSON.stringify(payload, null, 2)); -' "$workdir/requested-invocation.json" - -host_timeout_seconds="${SWITCHLOOM_PI_TIMEOUT_SECONDS:-180}" -run_pi_host() { - ( - cd "$workdir" - mkdir -p .pi-agent - PI_CODING_AGENT_DIR="$PWD/.pi-agent" \ - PI_OFFLINE=1 \ - pi --print --no-session --no-tools --no-extensions --no-skills \ - --provider "$requested_provider" \ - --model "$requested_model_id" \ - --thinking "$requested_thinking" \ - "$prompt" - ) -} - -run_with_timeout "$host_timeout_seconds" run_pi_host > "$workdir/host-output.txt" 2> "$workdir/host-output.stderr" - -node scripts/validate-pi-runtime-evidence.mjs \ - --workflow "$workdir/workflow.json" \ - --invocation "$workdir/requested-invocation.json" \ - --stdout "$workdir/host-output.txt" \ - --stderr "$workdir/host-output.stderr" \ - --workflow-receipt "$workdir/workflow-receipt.json" \ - --dispatch-receipt "$workdir/dispatch-evidence.json" \ - --package-digest "$package_digest" \ - --host-version "$host_version" \ - --profile "$profile" \ - --model "$requested_model" \ - --thinking "$requested_thinking" \ - --agent-type "$agent_type" \ - > "$workdir/runtime-evidence-validate.stdout" \ - 2> "$workdir/runtime-evidence-validate.stderr" - -"$routing_bin" evidence validate "$workdir/dispatch-evidence.json" --bundle "$workdir/bundle.json" \ - > "$workdir/evidence-validate.stdout" \ - 2> "$workdir/evidence-validate.stderr" - -printf 'Pi external runner certification oracle passed\n' -printf 'host: %s\n' "$host" -printf 'runtime host: %s\n' "$runtime_host" -printf 'workflow receipt: %s\n' "$workdir/workflow-receipt.json" -printf 'dispatch receipt: %s\n' "$workdir/dispatch-evidence.json" -printf 'bundle: %s\n' "$workdir/bundle.json" -printf 'invocation: %s\n' "$workdir/requested-invocation.json" -printf 'host output: %s\n' "$workdir/host-output.txt" -printf 'validation: %s\n' "$workdir/evidence-validate.stdout" -printf 'report: %s\n' "$report_dir" +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +routing_bin="${1:-$repo_root/target/debug/model-routing}" +exec cargo run --quiet --manifest-path "$repo_root/Cargo.toml" -p xtask -- certify pi --routing-bin "$routing_bin" diff --git a/scripts/planr-adapter-oracle.sh b/scripts/planr-adapter-oracle.sh index 7faaaca..2d3cde0 100755 --- a/scripts/planr-adapter-oracle.sh +++ b/scripts/planr-adapter-oracle.sh @@ -1,188 +1,5 @@ #!/usr/bin/env bash set -euo pipefail - repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -workdir="$(mktemp -d /private/tmp/model-routing-planr-oracle.XXXXXX)" -db="$workdir/.planr/planr.sqlite" - -extract_id() { - sed -n 's/.*"id": "\([^"]*\)".*/\1/p' | head -1 -} - -require_contains() { - local pattern="$1" - shift - if ! rg -F -q "$pattern" "$@"; then - printf 'missing expected pattern %s in %s\n' "$pattern" "$*" >&2 - exit 1 - fi -} - -require_absent() { - local pattern="$1" - shift - if rg -F -q "$pattern" "$@"; then - printf 'unexpected pattern %s in %s\n' "$pattern" "$*" >&2 - exit 1 - fi -} - -planr --db "$db" project init "Model Routing Planr Oracle" --json > "$workdir/project-init.json" - -cargo run --manifest-path "$repo_root/Cargo.toml" --bin model-routing -- compile balanced \ - --host codex-openai \ - --integration planr \ - --output "$workdir/planr.json" \ - > "$workdir/compile.stdout" \ - 2> "$workdir/compile.stderr" - -cargo run --manifest-path "$repo_root/Cargo.toml" --bin model-routing -- apply "$workdir/planr.json" \ - --repository "$workdir" \ - > "$workdir/apply.json" \ - 2> "$workdir/apply.stderr" - -sentinel_bin="$workdir/sentinel-bin" -sentinel_hit="$workdir/model-routing-sentinel-hit" -mkdir -p "$sentinel_bin" -printf '#!/usr/bin/env bash\nprintf "model-routing invoked\\n" > "%s"\nexit 99\n' "$sentinel_hit" > "$sentinel_bin/model-routing" -chmod +x "$sentinel_bin/model-routing" -sentinel_path="$sentinel_bin:$PATH" - -( - cd "$workdir" - export PATH="$sentinel_path" - planr --db "$db" agents check --json > agents-check.json - planr --db "$db" agents list --json > agents-list.json - planr --db "$db" prompt routing --client codex --json > routing-dispatch.json - - worker_json="$(planr --db "$db" item create "Oracle worker item" \ - --description "Worker route should expose generated native role" \ - --work-type code \ - --json)" - printf '%s\n' "$worker_json" > worker-create.json - worker_id="$(printf '%s' "$worker_json" | extract_id)" - planr --db "$db" item route "$worker_id" --json > worker-route.json - PLANR_WORKER_ID=oracle-worker planr --db "$db" pick --peek --json --work-type code > worker-pick-peek.json - PLANR_WORKER_ID=oracle-worker planr --db "$db" pick --json --work-type code > worker-pick.json - - review_json="$(planr --db "$db" item create "Oracle review item" \ - --description "Review route should expose generated native role" \ - --work-type review \ - --json)" - printf '%s\n' "$review_json" > review-create.json - review_id="$(printf '%s' "$review_json" | extract_id)" - planr --db "$db" item route "$review_id" --json > review-route.json - PLANR_WORKER_ID=oracle-reviewer planr --db "$db" pick --peek --json --work-type review > review-pick-peek.json - PLANR_WORKER_ID=oracle-reviewer planr --db "$db" pick --json --work-type review > review-pick.json - - plan_json="$(planr plan new "Loop Oracle Plan" --platform cli --json)" - printf '%s\n' "$plan_json" > loop-plan-new.json - plan_path="$(printf '%s' "$plan_json" | sed -n 's/.*"path": "\([^"]*\)".*/\1/p' | head -1)" - plan_id="$(printf '%s' "$plan_json" | sed -n 's/.*"id": "\([^"]*\)".*/\1/p' | head -1)" - cat > "$plan_path/TASKS.md" <<'TASKS' -# Tasks - -### TASK-001: Record oracle worker evidence - -Goal: -Complete this disposable oracle item without editing source files. - -Acceptance criteria: -- A completion log records that the generated worker role executed. -- The command `printf oracle-worker-ok` is recorded as verification evidence. -TASKS - planr context add "GOAL CONTRACT $plan_id: DONE when every in-scope map item is closed with log evidence, all reviews are closed with verdict complete, no open approvals remain, and a live verification log exists. Iteration budget: 4." \ - --tag goal-contract \ - --json > loop-goal-context.json - printf '%s\n' "$plan_id" > loop-plan-id.txt -) - -test ! -e "$sentinel_hit" - -require_contains '"profile": "model_routing_terra_high"' \ - "$workdir/worker-route.json" \ - "$workdir/worker-pick-peek.json" \ - "$workdir/worker-pick.json" \ - "$workdir/routing-dispatch.json" -require_contains '"profile": "model_routing_sol_high"' \ - "$workdir/review-route.json" \ - "$workdir/review-pick-peek.json" \ - "$workdir/review-pick.json" \ - "$workdir/routing-dispatch.json" -require_contains 'name = "model_routing_terra_high"' "$workdir/.codex/agents/model-routing-terra-high.toml" -require_contains 'name = "model_routing_sol_high"' "$workdir/.codex/agents/model-routing-sol-high.toml" -require_contains "Protocol preload: \$planr-work" "$workdir/.codex/agents/model-routing-terra-high.toml" -require_contains "Protocol preload: \$planr-review" "$workdir/.codex/agents/model-routing-sol-high.toml" -require_contains '[agents.model_routing_terra_high]' "$workdir/.codex/config.toml" -require_contains 'config_file = "./agents/model-routing-terra-high.toml"' "$workdir/.codex/config.toml" -require_contains '[agents.model_routing_sol_high]' "$workdir/.codex/config.toml" -require_contains 'config_file = "./agents/model-routing-sol-high.toml"' "$workdir/.codex/config.toml" -require_absent 'model-routing' \ - "$workdir/worker-route.json" \ - "$workdir/worker-pick-peek.json" \ - "$workdir/worker-pick.json" \ - "$workdir/review-route.json" \ - "$workdir/review-pick-peek.json" \ - "$workdir/review-pick.json" \ - "$workdir/routing-dispatch.json" \ - "$workdir/.codex/agents/model-routing-terra-high.toml" \ - "$workdir/.codex/agents/model-routing-sol-high.toml" - -loop_plan_id="$(cat "$workdir/loop-plan-id.txt")" -env PATH="$sentinel_path" codex exec \ - --json \ - --ephemeral \ - --skip-git-repo-check \ - -C "$workdir" \ - -s workspace-write \ - -c approval_policy='"never"' \ - -o "$workdir/loop-last-message.txt" \ - "Use \$planr-loop on plan $loop_plan_id. The loop contract is stored in Planr context (tag: goal-contract). Continue until the contract holds or four iterations are exhausted. This is a disposable oracle: do not edit source files; record evidence with Planr commands only. When dispatching work or review, use the generated Codex project agents selected by Planr routing; do not pass hard-coded model flags." \ - > "$workdir/loop-codex-events.jsonl" \ - 2> "$workdir/loop-codex.stderr" - -test -s "$workdir/loop-codex-events.jsonl" -test ! -e "$sentinel_hit" - -( - cd "$workdir" - planr plan audit "$loop_plan_id" --json > loop-final-audit.json - planr map show --json > loop-map-show.json - planr log list --json > loop-log-list.json -) - -require_contains '"holds": true' "$workdir/loop-final-audit.json" -require_contains 'model_routing_terra_high' "$workdir/loop-codex-events.jsonl" "$workdir/loop-last-message.txt" -require_contains 'model_routing_sol_high' "$workdir/loop-codex-events.jsonl" "$workdir/loop-last-message.txt" -require_contains 'collab_tool_call' "$workdir/loop-codex-events.jsonl" -require_contains '"kind": "verification"' "$workdir/loop-log-list.json" -require_contains 'printf oracle-worker-ok' "$workdir/loop-log-list.json" -require_contains 'review verdict: complete' "$workdir/loop-log-list.json" - -cargo run --manifest-path "$repo_root/Cargo.toml" --bin model-routing -- uninstall \ - --repository "$workdir" \ - > "$workdir/uninstall.json" \ - 2> "$workdir/uninstall.stderr" - -test ! -e "$workdir/.model-routing/manifest.json" -test ! -e "$workdir/.planr/agents.toml" -test ! -e "$workdir/.planr/policy.toml" - -post_json="$(planr --db "$db" item create "Post uninstall ordinary item" \ - --description "Planr still creates ordinary work after generated files are removed" \ - --work-type generic \ - --json)" -printf '%s\n' "$post_json" > "$workdir/post-uninstall-create.json" -post_id="$(printf '%s' "$post_json" | extract_id)" -planr --db "$db" item show "$post_id" --json > "$workdir/post-uninstall-show.json" -planr --db "$db" project show --json > "$workdir/post-uninstall-project.json" - -printf 'planr adapter oracle passed\n' -printf 'receipts: %s\n' "$workdir" -printf 'worker route: model_routing_terra_high\n' -printf 'review route: model_routing_sol_high\n' -printf "loop dispatch: codex exec ran \$planr-loop without model overrides\n" -printf 'worker agent: generated model_routing_terra_high role observed in loop evidence\n' -printf 'review agent: generated model_routing_sol_high role observed in loop evidence\n' -printf 'model-routing sentinel: untouched\n' -printf 'post uninstall item: %s\n' "$post_id" +routing_bin="${1:-$repo_root/target/debug/model-routing}" +exec cargo run --quiet --manifest-path "$repo_root/Cargo.toml" -p xtask -- certify planr --routing-bin "$routing_bin" diff --git a/scripts/regenerate-preset-catalog.mjs b/scripts/regenerate-preset-catalog.mjs index 959003a..6e58a49 100644 --- a/scripts/regenerate-preset-catalog.mjs +++ b/scripts/regenerate-preset-catalog.mjs @@ -1,50 +1,13 @@ #!/usr/bin/env node import { spawnSync } from "node:child_process"; -import { mkdirSync, readFileSync, rmSync } from "node:fs"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; -const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); - -function option(name, fallback) { - const index = process.argv.indexOf(name); - return index === -1 ? fallback : process.argv[index + 1]; -} - -const routingBin = resolve(packageRoot, option("--routing-bin", "target/release/model-routing")); -const catalog = resolve(packageRoot, "website/data/catalog.json"); -const bundles = resolve(packageRoot, "website/data/bundles"); - -function run(args) { - const result = spawnSync(routingBin, args, { - cwd: packageRoot, - stdio: "inherit", - env: process.env, - }); - if (result.error) throw result.error; - if (result.status !== 0) { - throw new Error(`${routingBin} ${args.join(" ")} exited with status ${result.status}`); - } -} - -try { - run(["catalog", "build", "--output", catalog]); - run(["catalog", "verify", catalog]); - const data = JSON.parse(readFileSync(catalog, "utf8")); - rmSync(bundles, { recursive: true, force: true }); - mkdirSync(bundles, { recursive: true }); - for (const entry of data.compositions ?? []) { - run([ - "compile", - entry.policy.id, - "--host", - entry.binding.id, - "--output", - resolve(bundles, `${entry.entryId}.json`), - ]); - } - console.log(`regenerated ${data.compositions.length} experimental Model Routing compositions and downloads`); -} catch (error) { - console.error(error instanceof Error ? error.message : error); - process.exitCode = 1; -} +const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const result = spawnSync( + "cargo", + ["run", "--quiet", "-p", "xtask", "--", "release", "prepare", "--allow-dirty"], + { cwd: root, stdio: "inherit" }, +); +if (result.error) throw result.error; +process.exitCode = result.status ?? 1; diff --git a/scripts/release.sh b/scripts/release.sh index d364036..a59fcd0 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -1,24 +1,14 @@ #!/usr/bin/env sh -# The supported release path. It validates one version across manifests, runs -# all local release gates, commits a version bump when needed, tags, and pushes. -# The tag-triggered GitHub workflow builds and publishes platform artifacts. -# -# Usage: scripts/release.sh "release summary" -# Dry run: RELEASE_DRY_RUN=1 scripts/release.sh "summary" -# Non-destructive local mode (requires a green RC matrix for the same tree): -# SWITCHLOOM_RC_RUN_ID= SWITCHLOOM_RELEASE_REUSE_DEPS=1 scripts/release.sh ... +# Credential-bound release handoff. Deterministic preparation, verification, +# packaging, inventories, provenance, and cleanliness policy live in xtask. set -eu cd "$(dirname "$0")/.." version="${1:-}" summary="${2:-}" -if ! echo "$version" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+(-(alpha|beta|rc)\.[0-9]+)?$'; then - echo "usage: scripts/release.sh \"release summary\"" >&2 - exit 1 -fi -if [ -z "$summary" ]; then - echo "release summary must not be empty" >&2 +if [ -z "$version" ] || [ -z "$summary" ]; then + echo "usage: scripts/release.sh \"release summary\"" >&2 exit 1 fi @@ -27,10 +17,6 @@ if [ "$branch" != "main" ]; then echo "release must run on main (current: $branch)" >&2 exit 1 fi -if [ -n "$(git status --porcelain)" ]; then - echo "worktree is dirty; commit or stash before releasing" >&2 - exit 1 -fi git fetch origin main --tags if [ "$(git rev-parse HEAD)" != "$(git rev-parse origin/main)" ]; then @@ -41,17 +27,8 @@ if git rev-parse "v$version" >/dev/null 2>&1; then echo "tag v$version already exists" >&2 exit 1 fi -if ! grep -q "^## \[$version\]" CHANGELOG.md; then - echo "CHANGELOG.md has no '## [$version]' section" >&2 - exit 1 -fi rc_run_id="${SWITCHLOOM_RC_RUN_ID:-}" -reuse_deps="${SWITCHLOOM_RELEASE_REUSE_DEPS:-0}" -if [ "$reuse_deps" = "1" ] && [ -z "$rc_run_id" ]; then - echo "SWITCHLOOM_RELEASE_REUSE_DEPS=1 requires SWITCHLOOM_RC_RUN_ID" >&2 - exit 1 -fi if [ -n "$rc_run_id" ]; then rc_line="$(gh run view "$rc_run_id" --json conclusion,headSha --jq '.conclusion + " " + .headSha')" rc_conclusion="${rc_line%% *}" @@ -64,66 +41,20 @@ if [ -n "$rc_run_id" ]; then echo "release candidate run $rc_run_id does not match the current source tree" >&2 exit 1 fi - echo "verified release candidate run $rc_run_id for source tree $rc_sha" fi -current_version="$(sed -n 's/^version = "\([^"]*\)"/\1/p' Cargo.toml | head -n 1)" -if [ "${RELEASE_DRY_RUN:-0}" = "1" ] && [ "$version" != "$current_version" ]; then - echo "dry runs require the current manifest version ($current_version)" >&2 - exit 1 -fi - -replace() { - file="$1" - pattern="$2" - sed "$pattern" "$file" > "$file.release-tmp" - mv "$file.release-tmp" "$file" -} - -replace Cargo.toml "s/^version = \".*\"/version = \"$version\"/" -replace package.json "s/\"version\": \".*\"/\"version\": \"$version\"/" -# Refresh only the root package version in Cargo.lock before all subsequent -# locked builds and package checks. -cargo check --quiet -cargo run --quiet --bin model-routing -- compile balanced --host codex-openai --integration planr \ - --output fixtures/routing-bundle-v1/valid-balanced-codex.json -cargo run --quiet --bin model-routing -- compile balanced --host mixed-host --integration planr \ - --output fixtures/routing-bundle-v1/valid-balanced-mixed.json -cargo build --release --locked -node scripts/regenerate-preset-catalog.mjs - -cargo fmt --all -- --check -cargo clippy --workspace --all-targets --all-features -- -D warnings -cargo test --workspace --all-targets --all-features -if [ "$reuse_deps" = "1" ]; then - test -x node_modules/.bin/vitest - test -x node_modules/.bin/astro - ./node_modules/.bin/vitest run - node --test website/alchemy-runtime.test.mjs website/cloudflare-launcher.test.mjs - ./node_modules/.bin/astro check - ./node_modules/.bin/astro build - node scripts/build-site.mjs - bash scripts/npm-pack-check.sh -else - pnpm install --frozen-lockfile - pnpm site:check - pnpm pack:check -fi -cargo package --locked --allow-dirty --no-verify -scripts/secleak-check.sh -if [ -n "$rc_run_id" ]; then - echo "release archives already verified by release candidate run $rc_run_id" -else - scripts/build-release.sh +cargo run --quiet -p xtask -- release prepare --version "$version" +cargo run --quiet -p xtask -- release verify +if [ -z "$rc_run_id" ]; then + cargo run --quiet -p xtask -- release package fi -SWITCHLOOM_NATIVE_BIN="$(pwd)/target/release/model-routing" node npm/bin/model-routing.js --version if [ "${RELEASE_DRY_RUN:-0}" = "1" ]; then echo "release dry run passed for v$version" exit 0 fi -git add -- Cargo.toml Cargo.lock package.json \ +git add -- Cargo.toml Cargo.lock xtask/Cargo.toml package.json \ fixtures/routing-bundle-v1/valid-balanced-codex.json \ fixtures/routing-bundle-v1/valid-balanced-mixed.json \ website/data/catalog.json website/data/bundles @@ -133,4 +64,4 @@ fi git tag -a "v$version" -m "Switchloom v$version: $summary" git push origin HEAD "v$version" -echo "released v$version; the Release workflow will publish signed checksums and platform archives" +echo "released v$version; GitHub Actions owns publication" diff --git a/scripts/validate-codex-runtime-evidence.mjs b/scripts/validate-codex-runtime-evidence.mjs index 9f953ee..cfca025 100644 --- a/scripts/validate-codex-runtime-evidence.mjs +++ b/scripts/validate-codex-runtime-evidence.mjs @@ -1,206 +1,11 @@ #!/usr/bin/env node -import { readFile } from "node:fs/promises"; - -const positional = []; -const options = new Map(); -for (let index = 2; index < process.argv.length; index += 1) { - const arg = process.argv[index]; - if (arg === "--expect") { - options.set("expect", process.argv[index + 1]); - index += 1; - } else { - positional.push(arg); - } -} - -const [receiptPath] = positional; -if (!receiptPath || positional.length > 1 || (options.has("expect") && !options.get("expect"))) { - throw new Error("usage: node scripts/validate-codex-runtime-evidence.mjs [--expect ]"); -} - -const schemaVersion = "switchloom.codex_runtime_evidence.v1"; -const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; -const sha256DigestPattern = /^sha256:[0-9a-f]{64}$/; -const sha256HexPattern = /^[0-9a-f]{64}$/; -const codexVersionPattern = /^codex(?:-cli)?\s+\d+\.\d+\.\d+(?:\b|[-+])/; - -function fail(message) { - throw new Error(`codex runtime evidence validation failed: ${message}`); -} - -function assert(condition, message) { - if (!condition) fail(message); -} - -function assertUuid(value, label) { - assert(typeof value === "string" && uuidPattern.test(value), `${label} must be a UUID`); -} - -function assertString(value, label) { - assert(typeof value === "string" && value.trim().length > 0, `${label} must not be blank`); -} - -function expectedChildrenFrom(receipt, expected) { - const children = expected?.children ?? receipt.children; - assert(Array.isArray(children), "expected children must be an array"); - return new Map(children.map((child) => { - const kind = child.semantic_role ?? child.kind; - const model = child.model ?? child.state?.model; - const effort = child.effort ?? child.state?.reasoning_effort; - assertString(kind, "expected child semantic_role"); - assertString(child.agent_type, `${kind} expected agent_type`); - assertString(child.profile, `${kind} expected profile`); - assertString(child.task_name, `${kind} expected task_name`); - assertString(child.message_sha256 ?? child.message_ciphertext_sha256 ?? child.input?.message_sha256, `${kind} expected message hash`); - assertString(model, `${kind} expected model`); - assertString(effort, `${kind} expected effort`); - return [kind, { ...child, model, effort }]; - })); -} - -const receipt = JSON.parse(await readFile(receiptPath, "utf8")); -const expectedReceipt = options.has("expect") - ? JSON.parse(await readFile(options.get("expect"), "utf8")) - : undefined; -const requiredChildren = expectedChildrenFrom(receipt, expectedReceipt); -assert(receipt.schema_version === schemaVersion, `schema_version must be ${schemaVersion}`); -assert(receipt.run?.status === "complete", "run.status must be complete"); -assert(receipt.run?.complete_marker === "SWITCHLOOM_CODEX_RUNTIME_EVIDENCE_COMPLETE", "run complete marker missing"); -assert(receipt.run?.evidence_source === "codex_persisted_spawn_state", "run.evidence_source must be codex_persisted_spawn_state"); -assertUuid(receipt.run?.parent_thread_id, "run.parent_thread_id"); -assert(typeof receipt.run?.parent_session === "string" && receipt.run.parent_session.endsWith(".jsonl"), "run.parent_session must name a persisted session jsonl"); -assert(typeof receipt.run?.workdir === "string" && receipt.run.workdir.startsWith("/"), "run.workdir must be absolute"); -assert(Array.isArray(receipt.children), "children must be an array"); -assert(receipt.children.length === requiredChildren.size, "children must contain worker and reviewer only"); -assert(Array.isArray(receipt.dispatch_evidence), "dispatch_evidence must be an array"); -assert(receipt.dispatch_evidence.length === requiredChildren.size, "dispatch_evidence must contain worker and reviewer receipts only"); - -const seenKinds = new Set(); -for (const child of receipt.children) { - const expected = requiredChildren.get(child.kind); - assert(expected, `unexpected child kind ${child.kind}`); - assert(!seenKinds.has(child.kind), `duplicate child kind ${child.kind}`); - seenKinds.add(child.kind); - assert(child.agent_type === expected.agent_type, `${child.kind} agent_type mismatch`); - assert(child.task_name === expected.task_name, `${child.kind} task_name mismatch`); - assert(/^[a-z][a-z0-9_]*$/.test(child.task_name), `${child.kind} task_name is invalid`); - assert(child.canonical_task === `/root/${child.task_name}`, `${child.kind} canonical_task mismatch`); - assert(child.parent_thread_id === receipt.run.parent_thread_id, `${child.kind} parent_thread_id mismatch`); - assertUuid(child.child_thread_id, `${child.kind} child_thread_id`); - assert(child.child_thread_id !== receipt.run.parent_thread_id, `${child.kind} child_thread_id must differ from parent`); - assert(child.spawn?.surface === "collaboration.spawn_agent", `${child.kind} spawn surface must be Codex V2 collaboration.spawn_agent`); - assert(child.spawn?.agent_type === child.agent_type, `${child.kind} spawn agent_type mismatch`); - assert(child.spawn?.task_name === child.task_name, `${child.kind} spawn task_name mismatch`); - assert(child.spawn?.fork_turns === "none", `${child.kind} fork_turns must be none`); - assertString(child.spawn?.call_id, `${child.kind} spawn call_id`); - assert(!("model" in child.spawn), `${child.kind} spawn must not manually override model`); - assert(!("reasoning_effort" in child.spawn), `${child.kind} spawn must not manually override effort`); - const expectedMessageSha256 = expected.message_sha256 ?? expected.input?.message_sha256; - const expectedCiphertextSha256 = expected.message_ciphertext_sha256; - const expectedMaxMessageBytes = expected.max_message_bytes ?? expected.input?.max_message_bytes; - if (expectedMessageSha256 !== undefined) { - assert(sha256HexPattern.test(expectedMessageSha256), `${child.kind} expected message_sha256 must be lowercase sha256 hex`); - } - if (expectedCiphertextSha256 !== undefined) { - assert(sha256HexPattern.test(expectedCiphertextSha256), `${child.kind} expected message_ciphertext_sha256 must be lowercase sha256 hex`); - } - assert(Number.isInteger(expectedMaxMessageBytes) && expectedMaxMessageBytes > 0, `${child.kind} expected max_message_bytes must be positive`); - assert(sha256HexPattern.test(child.input?.message_sha256), `${child.kind} input message_sha256 must be lowercase sha256 hex`); - const messageEncoding = child.input?.message_encoding ?? "plaintext"; - if (messageEncoding === "plaintext") { - assert(expectedMessageSha256 !== undefined, `${child.kind} plaintext input requires expected message_sha256`); - assert(child.input.message_sha256 === expectedMessageSha256, `${child.kind} input message_sha256 mismatch`); - assert(child.input.message_plaintext_verdict === "deterministic", `${child.kind} input message_plaintext_verdict mismatch`); - } else if (messageEncoding === "codex-encrypted") { - if (expectedCiphertextSha256 !== undefined) { - assert(child.input.message_sha256 === expectedCiphertextSha256, `${child.kind} input message_ciphertext_sha256 mismatch`); - } - assert(child.input.message_plaintext_verdict === "unsupported", `${child.kind} encrypted input cannot claim deterministic plaintext`); - if (child.input.message_plaintext_intent_sha256 !== undefined) { - assert(expectedMessageSha256 !== undefined, `${child.kind} encrypted input intent hash has no expected plaintext hash`); - assert(child.input.message_plaintext_intent_sha256 === expectedMessageSha256, `${child.kind} input message_plaintext_intent_sha256 mismatch`); - } - } else { - fail(`${child.kind} unsupported input message_encoding ${messageEncoding}`); - } - assert(Number.isInteger(child.input?.message_bytes) && child.input.message_bytes > 0, `${child.kind} input message_bytes must be positive`); - assert(child.input.message_bytes <= expectedMaxMessageBytes, `${child.kind} input message_bytes exceeds max_message_bytes`); - assert(child.input?.max_message_bytes === expectedMaxMessageBytes, `${child.kind} input max_message_bytes mismatch`); - assert( - child.spawn_output?.task_name === child.canonical_task || child.spawn_output?.agent_id === child.child_thread_id, - `${child.kind} spawn output task mismatch`, - ); - assert(child.session?.agent_role === child.agent_type, `${child.kind} session agent_role mismatch`); - assert( - child.session?.agent_path === child.canonical_task || child.session?.agent_path === null, - `${child.kind} session agent_path mismatch`, - ); - assert(child.session?.thread_source === "subagent", `${child.kind} session thread_source mismatch`); - assert(child.session?.parent_thread_id === receipt.run.parent_thread_id, `${child.kind} session parent mismatch`); - assert(typeof child.session?.session_file === "string" && child.session.session_file.endsWith(".jsonl"), `${child.kind} session file missing`); - assert(child.state?.agent_role === child.agent_type, `${child.kind} state agent_role mismatch`); - assert( - child.state?.agent_path === child.canonical_task || child.state?.agent_path === null, - `${child.kind} state agent_path mismatch`, - ); - assert(child.state?.model === expected.model, `${child.kind} effective model mismatch`); - assert(child.state?.reasoning_effort === expected.effort, `${child.kind} effective effort mismatch`); - assert(child.state?.thread_source === "subagent", `${child.kind} state thread_source mismatch`); - assert(child.state?.cwd === receipt.run.workdir, `${child.kind} state cwd mismatch`); - assert(child.state.model !== "gpt-5.6-sol" || child.state.reasoning_effort !== "medium", `${child.kind} inherited Sol Medium evidence is forbidden`); - assert(child.final_answer?.message_type === "FINAL_ANSWER", `${child.kind} final answer marker missing`); - - const dispatchEvidence = receipt.dispatch_evidence.find((evidence) => { - return evidence?.requested_dispatch?.semantic_role === child.kind - && evidence?.requested_dispatch?.agent_type === child.agent_type - && evidence?.child_identity?.task_name === child.task_name; - }); - assert(dispatchEvidence, `${child.kind} dispatch_evidence receipt missing`); - assert(dispatchEvidence.schema_version === 1, `${child.kind} dispatch_evidence schema_version mismatch`); - assertString(dispatchEvidence.package_digest, `${child.kind} dispatch_evidence package_digest`); - assertString(dispatchEvidence.host_version, `${child.kind} dispatch_evidence host_version`); - assert(sha256DigestPattern.test(dispatchEvidence.package_digest), `${child.kind} dispatch_evidence package_digest must be sha256:<64 lowercase hex>`); - assert(codexVersionPattern.test(dispatchEvidence.host_version), `${child.kind} dispatch_evidence host_version must come from codex --version`); - if (expectedReceipt) { - assert(dispatchEvidence.package_digest === expectedReceipt.package_digest, `${child.kind} package_digest mismatch`); - assert(dispatchEvidence.host_version === expectedReceipt.host_version, `${child.kind} host_version mismatch`); - } - assert(dispatchEvidence.requested_dispatch.profile === expected.profile, `${child.kind} requested profile mismatch`); - assert(dispatchEvidence.requested_dispatch.model === expected.model, `${child.kind} requested model mismatch`); - assert(dispatchEvidence.requested_dispatch.effort === expected.effort, `${child.kind} requested effort mismatch`); - assert(dispatchEvidence.requested_dispatch.agent_type === child.agent_type, `${child.kind} requested agent_type mismatch`); - assert(dispatchEvidence.requested_dispatch.fork_turns?.mode === "none", `${child.kind} requested fork_turns must be none`); - assert(!("turns" in dispatchEvidence.requested_dispatch.fork_turns), `${child.kind} fork_turns none must not include turns`); - assert(dispatchEvidence.requested_dispatch.message_sha256 === child.input.message_sha256, `${child.kind} requested message_sha256 mismatch`); - assert(dispatchEvidence.requested_dispatch.message_encoding === messageEncoding, `${child.kind} requested message_encoding mismatch`); - assert(dispatchEvidence.requested_dispatch.message_plaintext_verdict === child.input.message_plaintext_verdict, `${child.kind} requested message_plaintext_verdict mismatch`); - assert(dispatchEvidence.requested_dispatch.message_plaintext_intent_sha256 === child.input.message_plaintext_intent_sha256, `${child.kind} requested message_plaintext_intent_sha256 mismatch`); - assert(dispatchEvidence.requested_dispatch.message_bytes === child.input.message_bytes, `${child.kind} requested message_bytes mismatch`); - assert(dispatchEvidence.requested_dispatch.max_message_bytes === expectedMaxMessageBytes, `${child.kind} requested max_message_bytes mismatch`); - assert(dispatchEvidence.child_identity.host === "codex", `${child.kind} child host mismatch`); - assert(dispatchEvidence.child_identity.role === child.kind, `${child.kind} child role mismatch`); - assert(dispatchEvidence.child_identity.agent_role === child.agent_type, `${child.kind} child agent_role mismatch`); - assert(dispatchEvidence.child_identity.agent_type === child.agent_type, `${child.kind} child agent_type mismatch`); - assert(dispatchEvidence.effective_model === child.state.model, `${child.kind} effective model receipt mismatch`); - assert(dispatchEvidence.effective_effort === child.state.reasoning_effort, `${child.kind} effective effort receipt mismatch`); - assert(dispatchEvidence.nonce === `${receipt.run.parent_thread_id}:${child.child_thread_id}:${child.spawn.call_id}`, `${child.kind} dispatch_evidence nonce must bind parent thread, child thread, and spawn call`); - assert( - !dispatchEvidence.nonce.includes("nonce-") && !dispatchEvidence.nonce.includes("placeholder"), - `${child.kind} dispatch_evidence nonce must be runtime-derived, not a placeholder`, - ); - assert(Array.isArray(dispatchEvidence.raw_evidence_refs) && dispatchEvidence.raw_evidence_refs.length > 0, `${child.kind} raw evidence refs missing`); - assert( - dispatchEvidence.raw_evidence_refs.includes(`codex-session:${receipt.run.parent_session}`) - && dispatchEvidence.raw_evidence_refs.includes(`codex-session:${child.session.session_file}`) - && dispatchEvidence.raw_evidence_refs.includes(`state_5.sqlite:thread_spawn_edges:${receipt.run.parent_thread_id}:${child.child_thread_id}`) - && dispatchEvidence.raw_evidence_refs.includes(`spawn_call:${child.spawn.call_id}`), - `${child.kind} raw evidence refs must bind parent session, child session, spawn edge, and spawn call`, - ); - assert(dispatchEvidence.verdict === "deterministic", `${child.kind} dispatch_evidence verdict mismatch`); -} - -for (const kind of requiredChildren.keys()) { - assert(seenKinds.has(kind), `missing ${kind} child evidence`); -} - -console.log("codex runtime evidence validation passed"); +import { spawnSync } from "node:child_process"; + +const args = process.argv.slice(2); +const receipt = args[0]?.startsWith("--") ? undefined : args.shift(); +const translated = receipt ? ["--receipt", receipt, ...args] : args; +const result = spawnSync("cargo", [ + "run", "--quiet", "--manifest-path", new URL("../Cargo.toml", import.meta.url).pathname, + "-p", "xtask", "--", "certify", "codex", ...translated, +], { stdio: "inherit" }); +process.exit(result.status ?? 1); diff --git a/scripts/validate-codex-runtime-evidence.test.mjs b/scripts/validate-codex-runtime-evidence.test.mjs index 6ac4cce..50a81a6 100644 --- a/scripts/validate-codex-runtime-evidence.test.mjs +++ b/scripts/validate-codex-runtime-evidence.test.mjs @@ -1,410 +1,9 @@ import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; -import { createHash } from "node:crypto"; -import { mkdtemp, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; import test from "node:test"; -const parent = "11111111-1111-4111-8111-111111111111"; -const worker = "22222222-2222-4222-8222-222222222222"; -const reviewer = "33333333-3333-4333-8333-333333333333"; -const workdir = "/tmp/switchloom-fresh-repo"; -const packageDigest = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; -const hostVersion = "codex 0.144.0"; - -function sha256(value) { - return createHash("sha256").update(value).digest("hex"); -} - -function profileFor(kind) { - return kind === "worker" ? "codex-terra-high" : "codex-sol-high"; -} - -function child(kind, agentType, taskName, childThreadId, model, effort, profile = profileFor(kind)) { - const callId = `call-${kind}`; - const message = `${kind} bounded task input`; - return { - kind, - profile, - agent_type: agentType, - task_name: taskName, - canonical_task: `/root/${taskName}`, - parent_thread_id: parent, - child_thread_id: childThreadId, - spawn: { - surface: "collaboration.spawn_agent", - agent_type: agentType, - task_name: taskName, - fork_turns: "none", - call_id: callId, - }, - input: { - message_sha256: sha256(message), - message_bytes: Buffer.byteLength(message, "utf8"), - max_message_bytes: 512, - message_encoding: "plaintext", - message_plaintext_verdict: "deterministic", - }, - spawn_output: { - task_name: `/root/${taskName}`, - }, - session: { - agent_role: agentType, - agent_path: `/root/${taskName}`, - thread_source: "subagent", - parent_thread_id: parent, - session_file: `${childThreadId}.jsonl`, - }, - state: { - agent_role: agentType, - agent_path: `/root/${taskName}`, - model, - reasoning_effort: effort, - thread_source: "subagent", - cwd: workdir, - }, - final_answer: { - message_type: "FINAL_ANSWER", - }, - }; -} - -function validReceipt() { - const children = [ - child("worker", "model_routing_terra_high", "worker", worker, "gpt-5.6-terra", "high"), - child("reviewer", "model_routing_sol_high", "reviewer", reviewer, "gpt-5.6-sol", "high"), - ]; - return { - schema_version: "switchloom.codex_runtime_evidence.v1", - run: { - status: "complete", - complete_marker: "SWITCHLOOM_CODEX_RUNTIME_EVIDENCE_COMPLETE", - evidence_source: "codex_persisted_spawn_state", - parent_thread_id: parent, - parent_session: `${parent}.jsonl`, - workdir, - }, - children, - dispatch_evidence: children.map((entry) => ({ - schema_version: 1, - package_digest: packageDigest, - host_version: hostVersion, - requested_dispatch: { - semantic_role: entry.kind, - profile: entry.profile, - model: entry.state.model, - effort: entry.state.reasoning_effort, - agent_type: entry.agent_type, - fork_turns: { - mode: "none", - }, - message_sha256: entry.input.message_sha256, - message_encoding: entry.input.message_encoding ?? "plaintext", - message_plaintext_verdict: entry.input.message_plaintext_verdict ?? "deterministic", - message_plaintext_intent_sha256: entry.input.message_plaintext_intent_sha256, - message_bytes: entry.input.message_bytes, - max_message_bytes: entry.input.max_message_bytes, - }, - child_identity: { - host: "codex", - role: entry.kind, - agent_role: entry.agent_type, - agent_type: entry.agent_type, - task_name: entry.task_name, - }, - effective_model: entry.state.model, - effective_effort: entry.state.reasoning_effort, - nonce: `${parent}:${entry.child_thread_id}:${entry.spawn.call_id}`, - raw_evidence_refs: [ - `codex-session:${parent}.jsonl`, - `codex-session:${entry.child_thread_id}.jsonl`, - `state_5.sqlite:thread_spawn_edges:${parent}:${entry.child_thread_id}`, - `spawn_call:${entry.spawn.call_id}`, - ], - verdict: "deterministic", - })), - }; -} - -async function withReceipt(receipt, callback) { - const root = await mkdtemp(join(tmpdir(), "switchloom-codex-evidence-")); - try { - const path = join(root, "receipt.json"); - await writeFile(path, typeof receipt === "string" ? receipt : JSON.stringify(receipt, null, 2)); - return await callback(path); - } finally { - await rm(root, { recursive: true, force: true }); - } -} - -function run(path) { - return spawnSync(process.execPath, ["scripts/validate-codex-runtime-evidence.mjs", path], { - cwd: new URL("..", import.meta.url), - encoding: "utf8", - }); -} - -function runWithExpect(path, expectPath) { - return spawnSync(process.execPath, ["scripts/validate-codex-runtime-evidence.mjs", path, "--expect", expectPath], { - cwd: new URL("..", import.meta.url), - encoding: "utf8", - }); -} - -test("accepts complete correlated Codex runtime evidence", async () => { - await withReceipt(validReceipt(), (path) => { - const result = run(path); - assert.equal(result.status, 0, result.stderr); - assert.match(result.stdout, /runtime evidence validation passed/); - }); -}); - -test("rejects prose-only and incomplete evidence", async () => { - await withReceipt("The worker used Terra High and the reviewer used Sol High.", (path) => { - const result = run(path); - assert.notEqual(result.status, 0); - assert.match(result.stderr, /JSON|Unexpected token|not valid/i); - }); - - const incomplete = validReceipt(); - delete incomplete.run.complete_marker; - await withReceipt(incomplete, (path) => { - const result = run(path); - assert.notEqual(result.status, 0); - assert.match(result.stderr, /complete marker missing/); - }); -}); - -test("rejects missing persisted source and uncorrelated child state", async () => { - const synthetic = validReceipt(); - delete synthetic.run.evidence_source; - await withReceipt(synthetic, (path) => { - const result = run(path); - assert.notEqual(result.status, 0); - assert.match(result.stderr, /evidence_source/); - }); - - const uncorrelated = validReceipt(); - uncorrelated.children[0].session.parent_thread_id = reviewer; - await withReceipt(uncorrelated, (path) => { - const result = run(path); - assert.notEqual(result.status, 0); - assert.match(result.stderr, /session parent mismatch/); - }); -}); - -test("rejects inherited Sol Medium behavior", async () => { - const inherited = validReceipt(); - inherited.children[1].state.model = "gpt-5.6-sol"; - inherited.children[1].state.reasoning_effort = "medium"; - await withReceipt(inherited, (path) => { - const result = run(path); - assert.notEqual(result.status, 0); - assert.match(result.stderr, /effective effort mismatch|inherited Sol Medium/); - }); -}); - -test("rejects receipts without correlated dispatch evidence", async () => { - const missing = validReceipt(); - delete missing.dispatch_evidence; - await withReceipt(missing, (path) => { - const result = run(path); - assert.notEqual(result.status, 0); - assert.match(result.stderr, /dispatch_evidence/); - }); - - const uncorrelated = validReceipt(); - uncorrelated.dispatch_evidence[0].child_identity.agent_role = "model_routing_sol_medium"; - await withReceipt(uncorrelated, (path) => { - const result = run(path); - assert.notEqual(result.status, 0); - assert.match(result.stderr, /agent_role mismatch/); - }); - - const missingNonce = validReceipt(); - missingNonce.dispatch_evidence[0].nonce = ""; - await withReceipt(missingNonce, (path) => { - const result = run(path); - assert.notEqual(result.status, 0); - assert.match(result.stderr, /nonce/); - }); - - const staleNonce = validReceipt(); - staleNonce.dispatch_evidence[0].nonce = `${parent}:${worker}:stale-call`; - await withReceipt(staleNonce, (path) => { - const result = run(path); - assert.notEqual(result.status, 0); - assert.match(result.stderr, /nonce must bind parent thread, child thread, and spawn call/); - }); - - const echoedNonce = validReceipt(); - echoedNonce.dispatch_evidence[0].nonce = "nonce-123"; - await withReceipt(echoedNonce, (path) => { - const result = run(path); - assert.notEqual(result.status, 0); - assert.match(result.stderr, /nonce must bind parent thread, child thread, and spawn call/); - }); -}); - -test("rejects placeholder package and host provenance", async () => { - const placeholder = validReceipt(); - placeholder.dispatch_evidence[0].package_digest = `codex-runtime:${parent}`; - await withReceipt(placeholder, (path) => { - const result = run(path); - assert.notEqual(result.status, 0); - assert.match(result.stderr, /package_digest/); - }); - - const proseVersion = validReceipt(); - proseVersion.dispatch_evidence[0].host_version = "codex native persisted spawn state"; - await withReceipt(proseVersion, (path) => { - const result = run(path); - assert.notEqual(result.status, 0); - assert.match(result.stderr, /host_version/); - }); -}); - -test("rejects non-V2 spawn surface, direct override, and mismatched task identity", async () => { - const external = validReceipt(); - external.children[0].spawn.surface = "multi_agent_v1__spawn_agent"; - await withReceipt(external, (path) => { - const result = run(path); - assert.notEqual(result.status, 0); - assert.match(result.stderr, /spawn surface/); - }); - - const directOverride = validReceipt(); - directOverride.children[0].spawn.model = "gpt-5.6-sol"; - await withReceipt(directOverride, (path) => { - const result = run(path); - assert.notEqual(result.status, 0); - assert.match(result.stderr, /manually override model/); - }); - - const mismatchedTask = validReceipt(); - mismatchedTask.children[0].task_name = "unrelated_worker"; - await withReceipt(mismatchedTask, (path) => { - const result = run(path); - assert.notEqual(result.status, 0); - assert.match(result.stderr, /canonical_task mismatch|task_name mismatch/); - }); -}); - -test("rejects missing, changed, and oversized bounded task input evidence", async () => { - const missing = validReceipt(); - delete missing.children[0].input; - await withReceipt(missing, (path) => { - const result = run(path); - assert.notEqual(result.status, 0); - assert.match(result.stderr, /expected message hash|expected message_sha256|input message_sha256/); - }); - - const changed = validReceipt(); - changed.children[0].input.message_sha256 = sha256("different message"); - await withReceipt(changed, (path) => { - const result = run(path); - assert.notEqual(result.status, 0); - assert.match(result.stderr, /message_sha256 mismatch/); - }); - - const oversized = validReceipt(); - oversized.children[0].input.message_bytes = oversized.children[0].input.max_message_bytes + 1; - oversized.dispatch_evidence[0].requested_dispatch.message_bytes = oversized.children[0].input.message_bytes; - await withReceipt(oversized, (path) => { - const result = run(path); - assert.notEqual(result.status, 0); - assert.match(result.stderr, /exceeds max_message_bytes/); - }); -}); - -test("rejects raw refs that do not bind parent and child Codex sessions", async () => { - const missingChildSession = validReceipt(); - missingChildSession.dispatch_evidence[0].raw_evidence_refs = [ - `codex-session:${parent}.jsonl`, - `state_5.sqlite:thread_spawn_edges:${parent}:${worker}`, - "spawn_call:call-worker", - ]; - await withReceipt(missingChildSession, (path) => { - const result = run(path); - assert.notEqual(result.status, 0); - assert.match(result.stderr, /raw evidence refs must bind/); - }); -}); - -test("accepts custom role receipts with explicit expected semantic roles and profiles", async () => { - const implementer = child("implementer", "switchloom_implementer", "implementer", worker, "gpt-5.6-terra", "high", "switchloom_implementer"); - const customReviewer = child("reviewer", "switchloom_reviewer", "reviewer", reviewer, "gpt-5.6-sol", "high", "switchloom_reviewer"); - const custom = validReceipt(); - custom.children = [implementer, customReviewer]; - custom.dispatch_evidence = custom.children.map((entry) => ({ - schema_version: 1, - package_digest: packageDigest, - host_version: hostVersion, - requested_dispatch: { - semantic_role: entry.kind, - profile: entry.profile, - model: entry.state.model, - effort: entry.state.reasoning_effort, - agent_type: entry.agent_type, - fork_turns: { mode: "none" }, - message_sha256: entry.input.message_sha256, - message_encoding: entry.input.message_encoding ?? "plaintext", - message_plaintext_verdict: entry.input.message_plaintext_verdict ?? "deterministic", - message_plaintext_intent_sha256: entry.input.message_plaintext_intent_sha256, - message_bytes: entry.input.message_bytes, - max_message_bytes: entry.input.max_message_bytes, - }, - child_identity: { - host: "codex", - role: entry.kind, - agent_role: entry.agent_type, - agent_type: entry.agent_type, - task_name: entry.task_name, - }, - effective_model: entry.state.model, - effective_effort: entry.state.reasoning_effort, - nonce: `${parent}:${entry.child_thread_id}:${entry.spawn.call_id}`, - raw_evidence_refs: [ - `codex-session:${parent}.jsonl`, - `codex-session:${entry.child_thread_id}.jsonl`, - `state_5.sqlite:thread_spawn_edges:${parent}:${entry.child_thread_id}`, - `spawn_call:${entry.spawn.call_id}`, - ], - verdict: "deterministic", - })); - - const expected = { - package_digest: packageDigest, - host_version: hostVersion, - children: [ - { - semantic_role: "implementer", - profile: "switchloom_implementer", - agent_type: "switchloom_implementer", - task_name: "implementer", - message_sha256: implementer.input.message_sha256, - max_message_bytes: implementer.input.max_message_bytes, - model: "gpt-5.6-terra", - effort: "high", - }, - { - semantic_role: "reviewer", - profile: "switchloom_reviewer", - agent_type: "switchloom_reviewer", - task_name: "reviewer", - message_sha256: customReviewer.input.message_sha256, - max_message_bytes: customReviewer.input.max_message_bytes, - model: "gpt-5.6-sol", - effort: "high", - }, - ], - }; - await withReceipt(custom, async (path) => { - await withReceipt(expected, (expectPath) => { - const result = runWithExpect(path, expectPath); - assert.equal(result.status, 0, result.stderr); - assert.match(result.stdout, /runtime evidence validation passed/); - }); - }); +test("Codex compatibility path delegates its contract to Rust xtask", () => { + const result = spawnSync(process.execPath, ["scripts/validate-codex-runtime-evidence.mjs", "--help"], { encoding: "utf8" }); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /Validate an extracted Codex persisted-runtime receipt/); }); diff --git a/scripts/validate-codex-spawn-state.mjs b/scripts/validate-codex-spawn-state.mjs index f49ebff..2776fc4 100644 --- a/scripts/validate-codex-spawn-state.mjs +++ b/scripts/validate-codex-spawn-state.mjs @@ -1,442 +1,8 @@ #!/usr/bin/env node import { spawnSync } from "node:child_process"; -import { createHash } from "node:crypto"; -import { existsSync, readdirSync, readFileSync } from "node:fs"; -import { homedir } from "node:os"; -import { basename, join, resolve } from "node:path"; -function usage() { - console.error( - "usage: validate-codex-spawn-state.mjs --events --workdir --expect [--state-db ] [--sessions-dir ]", - ); - process.exit(2); -} - -const args = new Map(); -for (let i = 2; i < process.argv.length; i += 2) { - if (!process.argv[i]?.startsWith("--") || !process.argv[i + 1]) usage(); - args.set(process.argv[i].slice(2), process.argv[i + 1]); -} - -const eventsPath = args.get("events"); -const workdir = args.get("workdir"); -const expectPath = args.get("expect"); -const stateDb = args.get("state-db") ?? join(homedir(), ".codex", "state_5.sqlite"); -const sessionsDir = args.get("sessions-dir") ?? join(homedir(), ".codex", "sessions"); -const archivedSessionsDir = args.get("archived-sessions-dir") ?? join(homedir(), ".codex", "archived_sessions"); -if (!eventsPath || !workdir || !expectPath) usage(); - -const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; -const sha256DigestPattern = /^sha256:[0-9a-f]{64}$/; -const codexVersionPattern = /^codex(?:-cli)?\s+\d+\.\d+\.\d+(?:\b|[-+])/; - -function fail(message) { - console.error(`codex spawn state validation failed: ${message}`); - process.exit(1); -} - -function assert(condition, message) { - if (!condition) fail(message); -} - -function readJsonl(path) { - return readFileSync(path, "utf8") - .split(/\r?\n/) - .filter(Boolean) - .map((line, index) => { - try { - return JSON.parse(line); - } catch (error) { - fail(`${path}:${index + 1} is not JSON: ${error.message}`); - } - }); -} - -function walk(dir, suffix, hits = []) { - if (!existsSync(dir)) return hits; - for (const entry of readdirSync(dir, { withFileTypes: true })) { - const path = join(dir, entry.name); - if (entry.isDirectory()) { - walk(path, suffix, hits); - } else if (entry.isFile() && entry.name.endsWith(suffix)) { - hits.push(path); - } - } - return hits; -} - -function findSession(threadId) { - assert(uuidPattern.test(threadId), `invalid thread id ${threadId}`); - const suffix = `${threadId}.jsonl`; - const hits = [...walk(sessionsDir, suffix), ...walk(archivedSessionsDir, suffix)]; - assert(hits.length > 0, `no persisted Codex session found for ${threadId}`); - assert(hits.length === 1, `multiple persisted Codex sessions found for ${threadId}: ${hits.join(", ")}`); - return hits[0]; -} - -function parseJsonObject(value, label) { - assert(typeof value === "string" && value.length > 0, `${label} is not a JSON string`); - try { - return JSON.parse(value); - } catch (error) { - fail(`${label} is not valid JSON: ${error.message}`); - } -} - -function sha256(value) { - return createHash("sha256").update(value).digest("hex"); -} - -function byteLength(value) { - return Buffer.byteLength(value, "utf8"); -} - -function jsStringField(source, field) { - const escaped = field.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const match = source.match(new RegExp(`${escaped}\\s*:\\s*["']([^"']+)["']`)); - return match?.[1]; -} - -function sqliteJson(query) { - assert(existsSync(stateDb), `Codex state DB not found at ${stateDb}`); - const result = spawnSync("sqlite3", ["-json", stateDb, query], { encoding: "utf8" }); - assert(result.status === 0, `sqlite3 query failed: ${result.stderr || result.stdout}`); - return result.stdout.trim() ? JSON.parse(result.stdout) : []; -} - -const expected = JSON.parse(readFileSync(expectPath, "utf8")); -assert(Array.isArray(expected.children) && expected.children.length > 0, "expected children list is empty"); -assert(sha256DigestPattern.test(expected.package_digest), "expected package_digest must be sha256:<64 lowercase hex>"); -assert(codexVersionPattern.test(expected.host_version), "expected host_version must be observed codex --version output"); - -const outerEvents = readJsonl(eventsPath); -const started = outerEvents.find((record) => record.type === "thread.started"); -assert(started?.thread_id, "Codex exec JSONL did not contain thread.started.thread_id"); -const parentThreadId = started.thread_id; -assert(uuidPattern.test(parentThreadId), `invalid parent thread id ${parentThreadId}`); - -const parentSessionPath = findSession(parentThreadId); -const parentRecords = readJsonl(parentSessionPath); -const parentMeta = parentRecords.find((record) => record.type === "session_meta")?.payload; -assert(parentMeta?.id === parentThreadId, "parent session_meta id does not match thread.started"); -assert(parentMeta?.thread_source === "user", "parent session is not a user thread"); -assert(resolve(parentMeta?.cwd ?? "") === resolve(workdir), "parent session cwd does not match oracle workdir"); - -const edgeRows = sqliteJson(` - select - e.parent_thread_id, - e.child_thread_id, - e.status, - t.agent_path, - t.agent_role, - t.model, - t.reasoning_effort, - t.thread_source, - t.cwd - from thread_spawn_edges e - join threads t on t.id = e.child_thread_id - where e.parent_thread_id = '${parentThreadId}' -`); - -const expectedByIdentity = new Map(); -const expectedCanonicalTasks = new Set(); -const expectedAgentTypes = new Set(); -for (const child of expected.children) { - assert(typeof child.agent_type === "string" && child.agent_type.length > 0, "expected child missing agent_type"); - assert(typeof child.task_name === "string" && child.task_name.length > 0, "expected child missing task_name"); - assert(typeof child.semantic_role === "string" && child.semantic_role.length > 0, `${child.agent_type} missing semantic_role`); - assert(typeof child.profile === "string" && child.profile.length > 0, `${child.agent_type} missing profile`); - assert(typeof child.canonical_task === "string" && child.canonical_task.startsWith("/root/"), `${child.agent_type} has invalid canonical_task`); - if ("message_sha256" in child) { - assert(typeof child.message_sha256 === "string" && /^[0-9a-f]{64}$/.test(child.message_sha256), `${child.agent_type} expected message_sha256 must be lowercase sha256 hex`); - } - if ("message_ciphertext_sha256" in child) { - assert(typeof child.message_ciphertext_sha256 === "string" && /^[0-9a-f]{64}$/.test(child.message_ciphertext_sha256), `${child.agent_type} expected message_ciphertext_sha256 must be lowercase sha256 hex`); - } - assert(Number.isInteger(child.max_message_bytes) && child.max_message_bytes > 0, `${child.agent_type} expected max_message_bytes must be positive`); - const identity = `${child.agent_type}\0${child.task_name}`; - assert(!expectedByIdentity.has(identity), `${child.agent_type} duplicate expected agent_type/task_name`); - expectedByIdentity.set(identity, child); - expectedCanonicalTasks.add(child.canonical_task); - expectedAgentTypes.add(child.agent_type); -} - -const allV2SpawnCalls = parentRecords.filter((record) => { - const payload = record.payload; - return record.type === "response_item" - && payload?.type === "function_call" - && payload.namespace === "collaboration" - && payload.name === "spawn_agent"; -}); -assert(allV2SpawnCalls.length === expected.children.length, `parent must contain exactly ${expected.children.length} V2 spawn_agent calls`); -for (const record of allV2SpawnCalls) { - const payload = record.payload; - const callArgs = parseJsonObject(payload.arguments, `spawn_agent arguments for ${payload.call_id}`); - const identity = `${callArgs.agent_type ?? ""}\0${callArgs.task_name ?? ""}`; - assert(expectedByIdentity.has(identity), `unexpected spawn_agent call for agent_type=${callArgs.agent_type ?? ""} task_name=${callArgs.task_name ?? ""}`); -} - -const allStartedActivities = parentRecords.filter((record) => { - const payload = record.payload; - return record.type === "event_msg" - && payload?.type === "sub_agent_activity" - && payload.kind === "started"; -}); -assert(allStartedActivities.length === expected.children.length, `parent must contain exactly ${expected.children.length} sub_agent_activity started events`); -for (const record of allStartedActivities) { - const payload = record.payload; - assert(expectedCanonicalTasks.has(payload.agent_path), `unexpected started child path ${payload.agent_path ?? ""}`); -} - -assert(edgeRows.length === expected.children.length, `parent must contain exactly ${expected.children.length} persisted child edges`); -for (const edge of edgeRows) { - assert(expectedAgentTypes.has(edge.agent_role), `unexpected persisted child agent_role ${edge.agent_role ?? ""}`); - if (edge.agent_path !== null) { - assert(expectedCanonicalTasks.has(edge.agent_path), `unexpected persisted child path ${edge.agent_path}`); - } -} - -const observed = []; -for (const child of expected.children) { - const canonicalTask = child.canonical_task; - - const legacySpawnCalls = parentRecords.filter((record) => { - const payload = record.payload; - if (record.type !== "response_item" || payload?.type !== "function_call") return false; - if (payload.namespace !== "collaboration" || payload.name !== "spawn_agent") return false; - const callArgs = parseJsonObject(payload.arguments, `spawn_agent arguments for ${payload.call_id}`); - return callArgs.agent_type === child.agent_type && callArgs.task_name === child.task_name; - }); - const v1SpawnCalls = parentRecords.filter((record) => { - const payload = record.payload; - if (record.type !== "response_item" || payload?.type !== "custom_tool_call") return false; - if (payload.name !== "exec" || !payload.input?.includes("multi_agent_v1__spawn_agent")) return false; - return jsStringField(payload.input, "agent_type") === child.agent_type; - }); - assert(v1SpawnCalls.length === 0, `${child.agent_type} V1 spawn evidence is not accepted for Codex V2 certification`); - assert( - legacySpawnCalls.length === 1, - `${child.agent_type} must have exactly one raw spawn_agent call`, - ); - - let spawnArgs; - let spawnOutput; - let childThreadId; - let observedAgentPath; - let spawnCallId; - let spawnSurface; - const spawnCall = legacySpawnCalls[0].payload; - spawnCallId = spawnCall.call_id; - spawnSurface = "collaboration.spawn_agent"; - spawnArgs = parseJsonObject(spawnCall.arguments, `spawn_agent arguments for ${child.agent_type}`); - assert(spawnArgs.agent_type === child.agent_type, `${child.agent_type} spawn agent_type mismatch`); - assert(spawnArgs.fork_turns === "none", `${child.agent_type} spawn did not use fork_turns=none`); - assert(spawnArgs.task_name === child.task_name, `${child.agent_type} spawn task_name mismatch`); - assert(typeof spawnArgs.message === "string" && spawnArgs.message.length > 0, `${child.agent_type} spawn message missing`); - const messageBytes = byteLength(spawnArgs.message); - assert(messageBytes <= child.max_message_bytes, `${child.agent_type} spawn message exceeds max_message_bytes`); - const messageSha256 = sha256(spawnArgs.message); - const messageInput = { - message_sha256: messageSha256, - message_bytes: messageBytes, - max_message_bytes: child.max_message_bytes, - message_encoding: "plaintext", - message_plaintext_verdict: "deterministic", - }; - if (messageSha256 !== child.message_sha256) { - assert(/^gAAAA[A-Za-z0-9_-]+={0,2}$/.test(spawnArgs.message), `${child.agent_type} spawn message_sha256 mismatch`); - assert(child.allow_encrypted_message === true || child.message_ciphertext_sha256, `${child.agent_type} encrypted spawn message cannot certify expected plaintext`); - if (child.message_ciphertext_sha256) { - assert(messageSha256 === child.message_ciphertext_sha256, `${child.agent_type} encrypted spawn message_ciphertext_sha256 mismatch`); - } - messageInput.message_encoding = "codex-encrypted"; - messageInput.message_plaintext_verdict = "unsupported"; - if (child.message_sha256) { - messageInput.message_plaintext_intent_sha256 = child.message_sha256; - } - } - assert(!("model" in spawnArgs), `${child.agent_type} spawn manually overrode model`); - assert(!("reasoning_effort" in spawnArgs), `${child.agent_type} spawn manually overrode reasoning_effort`); - - const outputPayload = parentRecords.find((record) => { - const payload = record.payload; - return record.type === "response_item" && payload?.type === "function_call_output" && payload.call_id === spawnCall.call_id; - })?.payload; - spawnOutput = parseJsonObject(outputPayload?.output, `spawn_agent output for ${child.agent_type}`); - assert(spawnOutput.task_name === canonicalTask, `${child.agent_type} spawn output task_name mismatch`); - - const startedActivity = parentRecords.find((record) => { - const payload = record.payload; - return record.type === "event_msg" - && payload?.type === "sub_agent_activity" - && payload.event_id === spawnCall.call_id - && payload.kind === "started"; - })?.payload; - assert(startedActivity, `${child.agent_type} missing sub_agent_activity started event`); - assert(uuidPattern.test(startedActivity.agent_thread_id), `${child.agent_type} started event missing child thread id`); - assert(startedActivity.agent_path === canonicalTask, `${child.agent_type} started event agent_path mismatch`); - childThreadId = startedActivity.agent_thread_id; - observedAgentPath = startedActivity.agent_path; - - const finalMessages = parentRecords.filter((record) => { - const payload = record.payload; - return record.type === "response_item" - && payload?.type === "agent_message" - && payload.author === canonicalTask - && payload.recipient === "/root"; - }).map((record) => record.payload); - let finalMessage = finalMessages.find((payload) => { - const finalText = payload.content?.map((part) => part.text ?? "").join("\n") ?? ""; - return finalText.includes("Message Type: FINAL_ANSWER") - && (!child.completion_contains || finalText.includes(child.completion_contains)); - }); - if (!finalMessage) { - finalMessage = parentRecords.find((record) => { - const payload = record.payload; - const text = payload?.content?.map((part) => part.text ?? "").join("\n") ?? ""; - return record.type === "response_item" - && payload?.type === "message" - && payload.role === "user" - && text.includes("") - && text.includes(childThreadId) - && (!child.completion_contains || text.includes(child.completion_contains)); - })?.payload; - } - assert(finalMessage, `${child.agent_type} missing child FINAL_ANSWER payload in parent session`); - const finalText = finalMessage.content?.map((part) => part.text ?? "").join("\n") ?? ""; - if (child.completion_contains) { - assert(finalText.includes(child.completion_contains), `${child.agent_type} final answer missing ${child.completion_contains}`); - } - - const edge = edgeRows.find((row) => row.child_thread_id === childThreadId); - assert(edge, `${child.agent_type} missing thread_spawn_edges row`); - assert(edge.parent_thread_id === parentThreadId, `${child.agent_type} edge parent mismatch`); - assert(edge.status && edge.status !== "unknown", `${child.agent_type} edge has empty status`); - if (edge.agent_path !== null) { - assert(edge.agent_path === canonicalTask, `${child.agent_type} state agent_path mismatch`); - } - assert(edge.agent_role === child.agent_type, `${child.agent_type} state agent_role mismatch`); - assert(edge.model === child.model, `${child.agent_type} effective model mismatch: expected ${child.model}, observed ${edge.model}`); - assert(edge.reasoning_effort === child.effort, `${child.agent_type} effective effort mismatch: expected ${child.effort}, observed ${edge.reasoning_effort}`); - assert(edge.thread_source === "subagent", `${child.agent_type} state thread_source mismatch`); - assert(resolve(edge.cwd) === resolve(workdir), `${child.agent_type} state cwd mismatch`); - - const childSessionPath = findSession(childThreadId); - const childRecords = readJsonl(childSessionPath); - const childMeta = childRecords.find((record) => record.type === "session_meta")?.payload; - assert(childMeta?.id === childThreadId, `${child.agent_type} child session id mismatch`); - assert(childMeta?.parent_thread_id === parentThreadId, `${child.agent_type} child parent_thread_id mismatch`); - assert(childMeta?.thread_source === "subagent", `${child.agent_type} child thread_source mismatch`); - if (childMeta?.agent_path !== null && childMeta?.agent_path !== undefined) { - assert(childMeta?.agent_path === canonicalTask, `${child.agent_type} child session agent_path mismatch`); - } - assert(childMeta?.agent_role === child.agent_type, `${child.agent_type} child session agent_role mismatch`); - assert(childMeta?.source?.subagent?.thread_spawn?.parent_thread_id === parentThreadId, `${child.agent_type} child source parent mismatch`); - if (childMeta?.source?.subagent?.thread_spawn?.agent_path !== null && childMeta?.source?.subagent?.thread_spawn?.agent_path !== undefined) { - assert(childMeta?.source?.subagent?.thread_spawn?.agent_path === canonicalTask, `${child.agent_type} child source agent_path mismatch`); - } - assert(childMeta?.source?.subagent?.thread_spawn?.agent_role === child.agent_type, `${child.agent_type} child source agent_role mismatch`); - - const childContext = childRecords.find((record) => record.type === "turn_context")?.payload; - assert(childContext?.model === child.model, `${child.agent_type} child turn_context model mismatch`); - assert(childContext?.effort === child.effort, `${child.agent_type} child turn_context effort mismatch`); - assert(childContext?.collaboration_mode?.settings?.model === child.model, `${child.agent_type} collaboration model mismatch`); - assert(childContext?.collaboration_mode?.settings?.reasoning_effort === child.effort, `${child.agent_type} collaboration effort mismatch`); - - observed.push({ - kind: child.semantic_role, - profile: child.profile, - agent_type: child.agent_type, - task_name: child.task_name, - canonical_task: canonicalTask, - parent_thread_id: parentThreadId, - child_thread_id: childThreadId, - model: edge.model, - effort: edge.reasoning_effort, - parent_session: basename(parentSessionPath), - child_session: basename(childSessionPath), - spawn: { - surface: spawnSurface, - agent_type: spawnArgs.agent_type, - task_name: spawnArgs.task_name, - fork_turns: spawnArgs.fork_turns ?? "none", - call_id: spawnCallId, - }, - input: messageInput, - spawn_output: { - task_name: spawnOutput.task_name, - agent_id: spawnOutput.agent_id, - }, - session: { - agent_role: childMeta.agent_role, - agent_path: childMeta.agent_path ?? observedAgentPath, - thread_source: childMeta.thread_source, - parent_thread_id: childMeta.parent_thread_id, - session_file: basename(childSessionPath), - }, - state: { - agent_role: edge.agent_role, - agent_path: edge.agent_path, - model: edge.model, - reasoning_effort: edge.reasoning_effort, - thread_source: edge.thread_source, - cwd: edge.cwd, - }, - final_answer: { - message_type: "FINAL_ANSWER", - }, - }); -} - -assert(observed.length === expected.children.length, "not all expected children were observed"); -const dispatchEvidence = observed.map((child) => ({ - schema_version: 1, - package_digest: expected.package_digest, - host_version: expected.host_version, - requested_dispatch: { - semantic_role: child.kind, - profile: child.profile, - model: child.model, - effort: child.effort, - agent_type: child.agent_type, - fork_turns: { - mode: child.spawn.fork_turns, - }, - message_sha256: child.input.message_sha256, - message_encoding: child.input.message_encoding, - message_plaintext_verdict: child.input.message_plaintext_verdict, - message_plaintext_intent_sha256: child.input.message_plaintext_intent_sha256, - message_bytes: child.input.message_bytes, - max_message_bytes: child.input.max_message_bytes, - }, - child_identity: { - host: "codex", - role: child.kind, - agent_role: child.state.agent_role, - agent_type: child.agent_type, - task_name: child.task_name, - }, - effective_model: child.model, - effective_effort: child.effort, - nonce: `${parentThreadId}:${child.child_thread_id}:${child.spawn.call_id}`, - raw_evidence_refs: [ - `codex-session:${child.parent_session}`, - `codex-session:${child.child_session}`, - `state_5.sqlite:thread_spawn_edges:${parentThreadId}:${child.child_thread_id}`, - `spawn_call:${child.spawn.call_id}`, - ], - verdict: "deterministic", -})); -console.log(JSON.stringify({ - schema_version: "switchloom.codex_runtime_evidence.v1", - run: { - status: "complete", - complete_marker: "SWITCHLOOM_CODEX_RUNTIME_EVIDENCE_COMPLETE", - evidence_source: "codex_persisted_spawn_state", - parent_thread_id: parentThreadId, - parent_session: basename(parentSessionPath), - workdir: resolve(workdir), - }, - children: observed, - dispatch_evidence: dispatchEvidence, -}, null, 2)); +const result = spawnSync("cargo", [ + "run", "--quiet", "--manifest-path", new URL("../Cargo.toml", import.meta.url).pathname, + "-p", "xtask", "--", "certify", "codex", ...process.argv.slice(2), +], { stdio: "inherit" }); +process.exit(result.status ?? 1); diff --git a/scripts/validate-native-provenance.mjs b/scripts/validate-native-provenance.mjs index bca03e0..768a93c 100644 --- a/scripts/validate-native-provenance.mjs +++ b/scripts/validate-native-provenance.mjs @@ -1,66 +1,13 @@ #!/usr/bin/env node -import assert from "node:assert/strict"; -import { createHash } from "node:crypto"; -import { existsSync, readFileSync } from "node:fs"; -import { relative, resolve } from "node:path"; import { spawnSync } from "node:child_process"; - -const repoRoot = resolve(new URL("..", import.meta.url).pathname); -const provenancePath = resolve(repoRoot, "npm/native/provenance.json"); -const packagePath = resolve(repoRoot, "package.json"); -const packageVersion = JSON.parse(readFileSync(packagePath, "utf8")).version; -const expectedTargets = new Set([ - "darwin-arm64", - "darwin-x86_64", - "linux-arm64", - "linux-x86_64", -]); - -function sha256(path) { - return createHash("sha256").update(readFileSync(path)).digest("hex"); -} - -function assertShape(value, message) { - assert(value && typeof value === "object" && !Array.isArray(value), message); -} - -assert(existsSync(provenancePath), "npm/native/provenance.json is missing"); -const provenance = JSON.parse(readFileSync(provenancePath, "utf8")); -assertShape(provenance, "provenance must be an object"); -assert.equal(provenance.schema_version, "switchloom.native_provenance.v1"); -assert.equal(provenance.package_version, packageVersion); -assert.equal(typeof provenance.git_sha, "string"); -assert.match(provenance.git_sha, /^[0-9a-f]{40}$/); -assert(Array.isArray(provenance.targets), "targets must be an array"); -assert.equal(provenance.targets.length, expectedTargets.size); - -for (const target of provenance.targets) { - assertShape(target, "target entry must be an object"); - assert(expectedTargets.delete(target.target), `unexpected target ${target.target}`); - assert.equal(target.version, `model-routing ${packageVersion}`); - assert.match(target.sha256, /^[0-9a-f]{64}$/); - assert.equal(typeof target.rust_target, "string"); - assert.equal(typeof target.runner, "string"); - assert.equal(typeof target.built_at, "string"); - assert.equal(target.git_sha, provenance.git_sha); - const binaryPath = resolve(repoRoot, target.path); - assert(relative(repoRoot, binaryPath).startsWith("npm/native/"), `${target.path} must be under npm/native`); - assert(existsSync(binaryPath), `${target.path} is missing`); - assert.equal(sha256(binaryPath), target.sha256, `${target.path} sha256 mismatch`); -} - -assert.equal(expectedTargets.size, 0, `missing targets: ${Array.from(expectedTargets).join(", ")}`); - -const current = { darwin: "darwin", linux: "linux" }[process.platform]; -const arch = { arm64: "arm64", x64: "x86_64" }[process.arch]; -if (current && arch) { - const target = `${current}-${arch}`; - const binaryPath = resolve(repoRoot, "npm/native", target, "model-routing"); - if (existsSync(binaryPath)) { - const result = spawnSync(binaryPath, ["--version"], { encoding: "utf8" }); - assert.equal(result.status, 0, result.stderr); - assert.equal(result.stdout.trim(), `model-routing ${packageVersion}`); - } -} - -console.log(`native provenance validated for ${packageVersion}`); +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const result = spawnSync( + "cargo", + ["run", "--quiet", "-p", "xtask", "--", "release", "verify", "--inventory-only"], + { cwd: root, stdio: "inherit" }, +); +if (result.error) throw result.error; +process.exitCode = result.status ?? 1; diff --git a/scripts/validate-opencode-runtime-evidence.mjs b/scripts/validate-opencode-runtime-evidence.mjs index 714b18a..ace5612 100644 --- a/scripts/validate-opencode-runtime-evidence.mjs +++ b/scripts/validate-opencode-runtime-evidence.mjs @@ -1,186 +1,8 @@ #!/usr/bin/env node -import { readFileSync, writeFileSync } from "node:fs"; +import { spawnSync } from "node:child_process"; -function usage() { - console.error("usage: validate-opencode-runtime-evidence.mjs --jsonl --invocation --receipt --package-digest --host-version --profile --model --variant --worker "); - process.exit(2); -} - -function arg(name) { - const index = process.argv.indexOf(`--${name}`); - if (index === -1 || index + 1 >= process.argv.length) usage(); - return process.argv[index + 1]; -} - -function parseJsonl(path) { - return readFileSync(path, "utf8") - .split(/\n+/) - .filter((line) => line.trim()) - .map((line, index) => { - try { - return JSON.parse(line); - } catch (error) { - throw new Error(`host output line ${index + 1} is not JSON: ${error.message}`); - } - }); -} - -function visit(value, fn, path = []) { - if (Array.isArray(value)) { - value.forEach((entry, index) => visit(entry, fn, path.concat(String(index)))); - return; - } - if (value && typeof value === "object") { - for (const [key, entry] of Object.entries(value)) { - fn(key, entry, path.concat(key), value); - visit(entry, fn, path.concat(key)); - } - } -} - -function stringValueForKeys(value, keys) { - let found = null; - visit(value, (key, entry) => { - if (found === null && keys.includes(key) && typeof entry === "string") found = entry; - }); - return found; -} - -function idValue(value) { - return stringValueForKeys(value, ["id", "toolCallID", "toolCallId", "call_id", "callId", "taskID", "taskId"]); -} - -function eventContains(value, needle) { - return JSON.stringify(value).includes(needle); -} - -function eventMentionsTask(value) { - let structured = false; - visit(value, (key, entry) => { - const keyLower = key.toLowerCase(); - if (typeof entry === "string") { - const valueLower = entry.toLowerCase(); - if ((keyLower.includes("tool") || keyLower.includes("type") || keyLower.includes("name")) && valueLower.includes("task")) { - structured = true; - } - } - }); - return structured; -} - -function eventAgent(value) { - return stringValueForKeys(value, ["agent", "agentName", "agent_name", "subagent", "subagentName", "taskAgent", "task_agent"]); -} - -function eventIsResult(value) { - let result = false; - visit(value, (key, entry) => { - const keyLower = key.toLowerCase(); - if (typeof entry === "string") { - const valueLower = entry.toLowerCase(); - if ((keyLower.includes("type") || keyLower.includes("event") || keyLower.includes("kind")) && valueLower.includes("result")) { - result = true; - } - if ((keyLower.includes("tool") || keyLower.includes("name")) && valueLower.includes("result")) { - result = true; - } - } - }); - return result; -} - -function firstModel(value) { - return stringValueForKeys(value, ["model", "modelID", "modelId", "providerModel", "provider_model"]); -} - -function firstVariant(value) { - return stringValueForKeys(value, ["variant", "effort", "reasoningEffort", "reasoning_effort"]); -} - -const jsonlPath = arg("jsonl"); -const invocationPath = arg("invocation"); -const receiptPath = arg("receipt"); -const packageDigest = arg("package-digest"); -const hostVersion = arg("host-version"); -const profile = arg("profile"); -const requestedModel = arg("model"); -const requestedVariant = arg("variant"); -const worker = arg("worker"); - -const invocation = JSON.parse(readFileSync(invocationPath, "utf8")); -const nonce = invocation.nonce; -if (!nonce) throw new Error("requested invocation must include nonce"); - -const events = parseJsonl(jsonlPath); -if (events.length === 0) throw new Error("host output has no JSON events"); - -const taskInvocations = events - .map((event, index) => ({ event, index, id: idValue(event) })) - .filter(({ event, id }) => id && eventContains(event, worker) && eventMentionsTask(event)); -if (taskInvocations.length === 0) { - throw new Error(`no structured Task invocation with non-null call ID targeted ${worker}`); -} - -const taskIds = new Set(taskInvocations.map(({ id }) => id).filter(Boolean)); -const mismatchedResult = events - .map((event) => ({ event, id: idValue(event), agent: eventAgent(event), result: eventIsResult(event) })) - .find(({ event, id, agent, result }) => eventContains(event, nonce) && result && id && taskIds.has(id) && agent && agent !== worker); -if (mismatchedResult) { - throw new Error(`worker result came from ${mismatchedResult.agent}, expected ${worker}`); -} -const workerResults = events - .map((event, index) => ({ event, index, id: idValue(event), agent: eventAgent(event), result: eventIsResult(event) })) - .filter(({ event, id, agent, result }) => { - if (!eventContains(event, nonce)) return false; - if (!result) return false; - if (!id || !taskIds.has(id)) return false; - if (agent !== worker) return false; - return true; - }); -if (workerResults.length === 0) { - throw new Error(`nonce ${nonce} was not returned by an explicit ${worker} Task result with matching call ID`); -} - -const workerEvent = workerResults[0].event; -const effectiveModel = firstModel(workerEvent) ?? taskInvocations.map(({ event }) => firstModel(event)).find(Boolean) ?? null; -const effectiveVariant = firstVariant(workerEvent) ?? taskInvocations.map(({ event }) => firstVariant(event)).find(Boolean) ?? null; -const observedAgent = eventAgent(workerEvent); -if (!observedAgent) { - throw new Error(`worker result is missing explicit child identity for ${worker}`); -} -if (observedAgent !== worker) { - throw new Error(`worker result came from ${observedAgent}, expected ${worker}`); -} - -const receipt = { - schema_version: 1, - package_digest: packageDigest, - host_version: hostVersion, - requested_dispatch: { - semantic_role: "worker", - profile, - model: requestedModel, - effort: requestedVariant, - agent_type: worker, - fork_turns: { mode: "none" }, - }, - child_identity: { - host: "opencode", - role: "worker", - agent_role: observedAgent, - agent_type: observedAgent, - task_name: observedAgent, - }, - nonce, - raw_evidence_refs: [ - "requested-invocation:requested-invocation.json#argv", - "host-output:host-output.jsonl#task", - "host-stderr:host-output.stderr", - ], - verdict: "advisory", -}; -if (effectiveModel) receipt.effective_model = effectiveModel; -if (effectiveVariant) receipt.effective_effort = effectiveVariant; - -writeFileSync(receiptPath, `${JSON.stringify(receipt, null, 2)}\n`); -console.log("opencode runtime evidence validated"); +const result = spawnSync("cargo", [ + "run", "--quiet", "--manifest-path", new URL("../Cargo.toml", import.meta.url).pathname, + "-p", "xtask", "--", "certify", "opencode", ...process.argv.slice(2), +], { stdio: "inherit" }); +process.exit(result.status ?? 1); diff --git a/scripts/validate-opencode-runtime-evidence.test.mjs b/scripts/validate-opencode-runtime-evidence.test.mjs index 2c3ee3e..3c6d16f 100644 --- a/scripts/validate-opencode-runtime-evidence.test.mjs +++ b/scripts/validate-opencode-runtime-evidence.test.mjs @@ -1,77 +1,9 @@ -import { mkdtemp, writeFile, readFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { spawnSync } from "node:child_process"; -import { test } from "node:test"; import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import test from "node:test"; -const script = "scripts/validate-opencode-runtime-evidence.mjs"; - -async function runFixture(name, events) { - const dir = await mkdtemp(join(tmpdir(), `opencode-evidence-${name}-`)); - const jsonl = join(dir, "host-output.jsonl"); - const invocation = join(dir, "requested-invocation.json"); - const receipt = join(dir, "dispatch-evidence.json"); - await writeFile(jsonl, events.map((event) => JSON.stringify(event)).join("\n")); - await writeFile(invocation, JSON.stringify({ nonce: "nonce-123" })); - const result = spawnSync(process.execPath, [ - script, - "--jsonl", jsonl, - "--invocation", invocation, - "--receipt", receipt, - "--package-digest", "sha256:abc", - "--host-version", "1.14.17", - "--profile", "opencode-worker", - "--model", "opencode/gpt-5-nano", - "--variant", "low", - "--worker", "model-routing-preset-worker", - ], { cwd: process.cwd(), encoding: "utf8" }); - return { ...result, receipt }; -} - -test("accepts correlated OpenCode Task evidence for the worker", async () => { - const result = await runFixture("valid", [ - { type: "tool_call", tool: "Task", id: "call-1", agent: "model-routing-preset-worker", model: "opencode/gpt-5-nano", variant: "low" }, - { type: "tool_result", toolCallID: "call-1", agent: "model-routing-preset-worker", result: "nonce-123", model: "opencode/gpt-5-nano", variant: "low" }, - ]); +test("OpenCode compatibility path delegates its contract to Rust xtask", () => { + const result = spawnSync(process.execPath, ["scripts/validate-opencode-runtime-evidence.mjs", "--help"], { encoding: "utf8" }); assert.equal(result.status, 0, result.stderr); - const receipt = JSON.parse(await readFile(result.receipt, "utf8")); - assert.equal(receipt.child_identity.agent_role, "model-routing-preset-worker"); - assert.equal(receipt.effective_model, "opencode/gpt-5-nano"); - assert.equal(receipt.effective_effort, "low"); -}); - -test("rejects a driver-only echoed nonce", async () => { - const result = await runFixture("driver-echo", [ - { type: "message", agent: "model-routing-preset-driver", result: "nonce-123", model: "opencode/gpt-5-nano" }, - ]); - assert.notEqual(result.status, 0); - assert.match(result.stderr, /no structured Task invocation/); -}); - -test("rejects a task invocation whose result does not come from the worker", async () => { - const result = await runFixture("mismatched-child", [ - { type: "tool_call", tool: "Task", id: "call-1", agent: "model-routing-preset-worker", model: "opencode/gpt-5-nano" }, - { type: "tool_result", toolCallID: "call-1", agent: "other-worker", result: "nonce-123" }, - ]); - assert.notEqual(result.status, 0); - assert.match(result.stderr, /worker result came from other-worker/); -}); - -test("rejects an echoed nonce in the requested input event", async () => { - const result = await runFixture("input-echo", [ - { type: "input", prompt: "Return nonce-123 using model-routing-preset-worker" }, - { type: "tool_call", tool: "Task", id: "call-1", agent: "model-routing-preset-worker" }, - ]); - assert.notEqual(result.status, 0); - assert.match(result.stderr, /was not returned by an explicit/); -}); - -test("rejects a later driver message that mentions the worker and nonce", async () => { - const result = await runFixture("later-driver-echo", [ - { type: "tool_call", tool: "Task", id: "call-1", agent: "model-routing-preset-worker", model: "opencode/gpt-5-nano" }, - { type: "message", agent: "model-routing-preset-driver", text: "model-routing-preset-worker returned nonce-123" }, - ]); - assert.notEqual(result.status, 0); - assert.match(result.stderr, /was not returned by an explicit/); + assert.match(result.stdout, /--package-digest/); }); diff --git a/scripts/validate-pi-runtime-evidence.mjs b/scripts/validate-pi-runtime-evidence.mjs index 6a46c6a..fa3a615 100644 --- a/scripts/validate-pi-runtime-evidence.mjs +++ b/scripts/validate-pi-runtime-evidence.mjs @@ -1,181 +1,8 @@ #!/usr/bin/env node -import { createHash } from "node:crypto"; -import { readFileSync, writeFileSync } from "node:fs"; +import { spawnSync } from "node:child_process"; -function usage() { - console.error("usage: validate-pi-runtime-evidence.mjs --workflow --invocation --stdout --stderr --workflow-receipt --dispatch-receipt --package-digest --host-version --profile --model --thinking --agent-type "); - process.exit(2); -} - -function arg(name) { - const index = process.argv.indexOf(`--${name}`); - if (index === -1 || index + 1 >= process.argv.length) usage(); - return process.argv[index + 1]; -} - -function readJson(path, label) { - try { - return JSON.parse(readFileSync(path, "utf8")); - } catch (error) { - throw new Error(`${label} is not valid JSON: ${error.message}`); - } -} - -function assert(condition, message) { - if (!condition) throw new Error(message); -} - -function arraysEqual(left, right) { - return Array.isArray(left) && Array.isArray(right) && left.length === right.length && left.every((value, index) => value === right[index]); -} - -function expectedInvocationArgv(workflowArgv) { - return ["env", "PI_CODING_AGENT_DIR=.pi-agent", "PI_OFFLINE=1", ...workflowArgv]; -} - -function optionValue(argv, option) { - const index = argv.indexOf(option); - if (index === -1 || index + 1 >= argv.length) return null; - return argv[index + 1]; -} - -function sha256Text(value) { - return `sha256:${createHash("sha256").update(value).digest("hex")}`; -} - -const workflowPath = arg("workflow"); -const invocationPath = arg("invocation"); -const stdoutPath = arg("stdout"); -const stderrPath = arg("stderr"); -const workflowReceiptPath = arg("workflow-receipt"); -const dispatchReceiptPath = arg("dispatch-receipt"); -const packageDigest = arg("package-digest"); -const hostVersion = arg("host-version"); -const profile = arg("profile"); -const requestedModel = arg("model"); -const requestedThinking = arg("thinking"); -const agentType = arg("agent-type"); - -const workflow = readJson(workflowPath, "workflow"); -const invocation = readJson(invocationPath, "requested invocation"); -const stdout = readFileSync(stdoutPath, "utf8").trim(); -readFileSync(stderrPath, "utf8"); - -const nonce = invocation.nonce; -assert(typeof nonce === "string" && nonce.length > 0, "requested invocation must include nonce"); -assert(workflow.schema_version === 1, "workflow schema_version must be 1"); -assert(workflow.runner === "pi", "workflow runner must be pi"); -assert(workflow.runtime_class === "external-runner", "workflow runtime_class must be external-runner"); - -const args = workflow.arguments; -assert(args && typeof args === "object", "workflow must include typed arguments"); -assert(args.agent_type === agentType, `workflow agent_type ${args.agent_type} does not match ${agentType}`); -assert(args.provider_model === requestedModel, `workflow provider_model ${args.provider_model} does not match ${requestedModel}`); -assert(args.thinking === requestedThinking, `workflow thinking ${args.thinking} does not match ${requestedThinking}`); -assert(args.task?.semantic_role === "worker", "workflow task semantic_role must be worker"); -assert(args.task?.returns === "nonce-only", "workflow task must require nonce-only return"); -assert(args.isolation?.session === "none", "workflow isolation must disable session persistence"); -assert(args.isolation?.tools === "none", "workflow isolation must disable tools"); -assert(args.isolation?.extensions === "none", "workflow isolation must disable extensions"); -assert(args.isolation?.skills === "none", "workflow isolation must disable skills"); -assert(Array.isArray(workflow.process?.argv), "workflow process argv must be recorded"); -for (const required of ["--print", "--no-session", "--no-tools", "--no-extensions", "--no-skills", "--provider", "--model", "--thinking"]) { - assert(workflow.process.argv.includes(required), `workflow process argv must include ${required}`); -} -assert(Array.isArray(invocation.argv), "requested invocation must include argv"); -assert(invocation.env && typeof invocation.env === "object", "requested invocation must include env"); - -const expectedArgv = expectedInvocationArgv(workflow.process.argv); -assert( - arraysEqual(invocation.argv, expectedArgv), - `requested invocation argv does not match workflow process argv with report-local env boundary`, -); -assert(invocation.env.PI_CODING_AGENT_DIR === ".pi-agent", "requested invocation must set report-local PI_CODING_AGENT_DIR=.pi-agent"); -assert(invocation.env.PI_OFFLINE === "1", "requested invocation must set PI_OFFLINE=1"); -assert(invocation.argv[1] === "PI_CODING_AGENT_DIR=.pi-agent", "requested invocation argv must set report-local PI_CODING_AGENT_DIR=.pi-agent"); -assert(invocation.argv[2] === "PI_OFFLINE=1", "requested invocation argv must set PI_OFFLINE=1"); - -const executedArgv = invocation.argv.slice(3); -assert(executedArgv[0] === "pi", "requested invocation must execute pi"); -assert(executedArgv.includes("--print"), "requested invocation must use Pi print mode"); -assert(executedArgv.includes("--no-session"), "requested invocation must disable session persistence"); -assert(executedArgv.includes("--no-tools"), "requested invocation must disable tools"); -assert(executedArgv.includes("--no-extensions"), "requested invocation must disable extensions"); -assert(executedArgv.includes("--no-skills"), "requested invocation must disable skills"); -const executedProvider = optionValue(executedArgv, "--provider"); -const executedModel = optionValue(executedArgv, "--model"); -const executedThinking = optionValue(executedArgv, "--thinking"); -assert(executedProvider && executedModel, "requested invocation must include provider and model"); -assert(executedThinking, "requested invocation must include thinking"); -assert(`${executedProvider}/${executedModel}` === requestedModel, "requested invocation provider/model does not match requested model"); -assert(executedThinking === requestedThinking, "requested invocation thinking does not match requested thinking"); - -const expectedPromptHash = sha256Text(`Return only this nonce and no other text: ${nonce}`); -assert(invocation.prompt_sha256 === expectedPromptHash, "requested invocation prompt hash does not match nonce task"); - -const normalizedStdout = stdout.replace(/\s+/g, " ").trim(); -assert(normalizedStdout === nonce, `Pi child output did not exactly return nonce ${nonce}`); - -const workflowReceipt = { - schema_version: 1, - runner: "pi", - workflow: workflow.workflow, - runtime_class: "external-runner", - package_digest: packageDigest, - host_version: hostVersion, - invocation: { - argv: invocation.argv, - env: invocation.env, - prompt_sha256: invocation.prompt_sha256, - }, - requested: { - semantic_role: "worker", - profile, - agent_type: agentType, - provider_model: requestedModel, - thinking: requestedThinking, - isolation: args.isolation, - }, - observed: { - stdout_ref: "host-output:host-output.txt", - stderr_ref: "host-stderr:host-output.stderr", - nonce, - }, - verdict: "advisory", -}; - -const dispatchReceipt = { - schema_version: 1, - package_digest: packageDigest, - host_version: hostVersion, - requested_dispatch: { - semantic_role: "worker", - profile, - model: requestedModel, - effort: requestedThinking, - agent_type: agentType, - fork_turns: { mode: "none" }, - }, - child_identity: { - host: "pi", - role: "worker", - agent_role: agentType, - agent_type: agentType, - task_name: "model-routing-preset-runner", - }, - effective_model: requestedModel, - effective_effort: requestedThinking, - nonce, - raw_evidence_refs: [ - "workflow:workflow.json#arguments", - "requested-invocation:requested-invocation.json#argv", - "workflow-receipt:workflow-receipt.json", - "host-output:host-output.txt", - "host-stderr:host-output.stderr", - ], - verdict: "advisory", -}; - -writeFileSync(workflowReceiptPath, `${JSON.stringify(workflowReceipt, null, 2)}\n`); -writeFileSync(dispatchReceiptPath, `${JSON.stringify(dispatchReceipt, null, 2)}\n`); -console.log("pi runtime evidence validated"); +const result = spawnSync("cargo", [ + "run", "--quiet", "--manifest-path", new URL("../Cargo.toml", import.meta.url).pathname, + "-p", "xtask", "--", "certify", "pi", ...process.argv.slice(2), +], { stdio: "inherit" }); +process.exit(result.status ?? 1); diff --git a/scripts/validate-pi-runtime-evidence.test.mjs b/scripts/validate-pi-runtime-evidence.test.mjs index 30e90a1..49a4956 100644 --- a/scripts/validate-pi-runtime-evidence.test.mjs +++ b/scripts/validate-pi-runtime-evidence.test.mjs @@ -1,170 +1,9 @@ import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; -import { createHash } from "node:crypto"; -import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; -import { tmpdir } from "node:os"; -import { test } from "node:test"; +import test from "node:test"; -const script = "scripts/validate-pi-runtime-evidence.mjs"; - -function promptHash(nonce) { - return `sha256:${createHash("sha256").update(`Return only this nonce and no other text: ${nonce}`).digest("hex")}`; -} - -function fixture(name, overrides = {}) { - const dir = mkdtempSync(join(tmpdir(), `pi-evidence-${name}-`)); - mkdirSync(dir, { recursive: true }); - const workflow = { - schema_version: 1, - workflow: "model-routing-preset-runner", - runner: "pi", - runtime_class: "external-runner", - arguments: { - agent_type: overrides.agentType ?? "switchloom-pi-worker", - provider_model: overrides.model ?? "openai/gpt-4o-mini", - thinking: overrides.thinking ?? "low", - isolation: { - session: overrides.session ?? "none", - tools: overrides.tools ?? "none", - extensions: "none", - skills: "none", - agent_dir: "report-workdir/.pi-agent", - }, - task: { - semantic_role: "worker", - profile: "pi-worker", - returns: overrides.returns ?? "nonce-only", - }, - }, - process: { - argv: ["pi", "--print", "--no-session", "--no-tools", "--no-extensions", "--no-skills", "--provider", "openai", "--model", "gpt-4o-mini", "--thinking", "low"], - state_boundary: "PI_CODING_AGENT_DIR is set to a report-local directory for every certification run", - }, - security: { - filesystem_tools: "disabled", - session_persistence: "disabled", - native_subagents: "not used", - }, - }; - const invocation = { - nonce: "nonce-123", - argv: overrides.invocationArgv ?? ["env", "PI_CODING_AGENT_DIR=.pi-agent", "PI_OFFLINE=1", ...workflow.process.argv], - env: overrides.invocationEnv ?? { PI_CODING_AGENT_DIR: ".pi-agent", PI_OFFLINE: "1" }, - prompt_sha256: overrides.promptSha256 ?? promptHash("nonce-123"), - }; - writeFileSync(join(dir, "workflow.json"), JSON.stringify(workflow, null, 2)); - writeFileSync(join(dir, "requested-invocation.json"), JSON.stringify(invocation, null, 2)); - writeFileSync(join(dir, "host-output.txt"), `${overrides.stdout ?? "nonce-123"}\n`); - writeFileSync(join(dir, "host-output.stderr"), overrides.stderr ?? ""); - return dir; -} - -function run(dir, extraArgs = []) { - return spawnSync(process.execPath, [ - script, - "--workflow", join(dir, "workflow.json"), - "--invocation", join(dir, "requested-invocation.json"), - "--stdout", join(dir, "host-output.txt"), - "--stderr", join(dir, "host-output.stderr"), - "--workflow-receipt", join(dir, "workflow-receipt.json"), - "--dispatch-receipt", join(dir, "dispatch-evidence.json"), - "--package-digest", "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", - "--host-version", "0.66.1", - "--profile", "pi-worker", - "--model", "openai/gpt-4o-mini", - "--thinking", "low", - "--agent-type", "switchloom-pi-worker", - ...extraArgs, - ], { encoding: "utf8" }); -} - -test("accepts a nonce-only Pi workflow receipt", () => { - const dir = fixture("valid"); - const result = run(dir); +test("Pi compatibility path delegates its contract to Rust xtask", () => { + const result = spawnSync(process.execPath, ["scripts/validate-pi-runtime-evidence.mjs", "--help"], { encoding: "utf8" }); assert.equal(result.status, 0, result.stderr); - const workflowReceipt = JSON.parse(readFileSync(join(dir, "workflow-receipt.json"), "utf8")); - const dispatchEvidence = JSON.parse(readFileSync(join(dir, "dispatch-evidence.json"), "utf8")); - assert.equal(workflowReceipt.observed.nonce, "nonce-123"); - assert.equal(workflowReceipt.requested.isolation.tools, "none"); - assert.equal(dispatchEvidence.child_identity.host, "pi"); - assert.equal(dispatchEvidence.requested_dispatch.agent_type, "switchloom-pi-worker"); - assert.equal(dispatchEvidence.verdict, "advisory"); -}); - -test("rejects non-nonce Pi output", () => { - const dir = fixture("bad-output", { stdout: "nonce-123 plus explanation" }); - const result = run(dir); - assert.notEqual(result.status, 0); - assert.match(result.stderr, /did not exactly return nonce/); -}); - -test("rejects workflows that enable tools", () => { - const dir = fixture("tools", { tools: "default" }); - const result = run(dir); - assert.notEqual(result.status, 0); - assert.match(result.stderr, /disable tools/); -}); - -test("rejects mismatched agent type", () => { - const dir = fixture("agent", { agentType: "switchloom-pi-driver" }); - const result = run(dir); - assert.notEqual(result.status, 0); - assert.match(result.stderr, /agent_type/); -}); - -test("rejects workflows without nonce-only task contract", () => { - const dir = fixture("task", { returns: "summary" }); - const result = run(dir); - assert.notEqual(result.status, 0); - assert.match(result.stderr, /nonce-only/); -}); - -test("rejects replayed invocation argv that changes provider, model, and isolation", () => { - const dir = fixture("replay", { - invocationArgv: [ - "env", - "PI_CODING_AGENT_DIR=/tmp/shared-agent", - "PI_OFFLINE=1", - "pi", - "--print", - "--provider", - "anthropic", - "--model", - "claude-opus-4-5", - ], - invocationEnv: { PI_CODING_AGENT_DIR: "/tmp/shared-agent", PI_OFFLINE: "1" }, - }); - const result = run(dir); - assert.notEqual(result.status, 0); - assert.match(result.stderr, /argv does not match workflow process argv|PI_CODING_AGENT_DIR/); -}); - -test("rejects invocation argv missing thinking", () => { - const dir = fixture("missing-thinking", { - invocationArgv: ["env", "PI_CODING_AGENT_DIR=.pi-agent", "PI_OFFLINE=1", "pi", "--print", "--no-session", "--no-tools", "--no-extensions", "--no-skills", "--provider", "openai", "--model", "gpt-4o-mini"], - }); - const result = run(dir); - assert.notEqual(result.status, 0); - assert.match(result.stderr, /argv does not match workflow process argv|thinking/); -}); - -test("rejects invocation env outside the report-local Pi boundary", () => { - const workflowArgv = ["pi", "--print", "--no-session", "--no-tools", "--no-extensions", "--no-skills", "--provider", "openai", "--model", "gpt-4o-mini", "--thinking", "low"]; - const dir = fixture("bad-env", { - invocationArgv: ["env", "PI_CODING_AGENT_DIR=.pi-agent", "PI_OFFLINE=1", ...workflowArgv], - invocationEnv: { PI_CODING_AGENT_DIR: "/tmp/shared-agent", PI_OFFLINE: "1" }, - }); - const result = run(dir); - assert.notEqual(result.status, 0); - assert.match(result.stderr, /PI_CODING_AGENT_DIR/); -}); - -test("rejects prompt hashes that do not bind to the nonce task", () => { - const dir = fixture("bad-prompt", { - promptSha256: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - }); - const result = run(dir); - assert.notEqual(result.status, 0); - assert.match(result.stderr, /prompt hash/); + assert.match(result.stdout, /--workflow-receipt/); }); diff --git a/scripts/website-planr-setup-oracle.sh b/scripts/website-planr-setup-oracle.sh index d6137cc..2d3cde0 100644 --- a/scripts/website-planr-setup-oracle.sh +++ b/scripts/website-planr-setup-oracle.sh @@ -1,281 +1,5 @@ #!/usr/bin/env bash set -euo pipefail - repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -routing_bin="${1:-$repo_root/target/debug/switchloom}" -workdir="$(mktemp -d /private/tmp/switchloom-website-planr-oracle.XXXXXX)" -db="$workdir/.planr/planr.sqlite" - -extract_id() { - sed -n 's/.*"id": "\([^"]*\)".*/\1/p' | head -1 -} - -require_contains() { - local pattern="$1" - shift - if ! rg -F -q "$pattern" "$@"; then - printf 'missing expected pattern %s in %s\n' "$pattern" "$*" >&2 - exit 1 - fi -} - -require_absent() { - local pattern="$1" - shift - if rg -F -q "$pattern" "$@"; then - printf 'unexpected pattern %s in %s\n' "$pattern" "$*" >&2 - exit 1 - fi -} - -require_regex() { - local pattern="$1" - shift - if ! rg -q "$pattern" "$@"; then - printf 'missing expected regex %s in %s\n' "$pattern" "$*" >&2 - exit 1 - fi -} - -hash_file() { - shasum -a 256 "$1" | awk '{print $1}' -} - -routing_bin_sha256="$(hash_file "$routing_bin")" -codex_version="$(codex --version 2>&1 | rg '^codex(-cli)? ' | tail -n 1)" - -json="$workdir/website-planr-setup.json" -cat > "$json" <<'JSON' -{ - "schema_version": 1, - "host": "codex-openai", - "integration": "planr", - "usage_policy": "balanced", - "selected_roles": { - "orchestrator": { - "model": "gpt-5.6-sol", - "effort": "medium", - "spawn": { - "agent_type": "switchloom_orchestrator", - "task_name": "orchestrator", - "fork_turns": { "mode": "none" } - } - }, - "implementer": { - "model": "gpt-5.6-terra", - "effort": "high", - "spawn": { - "agent_type": "switchloom_implementer", - "task_name": "implementer", - "fork_turns": { "mode": "none" } - } - }, - "reviewer": { - "model": "gpt-5.6-sol", - "effort": "high", - "spawn": { - "agent_type": "switchloom_reviewer", - "task_name": "reviewer", - "fork_turns": { "mode": "none" } - } - } - }, - "routes": [ - { "work_type": "planning", "role": "orchestrator", "fallbacks": [] }, - { "work_type": "code", "role": "implementer", "fallbacks": [] }, - { "work_type": "review", "role": "reviewer", "fallbacks": [] }, - { "work_type": "verification", "role": "reviewer", "fallbacks": [] } - ], - "route_default": { "role": "orchestrator", "fallbacks": [] } -} -JSON -recipe="sw1_$(node -e 'const fs=require("node:fs"); process.stdout.write(Buffer.from(fs.readFileSync(process.argv[1])).toString("base64url"))' "$json")" -printf 'npx switchloom@latest apply --recipe '\''%s'\'' --repository .\n' "$recipe" > "$workdir/copied-command.txt" - -planr --db "$db" project init "Switchloom Website Planr Oracle" --json > "$workdir/project-init.json" - -"$routing_bin" apply --recipe "$recipe" --repository "$workdir" --yes \ - > "$workdir/apply.json" \ - 2> "$workdir/apply.stderr" - -require_contains ".planr/agents.toml" "$workdir/apply.json" -require_contains ".planr/policy.toml" "$workdir/apply.json" -require_contains ".codex/agents/switchloom_implementer.toml" "$workdir/apply.json" -require_contains ".codex/agents/switchloom_reviewer.toml" "$workdir/apply.json" -require_contains "Protocol preload: \$planr-work" "$workdir/.codex/agents/switchloom_implementer.toml" -require_contains "Protocol preload: \$planr-review" "$workdir/.codex/agents/switchloom_reviewer.toml" -require_absent "model-routing-native-routing" "$workdir/.codex/agents/switchloom_implementer.toml" "$workdir/.codex/agents/switchloom_reviewer.toml" - -sentinel_bin="$workdir/sentinel-bin" -sentinel_hit="$workdir/model-routing-sentinel-hit" -mkdir -p "$sentinel_bin" -printf '#!/usr/bin/env bash\nprintf "model-routing invoked\\n" > "%s"\nexit 99\n' "$sentinel_hit" > "$sentinel_bin/model-routing" -chmod +x "$sentinel_bin/model-routing" -sentinel_path="$sentinel_bin:$PATH" - -( - cd "$workdir" - export PATH="$sentinel_path" - planr --db "$db" agents check --json > agents-check.json - planr --db "$db" agents list --json > agents-list.json - planr --db "$db" prompt routing --client codex --json > routing-dispatch.json - - worker_json="$(planr --db "$db" item create "Website worker route item" \ - --description "Website Planr recipe should route code work to the generated native implementer role" \ - --work-type code \ - --json)" - printf '%s\n' "$worker_json" > worker-create.json - worker_id="$(printf '%s' "$worker_json" | extract_id)" - planr --db "$db" item route "$worker_id" --json > worker-route.json - planr --db "$db" pick --peek --json --work-type code > worker-pick-peek.json - - review_json="$(planr --db "$db" item create "Website review route item" \ - --description "Website Planr recipe should route review work to the generated native reviewer role" \ - --work-type review \ - --json)" - printf '%s\n' "$review_json" > review-create.json - review_id="$(printf '%s' "$review_json" | extract_id)" - planr --db "$db" item route "$review_id" --json > review-route.json - planr --db "$db" pick --peek --json --work-type review > review-pick-peek.json - - plan_json="$(planr plan new "Website Planr Child Agent Oracle" --platform cli --json)" - printf '%s\n' "$plan_json" > child-plan-new.json - plan_path="$(printf '%s' "$plan_json" | sed -n 's/.*"path": "\([^"]*\)".*/\1/p' | head -1)" - plan_id="$(printf '%s' "$plan_json" | sed -n 's/.*"id": "\([^"]*\)".*/\1/p' | head -1)" - cat > "$plan_path/TASKS.md" <<'TASKS' -# Tasks - -### TASK-001: Record website Planr worker evidence - -Goal: -Complete this disposable oracle item from the generated `switchloom_implementer` child agent without editing source files. - -Acceptance criteria: -- A completion log records that the generated website implementer role executed. -- The command `printf website-planr-worker-ok` is recorded as verification evidence. -TASKS - planr context add "GOAL CONTRACT $plan_id: DONE when every in-scope map item is closed with log evidence, all reviews are closed with verdict complete, no open approvals remain, and a live verification log exists. Iteration budget: 4." \ - --tag goal-contract \ - --json > child-goal-context.json - planr map build --from "$plan_id" > child-map-build.txt - sed -n 's/.* \([^ ]*\) \[ready\].*/\1/p' child-map-build.txt | head -1 > child-worker-item-id.txt - printf '%s\n' "$plan_id" > child-plan-id.txt -) - -test ! -e "$sentinel_hit" -mkdir -p "$workdir/child-receipts" -cat > "$workdir/expected-spawn-receipts.json" < "$workdir/child-dispatch-events.jsonl" \ - 2> "$workdir/child-dispatch.stderr" - -test -s "$workdir/child-dispatch-events.jsonl" -test ! -e "$sentinel_hit" -node "$repo_root/scripts/validate-codex-spawn-state.mjs" \ - --events "$workdir/child-dispatch-events.jsonl" \ - --workdir "$workdir" \ - --expect "$workdir/expected-spawn-receipts.json" \ - > "$workdir/codex-spawn-receipts.json" -node "$repo_root/scripts/validate-codex-runtime-evidence.mjs" \ - "$workdir/codex-spawn-receipts.json" \ - --expect "$workdir/expected-spawn-receipts.json" \ - > "$workdir/validate-codex-spawn-receipts.stdout" \ - 2> "$workdir/validate-codex-spawn-receipts.stderr" - -( - cd "$workdir" - planr plan audit "$child_plan_id" --json > child-final-audit.json - planr map show --json > child-map-show.json - planr log list --json > child-log-list.json - planr trace item "$(cat child-worker-item-id.txt)" --json > child-worker-trace.json - review_item_id="$(sed -n 's/.*"id": "\([^"]*\)".*/\1/p' child-map-show.json | rg '^i-review-' | head -1)" - planr trace item "$review_item_id" --json > child-review-trace.json -) - -require_contains '"holds": true' "$workdir/child-final-audit.json" -require_contains "switchloom_implementer" "$workdir/child-dispatch-events.jsonl" "$workdir/child-dispatch-last-message.txt" -require_contains "switchloom_reviewer" "$workdir/child-dispatch-events.jsonl" "$workdir/child-dispatch-last-message.txt" -require_contains "gpt-5.6-terra" "$workdir/child-dispatch-last-message.txt" -require_contains "gpt-5.6-sol" "$workdir/child-dispatch-last-message.txt" -require_contains "collab_tool_call" "$workdir/child-dispatch-events.jsonl" -require_contains '"status":"completed"' "$workdir/child-dispatch-events.jsonl" -require_contains '"agent_type": "switchloom_implementer"' "$workdir/codex-spawn-receipts.json" -require_contains '"agent_type": "switchloom_reviewer"' "$workdir/codex-spawn-receipts.json" -require_contains '"canonical_task": "/root/implementer"' "$workdir/codex-spawn-receipts.json" -require_contains '"canonical_task": "/root/reviewer"' "$workdir/codex-spawn-receipts.json" -require_contains '"model": "gpt-5.6-terra"' "$workdir/codex-spawn-receipts.json" -require_contains '"effort": "high"' "$workdir/codex-spawn-receipts.json" -require_contains '"kind": "verification"' "$workdir/child-log-list.json" -require_contains "printf website-planr-worker-ok" "$workdir/child-log-list.json" -require_contains "review verdict: complete" "$workdir/child-log-list.json" -require_contains '"profile": "switchloom_implementer"' "$workdir/child-worker-trace.json" -require_contains '"profile": "switchloom_reviewer"' "$workdir/child-review-trace.json" -require_regex '"worker_id": "switchloom[_-]implementer' "$workdir/child-map-show.json" "$workdir/child-worker-trace.json" -require_regex '"worker_id": "switchloom[_-]reviewer' "$workdir/child-map-show.json" "$workdir/child-review-trace.json" -require_contains '"role": "switchloom_implementer"' "$workdir/child-receipts/implementer.json" -require_contains '"canonical_task": "/root/implementer"' "$workdir/child-receipts/implementer.json" -require_contains '"protocol": "planr-work"' "$workdir/child-receipts/implementer.json" -require_contains '"role": "switchloom_reviewer"' "$workdir/child-receipts/reviewer.json" -require_contains '"canonical_task": "/root/reviewer"' "$workdir/child-receipts/reviewer.json" -require_contains '"protocol": "planr-review"' "$workdir/child-receipts/reviewer.json" -require_absent "PLANR_WORKER_ID=website-oracle" "$workdir/child-dispatch-events.jsonl" "$workdir/child-dispatch-last-message.txt" -require_absent "PLANR_WORKER_ID=switchloom-reviewer" "$workdir/child-dispatch-events.jsonl" "$workdir/child-dispatch-last-message.txt" - -printf 'website Planr setup oracle passed\n' -printf 'receipts: %s\n' "$workdir" -printf 'copied command: %s\n' "$(cat "$workdir/copied-command.txt")" -printf 'worker route: switchloom_implementer\n' -printf 'review route: switchloom_reviewer\n' -printf "child dispatch: codex exec spawned switchloom_implementer and switchloom_reviewer with persisted child thread receipts\n" -printf 'model-routing sentinel: untouched\n' +routing_bin="${1:-$repo_root/target/debug/model-routing}" +exec cargo run --quiet --manifest-path "$repo_root/Cargo.toml" -p xtask -- certify planr --routing-bin "$routing_bin" diff --git a/src/cli.rs b/src/cli.rs new file mode 100644 index 0000000..42a774f --- /dev/null +++ b/src/cli.rs @@ -0,0 +1,359 @@ +use crate::{ + Integration, apply_bundle_file, apply_saved_setup, apply_setup_config_file, apply_setup_recipe, + compile_json, inspect_bundle_json, list_policies, prepare_saved_setup, + prepare_setup_config_file, prepare_setup_recipe, preview_bundle_file, preview_prepared_setup, + preview_saved_setup, preview_setup_config_file, preview_setup_recipe, probe_host, + rollback_repository, show_policy, status_repository, uninstall_repository, update_bundle_file, + update_saved_setup, update_setup_config_file, update_setup_recipe, +}; +use anyhow::{Result, bail}; +use clap::{Args, Parser, Subcommand, ValueEnum}; +use std::fs; +use std::io::{self, Write}; +use std::path::PathBuf; + +#[derive(Parser)] +#[command(name = "model-routing", version, about)] +struct Cli { + #[command(subcommand)] + command: Command, +} + +#[derive(Subcommand)] +enum Command { + Policy(PolicyArgs), + Compile(CompileArgs), + Inspect(FileArgs), + Preview(LifecycleSourceArgs), + Apply(LifecycleApplyArgs), + Update(LifecycleSourceArgs), + Status(RepositoryArgs), + Uninstall(RepositoryArgs), + Rollback(RepositoryArgs), + /// Check host availability and version before previewing or applying setup. + Doctor(ProbeArgs), +} + +#[derive(Args)] +struct PolicyArgs { + #[command(subcommand)] + command: PolicyCommand, +} + +#[derive(Subcommand)] +enum PolicyCommand { + List, + Show(PolicySelector), +} + +#[derive(Args)] +struct PolicySelector { + policy: String, + #[arg(long)] + host: String, +} + +#[derive(Args)] +struct CompileArgs { + policy: String, + #[arg(long)] + host: String, + #[arg(long, value_enum, default_value_t = CliIntegration::Standalone)] + integration: CliIntegration, + #[arg(long)] + output: Option, +} + +#[derive(Clone, Copy, Debug, ValueEnum)] +enum CliIntegration { + Standalone, + Planr, +} + +impl From for Integration { + fn from(value: CliIntegration) -> Self { + match value { + CliIntegration::Standalone => Self::Standalone, + CliIntegration::Planr => Self::Planr, + } + } +} + +#[derive(Args)] +struct ProbeArgs { + host: String, + #[arg(long)] + command: Option, +} + +#[derive(Args)] +struct FileArgs { + file: PathBuf, +} + +#[derive(Args)] +struct LifecycleSourceArgs { + bundle: Option, + #[arg(long)] + config: Option, + #[arg(long)] + recipe: Option, + #[arg(long, default_value = ".")] + repository: PathBuf, +} + +#[derive(Args)] +struct LifecycleApplyArgs { + #[command(flatten)] + source: LifecycleSourceArgs, + #[arg(long)] + yes: bool, +} + +#[derive(Args)] +struct RepositoryArgs { + #[arg(long, default_value = ".")] + repository: PathBuf, +} + +pub fn main() { + if let Err(error) = run() { + eprintln!("error: {error:#}"); + std::process::exit(1); + } +} + +fn run() -> Result<()> { + match Cli::parse().command { + Command::Policy(args) => match args.command { + PolicyCommand::List => println!("{}", serde_json::to_string_pretty(&list_policies()?)?), + PolicyCommand::Show(selector) => println!( + "{}", + serde_json::to_string_pretty(&show_policy(&selector.policy, &selector.host)?)? + ), + }, + Command::Compile(args) => { + let output = compile_json(&args.policy, &args.host, args.integration.into())?; + if let Some(path) = args.output { + fs::write(path, output)?; + } else { + print!("{output}"); + } + } + Command::Inspect(args) => { + let current = fs::read_to_string(args.file)?; + println!( + "{}", + serde_json::to_string_pretty(&inspect_bundle_json(¤t)?)? + ); + } + Command::Preview(args) => println!( + "{}", + serde_json::to_string_pretty(&preview_lifecycle_source(&args)?)? + ), + Command::Apply(args) => { + if lifecycle_source_kind(&args.source)? == LifecycleSourceKind::Bundle { + println!( + "{}", + serde_json::to_string_pretty(&apply_lifecycle_source(&args.source)?)? + ); + } else { + let prepared = prepare_lifecycle_source(&args.source)?; + let preview = preview_prepared_setup(&args.source.repository, &prepared)?; + eprintln!("{}", serde_json::to_string_pretty(&preview)?); + confirm_setup_apply(args.yes)?; + println!( + "{}", + serde_json::to_string_pretty(&crate::apply_prepared_setup( + &args.source.repository, + &prepared, + &preview, + )?)? + ); + } + } + Command::Update(args) => println!( + "{}", + serde_json::to_string_pretty(&update_lifecycle_source(&args)?)? + ), + Command::Status(args) => println!( + "{}", + serde_json::to_string_pretty(&status_repository(&args.repository)?)? + ), + Command::Uninstall(args) => println!( + "{}", + serde_json::to_string_pretty(&uninstall_repository(&args.repository)?)? + ), + Command::Rollback(args) => println!( + "{}", + serde_json::to_string_pretty(&rollback_repository(&args.repository)?)? + ), + Command::Doctor(args) => println!( + "{}", + serde_json::to_string_pretty(&probe_host(&args.host, args.command.as_deref())?)? + ), + } + Ok(()) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum LifecycleSourceKind { + Bundle, + Config, + Recipe, + SavedConfig, +} + +fn lifecycle_source_kind(args: &LifecycleSourceArgs) -> Result { + let selected = [ + args.bundle.is_some(), + args.config.is_some(), + args.recipe.is_some(), + ] + .into_iter() + .filter(|selected| *selected) + .count(); + if selected > 1 { + bail!("choose only one of bundle, --config, or --recipe"); + } + Ok(if args.bundle.is_some() { + LifecycleSourceKind::Bundle + } else if args.config.is_some() { + LifecycleSourceKind::Config + } else if args.recipe.is_some() { + LifecycleSourceKind::Recipe + } else { + LifecycleSourceKind::SavedConfig + }) +} + +fn preview_lifecycle_source(args: &LifecycleSourceArgs) -> Result { + Ok(match lifecycle_source_kind(args)? { + LifecycleSourceKind::Bundle => preview_bundle_file( + &args.repository, + args.bundle.as_ref().expect("bundle source checked"), + ), + LifecycleSourceKind::Config => preview_setup_config_file( + &args.repository, + args.config.as_ref().expect("config source checked"), + ), + LifecycleSourceKind::Recipe => preview_setup_recipe( + &args.repository, + args.recipe.as_deref().expect("recipe checked"), + ), + LifecycleSourceKind::SavedConfig => preview_saved_setup(&args.repository), + }?) +} + +fn apply_lifecycle_source(args: &LifecycleSourceArgs) -> Result { + Ok(match lifecycle_source_kind(args)? { + LifecycleSourceKind::Bundle => apply_bundle_file( + &args.repository, + args.bundle.as_ref().expect("bundle source checked"), + ), + LifecycleSourceKind::Config => apply_setup_config_file( + &args.repository, + args.config.as_ref().expect("config source checked"), + ), + LifecycleSourceKind::Recipe => apply_setup_recipe( + &args.repository, + args.recipe.as_deref().expect("recipe checked"), + ), + LifecycleSourceKind::SavedConfig => apply_saved_setup(&args.repository), + }?) +} + +fn prepare_lifecycle_source(args: &LifecycleSourceArgs) -> Result { + Ok(match lifecycle_source_kind(args)? { + LifecycleSourceKind::Bundle => bail!("bundle lifecycle source cannot be prepared as setup"), + LifecycleSourceKind::Config => { + prepare_setup_config_file(args.config.as_ref().expect("config source checked")) + } + LifecycleSourceKind::Recipe => { + prepare_setup_recipe(args.recipe.as_deref().expect("recipe checked")) + } + LifecycleSourceKind::SavedConfig => prepare_saved_setup(&args.repository), + }?) +} + +fn update_lifecycle_source(args: &LifecycleSourceArgs) -> Result { + Ok(match lifecycle_source_kind(args)? { + LifecycleSourceKind::Bundle => update_bundle_file( + &args.repository, + args.bundle.as_ref().expect("bundle source checked"), + ), + LifecycleSourceKind::Config => update_setup_config_file( + &args.repository, + args.config.as_ref().expect("config source checked"), + ), + LifecycleSourceKind::Recipe => update_setup_recipe( + &args.repository, + args.recipe.as_deref().expect("recipe checked"), + ), + LifecycleSourceKind::SavedConfig => update_saved_setup(&args.repository), + }?) +} + +fn confirm_setup_apply(yes: bool) -> Result<()> { + if yes { + return Ok(()); + } + if !atty_stdin() { + bail!("setup apply requires --yes when stdin is not interactive"); + } + eprint!("Apply these repository-local setup changes? Type yes to continue: "); + io::stderr().flush()?; + let mut input = String::new(); + io::stdin().read_line(&mut input)?; + if input.trim() != "yes" { + bail!("setup apply cancelled"); + } + Ok(()) +} + +fn atty_stdin() -> bool { + use std::io::IsTerminal; + io::stdin().is_terminal() +} + +#[cfg(test)] +mod tests { + use super::Cli; + use clap::CommandFactory; + + #[test] + fn public_command_surface_is_the_v0_3_0_contract() { + let command = Cli::command(); + let actual = command + .get_subcommands() + .map(|subcommand| subcommand.get_name()) + .collect::>(); + assert_eq!( + actual, + [ + "policy", + "compile", + "inspect", + "preview", + "apply", + "update", + "status", + "uninstall", + "rollback", + "doctor", + ] + ); + } + + #[test] + fn public_help_does_not_expose_internal_command_roots() { + let mut help = Vec::new(); + Cli::command().write_long_help(&mut help).unwrap(); + let help = String::from_utf8(help).unwrap(); + for internal in [ + "certify", "evidence", "probe", "evaluate", "catalog", "registry", + ] { + assert!(!help.contains(internal), "public help exposed {internal}"); + } + } +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..adb7a37 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,300 @@ +use crate::contracts::*; +use crate::error::{Result, ResultContext}; +use crate::hosts::*; +use crate::{bail, product_error}; +use serde_json::{Value, json}; +use std::collections::BTreeMap; + +pub const SETUP_CONFIG_PATH: &str = ".switchloom/config.toml"; +pub const SETUP_RECIPE_PREFIX: &str = "sw1_"; +pub(crate) const MAX_SETUP_RECIPE_BYTES: usize = 65_536; +pub(crate) const MAX_SETUP_RECIPE_ENCODED_BYTES: usize = + encoded_base64url_len(MAX_SETUP_RECIPE_BYTES); + +pub fn setup_spec_for_policy( + policy: &str, + host: &str, + integration: Integration, +) -> Result { + let binding = binding_for_selector(host)?; + let selected_roles = binding + .profiles + .iter() + .map(|(role, profile)| { + ( + role.clone(), + SetupRoleSelection { + model: profile.model.clone(), + effort: profile.effort.clone(), + spawn: setup_spawn_policy_for_binding_role( + setup_runtime_host(&binding), + role, + profile, + ), + }, + ) + }) + .collect::>(); + let routes = binding + .routes + .iter() + .map(|route| SetupRouteMapping { + work_type: route.work_type.clone(), + role: route.role.clone(), + fallbacks: route.fallback_roles.clone(), + }) + .collect(); + let route_default = binding.default_role.clone().map(|role| SetupDefaultRoute { + role, + fallbacks: Vec::new(), + }); + let spec = SetupSpecV1 { + schema_version: 1, + host: binding.id.clone(), + integration, + usage_policy: policy.to_string(), + selected_roles, + routes, + route_default, + }; + validate_setup_spec(&spec)?; + Ok(spec) +} + +#[cfg(test)] +#[path = "tests/config.rs"] +mod tests; + +pub fn validate_setup_spec(spec: &SetupSpecV1) -> Result<()> { + if spec.schema_version != 1 { + bail!("unsupported setup schema_version {}", spec.schema_version); + } + if spec.usage_policy.trim().is_empty() { + bail!("setup usage_policy must not be blank"); + } + if spec.selected_roles.is_empty() { + bail!("setup selected_roles must not be empty"); + } + let binding = binding_for_selector(&spec.host)?; + let canonical_host = setup_runtime_host(&binding); + let model_catalog = setup_model_catalog(canonical_host); + for (role, selection) in &spec.selected_roles { + validate_setup_identifier("role", role)?; + if selection.model.trim().is_empty() { + bail!("setup role `{role}` model must not be blank"); + } + let matches_binding = selection_matches_binding_profile(role, selection, &binding); + if !matches_binding { + validate_model_effort(canonical_host, role, selection, &model_catalog)?; + } + validate_setup_spawn_policy(canonical_host, role, selection, matches_binding)?; + reject_setup_secret_like("role", role)?; + reject_setup_secret_like("model", &selection.model)?; + if let Some(effort) = &selection.effort { + reject_setup_secret_like("effort", effort)?; + } + if let Some(spawn) = &selection.spawn { + reject_setup_secret_like("agent_type", &spawn.agent_type)?; + reject_setup_secret_like("task_name", &spawn.task_name)?; + } + } + validate_setup_identity_collisions(spec, canonical_host, &binding)?; + if spec.routes.is_empty() && spec.route_default.is_none() { + bail!("setup must declare routes or route_default"); + } + for route in &spec.routes { + validate_setup_identifier("work_type", &route.work_type)?; + validate_setup_route_role(&spec.selected_roles, &route.role)?; + for fallback in &route.fallbacks { + validate_setup_route_role(&spec.selected_roles, fallback)?; + } + } + if let Some(default) = &spec.route_default { + validate_setup_route_role(&spec.selected_roles, &default.role)?; + for fallback in &default.fallbacks { + validate_setup_route_role(&spec.selected_roles, fallback)?; + } + } + Ok(()) +} + +pub fn setup_spec_from_json(input: &str) -> Result { + let spec: SetupSpecV1 = + serde_json::from_str(input).context("setup spec is not valid SetupSpecV1 JSON")?; + validate_setup_spec(&spec)?; + Ok(spec) +} + +pub fn setup_spec_from_toml(input: &str) -> Result { + let spec: SetupSpecV1 = + toml::from_str(input).context("setup spec is not valid SetupSpecV1 TOML")?; + validate_setup_spec(&spec)?; + Ok(spec) +} + +pub fn setup_spec_to_canonical_json(spec: &SetupSpecV1) -> Result { + validate_setup_spec(spec)?; + let mut json = serde_json::to_string_pretty(spec)?; + json.push('\n'); + Ok(json) +} + +pub fn setup_spec_to_canonical_toml(spec: &SetupSpecV1) -> Result { + validate_setup_spec(spec)?; + let mut toml = toml::to_string_pretty(spec)?; + if !toml.ends_with('\n') { + toml.push('\n'); + } + Ok(toml) +} + +pub fn setup_spec_to_recipe(spec: &SetupSpecV1) -> Result { + let json = setup_spec_to_canonical_json(spec)?; + if json.len() > MAX_SETUP_RECIPE_BYTES { + bail!("setup recipe exceeds {MAX_SETUP_RECIPE_BYTES} bytes"); + } + Ok(format!( + "{SETUP_RECIPE_PREFIX}{}", + encode_base64url(json.as_bytes()) + )) +} + +pub fn setup_spec_from_recipe(recipe: &str) -> Result { + let payload = recipe + .strip_prefix(SETUP_RECIPE_PREFIX) + .ok_or_else(|| product_error!("setup recipe must start with `{SETUP_RECIPE_PREFIX}`"))?; + if payload.is_empty() { + bail!("setup recipe payload must not be empty"); + } + validate_base64url_payload_len(payload)?; + let decoded = decode_base64url(payload)?; + if decoded.len() > MAX_SETUP_RECIPE_BYTES { + bail!("setup recipe exceeds {MAX_SETUP_RECIPE_BYTES} bytes"); + } + let json = String::from_utf8(decoded).context("setup recipe payload is not UTF-8")?; + setup_spec_from_json(&json) +} + +pub fn setup_contract_catalog_value() -> Result { + let hosts = [ + "codex", + "claude-code", + "cursor", + "opencode", + "pi", + "mixed-host", + ] + .into_iter() + .map(|host| { + let binding = binding_for_selector(host)?; + let runtime_host = setup_runtime_host(&binding); + Ok(json!({ + "id": host, + "binding": binding.id, + "runtimeHost": runtime_host, + "supportsPlanrIntegration": true, + "models": setup_model_catalog(runtime_host).into_iter().map(|option| json!({ + "id": option.id, + "efforts": option.efforts, + "tier": option.tier, + })).collect::>(), + "defaultSpec": setup_spec_for_policy("balanced", &binding.id, Integration::Standalone)?, + })) + }) + .collect::>>()?; + Ok(json!({ + "schemaVersion": 1, + "setupSpecVersion": 1, + "configPath": SETUP_CONFIG_PATH, + "recipePrefix": SETUP_RECIPE_PREFIX, + "transport": { + "encoding": "base64url-no-padding", + "maxDecodedBytes": MAX_SETUP_RECIPE_BYTES, + "mayContainCredentials": false, + "mayContainScripts": false, + }, + "hosts": hosts, + })) +} + +pub fn setup_contract_catalog_json() -> Result { + let mut output = serde_json::to_string_pretty(&setup_contract_catalog_value()?)?; + output.push('\n'); + Ok(output) +} + +pub(crate) fn encode_base64url(bytes: &[u8]) -> String { + const TABLE: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; + let mut output = String::with_capacity(bytes.len().div_ceil(3) * 4); + for chunk in bytes.chunks(3) { + let first = chunk[0]; + let second = *chunk.get(1).unwrap_or(&0); + let third = *chunk.get(2).unwrap_or(&0); + output.push(TABLE[(first >> 2) as usize] as char); + output.push(TABLE[(((first & 0b0000_0011) << 4) | (second >> 4)) as usize] as char); + if chunk.len() > 1 { + output.push(TABLE[(((second & 0b0000_1111) << 2) | (third >> 6)) as usize] as char); + } + if chunk.len() > 2 { + output.push(TABLE[(third & 0b0011_1111) as usize] as char); + } + } + output +} + +pub(crate) const fn encoded_base64url_len(decoded_len: usize) -> usize { + let full_chunks = decoded_len / 3; + match decoded_len % 3 { + 0 => full_chunks * 4, + 1 => full_chunks * 4 + 2, + _ => full_chunks * 4 + 3, + } +} + +pub(crate) fn validate_base64url_payload_len(input: &str) -> Result<()> { + if input.len() > MAX_SETUP_RECIPE_ENCODED_BYTES { + bail!( + "setup recipe payload exceeds {MAX_SETUP_RECIPE_ENCODED_BYTES} base64url characters for {MAX_SETUP_RECIPE_BYTES} decoded bytes" + ); + } + Ok(()) +} + +pub(crate) fn decode_base64url(input: &str) -> Result> { + validate_base64url_payload_len(input)?; + if input + .bytes() + .any(|byte| !(byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'_')) + { + bail!("setup recipe payload must be unpadded base64url"); + } + let mut sextets = Vec::with_capacity(input.len()); + for byte in input.bytes() { + sextets.push(match byte { + b'A'..=b'Z' => byte - b'A', + b'a'..=b'z' => byte - b'a' + 26, + b'0'..=b'9' => byte - b'0' + 52, + b'-' => 62, + b'_' => 63, + _ => unreachable!(), + }); + } + if sextets.len() % 4 == 1 { + bail!("setup recipe payload has invalid base64url length"); + } + let mut output = Vec::with_capacity(sextets.len() / 4 * 3); + for chunk in sextets.chunks(4) { + let a = chunk[0]; + let b = *chunk + .get(1) + .ok_or_else(|| product_error!("invalid base64url payload"))?; + output.push((a << 2) | (b >> 4)); + if let Some(c) = chunk.get(2) { + output.push(((b & 0b0000_1111) << 4) | (c >> 2)); + if let Some(d) = chunk.get(3) { + output.push(((c & 0b0000_0011) << 6) | d); + } + } + } + Ok(output) +} diff --git a/src/contracts.rs b/src/contracts.rs new file mode 100644 index 0000000..d3e9b88 --- /dev/null +++ b/src/contracts.rs @@ -0,0 +1,722 @@ +//! Stable serialized contracts shared by routing, host adapters, and integrations. + +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::BTreeMap; + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub enum Integration { + Standalone, + Planr, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct SetupSpecV1 { + pub schema_version: u32, + pub host: String, + pub integration: Integration, + pub usage_policy: String, + pub selected_roles: BTreeMap, + pub routes: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub route_default: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct SetupRoleSelection { + pub model: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub effort: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub spawn: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct SetupSpawnPolicy { + pub agent_type: String, + pub task_name: String, + pub fork_turns: ForkPolicy, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct SetupRouteMapping { + pub work_type: String, + pub role: String, + #[serde(default)] + pub fallbacks: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct SetupDefaultRoute { + pub role: String, + #[serde(default)] + pub fallbacks: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct EvaluationEvidence { + #[serde(default)] + pub evaluation_ids: Vec, + pub status: String, +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub(crate) struct CodexV2RuntimeEvidence { + pub(crate) schema_version: u32, + pub(crate) evidence_id: String, + pub(crate) observed_at: String, + pub(crate) installed_version: CodexInstalledVersionEvidence, + pub(crate) runtime_class: RuntimeClass, + pub(crate) backend_selection_owner: String, + pub(crate) switchloom_ownership: Vec, + pub(crate) codex_ownership: Vec, + pub(crate) trust_and_discovery: CodexTrustDiscoveryEvidence, + pub(crate) parallelism: CodexParallelismEvidence, + pub(crate) role_precedence: Vec, + pub(crate) shared_filesystem: bool, + pub(crate) delegation_modes: DelegationModesV1, + pub(crate) claim_provenance: BTreeMap>, + pub(crate) negative_contracts: Vec, +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub(crate) struct CodexInstalledVersionEvidence { + pub(crate) command: String, + pub(crate) stdout: String, + pub(crate) stdout_sha256: String, +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub(crate) struct CodexTrustDiscoveryEvidence { + pub(crate) trust_boundary: String, + pub(crate) discovery_behavior: String, +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub(crate) struct CodexParallelismEvidence { + pub(crate) max_parallel_children: u32, + pub(crate) source: String, +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub(crate) struct CodexClaimProvenance { + pub(crate) kind: String, + pub(crate) source: String, + pub(crate) observed_at: String, + pub(crate) codex_version: String, + pub(crate) observed_value: Value, + pub(crate) required_raw_fragments: Vec, + #[serde(default)] + pub(crate) source_url: Option, + #[serde(default)] + pub(crate) source_path: Option, + #[serde(default)] + pub(crate) raw_output: Option, + #[serde(default)] + pub(crate) raw_output_sha256: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct DispatchEvidenceV1 { + pub schema_version: u32, + pub package_digest: String, + pub host_version: String, + pub requested_dispatch: RequestedDispatchEvidence, + pub child_identity: ChildIdentityEvidence, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub effective_model: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub effective_effort: Option, + pub nonce: String, + pub raw_evidence_refs: Vec, + pub verdict: GuaranteeLevel, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct RequestedDispatchEvidence { + pub semantic_role: String, + pub profile: String, + pub model: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub effort: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub agent_type: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub fork_turns: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct ChildIdentityEvidence { + pub host: String, + pub role: String, + pub agent_role: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub agent_type: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub task_name: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct DispatchEvidenceContractV1 { + pub schema_version: u32, + pub required_verdicts: Vec, + pub receipt_schema: String, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] +#[serde(rename_all = "kebab-case")] +pub enum RuntimeClass { + NativeSubagent, + ExternalRunner, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] +#[serde(rename_all = "kebab-case")] +pub enum GuaranteeLevel { + Deterministic, + Advisory, + Unsupported, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct CapabilityGuarantee { + pub level: GuaranteeLevel, + pub reason: String, + pub evidence_required: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct HostCapabilityV1 { + pub schema_version: u32, + pub host: String, + pub host_version_constraints: HostVersionConstraints, + pub runtime_class: RuntimeClass, + pub runtime_behavior: RuntimeBehaviorV1, + pub discovery_artifacts: Vec, + pub dispatch_fields: Vec, + pub model_control: ControlCapability, + pub effort_control: ControlCapability, + pub context_semantics: ContextSemantics, + pub nesting: NestingCapability, + pub parallelism: ParallelismCapability, + pub observability: ObservabilityCapability, + pub guarantees: BTreeMap, + pub known_limitations: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct HostVersionConstraints { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub minimum: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub maximum: Option, + pub evidence_max_age_seconds: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct ControlCapability { + pub level: GuaranteeLevel, + pub field: String, + pub evidence_required: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct ContextSemantics { + pub supports_fork_none: bool, + pub supports_fork_all: bool, + pub requires_bounded_context_for_overrides: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct NestingCapability { + pub max_depth: u32, + pub level: GuaranteeLevel, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct ParallelismCapability { + pub max_parallel_children: u32, + pub level: GuaranteeLevel, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct ObservabilityCapability { + pub requested_dispatch: GuaranteeLevel, + pub effective_identity: GuaranteeLevel, + pub effective_model: GuaranteeLevel, + pub raw_evidence_refs: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct RuntimeBehaviorV1 { + pub capability_version: String, + pub installed_host_version_source: String, + pub backend_selection_source: String, + pub trust_boundary: String, + pub discovery_behavior: String, + pub role_precedence: Vec, + pub shared_filesystem: bool, + pub delegation_modes: DelegationModesV1, + pub source_references: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct DelegationModesV1 { + pub explicit_agent_type_dispatch: bool, + pub ultra_auto_delegation: bool, + pub automatic_delegation_requires_ultra: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct HostAdapterV1 { + pub schema_version: u32, + pub adapter_id: String, + pub adapter_version: String, + pub runtime_class: RuntimeClass, + pub accepts_intent_schema: String, + pub emitted_artifact_modes: Vec, + pub dispatch_recipe: DispatchRecipeV1, + pub lifecycle_owner: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct DispatchRecipeV1 { + pub invocation: String, + pub required_fields: Vec, + pub artifact_paths: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct AdapterContractV1 { + pub schema_version: u32, + pub routing_intent: RoutingIntentV1, + pub capability: HostCapabilityV1, + pub adapter: HostAdapterV1, + pub dispatch_evidence: DispatchEvidenceContractV1, + pub planr_handoff: PlanrHandoffV1, +} + +#[derive(Debug, Deserialize)] +pub(crate) struct HostBinding { + pub(crate) id: String, + pub(crate) version: String, + pub(crate) host: String, + pub(crate) runtime_class: RuntimeClass, + pub(crate) default_role: Option, + #[serde(default)] + pub(crate) capability_evidence: Vec, + #[serde(default)] + pub(crate) known_limitations: Vec, + pub(crate) capabilities: BindingCapabilities, + pub(crate) profiles: BTreeMap, + #[serde(default)] + pub(crate) routes: Vec, + pub(crate) verification: BindingVerification, + #[serde(default)] + pub(crate) artifacts: Vec, +} + +#[derive(Debug, Deserialize)] +pub(crate) struct BindingCapabilities { + pub(crate) model_override: bool, + pub(crate) effort_override: bool, + pub(crate) fork_none: bool, + pub(crate) fork_all: bool, +} + +#[derive(Debug, Deserialize)] +pub(crate) struct BindingProfile { + pub(crate) profile: String, + pub(crate) client: String, + pub(crate) model: String, + pub(crate) agent_type: Option, + pub(crate) effort: Option, + pub(crate) cost_tier: Option, + pub(crate) fork_turns: Option, +} + +#[derive(Debug, Deserialize)] +pub(crate) struct BindingRoute { + pub(crate) work_type: String, + pub(crate) role: String, + #[serde(default)] + pub(crate) fallback_roles: Vec, +} + +#[derive(Debug, Deserialize)] +pub(crate) struct BindingVerification { + pub(crate) id: String, + #[serde(default)] + pub(crate) max_age_seconds: Option, +} + +#[derive(Debug, Deserialize)] +pub(crate) struct BindingArtifact { + pub(crate) path: String, + pub(crate) kind: String, + pub(crate) content: String, +} + +#[derive(Debug)] +pub(crate) struct CompiledHostAdapter { + pub(crate) requirements: Vec, + pub(crate) profiles: BTreeMap, + pub(crate) routes: Vec, + pub(crate) route_default: Option, + pub(crate) artifacts: Vec, + pub(crate) adapter_contract: AdapterContractV1, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ProbeReport { + pub host: String, + pub command: Option, + pub available: bool, + pub version: Option, + pub capabilities: Vec, + pub authentication: String, + pub limitation: Option, +} + +#[derive(Debug, Clone, Copy)] +pub(crate) struct SetupModelOption { + pub(crate) id: &'static str, + pub(crate) efforts: &'static [&'static str], + pub(crate) tier: &'static str, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct PlanrHandoffV1 { + pub schema_version: u32, + pub switchloom_package: String, + pub semantic_role_contract: String, + pub required_consumer_behavior: Vec, + pub forbidden_duplicate_ownership: Vec, + pub certification_report_reference: String, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct PolicySource { + pub policy_id: String, + pub host: String, + pub policy_version: String, + pub binding_id: String, + pub binding_version: String, + pub generated_at: String, + pub requirements: Vec, + pub profiles: BTreeMap, + pub routes: Vec, + pub route_default: Option, + pub artifacts: Vec, + pub evidence: EvaluationEvidence, + pub adapter_contract: AdapterContractV1, + pub policy: PolicyContract, + #[serde(skip)] + pub(crate) policy_toml: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct HostRequirement { + pub host: String, + #[serde(default)] + pub capabilities: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct Profile { + pub client: String, + pub model: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub agent_type: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub effort: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cost_tier: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub capabilities: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub skill: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub notes: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub fork_turns: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct ForkPolicy { + pub mode: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub turns: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct RouteSelector { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub work_type: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub plan: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct Route { + #[serde(rename = "match")] + pub selector: RouteSelector, + pub profile: String, + #[serde(default)] + pub fallbacks: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct DefaultRoute { + pub profile: String, + #[serde(default)] + pub fallbacks: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct SourceArtifact { + pub path: String, + pub media_type: String, + pub mode: String, + pub content: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct RoutingIntentV1 { + pub schema_version: u32, + pub integration: Integration, + pub semantic_roles: Vec, + pub role_requests: Vec, + pub required_guarantees: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct RoutingRoleIntentV1 { + pub semantic_role: String, + pub requested_model: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub requested_effort: Option, + pub instructions: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct RoutingBundleV1 { + pub schema_version: u32, + pub bundle_id: String, + pub policy_id: String, + pub policy_version: String, + pub generated_at: String, + pub source: BundleSource, + pub policy: PolicyContract, + pub requirements: Vec, + pub profiles: BTreeMap, + pub routes: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub route_default: Option, + pub artifacts: Vec, + pub evidence: EvaluationEvidence, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub adapter_contract: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct BundleSource { + pub package: String, + pub package_version: String, + pub integration: Integration, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct BundleArtifact { + pub path: String, + pub media_type: String, + pub mode: String, + pub content: String, + pub sha256: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct PolicyContract { + pub schema_version: u32, + pub id: String, + pub version: String, + pub usage: UsageLimits, + pub transitions: TransitionPolicy, + pub materiality: MaterialityPolicy, + pub execution: ExecutionPolicy, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct UsageLimits { + pub max_active_agents: u32, + pub max_parallel_readers: u32, + pub max_parallel_writers: u32, + pub max_depth: u32, + pub max_attempts: u32, + pub max_wall_time_seconds: u32, + pub max_tool_calls: u32, + pub review_reserve_percent: u32, + pub budget_exhaustion: String, + pub metering: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct TransitionPolicy { + pub retry: RetryPolicy, + pub availability_fallback: AvailabilityFallbackPolicy, + pub quality_escalation: QualityEscalationPolicy, + pub quota_downgrade: QuotaDowngradePolicy, + pub safety_stop: SafetyStopPolicy, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct RetryPolicy { + pub max_same_route_retries: u32, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct AvailabilityFallbackPolicy { + pub max_fallbacks: u32, + pub require_same_capability_class: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct QualityEscalationPolicy { + pub max_escalations: u32, + pub require_verification_evidence: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct QuotaDowngradePolicy { + pub enabled: bool, + pub max_downgrades: u32, + pub noncritical_only: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct SafetyStopPolicy { + pub enabled: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct MaterialityPolicy { + pub changed_files_threshold: u32, + pub changed_lines_threshold: u32, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct ExecutionPolicy { + pub max_read_scope_entries: u32, + pub max_write_scope_entries: u32, + pub roles: BTreeMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct ExecutionRole { + #[serde(default)] + pub tools: Vec, + pub filesystem: FilesystemPolicy, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct FilesystemPolicy { + #[serde(default)] + pub read_roots: Vec, + #[serde(default)] + pub write_roots: Vec, + pub allow_overwrite: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct BundleInspection { + pub schema_version: u32, + pub bundle_id: String, + pub policy_id: String, + pub integration: Integration, + pub profile_count: usize, + pub route_count: usize, + pub artifact_count: usize, + pub valid: bool, +} + +#[derive(Debug, Clone, Serialize)] +pub struct PolicySummary { + pub policy_id: String, + pub host: String, + pub policy_version: String, + pub binding_id: String, + pub binding_version: String, + pub profile_count: usize, + pub artifact_count: usize, + pub evidence_status: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct EvaluationReport { + pub schema_version: u32, + pub suite_id: String, + pub suite_version: String, + pub suite_sha256: String, + pub policy_id: String, + pub host: String, + pub bundle_sha256: String, + pub scenario_count: usize, + pub offline_reproducible: bool, + pub live_evidence: Option, + pub status: String, + pub recommended: bool, +} diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..c2914f8 --- /dev/null +++ b/src/error.rs @@ -0,0 +1,100 @@ +use std::fmt::Display; + +/// Concrete failures exposed by the Switchloom product library. +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("{message}")] + InvalidInput { message: String }, + + #[error("{context}: {source}")] + Context { + context: String, + #[source] + source: Box, + }, + + #[error(transparent)] + Io(#[from] std::io::Error), + + #[error(transparent)] + Json(#[from] serde_json::Error), + + #[error(transparent)] + TomlDecode(#[from] toml::de::Error), + + #[error(transparent)] + TomlEncode(#[from] toml::ser::Error), + + #[error(transparent)] + Utf8(#[from] std::string::FromUtf8Error), + + #[error(transparent)] + Signature(#[from] ed25519_dalek::SignatureError), + + #[error(transparent)] + StripPrefix(#[from] std::path::StripPrefixError), +} + +impl Error { + pub(crate) fn message(message: impl Into) -> Self { + Self::InvalidInput { + message: message.into(), + } + } + + fn context(self, context: impl Into) -> Self { + Self::Context { + context: context.into(), + source: Box::new(self), + } + } +} + +pub type Result = std::result::Result; + +pub(crate) trait ResultContext { + fn context(self, context: impl Display) -> Result; + fn with_context(self, context: C) -> Result + where + C: FnOnce() -> String; +} + +pub(crate) trait OptionContext { + fn context(self, context: impl Display) -> Result; +} + +impl OptionContext for Option { + fn context(self, context: impl Display) -> Result { + self.ok_or_else(|| Error::message(context.to_string())) + } +} + +impl ResultContext for std::result::Result +where + E: Into, +{ + fn context(self, context: impl Display) -> Result { + self.map_err(|source| source.into().context(context.to_string())) + } + + fn with_context(self, context: C) -> Result + where + C: FnOnce() -> String, + { + self.map_err(|source| source.into().context(context())) + } +} + +#[macro_export] +macro_rules! bail { + ($($argument:tt)*) => { + return Err($crate::error::Error::message(format!($($argument)*))) + }; +} + +#[macro_export] +macro_rules! product_error { + ($($argument:tt)*) => { + $crate::error::Error::message(format!($($argument)*)) + }; +} diff --git a/src/evidence.rs b/src/evidence.rs new file mode 100644 index 0000000..2bade6d --- /dev/null +++ b/src/evidence.rs @@ -0,0 +1,687 @@ +use crate::contracts::*; +use crate::error::{Result, ResultContext}; +use crate::registry::*; +use crate::{bail, product_error}; +use serde_json::{Value, json}; +use std::collections::BTreeSet; + +pub(crate) const CODEX_V2_RUNTIME_EVIDENCE_JSON: &str = + include_str!("../docs/codex-v2-runtime-evidence.json"); + +pub(crate) fn codex_v2_runtime_evidence() -> Result { + let evidence: CodexV2RuntimeEvidence = serde_json::from_str(CODEX_V2_RUNTIME_EVIDENCE_JSON) + .context("Codex V2 runtime evidence must be valid JSON")?; + validate_codex_v2_runtime_evidence(&evidence)?; + Ok(evidence) +} + +#[cfg(test)] +#[path = "tests/evidence.rs"] +mod tests; + +pub(crate) fn validate_codex_v2_runtime_evidence(evidence: &CodexV2RuntimeEvidence) -> Result<()> { + if evidence.schema_version != 1 { + bail!("unsupported Codex V2 runtime evidence schema_version"); + } + if evidence.evidence_id.trim().is_empty() + || evidence.observed_at.trim().is_empty() + || evidence.installed_version.command != "codex --version" + || evidence.installed_version.stdout.trim().is_empty() + || evidence.backend_selection_owner.trim().is_empty() + || evidence + .trust_and_discovery + .trust_boundary + .trim() + .is_empty() + || evidence + .trust_and_discovery + .discovery_behavior + .trim() + .is_empty() + || evidence.parallelism.source.trim().is_empty() + { + bail!("Codex V2 runtime evidence fields must not be blank"); + } + if evidence.installed_version.stdout_sha256 + != sha256(format!("{}\n", evidence.installed_version.stdout).as_bytes()) + { + bail!("Codex V2 runtime evidence installed_version stdout digest mismatch"); + } + if evidence.runtime_class != RuntimeClass::NativeSubagent { + bail!("Codex V2 runtime evidence must describe native-subagent"); + } + if evidence.parallelism.max_parallel_children != 3 { + bail!("Codex V2 runtime evidence must record three parallel child slots"); + } + if !evidence.shared_filesystem + || !evidence.delegation_modes.explicit_agent_type_dispatch + || !evidence.delegation_modes.ultra_auto_delegation + || !evidence + .delegation_modes + .automatic_delegation_requires_ultra + { + bail!("Codex V2 runtime evidence must record filesystem and delegation guarantees"); + } + for (field, values) in [ + ("switchloom_ownership", &evidence.switchloom_ownership), + ("codex_ownership", &evidence.codex_ownership), + ("role_precedence", &evidence.role_precedence), + ("negative_contracts", &evidence.negative_contracts), + ] { + if values.is_empty() || values.iter().any(|value| value.trim().is_empty()) { + bail!("Codex V2 runtime evidence must record {field}"); + } + } + for claim in [ + "installed_version", + "backend_selection_owner", + "trust_and_discovery", + "parallelism", + "role_precedence", + "shared_filesystem", + "delegation_modes", + ] { + let Some(records) = evidence.claim_provenance.get(claim) else { + bail!("Codex V2 runtime evidence missing provenance for {claim}"); + }; + if records.is_empty() { + bail!("Codex V2 runtime evidence has incomplete provenance for {claim}"); + } + for record in records { + validate_codex_claim_provenance_record(evidence, claim, record)?; + } + } + Ok(()) +} + +pub(crate) fn validate_codex_claim_provenance_record( + evidence: &CodexV2RuntimeEvidence, + claim: &str, + record: &CodexClaimProvenance, +) -> Result<()> { + if record.kind.trim().is_empty() + || record.source.trim().is_empty() + || record.observed_at.trim().is_empty() + || record.codex_version != evidence.installed_version.stdout + { + bail!("Codex V2 runtime evidence has incomplete provenance for {claim}"); + } + if record.source_url.as_deref().unwrap_or("").trim().is_empty() + && record + .source_path + .as_deref() + .unwrap_or("") + .trim() + .is_empty() + { + bail!( + "Codex V2 runtime evidence provenance for {claim} must include source_url or source_path" + ); + } + let Some(raw_output) = record.raw_output.as_deref() else { + bail!("Codex V2 runtime evidence provenance for {claim} must include raw output"); + }; + let Some(raw_output_sha256) = record.raw_output_sha256.as_deref() else { + bail!("Codex V2 runtime evidence provenance for {claim} must include raw output digest"); + }; + if raw_output_sha256 != sha256(raw_output.as_bytes()) { + bail!("Codex V2 runtime evidence provenance raw output digest mismatch for {claim}"); + } + let expected_value = codex_claim_observed_value(evidence, claim)?; + if record.observed_value != expected_value { + bail!("Codex V2 runtime evidence provenance observed value mismatch for {claim}"); + } + if record.required_raw_fragments.is_empty() + || record + .required_raw_fragments + .iter() + .any(|fragment| fragment.trim().is_empty()) + { + bail!("Codex V2 runtime evidence provenance for {claim} must bind raw fragments"); + } + for fragment in codex_claim_required_raw_fragments(evidence, claim)? { + if !record + .required_raw_fragments + .iter() + .any(|recorded| recorded == &fragment) + { + bail!("Codex V2 runtime evidence provenance for {claim} missing required raw fragment"); + } + if !raw_output.contains(&fragment) { + bail!("Codex V2 runtime evidence raw capture does not support {claim}"); + } + } + for fragment in &record.required_raw_fragments { + if !raw_output.contains(fragment) { + bail!( + "Codex V2 runtime evidence raw capture does not contain declared fragment for {claim}" + ); + } + } + match record.kind.as_str() { + "host-command" => { + if claim != "installed_version" + || record.source != evidence.installed_version.command + || raw_output != format!("{}\n", evidence.installed_version.stdout) + || raw_output_sha256 != evidence.installed_version.stdout_sha256 + { + bail!("Codex V2 runtime evidence installed_version provenance mismatch"); + } + } + "source-document" => { + if record.source_url.as_deref().unwrap_or("").trim().is_empty() { + bail!( + "Codex V2 runtime evidence source-document provenance for {claim} must include source_url" + ); + } + } + "session-runtime-contract" => { + if record + .source_path + .as_deref() + .unwrap_or("") + .trim() + .is_empty() + { + bail!( + "Codex V2 runtime evidence session-runtime provenance for {claim} must include source_path" + ); + } + } + other => { + bail!("Codex V2 runtime evidence unsupported provenance kind `{other}` for {claim}") + } + } + validate_codex_claim_source_identity(claim, record)?; + Ok(()) +} + +pub(crate) fn validate_codex_claim_source_identity( + claim: &str, + record: &CodexClaimProvenance, +) -> Result<()> { + let source_url = record.source_url.as_deref(); + let source_path = record.source_path.as_deref(); + let matches = match claim { + "installed_version" => { + record.kind == "host-command" + && record.source == "codex --version" + && source_path == Some("local-shell:codex --version") + && source_url.is_none() + } + "backend_selection_owner" => { + record.kind == "source-document" + && source_url == Some("https://developers.openai.com/codex/config-reference") + && source_path.is_none() + } + "trust_and_discovery" => { + record.kind == "source-document" + && source_url == Some("https://developers.openai.com/codex/config-reference") + && source_path == Some("https://developers.openai.com/codex/subagents") + } + "parallelism" => { + record.kind == "session-runtime-contract" + && source_path == Some("current-session:developer-collaboration-runtime") + && source_url.is_none() + } + "role_precedence" => { + record.kind == "source-document" + && source_url == Some("https://developers.openai.com/codex/subagents") + && source_path.is_none() + } + "shared_filesystem" => { + record.kind == "session-runtime-contract" + && source_path == Some("current-session:developer-collaboration-runtime") + && source_url.is_none() + } + "delegation_modes" => { + record.kind == "source-document" + && source_url == Some("https://developers.openai.com/codex/subagents") + && source_path == Some("https://developers.openai.com/codex/models") + } + _ => false, + }; + if !matches { + bail!("Codex V2 runtime evidence provenance source identity mismatch for {claim}"); + } + Ok(()) +} + +pub(crate) fn codex_claim_observed_value( + evidence: &CodexV2RuntimeEvidence, + claim: &str, +) -> Result { + match claim { + "installed_version" => Ok(json!(evidence.installed_version.stdout)), + "backend_selection_owner" => Ok(json!(evidence.backend_selection_owner)), + "trust_and_discovery" => Ok(json!({ + "trust_boundary": evidence.trust_and_discovery.trust_boundary, + "discovery_behavior": evidence.trust_and_discovery.discovery_behavior, + })), + "parallelism" => Ok(json!({ + "max_parallel_children": evidence.parallelism.max_parallel_children, + "source": evidence.parallelism.source, + })), + "role_precedence" => Ok(json!(evidence.role_precedence)), + "shared_filesystem" => Ok(json!(evidence.shared_filesystem)), + "delegation_modes" => Ok(json!(evidence.delegation_modes)), + _ => bail!("Codex V2 runtime evidence unknown provenance claim `{claim}`"), + } +} + +pub(crate) fn codex_claim_required_raw_fragments( + evidence: &CodexV2RuntimeEvidence, + claim: &str, +) -> Result> { + match claim { + "installed_version" => Ok(vec![evidence.installed_version.stdout.clone()]), + "backend_selection_owner" => Ok(vec![ + "Project-scoped config cannot override machine-local provider, auth".to_string(), + "configuration profile selection".to_string(), + ]), + "trust_and_discovery" => Ok(vec![ + "project-scoped config files only when you trust the project".to_string(), + "standalone TOML files under .codex/agents/".to_string(), + ]), + "parallelism" => Ok(vec![ + "4 available concurrency slots".to_string(), + "including the root thread".to_string(), + "at most 3 parallel child agents".to_string(), + ]), + "role_precedence" => Ok(vec![ + "reapplies the parent turn live runtime overrides".to_string(), + "sandbox and approval choices".to_string(), + "model_reasoning_effort inherit from the parent session when omitted".to_string(), + ]), + "shared_filesystem" => Ok(vec![ + "All agents share the same container and filesystem".to_string(), + "edits made by one agent are immediately visible to all other agents".to_string(), + ]), + "delegation_modes" => Ok(vec![ + "With Ultra, ChatGPT can proactively delegate work".to_string(), + "At most intelligence levels, ask for delegation explicitly".to_string(), + ]), + _ => bail!("Codex V2 runtime evidence unknown provenance claim `{claim}`"), + } +} + +pub(crate) fn codex_v2_host_version(evidence: &CodexV2RuntimeEvidence) -> String { + evidence.installed_version.stdout.clone() +} + +pub fn validate_dispatch_evidence(evidence: &DispatchEvidenceV1) -> Result<()> { + if evidence.schema_version != 1 { + bail!("unsupported dispatch evidence schema_version"); + } + if evidence.package_digest.trim().is_empty() + || evidence.host_version.trim().is_empty() + || evidence.nonce.trim().is_empty() + { + bail!("dispatch evidence package_digest, host_version, and nonce must not be blank"); + } + if evidence.requested_dispatch.semantic_role.trim().is_empty() + || evidence.requested_dispatch.profile.trim().is_empty() + || evidence.requested_dispatch.model.trim().is_empty() + { + bail!("dispatch evidence requested dispatch must name role, profile, and model"); + } + if evidence.child_identity.host.trim().is_empty() + || evidence.child_identity.role.trim().is_empty() + || evidence.child_identity.agent_role.trim().is_empty() + { + bail!("dispatch evidence child identity must name host, role, and agent_role"); + } + if evidence.raw_evidence_refs.is_empty() + || evidence + .raw_evidence_refs + .iter() + .any(|reference| reference.trim().is_empty()) + { + bail!("dispatch evidence must include raw evidence references"); + } + if evidence.verdict == GuaranteeLevel::Deterministic { + let effective_model = evidence.effective_model.as_deref().ok_or_else(|| { + product_error!("deterministic dispatch evidence must include observed effective_model") + })?; + if effective_model != evidence.requested_dispatch.model { + bail!( + "deterministic dispatch evidence effective_model `{effective_model}` does not match requested model `{}`", + evidence.requested_dispatch.model + ); + } + if let Some(requested_effort) = evidence.requested_dispatch.effort.as_deref() { + let effective_effort = evidence.effective_effort.as_deref().ok_or_else(|| { + product_error!( + "deterministic dispatch evidence must include observed effective_effort" + ) + })?; + if effective_effort != requested_effort { + bail!( + "deterministic dispatch evidence effective_effort `{effective_effort}` does not match requested effort `{requested_effort}`" + ); + } + } + } + Ok(()) +} + +pub fn validate_dispatch_evidence_for_adapter( + evidence: &DispatchEvidenceV1, + contract: &AdapterContractV1, +) -> Result<()> { + validate_adapter_contract(contract)?; + validate_dispatch_evidence(evidence)?; + if evidence.child_identity.host != contract.capability.host { + bail!( + "dispatch evidence host `{}` does not match adapter host `{}`", + evidence.child_identity.host, + contract.capability.host + ); + } + if !contract + .dispatch_evidence + .required_verdicts + .contains(&evidence.verdict) + { + bail!("dispatch evidence verdict is not allowed by adapter contract"); + } + let request = contract + .routing_intent + .role_requests + .iter() + .find(|request| request.semantic_role == evidence.requested_dispatch.semantic_role) + .ok_or_else(|| { + product_error!( + "dispatch evidence role `{}` is not declared by adapter contract", + evidence.requested_dispatch.semantic_role + ) + })?; + if evidence.child_identity.role != evidence.requested_dispatch.semantic_role { + bail!( + "dispatch evidence child role `{}` does not match requested semantic role `{}`", + evidence.child_identity.role, + evidence.requested_dispatch.semantic_role + ); + } + if evidence.requested_dispatch.model != request.requested_model { + bail!( + "dispatch evidence requested model `{}` does not match adapter role request `{}`", + evidence.requested_dispatch.model, + request.requested_model + ); + } + if evidence.requested_dispatch.effort != request.requested_effort { + bail!("dispatch evidence requested effort does not match adapter role request"); + } + if evidence.verdict == GuaranteeLevel::Deterministic { + require_deterministic_observation(evidence, contract)?; + require_live_nonce_observation(evidence, contract)?; + } + Ok(()) +} + +pub(crate) fn require_deterministic_observation( + evidence: &DispatchEvidenceV1, + contract: &AdapterContractV1, +) -> Result<()> { + if contract.capability.observability.effective_model != GuaranteeLevel::Deterministic { + bail!( + "deterministic dispatch evidence for adapter `{}` is not allowed because effective model observability is {:?}", + contract.adapter.adapter_id, + contract.capability.observability.effective_model + ); + } + if evidence.requested_dispatch.effort.is_some() + && contract.capability.effort_control.level != GuaranteeLevel::Deterministic + { + bail!( + "deterministic dispatch evidence for adapter `{}` is not allowed because effective effort control is {:?}", + contract.adapter.adapter_id, + contract.capability.effort_control.level + ); + } + Ok(()) +} + +pub(crate) fn require_live_nonce_observation( + evidence: &DispatchEvidenceV1, + contract: &AdapterContractV1, +) -> Result<()> { + if contract.capability.observability.effective_model == GuaranteeLevel::Deterministic + && !evidence.raw_evidence_refs.iter().any(|reference| { + reference.starts_with("host-output:") || reference.starts_with("codex-session:") + }) + { + bail!( + "deterministic dispatch evidence for adapter `{}` requires a live host output reference", + contract.adapter.adapter_id + ); + } + if evidence + .raw_evidence_refs + .iter() + .any(|reference| reference.contains("status:not-run")) + { + bail!( + "dispatch evidence for adapter `{}` cannot cite a not-run host output", + contract.adapter.adapter_id + ); + } + Ok(()) +} + +pub(crate) fn validate_adapter_contract(contract: &AdapterContractV1) -> Result<()> { + if contract.schema_version != 1 + || contract.routing_intent.schema_version != 1 + || contract.capability.schema_version != 1 + || contract.adapter.schema_version != 1 + || contract.dispatch_evidence.schema_version != 1 + || contract.planr_handoff.schema_version != 1 + { + bail!("unsupported adapter contract schema_version"); + } + if contract.routing_intent.semantic_roles.is_empty() { + bail!("adapter contract must declare semantic roles"); + } + if contract.routing_intent.role_requests.is_empty() { + bail!("adapter contract must declare role requests"); + } + let semantic_roles = contract + .routing_intent + .semantic_roles + .iter() + .cloned() + .collect::>(); + for request in &contract.routing_intent.role_requests { + if !semantic_roles.contains(&request.semantic_role) { + bail!( + "adapter contract role request references unknown semantic role `{}`", + request.semantic_role + ); + } + if request.requested_model.trim().is_empty() || request.instructions.trim().is_empty() { + bail!("adapter contract role requests must include model and instructions"); + } + } + if contract.capability.host.trim().is_empty() || contract.adapter.adapter_id.trim().is_empty() { + bail!("adapter contract host and adapter_id must not be blank"); + } + if contract.capability.runtime_class != contract.adapter.runtime_class { + bail!("adapter contract runtime_class mismatch"); + } + validate_runtime_behavior(&contract.capability)?; + if contract.adapter.accepts_intent_schema != "RoutingIntentV1" { + bail!("adapter contract must accept RoutingIntentV1"); + } + if contract.capability.model_control.field.trim().is_empty() + || contract.capability.effort_control.field.trim().is_empty() + { + bail!("adapter contract control fields must not be blank"); + } + if contract + .adapter + .dispatch_recipe + .invocation + .trim() + .is_empty() + { + bail!("adapter contract dispatch recipe invocation must not be blank"); + } + if contract.adapter.dispatch_recipe.required_fields.is_empty() { + bail!("adapter contract dispatch recipe must declare required fields"); + } + for required in &contract.routing_intent.required_guarantees { + let Some(guarantee) = contract.capability.guarantees.get(required) else { + bail!("adapter contract requires undeclared guarantee `{required}`"); + }; + if guarantee.level == GuaranteeLevel::Unsupported { + bail!("adapter contract required guarantee `{required}` is unsupported"); + } + } + for (name, guarantee) in &contract.capability.guarantees { + if name.trim().is_empty() || guarantee.reason.trim().is_empty() { + bail!("adapter contract guarantee names and reasons must not be blank"); + } + } + let verdicts = contract + .dispatch_evidence + .required_verdicts + .iter() + .copied() + .collect::>(); + for verdict in [ + GuaranteeLevel::Deterministic, + GuaranteeLevel::Advisory, + GuaranteeLevel::Unsupported, + ] { + if !verdicts.contains(&verdict) { + bail!("adapter contract dispatch evidence must enumerate all guarantee verdicts"); + } + } + if contract.dispatch_evidence.receipt_schema != "DispatchEvidenceV1" { + bail!("adapter contract dispatch evidence must reference DispatchEvidenceV1"); + } + Ok(()) +} + +pub(crate) fn validate_runtime_behavior(capability: &HostCapabilityV1) -> Result<()> { + let behavior = &capability.runtime_behavior; + if behavior.capability_version.trim().is_empty() + || behavior.installed_host_version_source.trim().is_empty() + || behavior.backend_selection_source.trim().is_empty() + || behavior.trust_boundary.trim().is_empty() + || behavior.discovery_behavior.trim().is_empty() + { + bail!("adapter contract runtime behavior fields must not be blank"); + } + if behavior.role_precedence.is_empty() + || behavior + .role_precedence + .iter() + .any(|entry| entry.trim().is_empty()) + { + bail!("adapter contract runtime behavior must declare role precedence"); + } + if behavior.source_references.is_empty() + || behavior + .source_references + .iter() + .any(|entry| entry.trim().is_empty()) + { + bail!("adapter contract runtime behavior must declare source references"); + } + if capability.host == "codex" { + let evidence = codex_v2_runtime_evidence()?; + let expected_source_reference = codex_v2_runtime_evidence_reference(); + let expected_host_version = codex_v2_host_version(&evidence); + if behavior.capability_version != evidence.evidence_id { + bail!("Codex V2 runtime capability_version must match parsed evidence_id"); + } + if behavior.installed_host_version_source + != format!( + "{} via {}", + evidence.installed_version.stdout, evidence.installed_version.command + ) + { + bail!( + "Codex V2 runtime installed host version must match parsed evidence command output" + ); + } + if capability.host_version_constraints.minimum.as_deref() + != Some(expected_host_version.as_str()) + || capability.host_version_constraints.maximum.as_deref() + != Some(expected_host_version.as_str()) + { + bail!("Codex V2 host_version_constraints must freeze the parsed evidence version"); + } + if !capability + .discovery_artifacts + .iter() + .any(|artifact| artifact == &evidence.evidence_id) + { + bail!("Codex V2 discovery artifacts must include the parsed evidence id"); + } + if behavior.source_references != vec![expected_source_reference] { + bail!( + "Codex V2 runtime source reference must match the digest-bound evidence artifact" + ); + } + if capability.parallelism.max_parallel_children + != evidence.parallelism.max_parallel_children + { + bail!("Codex V2 runtime must declare exactly the parsed evidence child slots"); + } + if behavior.backend_selection_source != evidence.backend_selection_owner { + bail!("Codex V2 backend selection source must match parsed evidence"); + } + if behavior.trust_boundary != evidence.trust_and_discovery.trust_boundary + || behavior.discovery_behavior != evidence.trust_and_discovery.discovery_behavior + { + bail!("Codex V2 trust and discovery behavior must match parsed evidence"); + } + if behavior.role_precedence != evidence.role_precedence { + bail!("Codex V2 role precedence must match parsed evidence"); + } + if behavior.shared_filesystem != evidence.shared_filesystem { + bail!("Codex V2 shared filesystem flag must match parsed evidence"); + } + if behavior.delegation_modes != evidence.delegation_modes { + bail!("Codex V2 delegation modes must match parsed evidence"); + } + if !evidence + .codex_ownership + .iter() + .any(|owner| owner.contains("execution timing and orchestration")) + || !evidence + .switchloom_ownership + .iter() + .any(|owner| owner.contains("semantic role compilation")) + { + bail!("Codex V2 ownership boundaries must be recorded in parsed evidence"); + } + if capability.runtime_class != RuntimeClass::NativeSubagent { + bail!("Codex V2 runtime must be a native-subagent contract"); + } + if !behavior.shared_filesystem { + bail!("Codex V2 runtime must declare shared filesystem behavior"); + } + if !behavior.delegation_modes.explicit_agent_type_dispatch { + bail!("Codex V2 runtime must declare explicit agent_type dispatch"); + } + if !behavior.delegation_modes.ultra_auto_delegation + || !behavior + .delegation_modes + .automatic_delegation_requires_ultra + { + bail!("Codex V2 runtime must declare Ultra automatic delegation boundaries"); + } + } + Ok(()) +} + +pub(crate) fn codex_v2_runtime_evidence_reference() -> String { + format!( + "docs/codex-v2-runtime-evidence.json#sha256:{}", + sha256(CODEX_V2_RUNTIME_EVIDENCE_JSON.as_bytes()) + ) +} diff --git a/src/hosts.rs b/src/hosts.rs new file mode 100644 index 0000000..f340d9a --- /dev/null +++ b/src/hosts.rs @@ -0,0 +1,1008 @@ +use crate::contracts::*; +use crate::error::{OptionContext, Result, ResultContext}; +use crate::evidence::*; +use crate::{bail, product_error}; +use serde::Serialize; +use serde_json::Value; +use std::collections::BTreeMap; +use std::process::Command; + +pub(crate) const NPM_PACKAGE_JSON: &str = include_str!("../package.json"); +pub(crate) const BINDINGS: [(&str, &str); 7] = [ + ( + "codex-openai", + include_str!("../host-bindings/codex-openai.toml"), + ), + ( + "cursor-openai", + include_str!("../host-bindings/cursor-openai.toml"), + ), + ( + "cursor-fable-grok", + include_str!("../host-bindings/cursor-fable-grok.toml"), + ), + ( + "claude-native", + include_str!("../host-bindings/claude-native.toml"), + ), + ( + "opencode-native", + include_str!("../host-bindings/opencode-native.toml"), + ), + ( + "pi-external", + include_str!("../host-bindings/pi-external.toml"), + ), + ( + "mixed-host", + include_str!("../host-bindings/mixed-host.toml"), + ), +]; + +pub fn probe_host(host: &str, command_override: Option<&str>) -> Result { + let binding = binding_for_selector(host)?; + let default_command = match binding.host.as_str() { + "codex" => Some("codex"), + "cursor" => Some("cursor-agent"), + "claude-code" => Some("claude"), + "opencode" => Some("opencode"), + "pi" => Some("pi"), + "mixed-host" => None, + _ => None, + }; + let command = command_override.or(default_command); + let (available, version, limitation) = if let Some(command) = command { + match Command::new(command).arg("--version").output() { + Ok(output) if output.status.success() => ( + true, + Some(String::from_utf8_lossy(&output.stdout).trim().to_string()), + None, + ), + Ok(output) => ( + false, + None, + Some(format!("version probe exited with {}", output.status)), + ), + Err(error) => (false, None, Some(error.to_string())), + } + } else { + ( + false, + None, + Some("mixed-host bindings require separate probes for each declared host".to_string()), + ) + }; + Ok(ProbeReport { + host: host.to_string(), + command: command.map(ToOwned::to_owned), + available, + version, + capabilities: requirement_capabilities_for_binding(&binding), + authentication: "not_tested".to_string(), + limitation, + }) +} + +#[cfg(test)] +#[path = "tests/hosts.rs"] +mod tests; + +pub(crate) fn validate_host_adapter(binding: &HostBinding) -> Result<()> { + if binding.id.trim().is_empty() + || binding.version.trim().is_empty() + || binding.host.trim().is_empty() + { + bail!("host adapter id, version, and host must not be blank"); + } + if binding.profiles.is_empty() { + bail!("host adapter `{}` must declare profiles", binding.id); + } + if binding.default_role.is_none() && binding.routes.is_empty() { + bail!( + "host adapter `{}` must declare routes or a default role", + binding.id + ); + } + if let Some(default_role) = &binding.default_role { + binding_profile_id(binding, default_role)?; + } + let mut profile_ids = BTreeMap::::new(); + for (role, profile) in &binding.profiles { + validate_setup_identifier("binding role", role)?; + if profile.profile.trim().is_empty() + || profile.client.trim().is_empty() + || profile.model.trim().is_empty() + { + bail!( + "host adapter `{}` profile `{role}` has blank identity fields", + binding.id + ); + } + if let Some(existing) = profile_ids.insert(profile.profile.clone(), role.clone()) { + bail!( + "host adapter `{}` roles `{existing}` and `{role}` both normalize to profile `{}`", + binding.id, + profile.profile + ); + } + if profile.client == "codex" && profile.agent_type.is_none() { + bail!( + "host adapter `{}` Codex profile `{role}` must declare agent_type", + binding.id + ); + } + } + for route in &binding.routes { + if route.work_type.trim().is_empty() { + bail!( + "host adapter `{}` route work_type must not be blank", + binding.id + ); + } + binding_profile_id(binding, &route.role)?; + for fallback in &route.fallback_roles { + binding_profile_id(binding, fallback)?; + } + } + let mut artifact_paths = BTreeMap::::new(); + let mut codex_agent_types = BTreeMap::::new(); + for artifact in &binding.artifacts { + if artifact.path.trim().is_empty() || artifact.kind.trim().is_empty() { + bail!( + "host adapter `{}` artifacts must declare path and kind", + binding.id + ); + } + if let Some(existing) = artifact_paths.insert(artifact.path.clone(), artifact.kind.clone()) + { + bail!( + "host adapter `{}` artifacts `{existing}` and `{}` both emit path `{}`", + binding.id, + artifact.kind, + artifact.path + ); + } + if artifact.content.trim().is_empty() { + bail!( + "host adapter `{}` artifact `{}` must not be empty", + binding.id, + artifact.path + ); + } + if artifact.path.starts_with(".codex/agents/") { + let parsed: toml::Value = toml::from_str(&artifact.content).with_context(|| { + format!( + "host adapter `{}` artifact `{}` must be TOML", + binding.id, artifact.path + ) + })?; + let agent_type = parsed + .get("name") + .and_then(toml::Value::as_str) + .ok_or_else(|| { + product_error!( + "host adapter `{}` artifact `{}` must declare name", + binding.id, + artifact.path + ) + })?; + if let Some(existing) = + codex_agent_types.insert(agent_type.to_string(), artifact.path.clone()) + { + bail!( + "host adapter `{}` artifacts `{existing}` and `{}` both declare Codex agent_type `{agent_type}`", + binding.id, + artifact.path + ); + } + } + } + Ok(()) +} + +pub(crate) fn requirement_capabilities_for_binding(binding: &HostBinding) -> Vec { + let mut capabilities = Vec::new(); + if binding.capabilities.model_override { + capabilities.push("model_override".to_string()); + } + if binding.capabilities.effort_override { + capabilities.push("reasoning_effort".to_string()); + } + if binding.capabilities.fork_none { + capabilities.push("fork_none".to_string()); + } + if binding.capabilities.fork_all { + capabilities.push("bounded_context_fork".to_string()); + } + capabilities +} + +pub(crate) fn artifacts_for_binding(binding: &HostBinding) -> Vec { + binding + .artifacts + .iter() + .map(|artifact| SourceArtifact { + media_type: media_type_for(&artifact.path, &artifact.kind), + path: artifact.path.clone(), + mode: "create".to_string(), + content: artifact.content.clone(), + }) + .collect() +} + +pub(crate) fn npm_package_identity() -> Result { + let package: Value = serde_json::from_str(NPM_PACKAGE_JSON)?; + let name = package + .get("name") + .and_then(Value::as_str) + .context("package.json must declare string name")?; + let version = package + .get("version") + .and_then(Value::as_str) + .context("package.json must declare string version")?; + Ok(format!("{name}@{version}")) +} + +pub(crate) fn control_level(supported: bool, deterministic: bool) -> GuaranteeLevel { + match (supported, deterministic) { + (true, true) => GuaranteeLevel::Deterministic, + (true, false) => GuaranteeLevel::Advisory, + (false, _) => GuaranteeLevel::Unsupported, + } +} + +pub(crate) fn max_parallel_children_for_binding(binding: &HostBinding) -> Result { + if binding.host == "codex" { + Ok(codex_v2_runtime_evidence()? + .parallelism + .max_parallel_children) + } else { + Ok(1) + } +} + +pub(crate) fn host_version_constraints_for_binding( + binding: &HostBinding, +) -> Result { + if binding.host == "codex" { + let evidence = codex_v2_runtime_evidence()?; + let host_version = codex_v2_host_version(&evidence); + return Ok(HostVersionConstraints { + minimum: Some(host_version.clone()), + maximum: Some(host_version), + evidence_max_age_seconds: binding.verification.max_age_seconds.unwrap_or(0), + }); + } + Ok(HostVersionConstraints { + minimum: None, + maximum: None, + evidence_max_age_seconds: binding.verification.max_age_seconds.unwrap_or(0), + }) +} + +pub(crate) fn runtime_behavior_for_binding(binding: &HostBinding) -> Result { + if binding.host == "codex" { + let evidence = codex_v2_runtime_evidence()?; + return Ok(RuntimeBehaviorV1 { + capability_version: evidence.evidence_id, + installed_host_version_source: format!( + "{} via {}", + evidence.installed_version.stdout, evidence.installed_version.command + ), + backend_selection_source: evidence.backend_selection_owner, + trust_boundary: evidence.trust_and_discovery.trust_boundary, + discovery_behavior: evidence.trust_and_discovery.discovery_behavior, + role_precedence: evidence.role_precedence, + shared_filesystem: evidence.shared_filesystem, + delegation_modes: evidence.delegation_modes, + source_references: vec![codex_v2_runtime_evidence_reference()], + }); + } + + let source_references = if binding.capability_evidence.is_empty() { + vec![format!("host-binding:{}", binding.id)] + } else { + binding.capability_evidence.clone() + }; + + Ok(RuntimeBehaviorV1 { + capability_version: binding.verification.id.clone(), + installed_host_version_source: format!("{} --version", binding.host), + backend_selection_source: "host account, workspace, provider, or runner configuration outside Switchloom ownership".to_string(), + trust_boundary: "repository-local generated artifacts are Switchloom-managed; host authentication, account policy, and execution state are host-owned".to_string(), + discovery_behavior: "host-specific project artifact discovery".to_string(), + role_precedence: vec![ + "Switchloom declares requested semantic role, profile, model, effort, and artifacts".to_string(), + "the host runtime remains the authority for effective execution".to_string(), + ], + shared_filesystem: binding.runtime_class == RuntimeClass::NativeSubagent, + delegation_modes: DelegationModesV1 { + explicit_agent_type_dispatch: binding.capabilities.fork_none, + ultra_auto_delegation: false, + automatic_delegation_requires_ultra: false, + }, + source_references, + }) +} + +pub(crate) fn dispatch_fields_for_binding(binding: &HostBinding) -> Vec { + let mut fields = vec!["profile".to_string(), "model".to_string()]; + if binding.runtime_class == RuntimeClass::ExternalRunner { + fields.push("provider".to_string()); + } + if binding.capabilities.effort_override { + fields.push("effort".to_string()); + } + if binding + .profiles + .values() + .any(|profile| profile.agent_type.is_some()) + { + fields.push("agent_type".to_string()); + } + if binding.capabilities.fork_none || binding.capabilities.fork_all { + fields.push("fork_turns".to_string()); + } + if binding.runtime_class == RuntimeClass::ExternalRunner { + fields.push("isolation".to_string()); + fields.push("task".to_string()); + } + fields +} + +pub(crate) fn capability_guarantees_for_binding( + binding: &HostBinding, +) -> BTreeMap { + BTreeMap::from([ + ( + "artifact_lifecycle".to_string(), + CapabilityGuarantee { + level: GuaranteeLevel::Deterministic, + reason: "Switchloom owns preview/apply/update/rollback/uninstall for managed artifacts.".to_string(), + evidence_required: false, + }, + ), + ( + "dispatch_identity".to_string(), + CapabilityGuarantee { + level: if binding.capabilities.fork_none { + GuaranteeLevel::Deterministic + } else { + GuaranteeLevel::Unsupported + }, + reason: if binding.capabilities.fork_none { + "Adapter can emit explicit local child identity and non-all context policy.".to_string() + } else { + "Host binding has no explicit non-all child dispatch contract.".to_string() + }, + evidence_required: binding.capabilities.fork_none, + }, + ), + ( + "model_selection".to_string(), + CapabilityGuarantee { + level: if binding.capabilities.model_override && binding.host == "codex" { + GuaranteeLevel::Deterministic + } else if binding.capabilities.model_override { + GuaranteeLevel::Advisory + } else { + GuaranteeLevel::Unsupported + }, + reason: if binding.capabilities.model_override && binding.host == "codex" { + "Codex project agent files declare the child model; live evidence is still required for certification.".to_string() + } else if binding.capabilities.model_override { + "Host accepts a requested model but may apply account, workspace, or runtime precedence.".to_string() + } else { + "Host binding exposes no model override control.".to_string() + }, + evidence_required: binding.capabilities.model_override, + }, + ), + ( + "effort_selection".to_string(), + CapabilityGuarantee { + level: if binding.capabilities.effort_override && binding.host == "codex" { + GuaranteeLevel::Deterministic + } else if binding.capabilities.effort_override { + GuaranteeLevel::Advisory + } else { + GuaranteeLevel::Unsupported + }, + reason: if binding.capabilities.effort_override && binding.host == "codex" { + "Codex project agent files declare model_reasoning_effort for role-local child dispatch.".to_string() + } else if binding.capabilities.effort_override { + "Host accepts an effort-like field but effective precedence must be proven separately.".to_string() + } else { + "Host binding exposes no effort override control.".to_string() + }, + evidence_required: binding.capabilities.effort_override, + }, + ), + ( + "effective_runtime_evidence".to_string(), + CapabilityGuarantee { + level: GuaranteeLevel::Advisory, + reason: "Generated bundles declare requested routing; certification must persist requested-versus-effective host evidence.".to_string(), + evidence_required: true, + }, + ), + ]) +} + +pub(crate) fn binding_artifact_for_role( + binding: &HostBinding, + artifacts: &[SourceArtifact], + role: &str, +) -> Result { + let profile = binding + .profiles + .get(role) + .ok_or_else(|| product_error!("setup role `{role}` is missing from binding"))?; + let agent_type = profile + .agent_type + .as_ref() + .ok_or_else(|| product_error!("setup role `{role}` has no binding agent_type"))?; + artifacts + .iter() + .find(|artifact| { + artifact + .content + .contains(&format!("name = \"{agent_type}\"")) + }) + .cloned() + .ok_or_else(|| product_error!("binding role `{role}` has no generated host artifact")) +} + +pub(crate) fn binding_artifact_path_for_role(binding: &HostBinding, role: &str) -> Result { + let profile = binding + .profiles + .get(role) + .ok_or_else(|| product_error!("setup role `{role}` is missing from binding"))?; + let agent_type = profile + .agent_type + .as_ref() + .ok_or_else(|| product_error!("setup role `{role}` has no binding agent_type"))?; + binding + .artifacts + .iter() + .find(|artifact| { + artifact + .content + .contains(&format!("name = \"{agent_type}\"")) + }) + .map(|artifact| artifact.path.clone()) + .ok_or_else(|| product_error!("binding role `{role}` has no generated host artifact")) +} + +pub(crate) fn binding_profile_id<'a>(binding: &'a HostBinding, role: &str) -> Result<&'a str> { + binding + .profiles + .get(role) + .map(|profile| profile.profile.as_str()) + .ok_or_else(|| product_error!("binding route references unknown role `{role}`")) +} + +pub(crate) fn binding_for_selector(selector: &str) -> Result { + let binding_id = canonical_binding_id(selector); + let raw = BINDINGS + .iter() + .find(|(id, _)| *id == binding_id) + .map(|(_, raw)| *raw) + .ok_or_else(|| product_error!("unknown setup host `{selector}`"))?; + Ok(toml::from_str(raw)?) +} + +pub(crate) fn canonical_binding_id(selector: &str) -> &str { + match selector { + "codex" => "codex-openai", + "claude-code" => "claude-native", + "cursor" => "cursor-openai", + "opencode" => "opencode-native", + "pi" => "pi-external", + other => other, + } +} + +pub(crate) fn setup_runtime_host(binding: &HostBinding) -> &str { + binding.host.as_str() +} + +pub(crate) fn setup_model_catalog(host: &str) -> Vec { + match host { + "codex" => vec![ + SetupModelOption { + id: "gpt-5.6-sol", + efforts: &["low", "medium", "high", "xhigh", "ultra"], + tier: "premium", + }, + SetupModelOption { + id: "gpt-5.6-terra", + efforts: &["low", "medium", "high", "xhigh", "ultra"], + tier: "standard", + }, + SetupModelOption { + id: "gpt-5.6-luna", + efforts: &["low", "medium", "high", "xhigh"], + tier: "standard", + }, + ], + "cursor" => vec![ + SetupModelOption { + id: "gpt-5.6-sol", + efforts: &["low", "medium", "high", "xhigh", "max"], + tier: "premium", + }, + SetupModelOption { + id: "gpt-5.6-terra", + efforts: &["low", "medium", "high", "xhigh", "max"], + tier: "standard", + }, + SetupModelOption { + id: "gpt-5.6-luna", + efforts: &["low", "medium", "high", "xhigh", "max"], + tier: "standard", + }, + SetupModelOption { + id: "fable-5", + efforts: &["low", "medium", "high", "xhigh", "max"], + tier: "premium", + }, + SetupModelOption { + id: "claude-opus-4-8", + efforts: &["low", "medium", "high", "xhigh", "max"], + tier: "premium", + }, + SetupModelOption { + id: "claude-sonnet-5", + efforts: &["low", "medium", "high", "xhigh", "max"], + tier: "standard", + }, + SetupModelOption { + id: "grok-4.5", + efforts: &["low", "medium", "high"], + tier: "premium", + }, + SetupModelOption { + id: "composer-2.5", + efforts: &[], + tier: "standard", + }, + ], + "claude-code" => vec![ + SetupModelOption { + id: "opus", + efforts: &["medium", "high"], + tier: "premium", + }, + SetupModelOption { + id: "sonnet", + efforts: &["medium", "high"], + tier: "standard", + }, + ], + "opencode" => vec![ + SetupModelOption { + id: "opencode/gpt-5-nano", + efforts: &["low", "medium", "high", "max"], + tier: "standard", + }, + SetupModelOption { + id: "anthropic/claude-sonnet-4-5", + efforts: &["low", "medium", "high"], + tier: "standard", + }, + SetupModelOption { + id: "anthropic/claude-opus-4-5", + efforts: &["high", "max"], + tier: "premium", + }, + ], + "pi" => vec![ + SetupModelOption { + id: "openai/gpt-4o-mini", + efforts: &["low", "medium", "high", "xhigh"], + tier: "standard", + }, + SetupModelOption { + id: "google/gemini-2.5-flash", + efforts: &["low", "medium", "high", "xhigh"], + tier: "standard", + }, + SetupModelOption { + id: "anthropic/claude-sonnet-4-5", + efforts: &["low", "medium", "high", "xhigh"], + tier: "premium", + }, + ], + "mixed-host" => vec![ + SetupModelOption { + id: "gpt-5.6-sol", + efforts: &["medium", "high", "xhigh"], + tier: "premium", + }, + SetupModelOption { + id: "gpt-5.6-terra", + efforts: &["low", "medium", "high"], + tier: "standard", + }, + SetupModelOption { + id: "opus", + efforts: &["high"], + tier: "premium", + }, + SetupModelOption { + id: "sonnet", + efforts: &["medium"], + tier: "standard", + }, + ], + _ => Vec::new(), + } +} + +pub(crate) fn validate_model_effort( + host: &str, + role: &str, + selection: &SetupRoleSelection, + catalog: &[SetupModelOption], +) -> Result<()> { + let option = catalog + .iter() + .find(|option| option.id == selection.model) + .ok_or_else(|| { + product_error!( + "setup role `{role}` model `{}` is not supported by host `{host}`", + selection.model + ) + })?; + match (&selection.effort, option.efforts.is_empty()) { + (None, true) => Ok(()), + (Some(_), true) => bail!( + "setup role `{role}` model `{}` does not accept effort", + selection.model + ), + (None, false) => bail!( + "setup role `{role}` model `{}` requires effort", + selection.model + ), + (Some(effort), false) if option.efforts.contains(&effort.as_str()) => Ok(()), + (Some(effort), false) => bail!( + "setup role `{role}` effort `{effort}` is not supported for model `{}` on host `{host}`", + selection.model + ), + } +} + +pub(crate) fn selection_matches_binding_profile( + role: &str, + selection: &SetupRoleSelection, + binding: &HostBinding, +) -> bool { + binding.profiles.get(role).is_some_and(|profile| { + selection.model == profile.model + && selection.effort == profile.effort + && selection_spawn_matches_binding( + setup_runtime_host(binding), + role, + selection, + profile, + ) + }) +} + +pub(crate) fn setup_spawn_policy_for_binding_role( + runtime_host: &str, + role: &str, + profile: &BindingProfile, +) -> Option { + if runtime_host != "codex" { + return None; + } + Some(SetupSpawnPolicy { + agent_type: profile.agent_type.clone()?, + task_name: identifier_token(role), + fork_turns: profile.fork_turns.clone()?, + }) +} + +pub(crate) fn selection_spawn_matches_binding( + runtime_host: &str, + role: &str, + selection: &SetupRoleSelection, + profile: &BindingProfile, +) -> bool { + if runtime_host != "codex" { + return selection.spawn.is_none(); + } + match (&selection.spawn, &profile.agent_type, &profile.fork_turns) { + (None, None, None) => true, + (None, Some(_), None) => true, + (Some(spawn), Some(agent_type), Some(fork_turns)) => { + spawn.agent_type == *agent_type + && spawn.task_name == identifier_token(role) + && spawn.fork_turns == *fork_turns + } + _ => false, + } +} + +pub(crate) fn validate_setup_spawn_policy( + runtime_host: &str, + role: &str, + selection: &SetupRoleSelection, + matches_binding: bool, +) -> Result<()> { + if runtime_host != "codex" { + if selection.spawn.is_some() { + bail!("setup role `{role}` spawn policy is only supported for Codex hosts"); + } + return Ok(()); + } + if matches_binding && selection.spawn.is_none() { + return Ok(()); + } + let Some(spawn) = &selection.spawn else { + bail!( + "setup role `{role}` must declare Codex spawn policy with exact agent_type, task_name, and fork_turns" + ); + }; + if spawn.task_name.contains('/') || spawn.task_name.starts_with('.') { + bail!( + "setup role `{role}` task_name must be a local lowercase identifier, not a canonical task path" + ); + } + validate_setup_snake_identifier("agent_type", &spawn.agent_type)?; + validate_setup_snake_identifier("task_name", &spawn.task_name)?; + let expected_task_name = identifier_token(role); + if spawn.task_name != expected_task_name { + bail!( + "setup role `{role}` task_name `{}` must match `{expected_task_name}`", + spawn.task_name + ); + } + if spawn.agent_type.trim().is_empty() { + bail!("setup role `{role}` agent_type must not be blank"); + } + let fork_turns = &spawn.fork_turns; + { + match fork_turns.mode.as_str() { + "none" => { + if fork_turns.turns.is_some() { + bail!("setup role `{role}` fork_turns none must not declare turns"); + } + } + "bounded" => match fork_turns.turns { + Some(turns) if turns > 0 => {} + _ => bail!("setup role `{role}` bounded fork_turns must use positive turns"), + }, + "all" => { + bail!("setup role `{role}` must not use fork_turns all for Codex role overrides") + } + other => bail!("setup role `{role}` has unsupported fork_turns mode `{other}`"), + } + } + Ok(()) +} + +pub(crate) fn validate_setup_snake_identifier(kind: &str, value: &str) -> Result<()> { + let valid = !value.is_empty() + && value.len() <= 64 + && value + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_'); + if !valid { + bail!("setup {kind} `{value}` must use lowercase ASCII letters, digits, or `_`"); + } + reject_setup_secret_like(kind, value) +} + +pub(crate) fn validate_setup_identifier(kind: &str, value: &str) -> Result<()> { + let valid = !value.is_empty() + && value.len() <= 64 + && value.bytes().all(|byte| { + byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-' || byte == b'_' + }); + if !valid { + bail!("setup {kind} `{value}` must use lowercase ASCII letters, digits, `_`, or `-`"); + } + reject_setup_secret_like(kind, value) +} + +pub(crate) fn validate_setup_identity_collisions( + spec: &SetupSpecV1, + runtime_host: &str, + binding: &HostBinding, +) -> Result<()> { + let mut normalized_roles = BTreeMap::::new(); + let mut artifact_paths = BTreeMap::::new(); + let mut codex_agent_types = BTreeMap::::new(); + let mut codex_task_names = BTreeMap::::new(); + for (role, selection) in &spec.selected_roles { + let normalized = identifier_token(role); + if let Some(existing) = normalized_roles.insert(normalized.clone(), role.clone()) { + bail!("setup roles `{existing}` and `{role}` both normalize to `{normalized}`"); + } + if runtime_host == "codex" { + let agent_type = if let Some(spawn) = &selection.spawn { + spawn.agent_type.clone() + } else if selection_matches_binding_profile(role, selection, binding) { + binding + .profiles + .get(role) + .and_then(|profile| profile.agent_type.clone()) + .unwrap_or_default() + } else { + String::new() + }; + if !agent_type.is_empty() { + if let Some(existing) = codex_agent_types.insert(agent_type.clone(), role.clone()) { + bail!( + "setup roles `{existing}` and `{role}` both declare Codex agent_type `{agent_type}`" + ); + } + } + if let Some(spawn) = &selection.spawn { + if let Some(existing) = + codex_task_names.insert(spawn.task_name.clone(), role.clone()) + { + bail!( + "setup roles `{existing}` and `{role}` both declare Codex task_name `{}`", + spawn.task_name + ); + } + } + } + let artifact_path = if runtime_host == "codex" + && selection.spawn.is_none() + && selection_matches_binding_profile(role, selection, binding) + { + Some(binding_artifact_path_for_role(binding, role)?) + } else if runtime_host != "codex" || selection.spawn.is_some() { + Some(setup_artifact_path(runtime_host, role, selection)?) + } else { + None + }; + if let Some(artifact_path) = artifact_path { + if let Some(existing) = artifact_paths.insert(artifact_path.clone(), role.clone()) { + bail!( + "setup roles `{existing}` and `{role}` both generate artifact path `{artifact_path}`" + ); + } + } + } + Ok(()) +} + +pub(crate) fn setup_artifact_path( + runtime_host: &str, + role: &str, + selection: &SetupRoleSelection, +) -> Result { + let file_role = identifier_token(role); + Ok(match runtime_host { + "codex" => { + let spawn = selection.spawn.as_ref().ok_or_else(|| { + product_error!("setup role `{role}` must declare Codex spawn policy") + })?; + format!(".codex/agents/{}.toml", spawn.agent_type) + } + "claude-code" => format!(".claude/agents/switchloom-{file_role}.md"), + "cursor" => format!(".cursor/agents/switchloom-{file_role}.md"), + "opencode" => format!(".opencode/agents/switchloom-{file_role}.md"), + "pi" => format!(".pi/workflows/switchloom-{file_role}.json"), + "mixed-host" => format!(".model-routing/roles/{file_role}.toml"), + other => bail!("unsupported setup runtime host `{other}`"), + }) +} + +pub(crate) fn validate_setup_route_role( + roles: &BTreeMap, + role: &str, +) -> Result<()> { + if !roles.contains_key(role) { + bail!("setup route references unknown role `{role}`"); + } + Ok(()) +} + +pub(crate) fn reject_setup_secret_like(kind: &str, value: &str) -> Result<()> { + let lower = value.to_ascii_lowercase(); + for token in [ + "api_key", + "apikey", + "token", + "secret", + "credential", + "password", + ] { + if lower.contains(token) { + bail!("setup {kind} must not contain credential-like token `{token}`"); + } + } + Ok(()) +} + +pub(crate) fn identifier_token(value: &str) -> String { + value + .chars() + .map(|ch| { + if ch.is_ascii_alphanumeric() { + ch.to_ascii_lowercase() + } else { + '_' + } + }) + .collect() +} + +pub(crate) fn media_type_for(path: &str, kind: &str) -> String { + if path.ends_with(".toml") { + "application/toml" + } else if path.ends_with(".json") { + "application/json" + } else if path.ends_with(".md") || kind.ends_with("_skill") || kind.ends_with("_agent") { + "text/markdown" + } else { + "text/plain" + } + .to_string() +} + +pub(crate) fn render_codex_agent_registration_artifact( + artifacts: &[SourceArtifact], +) -> Result> { + #[derive(Serialize)] + struct CodexAgentRegistrationConfig { + agents: BTreeMap, + } + + #[derive(Serialize)] + struct CodexAgentRegistration { + config_file: String, + } + + let mut agents = BTreeMap::new(); + for artifact in artifacts + .iter() + .filter(|artifact| artifact.path.starts_with(".codex/agents/")) + { + let parsed: toml::Value = toml::from_str(&artifact.content) + .with_context(|| format!("Codex agent artifact `{}` must be TOML", artifact.path))?; + let agent_type = parsed + .get("name") + .and_then(toml::Value::as_str) + .ok_or_else(|| { + product_error!("Codex agent artifact `{}` must declare name", artifact.path) + })?; + let Some(file_name) = artifact.path.strip_prefix(".codex/") else { + bail!( + "Codex agent artifact `{}` must be relative to .codex", + artifact.path + ); + }; + if let Some(existing) = agents.insert( + agent_type.to_string(), + CodexAgentRegistration { + config_file: format!("./{file_name}"), + }, + ) { + bail!( + "Codex agent_type `{agent_type}` is registered by multiple artifacts, including `{}`", + existing.config_file + ); + } + } + if agents.is_empty() { + return Ok(None); + } + let mut content = toml::to_string_pretty(&CodexAgentRegistrationConfig { agents })?; + if !content.ends_with('\n') { + content.push('\n'); + } + Ok(Some(SourceArtifact { + path: ".codex/config.toml".to_string(), + media_type: "application/toml".to_string(), + mode: "replace".to_string(), + content, + })) +} diff --git a/src/integrations.rs b/src/integrations.rs new file mode 100644 index 0000000..cdb5921 --- /dev/null +++ b/src/integrations.rs @@ -0,0 +1,115 @@ +use crate::contracts::*; +use serde_json::{Value, json}; + +pub(crate) fn render_planr_native_role(artifact: &SourceArtifact) -> String { + let protocol = if is_reviewer_role(artifact) { + Some(( + "$planr-review", + "Use the existing Planr internal review protocol for exactly one Planr review item. Read the pick packet, audit the target item and evidence, report findings first, and return the review verdict through Planr. Do not create or invoke any routing-specific, goal, or loop workflow skill. Planr users enter only through $planr-goal and $planr-loop.", + )) + } else if is_worker_role(artifact) { + Some(( + "$planr-work", + "Use the existing Planr internal worker protocol for exactly one picked Planr item. Read the pick packet, implement only that item, log changed files and real verification commands, request review through Planr, and stop. Do not create or invoke any routing-specific, goal, or loop workflow skill. Planr users enter only through $planr-goal and $planr-loop.", + )) + } else { + None + }; + let Some((protocol_name, instructions)) = protocol else { + return artifact.content.clone(); + }; + if artifact.path.starts_with(".pi/workflows/") { + rewrite_json_workflow_protocol_preload(&artifact.content, protocol_name, instructions) + } else if artifact.path.starts_with(".codex/agents/") { + rewrite_codex_developer_instructions(&artifact.content, protocol_name, instructions) + } else { + rewrite_markdown_agent_body(&artifact.content, protocol_name, instructions) + } +} + +#[cfg(test)] +#[path = "tests/integrations.rs"] +mod tests; + +pub(crate) fn is_worker_role(artifact: &SourceArtifact) -> bool { + artifact.path.contains("terra-high") + || artifact.path.contains("luna-xhigh") + || artifact.path.contains("preset-worker") + || artifact.path.starts_with(".pi/workflows/") + || artifact.path.contains("implementer") + || artifact.content.contains("Normal implementation") + || artifact.content.contains("Bounded checklist") + || artifact.content.contains("custom implementer role") +} + +pub(crate) fn is_reviewer_role(artifact: &SourceArtifact) -> bool { + artifact.path.contains("sol-high") + || artifact.path.contains("reviewer") + || artifact.path.contains("verifier") + || artifact.content.contains("Independent final review") + || artifact.content.contains("custom reviewer role") + || artifact.content.contains("custom verifier role") +} + +pub(crate) fn rewrite_codex_developer_instructions( + content: &str, + protocol_name: &str, + instructions: &str, +) -> String { + let marker = "developer_instructions = \"\"\"\n"; + if let Some(start) = content.find(marker) { + let body_start = start + marker.len(); + if let Some(end) = content[body_start..].find("\n\"\"\"") { + let body_end = body_start + end; + let mut output = String::new(); + output.push_str(&content[..body_start]); + output.push_str(instructions); + output.push_str("\n\nProtocol preload: "); + output.push_str(protocol_name); + output.push_str(&content[body_end..]); + return output; + } + } + format!("{content}\n\nProtocol preload: {protocol_name}\n{instructions}\n") +} + +pub(crate) fn rewrite_markdown_agent_body( + content: &str, + protocol_name: &str, + instructions: &str, +) -> String { + if let Some(rest) = content.strip_prefix("---\n") { + if let Some(end) = rest.find("\n---\n") { + let split = "---\n".len() + end + "\n---\n".len(); + return format!( + "{}Protocol preload: {}\n\n{}\n", + &content[..split], + protocol_name, + instructions + ); + } + } + format!("Protocol preload: {protocol_name}\n\n{instructions}\n") +} + +pub(crate) fn rewrite_json_workflow_protocol_preload( + content: &str, + protocol_name: &str, + instructions: &str, +) -> String { + let mut value: Value = serde_json::from_str(content).unwrap_or_else(|_| json!({})); + if let Some(object) = value.as_object_mut() { + object.insert( + "protocol_preload".to_string(), + json!({ + "marker": format!("Protocol preload: {protocol_name}"), + "instructions": instructions + }), + ); + } + let mut output = serde_json::to_string_pretty(&value).unwrap_or_else(|_| { + format!("{content}\n\nProtocol preload: {protocol_name}\n{instructions}\n") + }); + output.push('\n'); + output +} diff --git a/src/lib.rs b/src/lib.rs index 783d413..d72ced9 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,8535 +1,25 @@ -//! Standalone model-routing policy compiler. -//! -//! This package is the sole owner of named usage policies, model names, host -//! bindings, routing topologies, and generated host artifacts. It emits the -//! provider-neutral `RoutingBundle v1` contract consumed by supported host and optional integration adapters. - -use anyhow::{Context, Result, bail}; -use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey}; -use serde::{Deserialize, Serialize}; -use serde_json::{Value, json}; -use sha2::{Digest, Sha256}; -use std::cell::Cell; -use std::collections::BTreeMap; -use std::collections::BTreeSet; -use std::fmt::Write; -use std::fs; -use std::path::{Component, Path, PathBuf}; -use std::process::Command; -use std::time::{SystemTime, UNIX_EPOCH}; - -const PACKAGE_VERSION: &str = env!("CARGO_PKG_VERSION"); -const GENERATED_AT: &str = "2026-07-16T00:00:00Z"; -const GENERATED_AT_UNIX: i64 = 1_784_160_000; -pub const SETUP_CONFIG_PATH: &str = ".switchloom/config.toml"; -pub const SETUP_RECIPE_PREFIX: &str = "sw1_"; -const CODEX_CONFIG_PATH: &str = ".codex/config.toml"; -const MAX_SETUP_RECIPE_BYTES: usize = 65_536; -const MAX_SETUP_RECIPE_ENCODED_BYTES: usize = encoded_base64url_len(MAX_SETUP_RECIPE_BYTES); -const EVALUATION_SUITE: &str = include_str!("../evaluations/preset-suite-v1.toml"); -const NPM_PACKAGE_JSON: &str = include_str!("../package.json"); -const CODEX_V2_RUNTIME_EVIDENCE_JSON: &str = include_str!("../docs/codex-v2-runtime-evidence.json"); -const MANIFEST_PATH: &str = ".model-routing/manifest.json"; -const TRANSACTION_JOURNAL: &str = "journal.json"; -thread_local! { - static TRANSACTION_FAIL_AFTER_WRITES: Cell = const { Cell::new(0) }; - static TRANSACTION_FAIL_JOURNAL_REPLACE_AFTER: Cell = const { Cell::new(0) }; - static TRANSACTION_RETURN_JOURNAL_ERROR_AFTER: Cell = const { Cell::new(0) }; - static TRANSACTION_RETURN_STAGED_RENAME_ERROR_AFTER: Cell = const { Cell::new(0) }; - static TRANSACTION_FAIL_RESTORE: Cell = const { Cell::new(false) }; -} - -const POLICIES: [(&str, &str); 4] = [ - ("balanced", include_str!("../usage-policies/balanced.toml")), - ( - "low-usage", - include_str!("../usage-policies/low-usage.toml"), - ), - ( - "max-quality", - include_str!("../usage-policies/max-quality.toml"), - ), - ( - "read-only-audit", - include_str!("../usage-policies/read-only-audit.toml"), - ), -]; - -const BINDINGS: [(&str, &str); 7] = [ - ( - "codex-openai", - include_str!("../host-bindings/codex-openai.toml"), - ), - ( - "cursor-openai", - include_str!("../host-bindings/cursor-openai.toml"), - ), - ( - "cursor-fable-grok", - include_str!("../host-bindings/cursor-fable-grok.toml"), - ), - ( - "claude-native", - include_str!("../host-bindings/claude-native.toml"), - ), - ( - "opencode-native", - include_str!("../host-bindings/opencode-native.toml"), - ), - ( - "pi-external", - include_str!("../host-bindings/pi-external.toml"), - ), - ( - "mixed-host", - include_str!("../host-bindings/mixed-host.toml"), - ), -]; - -#[derive(Debug, Clone, Serialize, PartialEq, Eq)] -pub struct PolicySource { - pub policy_id: String, - pub host: String, - pub policy_version: String, - pub binding_id: String, - pub binding_version: String, - pub generated_at: String, - pub requirements: Vec, - pub profiles: BTreeMap, - pub routes: Vec, - pub route_default: Option, - pub artifacts: Vec, - pub evidence: EvaluationEvidence, - pub adapter_contract: AdapterContractV1, - pub policy: PolicyContract, - #[serde(skip)] - policy_toml: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(deny_unknown_fields)] -pub struct HostRequirement { - pub host: String, - #[serde(default)] - pub capabilities: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(deny_unknown_fields)] -pub struct Profile { - pub client: String, - pub model: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub agent_type: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub effort: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub cost_tier: Option, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub capabilities: Vec, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub skill: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub notes: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub fork_turns: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(deny_unknown_fields)] -pub struct ForkPolicy { - pub mode: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub turns: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(deny_unknown_fields)] -pub struct RouteSelector { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub work_type: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub plan: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(deny_unknown_fields)] -pub struct Route { - #[serde(rename = "match")] - pub selector: RouteSelector, - pub profile: String, - #[serde(default)] - pub fallbacks: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(deny_unknown_fields)] -pub struct DefaultRoute { - pub profile: String, - #[serde(default)] - pub fallbacks: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(deny_unknown_fields)] -pub struct SourceArtifact { - pub path: String, - pub media_type: String, - pub mode: String, - pub content: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(deny_unknown_fields)] -pub struct EvaluationEvidence { - #[serde(default)] - pub evaluation_ids: Vec, - pub status: String, -} - -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] -#[serde(rename_all = "kebab-case")] -pub enum RuntimeClass { - NativeSubagent, - ExternalRunner, -} - -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] -#[serde(rename_all = "kebab-case")] -pub enum GuaranteeLevel { - Deterministic, - Advisory, - Unsupported, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(deny_unknown_fields)] -pub struct CapabilityGuarantee { - pub level: GuaranteeLevel, - pub reason: String, - pub evidence_required: bool, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(deny_unknown_fields)] -pub struct RoutingIntentV1 { - pub schema_version: u32, - pub integration: Integration, - pub semantic_roles: Vec, - pub role_requests: Vec, - pub required_guarantees: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(deny_unknown_fields)] -pub struct RoutingRoleIntentV1 { - pub semantic_role: String, - pub requested_model: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub requested_effort: Option, - pub instructions: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(deny_unknown_fields)] -pub struct HostCapabilityV1 { - pub schema_version: u32, - pub host: String, - pub host_version_constraints: HostVersionConstraints, - pub runtime_class: RuntimeClass, - pub runtime_behavior: RuntimeBehaviorV1, - pub discovery_artifacts: Vec, - pub dispatch_fields: Vec, - pub model_control: ControlCapability, - pub effort_control: ControlCapability, - pub context_semantics: ContextSemantics, - pub nesting: NestingCapability, - pub parallelism: ParallelismCapability, - pub observability: ObservabilityCapability, - pub guarantees: BTreeMap, - pub known_limitations: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(deny_unknown_fields)] -pub struct HostVersionConstraints { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub minimum: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub maximum: Option, - pub evidence_max_age_seconds: u64, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(deny_unknown_fields)] -pub struct ControlCapability { - pub level: GuaranteeLevel, - pub field: String, - pub evidence_required: bool, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(deny_unknown_fields)] -pub struct ContextSemantics { - pub supports_fork_none: bool, - pub supports_fork_all: bool, - pub requires_bounded_context_for_overrides: bool, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(deny_unknown_fields)] -pub struct NestingCapability { - pub max_depth: u32, - pub level: GuaranteeLevel, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(deny_unknown_fields)] -pub struct ParallelismCapability { - pub max_parallel_children: u32, - pub level: GuaranteeLevel, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(deny_unknown_fields)] -pub struct ObservabilityCapability { - pub requested_dispatch: GuaranteeLevel, - pub effective_identity: GuaranteeLevel, - pub effective_model: GuaranteeLevel, - pub raw_evidence_refs: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(deny_unknown_fields)] -pub struct RuntimeBehaviorV1 { - pub capability_version: String, - pub installed_host_version_source: String, - pub backend_selection_source: String, - pub trust_boundary: String, - pub discovery_behavior: String, - pub role_precedence: Vec, - pub shared_filesystem: bool, - pub delegation_modes: DelegationModesV1, - pub source_references: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(deny_unknown_fields)] -pub struct DelegationModesV1 { - pub explicit_agent_type_dispatch: bool, - pub ultra_auto_delegation: bool, - pub automatic_delegation_requires_ultra: bool, -} - -#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] -#[serde(deny_unknown_fields)] -struct CodexV2RuntimeEvidence { - schema_version: u32, - evidence_id: String, - observed_at: String, - installed_version: CodexInstalledVersionEvidence, - runtime_class: RuntimeClass, - backend_selection_owner: String, - switchloom_ownership: Vec, - codex_ownership: Vec, - trust_and_discovery: CodexTrustDiscoveryEvidence, - parallelism: CodexParallelismEvidence, - role_precedence: Vec, - shared_filesystem: bool, - delegation_modes: DelegationModesV1, - claim_provenance: BTreeMap>, - negative_contracts: Vec, -} - -#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] -#[serde(deny_unknown_fields)] -struct CodexInstalledVersionEvidence { - command: String, - stdout: String, - stdout_sha256: String, -} - -#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] -#[serde(deny_unknown_fields)] -struct CodexTrustDiscoveryEvidence { - trust_boundary: String, - discovery_behavior: String, -} - -#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] -#[serde(deny_unknown_fields)] -struct CodexParallelismEvidence { - max_parallel_children: u32, - source: String, -} - -#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] -#[serde(deny_unknown_fields)] -struct CodexClaimProvenance { - kind: String, - source: String, - observed_at: String, - codex_version: String, - observed_value: Value, - required_raw_fragments: Vec, - #[serde(default)] - source_url: Option, - #[serde(default)] - source_path: Option, - #[serde(default)] - raw_output: Option, - #[serde(default)] - raw_output_sha256: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(deny_unknown_fields)] -pub struct HostAdapterV1 { - pub schema_version: u32, - pub adapter_id: String, - pub adapter_version: String, - pub runtime_class: RuntimeClass, - pub accepts_intent_schema: String, - pub emitted_artifact_modes: Vec, - pub dispatch_recipe: DispatchRecipeV1, - pub lifecycle_owner: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(deny_unknown_fields)] -pub struct DispatchRecipeV1 { - pub invocation: String, - pub required_fields: Vec, - pub artifact_paths: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(deny_unknown_fields)] -pub struct DispatchEvidenceV1 { - pub schema_version: u32, - pub package_digest: String, - pub host_version: String, - pub requested_dispatch: RequestedDispatchEvidence, - pub child_identity: ChildIdentityEvidence, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub effective_model: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub effective_effort: Option, - pub nonce: String, - pub raw_evidence_refs: Vec, - pub verdict: GuaranteeLevel, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(deny_unknown_fields)] -pub struct RequestedDispatchEvidence { - pub semantic_role: String, - pub profile: String, - pub model: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub effort: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub agent_type: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub fork_turns: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(deny_unknown_fields)] -pub struct ChildIdentityEvidence { - pub host: String, - pub role: String, - pub agent_role: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub agent_type: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub task_name: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(deny_unknown_fields)] -pub struct DispatchEvidenceContractV1 { - pub schema_version: u32, - pub required_verdicts: Vec, - pub receipt_schema: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(deny_unknown_fields)] -pub struct PlanrHandoffV1 { - pub schema_version: u32, - pub switchloom_package: String, - pub semantic_role_contract: String, - pub required_consumer_behavior: Vec, - pub forbidden_duplicate_ownership: Vec, - pub certification_report_reference: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(deny_unknown_fields)] -pub struct AdapterContractV1 { - pub schema_version: u32, - pub routing_intent: RoutingIntentV1, - pub capability: HostCapabilityV1, - pub adapter: HostAdapterV1, - pub dispatch_evidence: DispatchEvidenceContractV1, - pub planr_handoff: PlanrHandoffV1, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(deny_unknown_fields)] -pub struct RoutingBundleV1 { - pub schema_version: u32, - pub bundle_id: String, - pub policy_id: String, - pub policy_version: String, - pub generated_at: String, - pub source: BundleSource, - pub policy: PolicyContract, - pub requirements: Vec, - pub profiles: BTreeMap, - pub routes: Vec, - #[serde(skip_serializing_if = "Option::is_none")] - pub route_default: Option, - pub artifacts: Vec, - pub evidence: EvaluationEvidence, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub adapter_contract: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(deny_unknown_fields)] -pub struct BundleSource { - pub package: String, - pub package_version: String, - pub integration: Integration, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(deny_unknown_fields)] -pub struct BundleArtifact { - pub path: String, - pub media_type: String, - pub mode: String, - pub content: String, - pub sha256: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(deny_unknown_fields)] -pub struct PolicyContract { - pub schema_version: u32, - pub id: String, - pub version: String, - pub usage: UsageLimits, - pub transitions: TransitionPolicy, - pub materiality: MaterialityPolicy, - pub execution: ExecutionPolicy, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(deny_unknown_fields)] -pub struct UsageLimits { - pub max_active_agents: u32, - pub max_parallel_readers: u32, - pub max_parallel_writers: u32, - pub max_depth: u32, - pub max_attempts: u32, - pub max_wall_time_seconds: u32, - pub max_tool_calls: u32, - pub review_reserve_percent: u32, - pub budget_exhaustion: String, - pub metering: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(deny_unknown_fields)] -pub struct TransitionPolicy { - pub retry: RetryPolicy, - pub availability_fallback: AvailabilityFallbackPolicy, - pub quality_escalation: QualityEscalationPolicy, - pub quota_downgrade: QuotaDowngradePolicy, - pub safety_stop: SafetyStopPolicy, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(deny_unknown_fields)] -pub struct RetryPolicy { - pub max_same_route_retries: u32, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(deny_unknown_fields)] -pub struct AvailabilityFallbackPolicy { - pub max_fallbacks: u32, - pub require_same_capability_class: bool, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(deny_unknown_fields)] -pub struct QualityEscalationPolicy { - pub max_escalations: u32, - pub require_verification_evidence: bool, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(deny_unknown_fields)] -pub struct QuotaDowngradePolicy { - pub enabled: bool, - pub max_downgrades: u32, - pub noncritical_only: bool, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(deny_unknown_fields)] -pub struct SafetyStopPolicy { - pub enabled: bool, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(deny_unknown_fields)] -pub struct MaterialityPolicy { - pub changed_files_threshold: u32, - pub changed_lines_threshold: u32, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(deny_unknown_fields)] -pub struct ExecutionPolicy { - pub max_read_scope_entries: u32, - pub max_write_scope_entries: u32, - pub roles: BTreeMap, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(deny_unknown_fields)] -pub struct ExecutionRole { - #[serde(default)] - pub tools: Vec, - pub filesystem: FilesystemPolicy, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(deny_unknown_fields)] -pub struct FilesystemPolicy { - #[serde(default)] - pub read_roots: Vec, - #[serde(default)] - pub write_roots: Vec, - pub allow_overwrite: bool, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct BundleInspection { - pub schema_version: u32, - pub bundle_id: String, - pub policy_id: String, - pub integration: Integration, - pub profile_count: usize, - pub route_count: usize, - pub artifact_count: usize, - pub valid: bool, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct LifecycleReport { - pub action: String, - pub bundle_id: Option, - pub repository: String, - pub artifacts: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct LifecycleArtifactReport { - pub path: String, - pub mode: String, - pub status: String, - pub sha256: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub repair: Option, -} - -pub struct PreparedSetupLifecycle { - bundle: RoutingBundleV1, - bundle_input: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(deny_unknown_fields)] -struct ManagedManifest { - schema_version: u32, - bundle_id: String, - bundle_sha256: String, - artifacts: Vec, - #[serde(default, skip_serializing_if = "Option::is_none")] - previous: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(deny_unknown_fields)] -struct ManagedArtifact { - path: String, - sha256: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - content: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(deny_unknown_fields)] -struct ManagedSnapshot { - bundle_id: String, - bundle_sha256: String, - artifacts: Vec, -} - -#[derive(Debug, Clone, Serialize)] -pub struct PolicySummary { - pub policy_id: String, - pub host: String, - pub policy_version: String, - pub binding_id: String, - pub binding_version: String, - pub profile_count: usize, - pub artifact_count: usize, - pub evidence_status: String, -} - -#[derive(Debug, Deserialize)] -struct HostBinding { - id: String, - version: String, - host: String, - runtime_class: RuntimeClass, - default_role: Option, - #[serde(default)] - capability_evidence: Vec, - #[serde(default)] - known_limitations: Vec, - capabilities: BindingCapabilities, - profiles: BTreeMap, - #[serde(default)] - routes: Vec, - verification: BindingVerification, - #[serde(default)] - artifacts: Vec, -} - -#[derive(Debug, Deserialize)] -struct BindingCapabilities { - model_override: bool, - effort_override: bool, - fork_none: bool, - fork_all: bool, -} - -#[derive(Debug, Deserialize)] -struct BindingProfile { - profile: String, - client: String, - model: String, - agent_type: Option, - effort: Option, - cost_tier: Option, - fork_turns: Option, -} - -#[derive(Debug, Deserialize)] -struct BindingRoute { - work_type: String, - role: String, - #[serde(default)] - fallback_roles: Vec, -} - -#[derive(Debug, Deserialize)] -struct BindingVerification { - id: String, - #[serde(default)] - max_age_seconds: Option, -} - -#[derive(Debug, Deserialize)] -struct BindingArtifact { - path: String, - kind: String, - content: String, -} - -#[derive(Debug)] -struct CompiledHostAdapter { - requirements: Vec, - profiles: BTreeMap, - routes: Vec, - route_default: Option, - artifacts: Vec, - adapter_contract: AdapterContractV1, -} - -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "kebab-case")] -pub enum Integration { - Standalone, - Planr, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(deny_unknown_fields)] -pub struct SetupSpecV1 { - pub schema_version: u32, - pub host: String, - pub integration: Integration, - pub usage_policy: String, - pub selected_roles: BTreeMap, - pub routes: Vec, - #[serde(skip_serializing_if = "Option::is_none")] - pub route_default: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(deny_unknown_fields)] -pub struct SetupRoleSelection { - pub model: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub effort: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub spawn: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(deny_unknown_fields)] -pub struct SetupSpawnPolicy { - pub agent_type: String, - pub task_name: String, - pub fork_turns: ForkPolicy, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(deny_unknown_fields)] -pub struct SetupRouteMapping { - pub work_type: String, - pub role: String, - #[serde(default)] - pub fallbacks: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(deny_unknown_fields)] -pub struct SetupDefaultRoute { - pub role: String, - #[serde(default)] - pub fallbacks: Vec, -} - -pub fn setup_spec_for_policy( - policy: &str, - host: &str, - integration: Integration, -) -> Result { - let binding = binding_for_selector(host)?; - let selected_roles = binding - .profiles - .iter() - .map(|(role, profile)| { - ( - role.clone(), - SetupRoleSelection { - model: profile.model.clone(), - effort: profile.effort.clone(), - spawn: setup_spawn_policy_for_binding_role( - setup_runtime_host(&binding), - role, - profile, - ), - }, - ) - }) - .collect::>(); - let routes = binding - .routes - .iter() - .map(|route| SetupRouteMapping { - work_type: route.work_type.clone(), - role: route.role.clone(), - fallbacks: route.fallback_roles.clone(), - }) - .collect(); - let route_default = binding.default_role.clone().map(|role| SetupDefaultRoute { - role, - fallbacks: Vec::new(), - }); - let spec = SetupSpecV1 { - schema_version: 1, - host: binding.id.clone(), - integration, - usage_policy: policy.to_string(), - selected_roles, - routes, - route_default, - }; - validate_setup_spec(&spec)?; - Ok(spec) -} - -pub fn validate_setup_spec(spec: &SetupSpecV1) -> Result<()> { - if spec.schema_version != 1 { - bail!("unsupported setup schema_version {}", spec.schema_version); - } - if spec.usage_policy.trim().is_empty() { - bail!("setup usage_policy must not be blank"); - } - if spec.selected_roles.is_empty() { - bail!("setup selected_roles must not be empty"); - } - let binding = binding_for_selector(&spec.host)?; - let canonical_host = setup_runtime_host(&binding); - let model_catalog = setup_model_catalog(canonical_host); - for (role, selection) in &spec.selected_roles { - validate_setup_identifier("role", role)?; - if selection.model.trim().is_empty() { - bail!("setup role `{role}` model must not be blank"); - } - let matches_binding = selection_matches_binding_profile(role, selection, &binding); - if !matches_binding { - validate_model_effort(canonical_host, role, selection, &model_catalog)?; - } - validate_setup_spawn_policy(canonical_host, role, selection, matches_binding)?; - reject_setup_secret_like("role", role)?; - reject_setup_secret_like("model", &selection.model)?; - if let Some(effort) = &selection.effort { - reject_setup_secret_like("effort", effort)?; - } - if let Some(spawn) = &selection.spawn { - reject_setup_secret_like("agent_type", &spawn.agent_type)?; - reject_setup_secret_like("task_name", &spawn.task_name)?; - } - } - validate_setup_identity_collisions(spec, canonical_host, &binding)?; - if spec.routes.is_empty() && spec.route_default.is_none() { - bail!("setup must declare routes or route_default"); - } - for route in &spec.routes { - validate_setup_identifier("work_type", &route.work_type)?; - validate_setup_route_role(&spec.selected_roles, &route.role)?; - for fallback in &route.fallbacks { - validate_setup_route_role(&spec.selected_roles, fallback)?; - } - } - if let Some(default) = &spec.route_default { - validate_setup_route_role(&spec.selected_roles, &default.role)?; - for fallback in &default.fallbacks { - validate_setup_route_role(&spec.selected_roles, fallback)?; - } - } - let _ = show_policy(&spec.usage_policy, &binding.id)?; - Ok(()) -} - -pub fn setup_spec_from_json(input: &str) -> Result { - let spec: SetupSpecV1 = - serde_json::from_str(input).context("setup spec is not valid SetupSpecV1 JSON")?; - validate_setup_spec(&spec)?; - Ok(spec) -} - -pub fn setup_spec_from_toml(input: &str) -> Result { - let spec: SetupSpecV1 = - toml::from_str(input).context("setup spec is not valid SetupSpecV1 TOML")?; - validate_setup_spec(&spec)?; - Ok(spec) -} - -pub fn setup_spec_to_canonical_json(spec: &SetupSpecV1) -> Result { - validate_setup_spec(spec)?; - let mut json = serde_json::to_string_pretty(spec)?; - json.push('\n'); - Ok(json) -} - -pub fn setup_spec_to_canonical_toml(spec: &SetupSpecV1) -> Result { - validate_setup_spec(spec)?; - let mut toml = toml::to_string_pretty(spec)?; - if !toml.ends_with('\n') { - toml.push('\n'); - } - Ok(toml) -} - -pub fn setup_spec_to_recipe(spec: &SetupSpecV1) -> Result { - let json = setup_spec_to_canonical_json(spec)?; - if json.len() > MAX_SETUP_RECIPE_BYTES { - bail!("setup recipe exceeds {MAX_SETUP_RECIPE_BYTES} bytes"); - } - Ok(format!( - "{SETUP_RECIPE_PREFIX}{}", - encode_base64url(json.as_bytes()) - )) -} - -pub fn setup_spec_from_recipe(recipe: &str) -> Result { - let payload = recipe - .strip_prefix(SETUP_RECIPE_PREFIX) - .ok_or_else(|| anyhow::anyhow!("setup recipe must start with `{SETUP_RECIPE_PREFIX}`"))?; - if payload.is_empty() { - bail!("setup recipe payload must not be empty"); - } - validate_base64url_payload_len(payload)?; - let decoded = decode_base64url(payload)?; - if decoded.len() > MAX_SETUP_RECIPE_BYTES { - bail!("setup recipe exceeds {MAX_SETUP_RECIPE_BYTES} bytes"); - } - let json = String::from_utf8(decoded).context("setup recipe payload is not UTF-8")?; - setup_spec_from_json(&json) -} - -pub fn compile_setup_spec(spec: &SetupSpecV1) -> Result { - let source = source_from_setup_spec(spec)?; - let bundle = compile_source(source, spec.integration)?; - validate_bundle(&bundle)?; - Ok(bundle) -} - -pub fn compile_setup_json(input: &str) -> Result { - let spec = setup_spec_from_json(input)?; - let mut json = serde_json::to_string_pretty(&compile_setup_spec(&spec)?)?; - json.push('\n'); - Ok(json) -} - -pub fn preview_setup_config_file(repository: &Path, config_file: &Path) -> Result { - let spec = read_setup_config_file(config_file)?; - preview_setup(repository, &spec) -} - -pub fn preview_setup_recipe(repository: &Path, recipe: &str) -> Result { - let spec = setup_spec_from_recipe(recipe)?; - preview_setup(repository, &spec) -} - -pub fn preview_saved_setup(repository: &Path) -> Result { - let spec = read_saved_setup_config(repository)?; - preview_setup(repository, &spec) -} - -pub fn apply_setup_config_file(repository: &Path, config_file: &Path) -> Result { - let spec = read_setup_config_file(config_file)?; - apply_setup(repository, &spec) -} - -pub fn apply_setup_recipe(repository: &Path, recipe: &str) -> Result { - let spec = setup_spec_from_recipe(recipe)?; - apply_setup(repository, &spec) -} - -pub fn apply_saved_setup(repository: &Path) -> Result { - let spec = read_saved_setup_config(repository)?; - apply_setup(repository, &spec) -} - -pub fn update_setup_config_file(repository: &Path, config_file: &Path) -> Result { - let spec = read_setup_config_file(config_file)?; - update_setup(repository, &spec) -} - -pub fn update_setup_recipe(repository: &Path, recipe: &str) -> Result { - let spec = setup_spec_from_recipe(recipe)?; - update_setup(repository, &spec) -} - -pub fn update_saved_setup(repository: &Path) -> Result { - let spec = read_saved_setup_config(repository)?; - update_setup(repository, &spec) -} - -pub fn prepare_setup_config_file(config_file: &Path) -> Result { - prepare_setup_lifecycle(&read_setup_config_file(config_file)?) -} - -pub fn prepare_setup_recipe(recipe: &str) -> Result { - prepare_setup_lifecycle(&setup_spec_from_recipe(recipe)?) -} - -pub fn prepare_saved_setup(repository: &Path) -> Result { - prepare_setup_lifecycle(&read_saved_setup_config(repository)?) -} - -pub fn preview_prepared_setup( - repository: &Path, - prepared: &PreparedSetupLifecycle, -) -> Result { - preview_bundle(repository, &prepared.bundle) -} - -pub fn apply_prepared_setup( - repository: &Path, - prepared: &PreparedSetupLifecycle, - confirmed_preview: &LifecycleReport, -) -> Result { - let current_preview = preview_prepared_setup(repository, prepared)?; - if !same_lifecycle_plan(¤t_preview, confirmed_preview) { - bail!("repository state changed after preview; rerun preview/apply and confirm again"); - } - apply_bundle_json( - Path::new(&confirmed_preview.repository), - &prepared.bundle, - &prepared.bundle_input, - ) -} - -pub fn setup_contract_catalog_value() -> Result { - let hosts = [ - "codex", - "claude-code", - "cursor", - "opencode", - "pi", - "mixed-host", - ] - .into_iter() - .map(|host| { - let binding = binding_for_selector(host)?; - let runtime_host = setup_runtime_host(&binding); - Ok(json!({ - "id": host, - "binding": binding.id, - "runtimeHost": runtime_host, - "supportsPlanrIntegration": true, - "models": setup_model_catalog(runtime_host).into_iter().map(|option| json!({ - "id": option.id, - "efforts": option.efforts, - "tier": option.tier, - })).collect::>(), - "defaultSpec": setup_spec_for_policy("balanced", &binding.id, Integration::Standalone)?, - })) - }) - .collect::>>()?; - Ok(json!({ - "schemaVersion": 1, - "setupSpecVersion": 1, - "configPath": SETUP_CONFIG_PATH, - "recipePrefix": SETUP_RECIPE_PREFIX, - "transport": { - "encoding": "base64url-no-padding", - "maxDecodedBytes": MAX_SETUP_RECIPE_BYTES, - "mayContainCredentials": false, - "mayContainScripts": false, - }, - "hosts": hosts, - })) -} - -pub fn setup_contract_catalog_json() -> Result { - let mut output = serde_json::to_string_pretty(&setup_contract_catalog_value()?)?; - output.push('\n'); - Ok(output) -} - -pub fn list_policies() -> Result> { - let mut summaries = Vec::new(); - for (policy, _) in POLICIES { - for (host, _) in BINDINGS { - let source = show_policy(policy, host)?; - summaries.push(PolicySummary { - policy_id: source.policy_id, - host: source.host, - policy_version: source.policy_version, - binding_id: source.binding_id, - binding_version: source.binding_version, - profile_count: source.profiles.len(), - artifact_count: source.artifacts.len() + 2, - evidence_status: source.evidence.status, - }); - } - } - Ok(summaries) -} - -pub fn show_policy(policy: &str, host: &str) -> Result { - let binding_id = canonical_binding_id(host); - let policy_raw = POLICIES - .iter() - .find(|(id, _)| *id == policy) - .map(|(_, raw)| *raw) - .ok_or_else(|| anyhow::anyhow!("unknown routing policy `{policy}`"))?; - let binding_raw = BINDINGS - .iter() - .find(|(id, _)| *id == binding_id) - .map(|(_, raw)| *raw) - .ok_or_else(|| anyhow::anyhow!("unknown routing host `{host}`"))?; - let policy_contract: PolicyContract = toml::from_str(policy_raw)?; - validate_policy_contract(&policy_contract)?; - let binding: HostBinding = toml::from_str(binding_raw)?; - let adapter = compile_host_adapter( - policy_contract.id.as_str(), - &binding, - Integration::Standalone, - )?; - Ok(PolicySource { - policy_id: policy_contract.id.clone(), - host: host.to_string(), - policy_version: policy_contract.version.clone(), - binding_id: binding.id.clone(), - binding_version: binding.version.clone(), - generated_at: GENERATED_AT.to_string(), - requirements: adapter.requirements, - profiles: adapter.profiles, - routes: adapter.routes, - route_default: adapter.route_default, - artifacts: adapter.artifacts, - evidence: EvaluationEvidence { - evaluation_ids: vec![binding.verification.id.clone()], - status: "experimental".to_string(), - }, - adapter_contract: adapter.adapter_contract, - policy: policy_contract, - policy_toml: policy_raw.to_string(), - }) -} - -pub fn compile_policy( - policy: &str, - host: &str, - integration: Integration, -) -> Result { - compile_setup_spec(&setup_spec_for_policy(policy, host, integration)?) -} - -#[cfg(test)] -fn compile_builtin_policy_direct( - policy: &str, - host: &str, - integration: Integration, -) -> Result { - compile_source(show_policy(policy, host)?, integration) -} - -fn compile_source(source: PolicySource, integration: Integration) -> Result { - validate_source(&source)?; - let mut adapter_contract = adapter_contract_for_source(&source, integration)?; - let mut artifacts = Vec::new(); - if integration == Integration::Planr { - let registry = render_registry(&source)?; - artifacts.push(bundle_artifact(SourceArtifact { - path: ".planr/agents.toml".to_string(), - media_type: "application/toml".to_string(), - mode: "replace".to_string(), - content: registry, - })); - artifacts.push(bundle_artifact(SourceArtifact { - path: ".planr/policy.toml".to_string(), - media_type: "application/toml".to_string(), - mode: "replace".to_string(), - content: source.policy_toml.clone(), - })); - } - let mut host_artifacts = source - .artifacts - .iter() - .filter(|artifact| include_artifact_for_integration(artifact, integration)) - .cloned() - .map(|artifact| artifact_for_integration(artifact, integration)) - .collect::>(); - if let Some(codex_config) = render_codex_agent_registration_artifact(&host_artifacts)? { - host_artifacts.push(codex_config); - } - artifacts.extend(host_artifacts.into_iter().map(bundle_artifact)); - artifacts.sort_by(|left, right| left.path.cmp(&right.path)); - adapter_contract.adapter.emitted_artifact_modes = artifacts - .iter() - .map(|artifact| artifact.mode.clone()) - .collect::>() - .into_iter() - .collect(); - adapter_contract.adapter.dispatch_recipe.artifact_paths = artifacts - .iter() - .map(|artifact| artifact.path.clone()) - .collect(); - validate_adapter_contract(&adapter_contract)?; - - Ok(RoutingBundleV1 { - schema_version: 1, - bundle_id: format!( - "{}-{}@{}+{}", - source.policy_id, source.host, source.policy_version, source.binding_version - ), - policy_id: source.policy_id, - policy_version: source.policy_version, - generated_at: source.generated_at, - source: BundleSource { - package: "model-routing".to_string(), - package_version: PACKAGE_VERSION.to_string(), - integration, - }, - policy: source.policy, - requirements: source.requirements, - profiles: source.profiles, - routes: source.routes, - route_default: source.route_default, - artifacts, - evidence: source.evidence, - adapter_contract: Some(adapter_contract), - }) -} - -pub fn compile_json(policy: &str, host: &str, integration: Integration) -> Result { - let mut json = serde_json::to_string_pretty(&compile_policy(policy, host, integration)?)?; - json.push('\n'); - Ok(json) -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct ProbeReport { - pub host: String, - pub command: Option, - pub available: bool, - pub version: Option, - pub capabilities: Vec, - pub authentication: String, - pub limitation: Option, -} - -pub fn probe_host(host: &str, command_override: Option<&str>) -> Result { - let source = show_policy("balanced", host)?; - let requirement = source - .requirements - .first() - .ok_or_else(|| anyhow::anyhow!("binding has no host requirement"))?; - let default_command = match requirement.host.as_str() { - "codex" => Some("codex"), - "cursor" => Some("cursor-agent"), - "claude-code" => Some("claude"), - "opencode" => Some("opencode"), - "pi" => Some("pi"), - "mixed-host" => None, - _ => None, - }; - let command = command_override.or(default_command); - let (available, version, limitation) = if let Some(command) = command { - match Command::new(command).arg("--version").output() { - Ok(output) if output.status.success() => ( - true, - Some(String::from_utf8_lossy(&output.stdout).trim().to_string()), - None, - ), - Ok(output) => ( - false, - None, - Some(format!("version probe exited with {}", output.status)), - ), - Err(error) => (false, None, Some(error.to_string())), - } - } else { - ( - false, - None, - Some("mixed-host bindings require separate probes for each declared host".to_string()), - ) - }; - Ok(ProbeReport { - host: host.to_string(), - command: command.map(ToOwned::to_owned), - available, - version, - capabilities: requirement.capabilities.clone(), - authentication: "not_tested".to_string(), - limitation, - }) -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct EvaluationReport { - pub schema_version: u32, - pub suite_id: String, - pub suite_version: String, - pub suite_sha256: String, - pub policy_id: String, - pub host: String, - pub bundle_sha256: String, - pub scenario_count: usize, - pub offline_reproducible: bool, - pub live_evidence: Option, - pub status: String, - pub recommended: bool, -} - -pub fn evaluate_policy(policy: &str, host: &str) -> Result { - let suite: toml::Value = toml::from_str(EVALUATION_SUITE)?; - let suite_id = suite - .get("id") - .and_then(toml::Value::as_str) - .ok_or_else(|| anyhow::anyhow!("evaluation suite is missing id"))?; - let suite_version = suite - .get("version") - .and_then(toml::Value::as_str) - .ok_or_else(|| anyhow::anyhow!("evaluation suite is missing version"))?; - let scenario_count = suite - .get("tasks") - .and_then(toml::Value::as_array) - .map_or(0, Vec::len); - let bundle = compile_json(policy, host, Integration::Standalone)?; - Ok(EvaluationReport { - schema_version: 1, - suite_id: suite_id.to_string(), - suite_version: suite_version.to_string(), - suite_sha256: sha256(EVALUATION_SUITE.as_bytes()), - policy_id: policy.to_string(), - host: show_policy(policy, host)?.host, - bundle_sha256: sha256(bundle.as_bytes()), - scenario_count, - offline_reproducible: scenario_count > 0, - live_evidence: None, - status: "experimental".to_string(), - recommended: false, - }) -} - -pub fn catalog_value() -> Result { - let mut compositions = Vec::new(); - for summary in list_policies()? { - let source = show_policy(&summary.policy_id, &summary.host)?; - let report = evaluate_policy(&summary.policy_id, &summary.host)?; - let bundle = compile_policy(&summary.policy_id, &summary.host, Integration::Standalone)?; - compositions.push(json!({ - "id": format!("{}-{}@{}+{}", summary.policy_id, summary.binding_id, summary.policy_version, summary.binding_version), - "entryId": format!("{}-{}", summary.policy_id, summary.binding_id), - "entryVersion": format!("{}+{}", summary.policy_version, summary.binding_version), - "status": report.status, - "statusLabel": "Experimental", - "recommended": false, - "freshness": "current", - "lifecycle": "published", - "replacement": Value::Null, - "policy": { - "id": summary.policy_id, - "version": summary.policy_version, - "usage": source.policy.usage, - "transitions": source.policy.transitions, - "materiality": source.policy.materiality, - "execution": source.policy.execution, - }, - "binding": { - "id": summary.binding_id, - "selector": summary.host, - "version": summary.binding_version, - "host": source.requirements.first().map(|requirement| requirement.host.clone()), - "profiles": bundle.profiles, - "dispatch": bundle.routes, - }, - "compatibility": { - "hosts": source.requirements.iter().map(|requirement| requirement.host.clone()).collect::>(), - "minModelRoutingVersion": "0.1.0", - "maxModelRoutingVersion": Value::Null, - }, - "enforcement": [ - {"dimension": "Repository writes", "state": "verified", "detail": "Core previews and applies only allowlisted repository-local bundle artifacts."}, - {"dimension": "Model and effort", "state": "host_enforced", "detail": "The package generates exact host roles; the host remains execution authority."}, - {"dimension": "Effective route evidence", "state": "unavailable", "detail": "No authenticated live-host evidence is published for this generated catalog entry."} - ], - "evaluation": { - "suiteId": report.suite_id, - "suiteVersion": report.suite_version, - "evaluatedAtUnix": GENERATED_AT_UNIX, - "reviewAtUnix": Value::Null, - "status": report.status, - "metrics": {"runs": 0, "oracle_passes": 0, "average_quality_score_bps": Value::Null}, - "thresholds": {}, - "resultHashes": [], - "fixtureSha256": report.suite_sha256, - }, - "registry": { - "id": "model-routing-official", - "version": PACKAGE_VERSION, - "manifestSha256": report.bundle_sha256, - "signer": Value::Null, - "signatureVerified": false, - "trustedMaintainer": false, - "artifacts": bundle.artifacts.iter().map(|artifact| json!({"path": artifact.path, "sha256": artifact.sha256})).collect::>(), - }, - "command": format!("model-routing compile {} --host {} --output routing-bundle.json", source.policy_id, source.host), - })); - } - Ok(json!({ - "schemaVersion": 1, - "generatedAtUnix": GENERATED_AT_UNIX, - "setupContract": setup_contract_catalog_value()?, - "source": { - "state": "package_generated", - "entryCount": compositions.len(), - "trust": "model_routing_unsigned_catalog_v1", - "message": "Entries stay experimental until authenticated live evidence and an offline maintainer signature pass." - }, - "compositions": compositions, - })) -} - -pub fn catalog_json() -> Result { - let mut output = serde_json::to_string_pretty(&catalog_value()?)?; - output.push('\n'); - Ok(output) -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct RegistrySignature { - pub algorithm: String, - pub signer: String, - pub content_sha256: String, - pub value: String, -} - -pub fn sign_registry( - content: &[u8], - signer: &str, - private_key_hex: &str, -) -> Result { - if signer.trim().is_empty() { - bail!("registry signer must not be blank"); - } - let seed = decode_hex::<32>(private_key_hex.trim()).ok_or_else(|| { - anyhow::anyhow!("private key file must contain exactly 64 hexadecimal characters") - })?; - let key = SigningKey::from_bytes(&seed); - let signature = key.sign(content); - Ok(RegistrySignature { - algorithm: "ed25519".to_string(), - signer: signer.to_string(), - content_sha256: sha256(content), - value: encode_hex(&signature.to_bytes()), - }) -} - -pub fn verify_registry_signature( - content: &[u8], - signature: &RegistrySignature, - trusted_signer: &str, - trusted_public_key_hex: &str, -) -> Result<()> { - if signature.algorithm != "ed25519" || signature.content_sha256 != sha256(content) { - bail!("registry signature metadata does not match content"); - } - if trusted_signer.trim().is_empty() || signature.signer != trusted_signer { - bail!("registry signature signer does not match the trusted signer"); - } - let public_key = decode_hex::<32>(trusted_public_key_hex.trim()) - .ok_or_else(|| anyhow::anyhow!("trusted registry public key is invalid"))?; - let signature_bytes = decode_hex::<64>(&signature.value) - .ok_or_else(|| anyhow::anyhow!("registry signature value is invalid"))?; - let key = VerifyingKey::from_bytes(&public_key)?; - key.verify(content, &Signature::from_bytes(&signature_bytes))?; - Ok(()) -} - -pub fn inspect_bundle_json(input: &str) -> Result { - let bundle = validate_bundle_json(input)?; - Ok(BundleInspection { - schema_version: bundle.schema_version, - bundle_id: bundle.bundle_id, - policy_id: bundle.policy_id, - integration: bundle.source.integration, - profile_count: bundle.profiles.len(), - route_count: bundle.routes.len(), - artifact_count: bundle.artifacts.len(), - valid: true, - }) -} - -pub fn validate_bundle_json(input: &str) -> Result { - let value: Value = serde_json::from_str(input).context("bundle is not valid JSON")?; - validate_raw_bundle_shape(&value)?; - let bundle: RoutingBundleV1 = serde_json::from_value(value).map_err(|error| { - anyhow::anyhow!("bundle does not match RoutingBundle v1 schema: {error}") - })?; - validate_bundle(&bundle)?; - Ok(bundle) -} - -pub fn validate_bundle(bundle: &RoutingBundleV1) -> Result<()> { - if bundle.schema_version != 1 { - bail!("unsupported schema_version {}", bundle.schema_version); - } - if bundle.bundle_id.trim().is_empty() - || bundle.policy_id.trim().is_empty() - || bundle.policy_version.trim().is_empty() - { - bail!("bundle id, policy id, and policy version must not be blank"); - } - if bundle.source.package != "model-routing" { - bail!("bundle source package must be model-routing"); - } - if bundle.policy.id != bundle.policy_id || bundle.policy.version != bundle.policy_version { - bail!("bundle policy contract does not match bundle policy identifiers"); - } - validate_policy_contract(&bundle.policy)?; - for route in &bundle.routes { - if !bundle.profiles.contains_key(&route.profile) { - bail!("route references unknown profile `{}`", route.profile); - } - for fallback in &route.fallbacks { - if !bundle.profiles.contains_key(fallback) { - bail!("route fallback references unknown profile `{fallback}`"); - } - } - } - if let Some(default) = &bundle.route_default { - if !bundle.profiles.contains_key(&default.profile) { - bail!( - "default route references unknown profile `{}`", - default.profile - ); - } - for fallback in &default.fallbacks { - if !bundle.profiles.contains_key(fallback) { - bail!("default route fallback references unknown profile `{fallback}`"); - } - } - } - let mut artifact_paths = BTreeSet::new(); - for artifact in &bundle.artifacts { - if artifact.path.trim().is_empty() { - bail!("artifact path must not be blank"); - } - if !artifact_paths.insert(artifact.path.clone()) { - bail!("duplicate artifact path `{}`", artifact.path); - } - if artifact.mode != "create" && artifact.mode != "replace" { - bail!( - "artifact `{}` has unsupported mode `{}`", - artifact.path, - artifact.mode - ); - } - let expected = sha256(artifact.content.as_bytes()); - if artifact.sha256 != expected { - bail!("artifact `{}` sha256 mismatch", artifact.path); - } - } - if let Some(contract) = &bundle.adapter_contract { - validate_adapter_contract(contract)?; - } - Ok(()) -} - -pub fn preview_bundle_file(repository: &Path, bundle_file: &Path) -> Result { - let bundle = read_bundle_file(bundle_file)?; - preview_bundle(repository, &bundle) -} - -pub fn apply_bundle_file(repository: &Path, bundle_file: &Path) -> Result { - let bundle_input = fs::read_to_string(bundle_file) - .with_context(|| format!("failed to read bundle `{}`", bundle_file.display()))?; - let bundle = validate_bundle_json(&bundle_input)?; - apply_bundle_json(repository, &bundle, &bundle_input) -} - -fn apply_bundle_json( - repository: &Path, - bundle: &RoutingBundleV1, - bundle_input: &str, -) -> Result { - let repository = canonicalize_existing_repository(repository)?; - recover_pending_transactions(&repository)?; - let planned = plan_artifacts(&repository, bundle, None)?; - ensure_apply_is_safe(&planned)?; - let manifest = manifest_from_bundle(bundle, sha256(bundle_input.as_bytes()), None); - commit_transaction(&repository, &planned, &manifest)?; - Ok(report_from_plan( - "apply", - &repository, - Some(&bundle.bundle_id), - &planned, - )) -} - -pub fn update_bundle_file(repository: &Path, bundle_file: &Path) -> Result { - let bundle_input = fs::read_to_string(bundle_file) - .with_context(|| format!("failed to read bundle `{}`", bundle_file.display()))?; - let bundle = validate_bundle_json(&bundle_input)?; - update_bundle_json(repository, &bundle, &bundle_input) -} - -fn update_bundle_json( - repository: &Path, - bundle: &RoutingBundleV1, - bundle_input: &str, -) -> Result { - let repository = canonicalize_existing_repository(repository)?; - recover_pending_transactions(&repository)?; - let current = read_manifest(&repository)? - .ok_or_else(|| anyhow::anyhow!("no model-routing manifest found"))?; - let planned = plan_artifacts(&repository, bundle, Some(¤t))?; - ensure_update_is_safe(&planned)?; - let manifest = manifest_from_plan( - &bundle.bundle_id, - sha256(bundle_input.as_bytes()), - &planned, - Some(snapshot_from_manifest(¤t)), - ); - commit_transaction(&repository, &planned, &manifest)?; - Ok(report_from_plan( - "update", - &repository, - Some(&bundle.bundle_id), - &planned, - )) -} - -pub fn status_repository(repository: &Path) -> Result { - let repository = canonicalize_existing_repository(repository)?; - recover_pending_transactions(&repository)?; - let Some(manifest) = read_manifest(&repository)? else { - return Ok(LifecycleReport { - action: "status".to_string(), - bundle_id: None, - repository: repository.display().to_string(), - artifacts: Vec::new(), - }); - }; - let mut reports = Vec::new(); - for artifact in &manifest.artifacts { - let target = resolve_repository_target(&repository, &artifact.path)?; - let status = status_for_managed_artifact(&target, artifact)?; - reports.push(LifecycleArtifactReport { - path: artifact.path.clone(), - mode: "managed".to_string(), - status: status.to_string(), - sha256: artifact.sha256.clone(), - repair: repair_for_status(status), - }); - } - Ok(LifecycleReport { - action: "status".to_string(), - bundle_id: Some(manifest.bundle_id), - repository: repository.display().to_string(), - artifacts: reports, - }) -} - -pub fn uninstall_repository(repository: &Path) -> Result { - let repository = canonicalize_existing_repository(repository)?; - recover_pending_transactions(&repository)?; - let manifest = read_manifest(&repository)? - .ok_or_else(|| anyhow::anyhow!("no model-routing manifest found"))?; - let mut reports = Vec::new(); - for artifact in &manifest.artifacts { - let target = resolve_repository_target(&repository, &artifact.path)?; - let status = uninstall_managed_artifact(&target, artifact)?; - reports.push(LifecycleArtifactReport { - path: artifact.path.clone(), - mode: "managed".to_string(), - status: status.to_string(), - sha256: artifact.sha256.clone(), - repair: repair_for_status(status), - }); - } - let residual_artifacts = manifest - .artifacts - .iter() - .zip(reports.iter()) - .filter(|(_, report)| report.status != "removed") - .map(|(artifact, _)| ManagedArtifact { - path: artifact.path.clone(), - sha256: artifact.sha256.clone(), - content: artifact.content.clone(), - }) - .collect::>(); - if residual_artifacts.is_empty() { - remove_manifest(&repository)?; - } else { - let residual = ManagedManifest { - schema_version: 1, - bundle_id: manifest.bundle_id.clone(), - bundle_sha256: manifest.bundle_sha256.clone(), - artifacts: residual_artifacts, - previous: manifest.previous.clone(), - }; - write_manifest_file(&repository, &residual)?; - } - Ok(LifecycleReport { - action: "uninstall".to_string(), - bundle_id: Some(manifest.bundle_id), - repository: repository.display().to_string(), - artifacts: reports, - }) -} - -pub fn rollback_repository(repository: &Path) -> Result { - let repository = canonicalize_existing_repository(repository)?; - recover_pending_transactions(&repository)?; - let manifest = read_manifest(&repository)? - .ok_or_else(|| anyhow::anyhow!("no model-routing manifest found"))?; - let previous = manifest - .previous - .clone() - .ok_or_else(|| anyhow::anyhow!("no rollback snapshot found"))?; - let planned = plan_rollback_artifacts(&repository, &manifest, &previous)?; - ensure_update_is_safe(&planned)?; - let rollback_manifest = manifest_from_plan( - &previous.bundle_id, - previous.bundle_sha256.clone(), - &planned, - None, - ); - commit_transaction(&repository, &planned, &rollback_manifest)?; - Ok(report_from_plan( - "rollback", - &repository, - Some(&rollback_manifest.bundle_id), - &planned, - )) -} - -fn read_bundle_file(bundle_file: &Path) -> Result { - let input = fs::read_to_string(bundle_file) - .with_context(|| format!("failed to read bundle `{}`", bundle_file.display()))?; - validate_bundle_json(&input) -} - -fn read_setup_config_file(config_file: &Path) -> Result { - let input = fs::read_to_string(config_file) - .with_context(|| format!("failed to read setup config `{}`", config_file.display()))?; - setup_spec_from_toml(&input) -} - -fn read_saved_setup_config(repository: &Path) -> Result { - let repository = canonicalize_existing_repository(repository)?; - let config_path = repository.join(SETUP_CONFIG_PATH); - read_setup_config_file(&config_path) -} - -fn preview_setup(repository: &Path, spec: &SetupSpecV1) -> Result { - let prepared = prepare_setup_lifecycle(spec)?; - preview_bundle(repository, &prepared.bundle) -} - -fn apply_setup(repository: &Path, spec: &SetupSpecV1) -> Result { - let prepared = prepare_setup_lifecycle(spec)?; - apply_bundle_json(repository, &prepared.bundle, &prepared.bundle_input) -} - -fn update_setup(repository: &Path, spec: &SetupSpecV1) -> Result { - let prepared = prepare_setup_lifecycle(spec)?; - update_bundle_json(repository, &prepared.bundle, &prepared.bundle_input) -} - -fn prepare_setup_lifecycle(spec: &SetupSpecV1) -> Result { - let normalized_config = setup_spec_to_canonical_toml(spec)?; - let mut bundle = compile_setup_spec(spec)?; - bundle.artifacts.push(bundle_artifact(SourceArtifact { - path: SETUP_CONFIG_PATH.to_string(), - media_type: "application/toml".to_string(), - mode: "replace".to_string(), - content: normalized_config, - })); - bundle - .artifacts - .sort_by(|left, right| left.path.cmp(&right.path)); - validate_bundle(&bundle)?; - let mut bundle_input = serde_json::to_string_pretty(&bundle)?; - bundle_input.push('\n'); - Ok(PreparedSetupLifecycle { - bundle, - bundle_input, - }) -} - -fn same_lifecycle_plan(left: &LifecycleReport, right: &LifecycleReport) -> bool { - left.action == right.action - && left.bundle_id == right.bundle_id - && left.repository == right.repository - && left.artifacts == right.artifacts -} - -#[derive(Debug)] -struct PlannedArtifact { - path: String, - target: PathBuf, - mode: String, - content: Option, - managed_content: Option, - sha256: String, - status: String, -} - -fn preview_bundle(repository: &Path, bundle: &RoutingBundleV1) -> Result { - let repository = canonicalize_existing_repository(repository)?; - recover_pending_transactions(&repository)?; - let planned = plan_artifacts(&repository, bundle, None)?; - Ok(report_from_plan( - "preview", - &repository, - Some(&bundle.bundle_id), - &planned, - )) -} - -fn plan_artifacts( - repository: &Path, - bundle: &RoutingBundleV1, - current_manifest: Option<&ManagedManifest>, -) -> Result> { - let mut seen_targets = BTreeSet::new(); - let mut planned = Vec::new(); - for artifact in &bundle.artifacts { - let target = resolve_repository_target(repository, &artifact.path)?; - let key = target.display().to_string(); - if !seen_targets.insert(key) { - bail!("duplicate resolved artifact target `{}`", artifact.path); - } - let managed_entry = current_manifest.and_then(|manifest| { - manifest - .artifacts - .iter() - .find(|managed| managed.path == artifact.path) - }); - if artifact.path == CODEX_CONFIG_PATH { - planned.push(plan_codex_config_artifact( - repository, - artifact, - target, - managed_entry, - )?); - continue; - } - let status = if target.exists() { - let metadata = fs::symlink_metadata(&target) - .with_context(|| format!("failed to inspect `{}`", target.display()))?; - if metadata.file_type().is_symlink() { - bail!("artifact target `{}` is a symlink", artifact.path); - } - let current = fs::read(&target) - .with_context(|| format!("failed to read `{}`", target.display()))?; - let current_sha = sha256(¤t); - if current_sha == artifact.sha256 { - "unchanged" - } else if let Some(managed) = managed_entry { - if current_sha == managed.sha256 { - "update" - } else { - "preserved-modified" - } - } else { - "conflict" - } - } else { - ensure_parent_is_safe(repository, &target)?; - "create" - }; - planned.push(PlannedArtifact { - path: artifact.path.clone(), - target, - mode: artifact.mode.clone(), - content: Some(artifact.content.clone()), - managed_content: Some(artifact.content.clone()), - sha256: artifact.sha256.clone(), - status: status.to_string(), - }); - } - if let Some(manifest) = current_manifest { - for artifact in &manifest.artifacts { - if bundle - .artifacts - .iter() - .any(|bundle_artifact| bundle_artifact.path == artifact.path) - { - continue; - } - let target = resolve_repository_target(repository, &artifact.path)?; - let (status, content) = if artifact.path == CODEX_CONFIG_PATH { - let status = preserved_or_removed_status(&target, artifact)?; - let content = if status == "removed" { - remove_managed_codex_config_entries(&target, artifact)? - } else { - artifact.content.clone() - }; - (status, content) - } else { - let status = preserved_or_removed_status(&target, artifact)?; - let content = if status == "removed" { - None - } else { - artifact.content.clone() - }; - (status, content) - }; - planned.push(PlannedArtifact { - path: artifact.path.clone(), - target, - mode: "delete".to_string(), - content, - managed_content: artifact.content.clone(), - sha256: artifact.sha256.clone(), - status, - }); - } - } - reject_parent_child_targets(&planned)?; - Ok(planned) -} - -fn ensure_apply_is_safe(planned: &[PlannedArtifact]) -> Result<()> { - for artifact in planned { - if artifact.status == "conflict" || artifact.status == "preserved-modified" { - bail!( - "artifact target `{}` already exists with different content", - artifact.path - ); - } - } - Ok(()) -} - -fn ensure_update_is_safe(planned: &[PlannedArtifact]) -> Result<()> { - for artifact in planned { - if artifact.status == "conflict" { - bail!( - "artifact target `{}` already exists with unmanaged content", - artifact.path - ); - } - } - Ok(()) -} - -fn reject_parent_child_targets(planned: &[PlannedArtifact]) -> Result<()> { - for (index, left) in planned.iter().enumerate() { - let left_relative = Path::new(&left.path); - for right in planned.iter().skip(index + 1) { - let right_relative = Path::new(&right.path); - if left_relative.starts_with(right_relative) - || right_relative.starts_with(left_relative) - { - bail!( - "artifact targets `{}` and `{}` have a parent-child collision", - left.path, - right.path - ); - } - } - } - Ok(()) -} - -fn plan_rollback_artifacts( - repository: &Path, - current_manifest: &ManagedManifest, - previous: &ManagedSnapshot, -) -> Result> { - let mut planned = Vec::new(); - for artifact in &previous.artifacts { - let content = artifact.content.clone().ok_or_else(|| { - anyhow::anyhow!( - "rollback artifact `{}` has no stored content", - artifact.path - ) - })?; - let target = resolve_repository_target(repository, &artifact.path)?; - let current = current_manifest - .artifacts - .iter() - .find(|managed| managed.path == artifact.path); - if artifact.path == CODEX_CONFIG_PATH { - planned.push(plan_codex_config_rollback_artifact( - repository, artifact, content, target, current, - )?); - continue; - } - let status = if target.exists() { - let current_content = fs::read(&target) - .with_context(|| format!("failed to read `{}`", target.display()))?; - let current_sha = sha256(¤t_content); - if current_sha == artifact.sha256 { - "unchanged" - } else if let Some(managed) = current { - if current_sha == managed.sha256 { - "rollback" - } else { - "preserved-modified" - } - } else { - "rollback" - } - } else { - ensure_parent_is_safe(repository, &target)?; - "create" - }; - planned.push(PlannedArtifact { - path: artifact.path.clone(), - target, - mode: "replace".to_string(), - content: Some(content), - managed_content: artifact.content.clone(), - sha256: artifact.sha256.clone(), - status: status.to_string(), - }); - } - for artifact in ¤t_manifest.artifacts { - if previous - .artifacts - .iter() - .any(|previous_artifact| previous_artifact.path == artifact.path) - { - continue; - } - let target = resolve_repository_target(repository, &artifact.path)?; - let (status, content) = if artifact.path == CODEX_CONFIG_PATH { - let status = preserved_or_removed_status(&target, artifact)?; - let content = if status == "removed" { - remove_managed_codex_config_entries(&target, artifact)? - } else { - artifact.content.clone() - }; - (status, content) - } else { - let status = preserved_or_removed_status(&target, artifact)?; - let content = if status == "removed" { - None - } else { - artifact.content.clone() - }; - (status, content) - }; - planned.push(PlannedArtifact { - path: artifact.path.clone(), - target, - mode: "delete".to_string(), - content, - managed_content: artifact.content.clone(), - sha256: artifact.sha256.clone(), - status, - }); - } - reject_parent_child_targets(&planned)?; - Ok(planned) -} - -fn plan_codex_config_artifact( - repository: &Path, - artifact: &BundleArtifact, - target: PathBuf, - managed_entry: Option<&ManagedArtifact>, -) -> Result { - let (status, content) = if target.exists() { - ensure_artifact_target_is_regular(&target, &artifact.path)?; - let current = fs::read_to_string(&target) - .with_context(|| format!("failed to read `{}`", target.display()))?; - if let Some(managed) = managed_entry { - if codex_config_contains_managed_entries(¤t, managed)? { - if codex_config_has_unmanaged_conflict(¤t, &artifact.content, Some(managed))? - { - ("conflict".to_string(), Some(current)) - } else if managed.content.as_deref() == Some(artifact.content.as_str()) - && codex_config_contains_desired_entries(¤t, &artifact.content)? - { - ("unchanged".to_string(), Some(current)) - } else { - ( - "update".to_string(), - merge_codex_config_entries( - Some(¤t), - Some(managed), - &artifact.content, - )?, - ) - } - } else { - ("preserved-modified".to_string(), Some(current)) - } - } else if codex_config_has_unmanaged_conflict(¤t, &artifact.content, None)? { - ("conflict".to_string(), Some(current)) - } else if codex_config_contains_desired_entries(¤t, &artifact.content)? { - ("unchanged".to_string(), Some(current)) - } else { - ( - "update".to_string(), - merge_codex_config_entries(Some(¤t), None, &artifact.content)?, - ) - } - } else { - ensure_parent_is_safe(repository, &target)?; - ("create".to_string(), Some(artifact.content.clone())) - }; - Ok(PlannedArtifact { - path: artifact.path.clone(), - target, - mode: artifact.mode.clone(), - content, - managed_content: Some(artifact.content.clone()), - sha256: artifact.sha256.clone(), - status, - }) -} - -fn plan_codex_config_rollback_artifact( - repository: &Path, - artifact: &ManagedArtifact, - desired_content: String, - target: PathBuf, - current: Option<&ManagedArtifact>, -) -> Result { - let (status, content) = if target.exists() { - ensure_artifact_target_is_regular(&target, &artifact.path)?; - let current_text = fs::read_to_string(&target) - .with_context(|| format!("failed to read `{}`", target.display()))?; - if let Some(managed) = current { - if codex_config_contains_managed_entries(¤t_text, managed)? { - if codex_config_has_unmanaged_conflict( - ¤t_text, - &desired_content, - Some(managed), - )? { - ("conflict".to_string(), Some(current_text)) - } else if managed.content.as_deref() == Some(desired_content.as_str()) - && codex_config_contains_desired_entries(¤t_text, &desired_content)? - { - ("unchanged".to_string(), Some(current_text)) - } else { - ( - "rollback".to_string(), - merge_codex_config_entries( - Some(¤t_text), - Some(managed), - &desired_content, - )?, - ) - } - } else { - ("preserved-modified".to_string(), Some(current_text)) - } - } else if codex_config_has_unmanaged_conflict(¤t_text, &desired_content, None)? { - ("conflict".to_string(), Some(current_text)) - } else { - ( - "rollback".to_string(), - merge_codex_config_entries(Some(¤t_text), None, &desired_content)?, - ) - } - } else { - ensure_parent_is_safe(repository, &target)?; - ("create".to_string(), Some(desired_content.clone())) - }; - Ok(PlannedArtifact { - path: artifact.path.clone(), - target, - mode: "replace".to_string(), - content, - managed_content: Some(desired_content), - sha256: artifact.sha256.clone(), - status, - }) -} - -fn ensure_artifact_target_is_regular(target: &Path, artifact_path: &str) -> Result<()> { - let metadata = fs::symlink_metadata(target) - .with_context(|| format!("failed to inspect `{}`", target.display()))?; - if metadata.file_type().is_symlink() { - bail!("artifact target `{artifact_path}` is a symlink"); - } - if !metadata.is_file() { - bail!("artifact target `{artifact_path}` is not a file"); - } - Ok(()) -} - -fn preserved_or_removed_status(target: &Path, artifact: &ManagedArtifact) -> Result { - if !target.exists() { - return Ok("missing".to_string()); - } - let metadata = fs::symlink_metadata(target) - .with_context(|| format!("failed to inspect `{}`", target.display()))?; - if metadata.file_type().is_symlink() { - bail!("artifact target `{}` is a symlink", artifact.path); - } - if artifact.path == CODEX_CONFIG_PATH { - let current = fs::read_to_string(target) - .with_context(|| format!("failed to read `{}`", target.display()))?; - return if codex_config_contains_managed_entries(¤t, artifact)? { - Ok("removed".to_string()) - } else { - Ok("preserved-modified".to_string()) - }; - } - let current = - fs::read(target).with_context(|| format!("failed to read `{}`", target.display()))?; - if sha256(¤t) == artifact.sha256 { - Ok("removed".to_string()) - } else { - Ok("preserved-modified".to_string()) - } -} - -fn status_for_managed_artifact(target: &Path, artifact: &ManagedArtifact) -> Result<&'static str> { - if !target.exists() { - return Ok("missing"); - } - let metadata = fs::symlink_metadata(target) - .with_context(|| format!("failed to inspect `{}`", target.display()))?; - if metadata.file_type().is_symlink() { - bail!("artifact target `{}` is a symlink", artifact.path); - } - if artifact.path == CODEX_CONFIG_PATH { - let current = fs::read_to_string(target) - .with_context(|| format!("failed to read `{}`", target.display()))?; - return if codex_config_contains_managed_entries(¤t, artifact)? { - Ok("managed") - } else { - Ok("modified") - }; - } - let content = - fs::read(target).with_context(|| format!("failed to read `{}`", target.display()))?; - if sha256(&content) == artifact.sha256 { - Ok("managed") - } else { - Ok("modified") - } -} - -fn uninstall_managed_artifact(target: &Path, artifact: &ManagedArtifact) -> Result<&'static str> { - if !target.exists() { - return Ok("missing"); - } - let metadata = fs::symlink_metadata(target) - .with_context(|| format!("failed to inspect `{}`", target.display()))?; - if metadata.file_type().is_symlink() { - bail!("artifact target `{}` is a symlink", artifact.path); - } - if artifact.path == CODEX_CONFIG_PATH { - let current = fs::read_to_string(target) - .with_context(|| format!("failed to read `{}`", target.display()))?; - if !codex_config_contains_managed_entries(¤t, artifact)? { - return Ok("preserved-modified"); - } - match remove_managed_codex_config_entries(target, artifact)? { - Some(content) => fs::write(target, content.as_bytes()) - .with_context(|| format!("failed to write `{}`", target.display()))?, - None => fs::remove_file(target) - .with_context(|| format!("failed to remove `{}`", target.display()))?, - } - return Ok("removed"); - } - let content = - fs::read(target).with_context(|| format!("failed to read `{}`", target.display()))?; - if sha256(&content) != artifact.sha256 { - Ok("preserved-modified") - } else { - fs::remove_file(target) - .with_context(|| format!("failed to remove `{}`", target.display()))?; - Ok("removed") - } -} - -fn codex_config_contains_managed_entries( - current_content: &str, - managed: &ManagedArtifact, -) -> Result { - let managed_content = managed.content.as_deref().ok_or_else(|| { - anyhow::anyhow!("managed artifact `{}` has no stored content", managed.path) - })?; - Ok( - !codex_config_has_unmanaged_conflict(current_content, managed_content, None)? - && codex_config_contains_desired_entries(current_content, managed_content)?, - ) -} - -fn codex_config_contains_desired_entries( - current_content: &str, - desired_content: &str, -) -> Result { - let current = codex_agent_entries(current_content)?; - let desired = codex_agent_entries(desired_content)?; - Ok(desired - .iter() - .all(|(name, desired_entry)| current.get(name) == Some(desired_entry))) -} - -fn codex_config_has_unmanaged_conflict( - current_content: &str, - desired_content: &str, - previously_managed: Option<&ManagedArtifact>, -) -> Result { - let current = codex_agent_entries(current_content)?; - let desired = codex_agent_entries(desired_content)?; - let old_keys = previously_managed - .and_then(|managed| managed.content.as_deref()) - .map(codex_agent_entry_names) - .transpose()? - .unwrap_or_default(); - Ok(desired.iter().any(|(name, desired_entry)| { - !old_keys.contains(name) - && current - .get(name) - .is_some_and(|entry| entry != desired_entry) - })) -} - -fn merge_codex_config_entries( - current_content: Option<&str>, - previously_managed: Option<&ManagedArtifact>, - desired_content: &str, -) -> Result> { - let mut root = match current_content { - Some(content) => parse_toml_root(content)?, - None => toml::value::Table::new(), - }; - if let Some(managed) = previously_managed { - let managed_content = managed.content.as_deref().ok_or_else(|| { - anyhow::anyhow!("managed artifact `{}` has no stored content", managed.path) - })?; - remove_codex_agent_entries(&mut root, &codex_agent_entry_names(managed_content)?)?; - } - upsert_codex_agent_entries(&mut root, codex_agent_entries(desired_content)?)?; - render_toml_root(root) -} - -fn remove_managed_codex_config_entries( - target: &Path, - managed: &ManagedArtifact, -) -> Result> { - let current = fs::read_to_string(target) - .with_context(|| format!("failed to read `{}`", target.display()))?; - let managed_content = managed.content.as_deref().ok_or_else(|| { - anyhow::anyhow!("managed artifact `{}` has no stored content", managed.path) - })?; - let mut root = parse_toml_root(¤t)?; - remove_codex_agent_entries(&mut root, &codex_agent_entry_names(managed_content)?)?; - render_toml_root(root) -} - -fn parse_toml_root(content: &str) -> Result { - match toml::from_str::(content)? { - toml::Value::Table(table) => Ok(table), - _ => bail!("Codex config must be a TOML table"), - } -} - -fn codex_agent_entry_names(content: &str) -> Result> { - Ok(codex_agent_entries(content)?.into_keys().collect()) -} - -fn codex_agent_entries(content: &str) -> Result> { - let root = parse_toml_root(content)?; - let Some(agents) = root.get("agents") else { - return Ok(BTreeMap::new()); - }; - let agents = agents - .as_table() - .ok_or_else(|| anyhow::anyhow!("Codex config `agents` must be a table"))?; - Ok(agents - .iter() - .map(|(name, value)| (name.clone(), value.clone())) - .collect()) -} - -fn remove_codex_agent_entries( - root: &mut toml::value::Table, - names: &BTreeSet, -) -> Result<()> { - let Some(agents_value) = root.get_mut("agents") else { - return Ok(()); - }; - let agents = agents_value - .as_table_mut() - .ok_or_else(|| anyhow::anyhow!("Codex config `agents` must be a table"))?; - for name in names { - agents.remove(name); - } - if agents.is_empty() { - root.remove("agents"); - } - Ok(()) -} - -fn upsert_codex_agent_entries( - root: &mut toml::value::Table, - entries: BTreeMap, -) -> Result<()> { - if entries.is_empty() { - return Ok(()); - } - if !root.contains_key("agents") { - root.insert( - "agents".to_string(), - toml::Value::Table(toml::value::Table::new()), - ); - } - let agents = root - .get_mut("agents") - .and_then(toml::Value::as_table_mut) - .ok_or_else(|| anyhow::anyhow!("Codex config `agents` must be a table"))?; - for (name, value) in entries { - agents.insert(name, value); - } - Ok(()) -} - -fn render_toml_root(root: toml::value::Table) -> Result> { - if root.is_empty() { - return Ok(None); - } - let mut content = toml::to_string_pretty(&toml::Value::Table(root))?; - if !content.ends_with('\n') { - content.push('\n'); - } - Ok(Some(content)) -} - -fn commit_transaction( - repository: &Path, - planned: &[PlannedArtifact], - manifest: &ManagedManifest, -) -> Result<()> { - let txn_root = repository.join(".model-routing").join(format!( - "txn-{}-{}", - std::process::id(), - SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_nanos() - )); - let stage_root = txn_root.join("stage"); - let backup_root = txn_root.join("backup"); - fs::create_dir_all(&stage_root) - .with_context(|| format!("failed to create `{}`", stage_root.display()))?; - fs::create_dir_all(&backup_root) - .with_context(|| format!("failed to create `{}`", backup_root.display()))?; - - let mut writes = Vec::new(); - for (index, artifact) in planned.iter().enumerate() { - if artifact.status == "unchanged" - || artifact.status == "preserved-modified" - || artifact.status == "missing" - { - continue; - } - let staged = match &artifact.content { - Some(content) => { - let staged = stage_root.join(format!("artifact-{index}")); - fs::write(&staged, content.as_bytes()) - .with_context(|| format!("failed to stage `{}`", artifact.path))?; - Some(staged) - } - None => None, - }; - writes.push(TransactionalWrite { - label: artifact.path.clone(), - target: artifact.target.clone(), - staged, - backup: backup_root.join(format!("artifact-{index}")), - committed: false, - backup_created: false, - had_original: artifact.target.exists(), - }); - } - - let manifest_path = repository.join(MANIFEST_PATH); - let manifest_stage = stage_root.join("manifest.json"); - fs::write(&manifest_stage, serde_json::to_vec_pretty(manifest)?) - .with_context(|| format!("failed to stage `{MANIFEST_PATH}`"))?; - writes.push(TransactionalWrite { - label: MANIFEST_PATH.to_string(), - target: manifest_path.clone(), - staged: Some(manifest_stage), - backup: backup_root.join("manifest.json"), - committed: false, - backup_created: false, - had_original: manifest_path.exists(), - }); - - write_transaction_journal(repository, &txn_root, &writes)?; - let result = commit_writes(&mut writes); - if let Err(error) = result { - if let Err(rollback_error) = rollback_writes(&writes) { - return Err(error).with_context(|| { - format!( - "transaction rollback incomplete; retained `{}` for recovery: {rollback_error:#}", - txn_root.display() - ) - }); - } - fs::remove_dir_all(&txn_root) - .with_context(|| format!("failed to remove `{}`", txn_root.display()))?; - return Err(error); - } - fs::remove_dir_all(&txn_root) - .with_context(|| format!("failed to remove `{}`", txn_root.display()))?; - Ok(()) -} - -#[derive(Debug)] -struct TransactionalWrite { - label: String, - target: PathBuf, - staged: Option, - backup: PathBuf, - committed: bool, - backup_created: bool, - had_original: bool, -} - -fn commit_writes(writes: &mut [TransactionalWrite]) -> Result<()> { - let txn_root = writes - .first() - .and_then(|write| write.backup.parent()) - .and_then(Path::parent) - .map(Path::to_path_buf) - .ok_or_else(|| anyhow::anyhow!("transaction has no writes"))?; - let repository = txn_root - .parent() - .and_then(Path::parent) - .ok_or_else(|| anyhow::anyhow!("transaction root is outside repository metadata"))? - .to_path_buf(); - for index in 0..writes.len() { - if let Some(parent) = writes[index].target.parent() { - fs::create_dir_all(parent) - .with_context(|| format!("failed to create `{}`", parent.display()))?; - } - if writes[index].target.exists() { - fs::rename(&writes[index].target, &writes[index].backup) - .with_context(|| format!("failed to backup `{}`", writes[index].label))?; - writes[index].backup_created = true; - write_transaction_journal(&repository, &txn_root, writes)?; - } - if let Some(staged) = &writes[index].staged { - maybe_return_staged_rename_error()?; - fs::rename(staged, &writes[index].target) - .with_context(|| format!("failed to commit `{}`", writes[index].label))?; - } - writes[index].committed = true; - write_transaction_journal(&repository, &txn_root, writes)?; - maybe_fail_after_transaction_write()?; - } - Ok(()) -} - -fn rollback_writes(writes: &[TransactionalWrite]) -> Result<()> { - for write in writes.iter().rev() { - if !write.committed && !write.backup_created { - continue; - } - maybe_fail_during_restore()?; - if write.target.exists() { - fs::remove_file(&write.target) - .with_context(|| format!("failed to remove `{}`", write.target.display()))?; - } - if write.had_original { - if let Some(parent) = write.target.parent() { - fs::create_dir_all(parent) - .with_context(|| format!("failed to create `{}`", parent.display()))?; - } - fs::rename(&write.backup, &write.target).with_context(|| { - format!( - "failed to restore `{}` from `{}`", - write.target.display(), - write.backup.display() - ) - })?; - } - } - Ok(()) -} - -#[derive(Debug, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -struct TransactionJournal { - schema_version: u32, - writes: Vec, -} - -#[derive(Debug, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -struct TransactionJournalWrite { - label: String, - target: String, - staged: Option, - backup: String, - committed: bool, - #[serde(default)] - backup_created: bool, - had_original: bool, -} - -fn write_transaction_journal( - repository: &Path, - txn_root: &Path, - writes: &[TransactionalWrite], -) -> Result<()> { - let journal = TransactionJournal { - schema_version: 1, - writes: writes - .iter() - .map(|write| { - Ok(TransactionJournalWrite { - label: write.label.clone(), - target: repository_relative(repository, &write.target)?, - staged: write - .staged - .as_ref() - .map(|staged| repository_relative(repository, staged)) - .transpose()?, - backup: repository_relative(repository, &write.backup)?, - committed: write.committed, - backup_created: write.backup_created, - had_original: write.had_original, - }) - }) - .collect::>>()?, - }; - let journal_path = txn_root.join(TRANSACTION_JOURNAL); - let temp_path = txn_root.join(format!("{TRANSACTION_JOURNAL}.tmp")); - fs::write(&temp_path, serde_json::to_vec_pretty(&journal)?).with_context(|| { - format!( - "failed to write transaction journal temp `{}`", - temp_path.display() - ) - })?; - sync_file(&temp_path)?; - maybe_return_journal_error()?; - maybe_fail_during_journal_replace(); - fs::rename(&temp_path, &journal_path).with_context(|| { - format!( - "failed to replace transaction journal `{}`", - journal_path.display() - ) - })?; - sync_directory(txn_root)?; - Ok(()) -} - -fn recover_pending_transactions(repository: &Path) -> Result<()> { - let metadata_dir = repository.join(".model-routing"); - if !metadata_dir.exists() { - return Ok(()); - } - for entry in fs::read_dir(&metadata_dir) - .with_context(|| format!("failed to read `{}`", metadata_dir.display()))? - { - let entry = entry?; - if !entry.file_type()?.is_dir() { - continue; - } - let name = entry.file_name(); - let Some(name) = name.to_str() else { - continue; - }; - if !name.starts_with("txn-") { - continue; - } - recover_transaction(repository, &entry.path())?; - } - Ok(()) -} - -fn recover_transaction(repository: &Path, txn_root: &Path) -> Result<()> { - let journal_path = txn_root.join(TRANSACTION_JOURNAL); - if journal_path.exists() { - let input = fs::read(&journal_path) - .with_context(|| format!("failed to read `{}`", journal_path.display()))?; - let journal: TransactionJournal = serde_json::from_slice(&input) - .with_context(|| format!("failed to parse `{}`", journal_path.display()))?; - for write in journal.writes.iter().rev() { - recover_transaction_write(repository, write).with_context(|| { - format!("failed to recover transaction write `{}`", write.label) - })?; - } - } - fs::remove_dir_all(txn_root) - .with_context(|| format!("failed to remove `{}`", txn_root.display()))?; - Ok(()) -} - -fn recover_transaction_write(repository: &Path, write: &TransactionJournalWrite) -> Result<()> { - maybe_fail_during_restore()?; - let target = repository.join(&write.target); - let backup = repository.join(&write.backup); - if backup.exists() { - if target.exists() { - fs::remove_file(&target) - .with_context(|| format!("failed to remove `{}`", target.display()))?; - } - if let Some(parent) = target.parent() { - fs::create_dir_all(parent) - .with_context(|| format!("failed to create `{}`", parent.display()))?; - } - fs::rename(&backup, &target).with_context(|| { - format!( - "failed to restore `{}` from `{}`", - target.display(), - backup.display() - ) - })?; - return Ok(()); - } - if !write.had_original - && write - .staged - .as_ref() - .is_some_and(|staged| !repository.join(staged).exists()) - && target.exists() - { - fs::remove_file(&target) - .with_context(|| format!("failed to remove partial `{}`", target.display()))?; - } - Ok(()) -} - -fn repository_relative(repository: &Path, path: &Path) -> Result { - Ok(path - .strip_prefix(repository) - .with_context(|| format!("`{}` is outside repository", path.display()))? - .to_str() - .ok_or_else(|| anyhow::anyhow!("path `{}` is not UTF-8", path.display()))? - .to_string()) -} - -fn sync_file(path: &Path) -> Result<()> { - fs::File::open(path) - .with_context(|| format!("failed to open `{}` for sync", path.display()))? - .sync_all() - .with_context(|| format!("failed to sync `{}`", path.display()))?; - Ok(()) -} - -fn sync_directory(path: &Path) -> Result<()> { - fs::File::open(path) - .with_context(|| format!("failed to open directory `{}` for sync", path.display()))? - .sync_all() - .with_context(|| format!("failed to sync directory `{}`", path.display()))?; - Ok(()) -} - -fn maybe_fail_after_transaction_write() -> Result<()> { - TRANSACTION_FAIL_AFTER_WRITES.with(|fail_after| { - let remaining = fail_after.get(); - if remaining == 0 { - return; - } - fail_after.set(remaining - 1); - if remaining == 1 { - panic!("injected transaction interruption after committed write"); - } - }); - Ok(()) -} - -fn maybe_fail_during_journal_replace() { - TRANSACTION_FAIL_JOURNAL_REPLACE_AFTER.with(|fail_after| { - let remaining = fail_after.get(); - if remaining == 0 { - return; - } - fail_after.set(remaining - 1); - if remaining == 1 { - panic!("injected transaction interruption during journal replacement"); - } - }); -} - -fn maybe_return_journal_error() -> Result<()> { - TRANSACTION_RETURN_JOURNAL_ERROR_AFTER.with(|fail_after| { - let remaining = fail_after.get(); - if remaining == 0 { - return Ok(()); - } - fail_after.set(remaining - 1); - if remaining == 1 { - bail!("injected transaction journal update error"); - } - Ok(()) - }) -} - -fn maybe_return_staged_rename_error() -> Result<()> { - TRANSACTION_RETURN_STAGED_RENAME_ERROR_AFTER.with(|fail_after| { - let remaining = fail_after.get(); - if remaining == 0 { - return Ok(()); - } - fail_after.set(remaining - 1); - if remaining == 1 { - bail!("injected staged rename error after backup"); - } - Ok(()) - }) -} - -fn maybe_fail_during_restore() -> Result<()> { - TRANSACTION_FAIL_RESTORE.with(|fail| { - if fail.replace(false) { - bail!("injected transaction restore failure"); - } - Ok(()) - }) -} - -fn canonicalize_existing_repository(repository: &Path) -> Result { - let canonical = repository - .canonicalize() - .with_context(|| format!("repository `{}` does not exist", repository.display()))?; - if !canonical.is_dir() { - bail!("repository `{}` is not a directory", canonical.display()); - } - Ok(canonical) -} - -fn resolve_repository_target(repository: &Path, artifact_path: &str) -> Result { - if artifact_path.trim().is_empty() { - bail!("artifact path must not be blank"); - } - let path = Path::new(artifact_path); - if path.is_absolute() { - bail!("artifact path `{artifact_path}` must be repository-relative"); - } - let mut normalized = PathBuf::new(); - for component in path.components() { - match component { - Component::Normal(part) => normalized.push(part), - Component::CurDir => {} - Component::ParentDir => bail!("artifact path `{artifact_path}` must not traverse"), - _ => bail!("artifact path `{artifact_path}` is unsupported"), - } - } - let normalized_text = normalized - .to_str() - .ok_or_else(|| anyhow::anyhow!("artifact path `{artifact_path}` is not UTF-8"))?; - if normalized_text.starts_with(".model-routing/") { - bail!("artifact path `{artifact_path}` targets a reserved path"); - } - if normalized_text == SETUP_CONFIG_PATH { - return Ok(repository.join(normalized)); - } - if !allowed_repository_target(normalized_text) { - bail!("artifact path `{artifact_path}` is not an allowed host artifact path"); - } - Ok(repository.join(normalized)) -} - -fn allowed_repository_target(path: &str) -> bool { - if path == ".codex/config.toml" { - return true; - } - [ - ".codex/agents/", - ".claude/agents/", - ".cursor/agents/", - ".opencode/agents/", - ".pi/workflows/", - ".planr/", - ] - .iter() - .any(|prefix| path.starts_with(prefix)) -} - -fn ensure_parent_is_safe(repository: &Path, target: &Path) -> Result<()> { - let mut current = repository.to_path_buf(); - let relative = target - .strip_prefix(repository) - .map_err(|_| anyhow::anyhow!("target escaped repository"))?; - if let Some(parent) = relative.parent() { - for component in parent.components() { - let Component::Normal(part) = component else { - bail!("artifact parent contains unsupported component"); - }; - current.push(part); - if current.exists() { - let metadata = fs::symlink_metadata(¤t) - .with_context(|| format!("failed to inspect `{}`", current.display()))?; - if metadata.file_type().is_symlink() { - bail!("artifact parent `{}` is a symlink", current.display()); - } - if !metadata.is_dir() { - bail!("artifact parent `{}` is not a directory", current.display()); - } - } - } - } - Ok(()) -} - -fn manifest_from_bundle( - bundle: &RoutingBundleV1, - bundle_sha256: String, - previous: Option, -) -> ManagedManifest { - ManagedManifest { - schema_version: 1, - bundle_id: bundle.bundle_id.clone(), - bundle_sha256, - artifacts: bundle - .artifacts - .iter() - .map(|artifact| ManagedArtifact { - path: artifact.path.clone(), - sha256: artifact.sha256.clone(), - content: Some(artifact.content.clone()), - }) - .collect(), - previous, - } -} - -fn manifest_from_plan( - bundle_id: &str, - bundle_sha256: String, - planned: &[PlannedArtifact], - previous: Option, -) -> ManagedManifest { - ManagedManifest { - schema_version: 1, - bundle_id: bundle_id.to_string(), - bundle_sha256, - artifacts: planned - .iter() - .filter(|artifact| artifact.status != "removed") - .map(|artifact| ManagedArtifact { - path: artifact.path.clone(), - sha256: artifact.sha256.clone(), - content: artifact.managed_content.clone(), - }) - .collect(), - previous, - } -} - -fn snapshot_from_manifest(manifest: &ManagedManifest) -> ManagedSnapshot { - ManagedSnapshot { - bundle_id: manifest.bundle_id.clone(), - bundle_sha256: manifest.bundle_sha256.clone(), - artifacts: manifest.artifacts.clone(), - } -} - -fn write_manifest_file(repository: &Path, manifest: &ManagedManifest) -> Result<()> { - let manifest_path = repository.join(MANIFEST_PATH); - if let Some(parent) = manifest_path.parent() { - fs::create_dir_all(parent) - .with_context(|| format!("failed to create `{}`", parent.display()))?; - } - fs::write(&manifest_path, serde_json::to_vec_pretty(&manifest)?) - .with_context(|| format!("failed to write `{}`", manifest_path.display()))?; - Ok(()) -} - -fn remove_manifest(repository: &Path) -> Result<()> { - let manifest_path = repository.join(MANIFEST_PATH); - if manifest_path.exists() { - fs::remove_file(&manifest_path) - .with_context(|| format!("failed to remove `{}`", manifest_path.display()))?; - } - Ok(()) -} - -fn read_manifest(repository: &Path) -> Result> { - let manifest_path = repository.join(MANIFEST_PATH); - if !manifest_path.exists() { - return Ok(None); - } - let input = fs::read(&manifest_path) - .with_context(|| format!("failed to read `{}`", manifest_path.display()))?; - Ok(Some(serde_json::from_slice(&input).with_context(|| { - format!("failed to parse `{}`", manifest_path.display()) - })?)) -} - -fn report_from_plan( - action: &str, - repository: &Path, - bundle_id: Option<&str>, - planned: &[PlannedArtifact], -) -> LifecycleReport { - LifecycleReport { - action: action.to_string(), - bundle_id: bundle_id.map(ToOwned::to_owned), - repository: repository.display().to_string(), - artifacts: planned - .iter() - .map(|artifact| LifecycleArtifactReport { - path: artifact.path.clone(), - mode: artifact.mode.clone(), - status: artifact.status.clone(), - sha256: artifact.sha256.clone(), - repair: repair_for_status(&artifact.status), - }) - .collect(), - } -} - -fn repair_for_status(status: &str) -> Option { - match status { - "modified" | "preserved-modified" => Some( - "user-modified file preserved; run update or rollback after reconciling local edits" - .to_string(), - ), - "missing" => Some( - "managed file is missing; run update to recreate or uninstall to drop ownership" - .to_string(), - ), - _ => None, - } -} - -fn validate_raw_bundle_shape(value: &Value) -> Result<()> { - let object = value - .as_object() - .ok_or_else(|| anyhow::anyhow!("bundle root must be a JSON object"))?; - let schema_version = object - .get("schema_version") - .and_then(Value::as_u64) - .ok_or_else(|| anyhow::anyhow!("bundle schema_version must be an integer"))?; - if schema_version != 1 { - bail!("unsupported schema_version {schema_version}"); - } - let allowed_root = BTreeSet::from([ - "schema_version", - "bundle_id", - "policy_id", - "policy_version", - "generated_at", - "source", - "policy", - "requirements", - "profiles", - "routes", - "route_default", - "artifacts", - "evidence", - "adapter_contract", - ]); - for key in object.keys() { - if !allowed_root.contains(key.as_str()) { - bail!("unknown bundle field `{key}`"); - } - } - let source = object - .get("source") - .and_then(Value::as_object) - .ok_or_else(|| anyhow::anyhow!("bundle source must be an object"))?; - if !source.contains_key("integration") { - bail!("bundle source.integration is required"); - } - let artifacts = object - .get("artifacts") - .and_then(Value::as_array) - .ok_or_else(|| anyhow::anyhow!("bundle artifacts must be an array"))?; - let allowed_artifact = BTreeSet::from(["path", "media_type", "mode", "content", "sha256"]); - for artifact in artifacts { - let artifact_object = artifact - .as_object() - .ok_or_else(|| anyhow::anyhow!("bundle artifact must be an object"))?; - let path = artifact_object - .get("path") - .and_then(Value::as_str) - .unwrap_or(""); - if artifact_object.contains_key("content") && artifact_object.contains_key("content_ref") { - bail!("artifact `{path}` cannot define both content and content_ref"); - } - for key in artifact_object.keys() { - if !allowed_artifact.contains(key.as_str()) { - bail!("artifact `{path}` has unknown field `{key}`"); - } - } - } - Ok(()) -} - -fn validate_policy_contract(policy: &PolicyContract) -> Result<()> { - if policy.schema_version != 1 { - bail!( - "unsupported policy schema_version {}", - policy.schema_version - ); - } - if policy.id.trim().is_empty() || policy.version.trim().is_empty() { - bail!("policy id and version must not be blank"); - } - if policy.usage.max_parallel_writers > policy.usage.max_active_agents { - bail!("policy max_parallel_writers cannot exceed max_active_agents"); - } - if policy.usage.max_parallel_readers > policy.usage.max_active_agents { - bail!("policy max_parallel_readers cannot exceed max_active_agents"); - } - if policy.usage.review_reserve_percent > 100 { - bail!("policy review_reserve_percent cannot exceed 100"); - } - if policy.execution.max_write_scope_entries == 0 { - for role in policy.execution.roles.values() { - if !role.filesystem.write_roots.is_empty() { - bail!("policy with zero write scope cannot declare writable roots"); - } - } - } - Ok(()) -} - -fn validate_source(source: &PolicySource) -> Result<()> { - if source.policy_id.trim().is_empty() || source.host.trim().is_empty() { - bail!("routing policy id and host must not be blank"); - } - for route in &source.routes { - if !source.profiles.contains_key(&route.profile) { - bail!("route references unknown profile `{}`", route.profile); - } - } - if let Some(default) = &source.route_default { - if !source.profiles.contains_key(&default.profile) { - bail!( - "default route references unknown profile `{}`", - default.profile - ); - } - } - if source.evidence.status == "recommended" { - bail!("policy sources cannot claim recommended without the evaluation gate"); - } - for profile in source.profiles.values() { - validate_profile_fork_policy(profile)?; - } - validate_adapter_contract(&source.adapter_contract)?; - validate_policy_contract(&source.policy)?; - Ok(()) -} - -fn compile_host_adapter( - policy_id: &str, - binding: &HostBinding, - integration: Integration, -) -> Result { - validate_host_adapter(binding)?; - Ok(CompiledHostAdapter { - requirements: vec![HostRequirement { - host: binding.host.clone(), - capabilities: requirement_capabilities_for_binding(binding), - }], - profiles: profiles_for_binding(binding), - routes: routes_for_binding(binding)?, - route_default: default_route_for_binding(binding)?, - artifacts: artifacts_for_binding(binding), - adapter_contract: adapter_contract_for_binding(policy_id, binding, integration)?, - }) -} - -fn validate_host_adapter(binding: &HostBinding) -> Result<()> { - if binding.id.trim().is_empty() - || binding.version.trim().is_empty() - || binding.host.trim().is_empty() - { - bail!("host adapter id, version, and host must not be blank"); - } - if binding.profiles.is_empty() { - bail!("host adapter `{}` must declare profiles", binding.id); - } - if binding.default_role.is_none() && binding.routes.is_empty() { - bail!( - "host adapter `{}` must declare routes or a default role", - binding.id - ); - } - if let Some(default_role) = &binding.default_role { - binding_profile_id(binding, default_role)?; - } - let mut profile_ids = BTreeMap::::new(); - for (role, profile) in &binding.profiles { - validate_setup_identifier("binding role", role)?; - if profile.profile.trim().is_empty() - || profile.client.trim().is_empty() - || profile.model.trim().is_empty() - { - bail!( - "host adapter `{}` profile `{role}` has blank identity fields", - binding.id - ); - } - if let Some(existing) = profile_ids.insert(profile.profile.clone(), role.clone()) { - bail!( - "host adapter `{}` roles `{existing}` and `{role}` both normalize to profile `{}`", - binding.id, - profile.profile - ); - } - if profile.client == "codex" && profile.agent_type.is_none() { - bail!( - "host adapter `{}` Codex profile `{role}` must declare agent_type", - binding.id - ); - } - validate_profile_fork_policy(&profile_from_binding_profile(profile))?; - } - for route in &binding.routes { - if route.work_type.trim().is_empty() { - bail!( - "host adapter `{}` route work_type must not be blank", - binding.id - ); - } - binding_profile_id(binding, &route.role)?; - for fallback in &route.fallback_roles { - binding_profile_id(binding, fallback)?; - } - } - let mut artifact_paths = BTreeMap::::new(); - let mut codex_agent_types = BTreeMap::::new(); - for artifact in &binding.artifacts { - if artifact.path.trim().is_empty() || artifact.kind.trim().is_empty() { - bail!( - "host adapter `{}` artifacts must declare path and kind", - binding.id - ); - } - if let Some(existing) = artifact_paths.insert(artifact.path.clone(), artifact.kind.clone()) - { - bail!( - "host adapter `{}` artifacts `{existing}` and `{}` both emit path `{}`", - binding.id, - artifact.kind, - artifact.path - ); - } - if artifact.content.trim().is_empty() { - bail!( - "host adapter `{}` artifact `{}` must not be empty", - binding.id, - artifact.path - ); - } - if artifact.path.starts_with(".codex/agents/") { - let parsed: toml::Value = toml::from_str(&artifact.content).with_context(|| { - format!( - "host adapter `{}` artifact `{}` must be TOML", - binding.id, artifact.path - ) - })?; - let agent_type = parsed - .get("name") - .and_then(toml::Value::as_str) - .ok_or_else(|| { - anyhow::anyhow!( - "host adapter `{}` artifact `{}` must declare name", - binding.id, - artifact.path - ) - })?; - if let Some(existing) = - codex_agent_types.insert(agent_type.to_string(), artifact.path.clone()) - { - bail!( - "host adapter `{}` artifacts `{existing}` and `{}` both declare Codex agent_type `{agent_type}`", - binding.id, - artifact.path - ); - } - } - } - Ok(()) -} - -fn requirement_capabilities_for_binding(binding: &HostBinding) -> Vec { - let mut capabilities = Vec::new(); - if binding.capabilities.model_override { - capabilities.push("model_override".to_string()); - } - if binding.capabilities.effort_override { - capabilities.push("reasoning_effort".to_string()); - } - if binding.capabilities.fork_none { - capabilities.push("fork_none".to_string()); - } - if binding.capabilities.fork_all { - capabilities.push("bounded_context_fork".to_string()); - } - capabilities -} - -fn profiles_for_binding(binding: &HostBinding) -> BTreeMap { - binding - .profiles - .values() - .map(|profile| { - ( - profile.profile.clone(), - profile_from_binding_profile(profile), - ) - }) - .collect() -} - -fn routes_for_binding(binding: &HostBinding) -> Result> { - binding - .routes - .iter() - .map(|route| { - Ok(Route { - selector: RouteSelector { - work_type: Some(route.work_type.clone()), - plan: None, - }, - profile: binding_profile_id(binding, &route.role)?.to_string(), - fallbacks: route - .fallback_roles - .iter() - .map(|role| binding_profile_id(binding, role).map(ToOwned::to_owned)) - .collect::>>()?, - }) - }) - .collect() -} - -fn default_route_for_binding(binding: &HostBinding) -> Result> { - binding - .default_role - .as_deref() - .map(|role| -> Result { - Ok(DefaultRoute { - profile: binding_profile_id(binding, role)?.to_string(), - fallbacks: Vec::new(), - }) - }) - .transpose() -} - -fn artifacts_for_binding(binding: &HostBinding) -> Vec { - binding - .artifacts - .iter() - .map(|artifact| SourceArtifact { - media_type: media_type_for(&artifact.path, &artifact.kind), - path: artifact.path.clone(), - mode: "create".to_string(), - content: artifact.content.clone(), - }) - .collect() -} - -fn adapter_contract_for_binding( - policy_id: &str, - binding: &HostBinding, - integration: Integration, -) -> Result { - let runtime_class = binding.runtime_class; - let semantic_roles = binding.profiles.keys().cloned().collect::>(); - let artifact_modes = if binding.artifacts.is_empty() { - Vec::new() - } else { - vec!["create".to_string(), "replace".to_string()] - }; - let dispatch_fields = dispatch_fields_for_binding(binding); - let artifact_paths = binding - .artifacts - .iter() - .map(|artifact| artifact.path.clone()) - .collect::>(); - Ok(AdapterContractV1 { - schema_version: 1, - routing_intent: RoutingIntentV1 { - schema_version: 1, - integration, - semantic_roles, - role_requests: role_intents_for_binding(binding), - required_guarantees: vec![ - "artifact_lifecycle".to_string(), - "dispatch_identity".to_string(), - ], - }, - capability: HostCapabilityV1 { - schema_version: 1, - host: binding.host.clone(), - host_version_constraints: host_version_constraints_for_binding(binding)?, - runtime_class, - runtime_behavior: runtime_behavior_for_binding(binding)?, - discovery_artifacts: binding.capability_evidence.clone(), - dispatch_fields: dispatch_fields.clone(), - model_control: ControlCapability { - level: control_level(binding.capabilities.model_override, binding.host == "codex"), - field: "model".to_string(), - evidence_required: binding.capabilities.model_override, - }, - effort_control: ControlCapability { - level: control_level(binding.capabilities.effort_override, binding.host == "codex"), - field: "effort".to_string(), - evidence_required: binding.capabilities.effort_override, - }, - context_semantics: ContextSemantics { - supports_fork_none: binding.capabilities.fork_none, - supports_fork_all: binding.capabilities.fork_all, - requires_bounded_context_for_overrides: binding.host == "codex", - }, - nesting: NestingCapability { - max_depth: 1, - level: if binding.capabilities.fork_none { - GuaranteeLevel::Deterministic - } else { - GuaranteeLevel::Unsupported - }, - }, - parallelism: ParallelismCapability { - max_parallel_children: max_parallel_children_for_binding(binding)?, - level: GuaranteeLevel::Advisory, - }, - observability: ObservabilityCapability { - requested_dispatch: GuaranteeLevel::Deterministic, - effective_identity: if binding.host == "codex" { - GuaranteeLevel::Deterministic - } else { - GuaranteeLevel::Advisory - }, - effective_model: if binding.host == "codex" { - GuaranteeLevel::Deterministic - } else { - GuaranteeLevel::Advisory - }, - raw_evidence_refs: binding.capability_evidence.clone(), - }, - guarantees: capability_guarantees_for_binding(binding), - known_limitations: binding.known_limitations.clone(), - }, - adapter: HostAdapterV1 { - schema_version: 1, - adapter_id: binding.id.clone(), - adapter_version: binding.version.clone(), - runtime_class, - accepts_intent_schema: "RoutingIntentV1".to_string(), - emitted_artifact_modes: artifact_modes, - dispatch_recipe: DispatchRecipeV1 { - invocation: match runtime_class { - RuntimeClass::NativeSubagent => "host-native-subagent".to_string(), - RuntimeClass::ExternalRunner => "external-runner-process".to_string(), - }, - required_fields: dispatch_fields, - artifact_paths, - }, - lifecycle_owner: "switchloom-managed".to_string(), - }, - dispatch_evidence: DispatchEvidenceContractV1 { - schema_version: 1, - required_verdicts: vec![ - GuaranteeLevel::Deterministic, - GuaranteeLevel::Advisory, - GuaranteeLevel::Unsupported, - ], - receipt_schema: "DispatchEvidenceV1".to_string(), - }, - planr_handoff: PlanrHandoffV1 { - schema_version: 1, - switchloom_package: npm_package_identity()?, - semantic_role_contract: format!( - "Planr supplies usage policy `{policy_id}`, work_type routes, and semantic roles; Switchloom owns the selected host binding, model, effort, fork/context policy, and generated dispatch artifacts." - ), - required_consumer_behavior: vec![ - "Consume RoutingIntentV1 as the only source for semantic_role, work_type, selected profile, model, effort, agent_type, and fork_turns inputs.".to_string(), - "Use the CLI lifecycle or SetupSpecV1 recipe commands to preview, apply, update, status, rollback, and uninstall repository-local artifacts.".to_string(), - "Record Switchloom package version, package digest, bundle_id, host version, requested dispatch, effective child identity, nonce, and receipt paths before claiming certification.".to_string(), - "Treat advisory or unsupported guarantees as uncertified until nonce-bearing live host evidence upgrades them.".to_string(), - "For the available-host release gate, Codex may be certified from deterministic effective-routing evidence; Cursor profiles may only claim advisory nonce-correlated requested-routing evidence unless the host exposes authenticated effective role/model telemetry. Claude Code, OpenCode, and Pi remain unavailable or unverified until authentic receipts exist.".to_string(), - ], - forbidden_duplicate_ownership: vec![ - "Do not maintain a Planr-side model catalog, effort catalog, preset compiler, host adapter, or fork policy normalizer for Switchloom-owned inputs.".to_string(), - "Do not re-normalize Switchloom model, effort, role, agent_type, profile, or fork policy identifiers in Planr.".to_string(), - "Do not overwrite Switchloom-managed artifacts outside preview/apply/update/rollback/uninstall.".to_string(), - "Do not mark Claude Code, OpenCode, Pi, or any advisory receipt as certified without live nonce-bearing child evidence.".to_string(), - ], - certification_report_reference: - "reports/native-host-certification///workdir/dispatch-evidence.json plus the matching bundle.json, invocation receipt, package digest, and validator stdout".to_string(), - }, - }) -} - -fn npm_package_identity() -> Result { - let package: Value = serde_json::from_str(NPM_PACKAGE_JSON)?; - let name = package - .get("name") - .and_then(Value::as_str) - .context("package.json must declare string name")?; - let version = package - .get("version") - .and_then(Value::as_str) - .context("package.json must declare string version")?; - Ok(format!("{name}@{version}")) -} - -fn adapter_contract_for_source( - source: &PolicySource, - integration: Integration, -) -> Result { - let mut contract = source.adapter_contract.clone(); - contract.routing_intent.integration = integration; - contract.adapter.emitted_artifact_modes = source - .artifacts - .iter() - .map(|artifact| artifact.mode.clone()) - .collect::>() - .into_iter() - .collect(); - validate_adapter_contract(&contract)?; - Ok(contract) -} - -fn role_intents_for_binding(binding: &HostBinding) -> Vec { - binding - .profiles - .iter() - .map(|(role, profile)| RoutingRoleIntentV1 { - semantic_role: role.clone(), - requested_model: profile.model.clone(), - requested_effort: profile.effort.clone(), - instructions: format!("Route `{role}` through `{}`.", profile.profile), - }) - .collect() -} - -fn role_intents_for_profiles(profiles: &BTreeMap) -> Vec { - profiles - .iter() - .map(|(role, profile)| RoutingRoleIntentV1 { - semantic_role: role.clone(), - requested_model: profile.model.clone(), - requested_effort: profile.effort.clone(), - instructions: format!("Route `{role}` through `{}`.", profile.client), - }) - .collect() -} - -fn control_level(supported: bool, deterministic: bool) -> GuaranteeLevel { - match (supported, deterministic) { - (true, true) => GuaranteeLevel::Deterministic, - (true, false) => GuaranteeLevel::Advisory, - (false, _) => GuaranteeLevel::Unsupported, - } -} - -fn codex_v2_runtime_evidence() -> Result { - let evidence: CodexV2RuntimeEvidence = serde_json::from_str(CODEX_V2_RUNTIME_EVIDENCE_JSON) - .context("Codex V2 runtime evidence must be valid JSON")?; - validate_codex_v2_runtime_evidence(&evidence)?; - Ok(evidence) -} - -fn validate_codex_v2_runtime_evidence(evidence: &CodexV2RuntimeEvidence) -> Result<()> { - if evidence.schema_version != 1 { - bail!("unsupported Codex V2 runtime evidence schema_version"); - } - if evidence.evidence_id.trim().is_empty() - || evidence.observed_at.trim().is_empty() - || evidence.installed_version.command != "codex --version" - || evidence.installed_version.stdout.trim().is_empty() - || evidence.backend_selection_owner.trim().is_empty() - || evidence - .trust_and_discovery - .trust_boundary - .trim() - .is_empty() - || evidence - .trust_and_discovery - .discovery_behavior - .trim() - .is_empty() - || evidence.parallelism.source.trim().is_empty() - { - bail!("Codex V2 runtime evidence fields must not be blank"); - } - if evidence.installed_version.stdout_sha256 - != sha256(format!("{}\n", evidence.installed_version.stdout).as_bytes()) - { - bail!("Codex V2 runtime evidence installed_version stdout digest mismatch"); - } - if evidence.runtime_class != RuntimeClass::NativeSubagent { - bail!("Codex V2 runtime evidence must describe native-subagent"); - } - if evidence.parallelism.max_parallel_children != 3 { - bail!("Codex V2 runtime evidence must record three parallel child slots"); - } - if !evidence.shared_filesystem - || !evidence.delegation_modes.explicit_agent_type_dispatch - || !evidence.delegation_modes.ultra_auto_delegation - || !evidence - .delegation_modes - .automatic_delegation_requires_ultra - { - bail!("Codex V2 runtime evidence must record filesystem and delegation guarantees"); - } - for (field, values) in [ - ("switchloom_ownership", &evidence.switchloom_ownership), - ("codex_ownership", &evidence.codex_ownership), - ("role_precedence", &evidence.role_precedence), - ("negative_contracts", &evidence.negative_contracts), - ] { - if values.is_empty() || values.iter().any(|value| value.trim().is_empty()) { - bail!("Codex V2 runtime evidence must record {field}"); - } - } - for claim in [ - "installed_version", - "backend_selection_owner", - "trust_and_discovery", - "parallelism", - "role_precedence", - "shared_filesystem", - "delegation_modes", - ] { - let Some(records) = evidence.claim_provenance.get(claim) else { - bail!("Codex V2 runtime evidence missing provenance for {claim}"); - }; - if records.is_empty() { - bail!("Codex V2 runtime evidence has incomplete provenance for {claim}"); - } - for record in records { - validate_codex_claim_provenance_record(evidence, claim, record)?; - } - } - Ok(()) -} - -fn validate_codex_claim_provenance_record( - evidence: &CodexV2RuntimeEvidence, - claim: &str, - record: &CodexClaimProvenance, -) -> Result<()> { - if record.kind.trim().is_empty() - || record.source.trim().is_empty() - || record.observed_at.trim().is_empty() - || record.codex_version != evidence.installed_version.stdout - { - bail!("Codex V2 runtime evidence has incomplete provenance for {claim}"); - } - if record.source_url.as_deref().unwrap_or("").trim().is_empty() - && record - .source_path - .as_deref() - .unwrap_or("") - .trim() - .is_empty() - { - bail!( - "Codex V2 runtime evidence provenance for {claim} must include source_url or source_path" - ); - } - let Some(raw_output) = record.raw_output.as_deref() else { - bail!("Codex V2 runtime evidence provenance for {claim} must include raw output"); - }; - let Some(raw_output_sha256) = record.raw_output_sha256.as_deref() else { - bail!("Codex V2 runtime evidence provenance for {claim} must include raw output digest"); - }; - if raw_output_sha256 != sha256(raw_output.as_bytes()) { - bail!("Codex V2 runtime evidence provenance raw output digest mismatch for {claim}"); - } - let expected_value = codex_claim_observed_value(evidence, claim)?; - if record.observed_value != expected_value { - bail!("Codex V2 runtime evidence provenance observed value mismatch for {claim}"); - } - if record.required_raw_fragments.is_empty() - || record - .required_raw_fragments - .iter() - .any(|fragment| fragment.trim().is_empty()) - { - bail!("Codex V2 runtime evidence provenance for {claim} must bind raw fragments"); - } - for fragment in codex_claim_required_raw_fragments(evidence, claim)? { - if !record - .required_raw_fragments - .iter() - .any(|recorded| recorded == &fragment) - { - bail!("Codex V2 runtime evidence provenance for {claim} missing required raw fragment"); - } - if !raw_output.contains(&fragment) { - bail!("Codex V2 runtime evidence raw capture does not support {claim}"); - } - } - for fragment in &record.required_raw_fragments { - if !raw_output.contains(fragment) { - bail!( - "Codex V2 runtime evidence raw capture does not contain declared fragment for {claim}" - ); - } - } - match record.kind.as_str() { - "host-command" => { - if claim != "installed_version" - || record.source != evidence.installed_version.command - || raw_output != format!("{}\n", evidence.installed_version.stdout) - || raw_output_sha256 != evidence.installed_version.stdout_sha256 - { - bail!("Codex V2 runtime evidence installed_version provenance mismatch"); - } - } - "source-document" => { - if record.source_url.as_deref().unwrap_or("").trim().is_empty() { - bail!( - "Codex V2 runtime evidence source-document provenance for {claim} must include source_url" - ); - } - } - "session-runtime-contract" => { - if record - .source_path - .as_deref() - .unwrap_or("") - .trim() - .is_empty() - { - bail!( - "Codex V2 runtime evidence session-runtime provenance for {claim} must include source_path" - ); - } - } - other => { - bail!("Codex V2 runtime evidence unsupported provenance kind `{other}` for {claim}") - } - } - validate_codex_claim_source_identity(claim, record)?; - Ok(()) -} - -fn validate_codex_claim_source_identity(claim: &str, record: &CodexClaimProvenance) -> Result<()> { - let source_url = record.source_url.as_deref(); - let source_path = record.source_path.as_deref(); - let matches = match claim { - "installed_version" => { - record.kind == "host-command" - && record.source == "codex --version" - && source_path == Some("local-shell:codex --version") - && source_url.is_none() - } - "backend_selection_owner" => { - record.kind == "source-document" - && source_url == Some("https://developers.openai.com/codex/config-reference") - && source_path.is_none() - } - "trust_and_discovery" => { - record.kind == "source-document" - && source_url == Some("https://developers.openai.com/codex/config-reference") - && source_path == Some("https://developers.openai.com/codex/subagents") - } - "parallelism" => { - record.kind == "session-runtime-contract" - && source_path == Some("current-session:developer-collaboration-runtime") - && source_url.is_none() - } - "role_precedence" => { - record.kind == "source-document" - && source_url == Some("https://developers.openai.com/codex/subagents") - && source_path.is_none() - } - "shared_filesystem" => { - record.kind == "session-runtime-contract" - && source_path == Some("current-session:developer-collaboration-runtime") - && source_url.is_none() - } - "delegation_modes" => { - record.kind == "source-document" - && source_url == Some("https://developers.openai.com/codex/subagents") - && source_path == Some("https://developers.openai.com/codex/models") - } - _ => false, - }; - if !matches { - bail!("Codex V2 runtime evidence provenance source identity mismatch for {claim}"); - } - Ok(()) -} - -fn codex_claim_observed_value(evidence: &CodexV2RuntimeEvidence, claim: &str) -> Result { - match claim { - "installed_version" => Ok(json!(evidence.installed_version.stdout)), - "backend_selection_owner" => Ok(json!(evidence.backend_selection_owner)), - "trust_and_discovery" => Ok(json!({ - "trust_boundary": evidence.trust_and_discovery.trust_boundary, - "discovery_behavior": evidence.trust_and_discovery.discovery_behavior, - })), - "parallelism" => Ok(json!({ - "max_parallel_children": evidence.parallelism.max_parallel_children, - "source": evidence.parallelism.source, - })), - "role_precedence" => Ok(json!(evidence.role_precedence)), - "shared_filesystem" => Ok(json!(evidence.shared_filesystem)), - "delegation_modes" => Ok(json!(evidence.delegation_modes)), - _ => bail!("Codex V2 runtime evidence unknown provenance claim `{claim}`"), - } -} - -fn codex_claim_required_raw_fragments( - evidence: &CodexV2RuntimeEvidence, - claim: &str, -) -> Result> { - match claim { - "installed_version" => Ok(vec![evidence.installed_version.stdout.clone()]), - "backend_selection_owner" => Ok(vec![ - "Project-scoped config cannot override machine-local provider, auth".to_string(), - "configuration profile selection".to_string(), - ]), - "trust_and_discovery" => Ok(vec![ - "project-scoped config files only when you trust the project".to_string(), - "standalone TOML files under .codex/agents/".to_string(), - ]), - "parallelism" => Ok(vec![ - "4 available concurrency slots".to_string(), - "including the root thread".to_string(), - "at most 3 parallel child agents".to_string(), - ]), - "role_precedence" => Ok(vec![ - "reapplies the parent turn live runtime overrides".to_string(), - "sandbox and approval choices".to_string(), - "model_reasoning_effort inherit from the parent session when omitted".to_string(), - ]), - "shared_filesystem" => Ok(vec![ - "All agents share the same container and filesystem".to_string(), - "edits made by one agent are immediately visible to all other agents".to_string(), - ]), - "delegation_modes" => Ok(vec![ - "With Ultra, ChatGPT can proactively delegate work".to_string(), - "At most intelligence levels, ask for delegation explicitly".to_string(), - ]), - _ => bail!("Codex V2 runtime evidence unknown provenance claim `{claim}`"), - } -} - -fn codex_v2_host_version(evidence: &CodexV2RuntimeEvidence) -> String { - evidence.installed_version.stdout.clone() -} - -fn max_parallel_children_for_binding(binding: &HostBinding) -> Result { - if binding.host == "codex" { - Ok(codex_v2_runtime_evidence()? - .parallelism - .max_parallel_children) - } else { - Ok(1) - } -} - -fn host_version_constraints_for_binding(binding: &HostBinding) -> Result { - if binding.host == "codex" { - let evidence = codex_v2_runtime_evidence()?; - let host_version = codex_v2_host_version(&evidence); - return Ok(HostVersionConstraints { - minimum: Some(host_version.clone()), - maximum: Some(host_version), - evidence_max_age_seconds: binding.verification.max_age_seconds.unwrap_or(0), - }); - } - Ok(HostVersionConstraints { - minimum: None, - maximum: None, - evidence_max_age_seconds: binding.verification.max_age_seconds.unwrap_or(0), - }) -} - -fn codex_v2_runtime_evidence_reference() -> String { - format!( - "docs/codex-v2-runtime-evidence.json#sha256:{}", - sha256(CODEX_V2_RUNTIME_EVIDENCE_JSON.as_bytes()) - ) -} - -fn runtime_behavior_for_binding(binding: &HostBinding) -> Result { - if binding.host == "codex" { - let evidence = codex_v2_runtime_evidence()?; - return Ok(RuntimeBehaviorV1 { - capability_version: evidence.evidence_id, - installed_host_version_source: format!( - "{} via {}", - evidence.installed_version.stdout, evidence.installed_version.command - ), - backend_selection_source: evidence.backend_selection_owner, - trust_boundary: evidence.trust_and_discovery.trust_boundary, - discovery_behavior: evidence.trust_and_discovery.discovery_behavior, - role_precedence: evidence.role_precedence, - shared_filesystem: evidence.shared_filesystem, - delegation_modes: evidence.delegation_modes, - source_references: vec![codex_v2_runtime_evidence_reference()], - }); - } - - let source_references = if binding.capability_evidence.is_empty() { - vec![format!("host-binding:{}", binding.id)] - } else { - binding.capability_evidence.clone() - }; - - Ok(RuntimeBehaviorV1 { - capability_version: binding.verification.id.clone(), - installed_host_version_source: format!("{} --version", binding.host), - backend_selection_source: "host account, workspace, provider, or runner configuration outside Switchloom ownership".to_string(), - trust_boundary: "repository-local generated artifacts are Switchloom-managed; host authentication, account policy, and execution state are host-owned".to_string(), - discovery_behavior: "host-specific project artifact discovery".to_string(), - role_precedence: vec![ - "Switchloom declares requested semantic role, profile, model, effort, and artifacts".to_string(), - "the host runtime remains the authority for effective execution".to_string(), - ], - shared_filesystem: binding.runtime_class == RuntimeClass::NativeSubagent, - delegation_modes: DelegationModesV1 { - explicit_agent_type_dispatch: binding.capabilities.fork_none, - ultra_auto_delegation: false, - automatic_delegation_requires_ultra: false, - }, - source_references, - }) -} - -fn dispatch_fields_for_binding(binding: &HostBinding) -> Vec { - let mut fields = vec!["profile".to_string(), "model".to_string()]; - if binding.runtime_class == RuntimeClass::ExternalRunner { - fields.push("provider".to_string()); - } - if binding.capabilities.effort_override { - fields.push("effort".to_string()); - } - if binding - .profiles - .values() - .any(|profile| profile.agent_type.is_some()) - { - fields.push("agent_type".to_string()); - } - if binding.capabilities.fork_none || binding.capabilities.fork_all { - fields.push("fork_turns".to_string()); - } - if binding.runtime_class == RuntimeClass::ExternalRunner { - fields.push("isolation".to_string()); - fields.push("task".to_string()); - } - fields -} - -fn capability_guarantees_for_binding( - binding: &HostBinding, -) -> BTreeMap { - BTreeMap::from([ - ( - "artifact_lifecycle".to_string(), - CapabilityGuarantee { - level: GuaranteeLevel::Deterministic, - reason: "Switchloom owns preview/apply/update/rollback/uninstall for managed artifacts.".to_string(), - evidence_required: false, - }, - ), - ( - "dispatch_identity".to_string(), - CapabilityGuarantee { - level: if binding.capabilities.fork_none { - GuaranteeLevel::Deterministic - } else { - GuaranteeLevel::Unsupported - }, - reason: if binding.capabilities.fork_none { - "Adapter can emit explicit local child identity and non-all context policy.".to_string() - } else { - "Host binding has no explicit non-all child dispatch contract.".to_string() - }, - evidence_required: binding.capabilities.fork_none, - }, - ), - ( - "model_selection".to_string(), - CapabilityGuarantee { - level: if binding.capabilities.model_override && binding.host == "codex" { - GuaranteeLevel::Deterministic - } else if binding.capabilities.model_override { - GuaranteeLevel::Advisory - } else { - GuaranteeLevel::Unsupported - }, - reason: if binding.capabilities.model_override && binding.host == "codex" { - "Codex project agent files declare the child model; live evidence is still required for certification.".to_string() - } else if binding.capabilities.model_override { - "Host accepts a requested model but may apply account, workspace, or runtime precedence.".to_string() - } else { - "Host binding exposes no model override control.".to_string() - }, - evidence_required: binding.capabilities.model_override, - }, - ), - ( - "effort_selection".to_string(), - CapabilityGuarantee { - level: if binding.capabilities.effort_override && binding.host == "codex" { - GuaranteeLevel::Deterministic - } else if binding.capabilities.effort_override { - GuaranteeLevel::Advisory - } else { - GuaranteeLevel::Unsupported - }, - reason: if binding.capabilities.effort_override && binding.host == "codex" { - "Codex project agent files declare model_reasoning_effort for role-local child dispatch.".to_string() - } else if binding.capabilities.effort_override { - "Host accepts an effort-like field but effective precedence must be proven separately.".to_string() - } else { - "Host binding exposes no effort override control.".to_string() - }, - evidence_required: binding.capabilities.effort_override, - }, - ), - ( - "effective_runtime_evidence".to_string(), - CapabilityGuarantee { - level: GuaranteeLevel::Advisory, - reason: "Generated bundles declare requested routing; certification must persist requested-versus-effective host evidence.".to_string(), - evidence_required: true, - }, - ), - ]) -} - -fn validate_adapter_contract(contract: &AdapterContractV1) -> Result<()> { - if contract.schema_version != 1 - || contract.routing_intent.schema_version != 1 - || contract.capability.schema_version != 1 - || contract.adapter.schema_version != 1 - || contract.dispatch_evidence.schema_version != 1 - || contract.planr_handoff.schema_version != 1 - { - bail!("unsupported adapter contract schema_version"); - } - if contract.routing_intent.semantic_roles.is_empty() { - bail!("adapter contract must declare semantic roles"); - } - if contract.routing_intent.role_requests.is_empty() { - bail!("adapter contract must declare role requests"); - } - let semantic_roles = contract - .routing_intent - .semantic_roles - .iter() - .cloned() - .collect::>(); - for request in &contract.routing_intent.role_requests { - if !semantic_roles.contains(&request.semantic_role) { - bail!( - "adapter contract role request references unknown semantic role `{}`", - request.semantic_role - ); - } - if request.requested_model.trim().is_empty() || request.instructions.trim().is_empty() { - bail!("adapter contract role requests must include model and instructions"); - } - } - if contract.capability.host.trim().is_empty() || contract.adapter.adapter_id.trim().is_empty() { - bail!("adapter contract host and adapter_id must not be blank"); - } - if contract.capability.runtime_class != contract.adapter.runtime_class { - bail!("adapter contract runtime_class mismatch"); - } - validate_runtime_behavior(&contract.capability)?; - if contract.adapter.accepts_intent_schema != "RoutingIntentV1" { - bail!("adapter contract must accept RoutingIntentV1"); - } - if contract.capability.model_control.field.trim().is_empty() - || contract.capability.effort_control.field.trim().is_empty() - { - bail!("adapter contract control fields must not be blank"); - } - if contract - .adapter - .dispatch_recipe - .invocation - .trim() - .is_empty() - { - bail!("adapter contract dispatch recipe invocation must not be blank"); - } - if contract.adapter.dispatch_recipe.required_fields.is_empty() { - bail!("adapter contract dispatch recipe must declare required fields"); - } - for required in &contract.routing_intent.required_guarantees { - let Some(guarantee) = contract.capability.guarantees.get(required) else { - bail!("adapter contract requires undeclared guarantee `{required}`"); - }; - if guarantee.level == GuaranteeLevel::Unsupported { - bail!("adapter contract required guarantee `{required}` is unsupported"); - } - } - for (name, guarantee) in &contract.capability.guarantees { - if name.trim().is_empty() || guarantee.reason.trim().is_empty() { - bail!("adapter contract guarantee names and reasons must not be blank"); - } - } - let verdicts = contract - .dispatch_evidence - .required_verdicts - .iter() - .copied() - .collect::>(); - for verdict in [ - GuaranteeLevel::Deterministic, - GuaranteeLevel::Advisory, - GuaranteeLevel::Unsupported, - ] { - if !verdicts.contains(&verdict) { - bail!("adapter contract dispatch evidence must enumerate all guarantee verdicts"); - } - } - if contract.dispatch_evidence.receipt_schema != "DispatchEvidenceV1" { - bail!("adapter contract dispatch evidence must reference DispatchEvidenceV1"); - } - Ok(()) -} - -fn validate_runtime_behavior(capability: &HostCapabilityV1) -> Result<()> { - let behavior = &capability.runtime_behavior; - if behavior.capability_version.trim().is_empty() - || behavior.installed_host_version_source.trim().is_empty() - || behavior.backend_selection_source.trim().is_empty() - || behavior.trust_boundary.trim().is_empty() - || behavior.discovery_behavior.trim().is_empty() - { - bail!("adapter contract runtime behavior fields must not be blank"); - } - if behavior.role_precedence.is_empty() - || behavior - .role_precedence - .iter() - .any(|entry| entry.trim().is_empty()) - { - bail!("adapter contract runtime behavior must declare role precedence"); - } - if behavior.source_references.is_empty() - || behavior - .source_references - .iter() - .any(|entry| entry.trim().is_empty()) - { - bail!("adapter contract runtime behavior must declare source references"); - } - if capability.host == "codex" { - let evidence = codex_v2_runtime_evidence()?; - let expected_source_reference = codex_v2_runtime_evidence_reference(); - let expected_host_version = codex_v2_host_version(&evidence); - if behavior.capability_version != evidence.evidence_id { - bail!("Codex V2 runtime capability_version must match parsed evidence_id"); - } - if behavior.installed_host_version_source - != format!( - "{} via {}", - evidence.installed_version.stdout, evidence.installed_version.command - ) - { - bail!( - "Codex V2 runtime installed host version must match parsed evidence command output" - ); - } - if capability.host_version_constraints.minimum.as_deref() - != Some(expected_host_version.as_str()) - || capability.host_version_constraints.maximum.as_deref() - != Some(expected_host_version.as_str()) - { - bail!("Codex V2 host_version_constraints must freeze the parsed evidence version"); - } - if !capability - .discovery_artifacts - .iter() - .any(|artifact| artifact == &evidence.evidence_id) - { - bail!("Codex V2 discovery artifacts must include the parsed evidence id"); - } - if behavior.source_references != vec![expected_source_reference] { - bail!( - "Codex V2 runtime source reference must match the digest-bound evidence artifact" - ); - } - if capability.parallelism.max_parallel_children - != evidence.parallelism.max_parallel_children - { - bail!("Codex V2 runtime must declare exactly the parsed evidence child slots"); - } - if behavior.backend_selection_source != evidence.backend_selection_owner { - bail!("Codex V2 backend selection source must match parsed evidence"); - } - if behavior.trust_boundary != evidence.trust_and_discovery.trust_boundary - || behavior.discovery_behavior != evidence.trust_and_discovery.discovery_behavior - { - bail!("Codex V2 trust and discovery behavior must match parsed evidence"); - } - if behavior.role_precedence != evidence.role_precedence { - bail!("Codex V2 role precedence must match parsed evidence"); - } - if behavior.shared_filesystem != evidence.shared_filesystem { - bail!("Codex V2 shared filesystem flag must match parsed evidence"); - } - if behavior.delegation_modes != evidence.delegation_modes { - bail!("Codex V2 delegation modes must match parsed evidence"); - } - if !evidence - .codex_ownership - .iter() - .any(|owner| owner.contains("execution timing and orchestration")) - || !evidence - .switchloom_ownership - .iter() - .any(|owner| owner.contains("semantic role compilation")) - { - bail!("Codex V2 ownership boundaries must be recorded in parsed evidence"); - } - if capability.runtime_class != RuntimeClass::NativeSubagent { - bail!("Codex V2 runtime must be a native-subagent contract"); - } - if !behavior.shared_filesystem { - bail!("Codex V2 runtime must declare shared filesystem behavior"); - } - if !behavior.delegation_modes.explicit_agent_type_dispatch { - bail!("Codex V2 runtime must declare explicit agent_type dispatch"); - } - if !behavior.delegation_modes.ultra_auto_delegation - || !behavior - .delegation_modes - .automatic_delegation_requires_ultra - { - bail!("Codex V2 runtime must declare Ultra automatic delegation boundaries"); - } - } - Ok(()) -} - -pub fn validate_dispatch_evidence(evidence: &DispatchEvidenceV1) -> Result<()> { - if evidence.schema_version != 1 { - bail!("unsupported dispatch evidence schema_version"); - } - if evidence.package_digest.trim().is_empty() - || evidence.host_version.trim().is_empty() - || evidence.nonce.trim().is_empty() - { - bail!("dispatch evidence package_digest, host_version, and nonce must not be blank"); - } - if evidence.requested_dispatch.semantic_role.trim().is_empty() - || evidence.requested_dispatch.profile.trim().is_empty() - || evidence.requested_dispatch.model.trim().is_empty() - { - bail!("dispatch evidence requested dispatch must name role, profile, and model"); - } - if evidence.child_identity.host.trim().is_empty() - || evidence.child_identity.role.trim().is_empty() - || evidence.child_identity.agent_role.trim().is_empty() - { - bail!("dispatch evidence child identity must name host, role, and agent_role"); - } - if evidence.raw_evidence_refs.is_empty() - || evidence - .raw_evidence_refs - .iter() - .any(|reference| reference.trim().is_empty()) - { - bail!("dispatch evidence must include raw evidence references"); - } - if evidence.verdict == GuaranteeLevel::Deterministic { - let effective_model = evidence.effective_model.as_deref().ok_or_else(|| { - anyhow::anyhow!("deterministic dispatch evidence must include observed effective_model") - })?; - if effective_model != evidence.requested_dispatch.model { - bail!( - "deterministic dispatch evidence effective_model `{effective_model}` does not match requested model `{}`", - evidence.requested_dispatch.model - ); - } - if let Some(requested_effort) = evidence.requested_dispatch.effort.as_deref() { - let effective_effort = evidence.effective_effort.as_deref().ok_or_else(|| { - anyhow::anyhow!( - "deterministic dispatch evidence must include observed effective_effort" - ) - })?; - if effective_effort != requested_effort { - bail!( - "deterministic dispatch evidence effective_effort `{effective_effort}` does not match requested effort `{requested_effort}`" - ); - } - } - } - Ok(()) -} - -pub fn validate_dispatch_evidence_for_adapter( - evidence: &DispatchEvidenceV1, - contract: &AdapterContractV1, -) -> Result<()> { - validate_adapter_contract(contract)?; - validate_dispatch_evidence(evidence)?; - if evidence.child_identity.host != contract.capability.host { - bail!( - "dispatch evidence host `{}` does not match adapter host `{}`", - evidence.child_identity.host, - contract.capability.host - ); - } - if !contract - .dispatch_evidence - .required_verdicts - .contains(&evidence.verdict) - { - bail!("dispatch evidence verdict is not allowed by adapter contract"); - } - let request = contract - .routing_intent - .role_requests - .iter() - .find(|request| request.semantic_role == evidence.requested_dispatch.semantic_role) - .ok_or_else(|| { - anyhow::anyhow!( - "dispatch evidence role `{}` is not declared by adapter contract", - evidence.requested_dispatch.semantic_role - ) - })?; - if evidence.child_identity.role != evidence.requested_dispatch.semantic_role { - bail!( - "dispatch evidence child role `{}` does not match requested semantic role `{}`", - evidence.child_identity.role, - evidence.requested_dispatch.semantic_role - ); - } - if evidence.requested_dispatch.model != request.requested_model { - bail!( - "dispatch evidence requested model `{}` does not match adapter role request `{}`", - evidence.requested_dispatch.model, - request.requested_model - ); - } - if evidence.requested_dispatch.effort != request.requested_effort { - bail!("dispatch evidence requested effort does not match adapter role request"); - } - if evidence.verdict == GuaranteeLevel::Deterministic { - require_deterministic_observation(evidence, contract)?; - require_live_nonce_observation(evidence, contract)?; - } - Ok(()) -} - -fn require_deterministic_observation( - evidence: &DispatchEvidenceV1, - contract: &AdapterContractV1, -) -> Result<()> { - if contract.capability.observability.effective_model != GuaranteeLevel::Deterministic { - bail!( - "deterministic dispatch evidence for adapter `{}` is not allowed because effective model observability is {:?}", - contract.adapter.adapter_id, - contract.capability.observability.effective_model - ); - } - if evidence.requested_dispatch.effort.is_some() - && contract.capability.effort_control.level != GuaranteeLevel::Deterministic - { - bail!( - "deterministic dispatch evidence for adapter `{}` is not allowed because effective effort control is {:?}", - contract.adapter.adapter_id, - contract.capability.effort_control.level - ); - } - Ok(()) -} - -fn require_live_nonce_observation( - evidence: &DispatchEvidenceV1, - contract: &AdapterContractV1, -) -> Result<()> { - if contract.capability.observability.effective_model == GuaranteeLevel::Deterministic - && !evidence.raw_evidence_refs.iter().any(|reference| { - reference.starts_with("host-output:") || reference.starts_with("codex-session:") - }) - { - bail!( - "deterministic dispatch evidence for adapter `{}` requires a live host output reference", - contract.adapter.adapter_id - ); - } - if evidence - .raw_evidence_refs - .iter() - .any(|reference| reference.contains("status:not-run")) - { - bail!( - "dispatch evidence for adapter `{}` cannot cite a not-run host output", - contract.adapter.adapter_id - ); - } - Ok(()) -} - -pub fn validate_dispatch_evidence_json_for_bundle( - evidence_json: &str, - bundle_json: &str, -) -> Result<()> { - let bundle: RoutingBundleV1 = serde_json::from_str(bundle_json)?; - validate_bundle(&bundle)?; - let contract = bundle - .adapter_contract - .as_ref() - .ok_or_else(|| anyhow::anyhow!("bundle is missing adapter_contract"))?; - let evidence: DispatchEvidenceV1 = serde_json::from_str(evidence_json)?; - validate_dispatch_evidence_for_adapter(&evidence, contract) -} - -fn source_from_setup_spec(spec: &SetupSpecV1) -> Result { - validate_setup_spec(spec)?; - let binding = binding_for_selector(&spec.host)?; - let mut source = show_policy(&spec.usage_policy, &binding.id)?; - if setup_matches_binding(spec, &binding)? { - return Ok(source); - } - let adapter = - compile_setup_adapter(source.policy_id.as_str(), &binding, spec, &source.artifacts)?; - source.requirements = adapter.requirements; - source.profiles = adapter.profiles; - source.routes = adapter.routes; - source.route_default = adapter.route_default; - source.artifacts = adapter.artifacts; - source.adapter_contract = adapter.adapter_contract; - source.evidence = EvaluationEvidence { - evaluation_ids: Vec::new(), - status: "custom-unverified".to_string(), - }; - Ok(source) -} - -fn compile_setup_adapter( - policy_id: &str, - binding: &HostBinding, - spec: &SetupSpecV1, - binding_artifacts: &[SourceArtifact], -) -> Result { - validate_host_adapter(binding)?; - let runtime_host = setup_runtime_host(binding); - let profiles = setup_profiles_from_intent(spec, binding)?; - let routes = setup_routes_from_intent(spec); - let route_default = setup_default_route_from_intent(spec); - let artifacts = setup_artifacts_from_intent( - runtime_host, - &spec.selected_roles, - binding, - binding_artifacts, - )?; - let mut adapter_contract = adapter_contract_for_binding(policy_id, binding, spec.integration)?; - adapter_contract.routing_intent.semantic_roles = profiles.keys().cloned().collect(); - adapter_contract.routing_intent.role_requests = role_intents_for_profiles(&profiles); - adapter_contract.adapter.emitted_artifact_modes = artifacts - .iter() - .map(|artifact| artifact.mode.clone()) - .collect::>() - .into_iter() - .collect(); - adapter_contract.adapter.dispatch_recipe.artifact_paths = artifacts - .iter() - .map(|artifact| artifact.path.clone()) - .collect(); - validate_adapter_contract(&adapter_contract)?; - Ok(CompiledHostAdapter { - requirements: vec![HostRequirement { - host: binding.host.clone(), - capabilities: requirement_capabilities_for_binding(binding), - }], - profiles, - routes, - route_default, - artifacts, - adapter_contract, - }) -} - -fn setup_profiles_from_intent( - spec: &SetupSpecV1, - binding: &HostBinding, -) -> Result> { - let runtime_host = setup_runtime_host(binding); - let model_catalog = setup_model_catalog(runtime_host); - spec.selected_roles - .iter() - .map(|(role, selection)| { - let option = model_catalog - .iter() - .find(|option| option.id == selection.model) - .ok_or_else(|| { - anyhow::anyhow!( - "setup role `{role}` model `{}` is not supported by host `{}`", - selection.model, - spec.host - ) - })?; - if runtime_host == "codex" - && selection.spawn.is_none() - && selection_matches_binding_profile(role, selection, binding) - { - return Ok(( - role.clone(), - profile_from_binding_profile(binding.profiles.get(role).ok_or_else(|| { - anyhow::anyhow!("setup role `{role}` is missing from binding") - })?), - )); - } - let agent_type = if runtime_host == "codex" { - Some( - selection - .spawn - .as_ref() - .ok_or_else(|| { - anyhow::anyhow!("setup role `{role}` must declare Codex spawn policy") - })? - .agent_type - .clone(), - ) - } else { - None - }; - Ok(( - role.clone(), - Profile { - client: runtime_host.to_string(), - model: selection.model.clone(), - agent_type, - effort: selection.effort.clone(), - cost_tier: Some(option.tier.to_string()), - capabilities: Vec::new(), - skill: None, - notes: Some("custom SetupSpecV1 role".to_string()), - fork_turns: selection - .spawn - .as_ref() - .map(|spawn| spawn.fork_turns.clone()), - }, - )) - }) - .collect() -} - -fn setup_routes_from_intent(spec: &SetupSpecV1) -> Vec { - spec.routes - .iter() - .map(|route| Route { - selector: RouteSelector { - work_type: Some(route.work_type.clone()), - plan: None, - }, - profile: route.role.clone(), - fallbacks: route.fallbacks.clone(), - }) - .collect() -} - -fn setup_default_route_from_intent(spec: &SetupSpecV1) -> Option { - spec.route_default.as_ref().map(|default| DefaultRoute { - profile: default.role.clone(), - fallbacks: default.fallbacks.clone(), - }) -} - -fn setup_matches_binding(spec: &SetupSpecV1, binding: &HostBinding) -> Result { - if canonical_binding_id(&spec.host) != binding.id { - return Ok(false); - } - if spec.selected_roles.len() != binding.profiles.len() { - return Ok(false); - } - for (role, binding_profile) in &binding.profiles { - let Some(selection) = spec.selected_roles.get(role) else { - return Ok(false); - }; - if selection.model != binding_profile.model - || selection.effort != binding_profile.effort - || !selection_spawn_matches_binding( - setup_runtime_host(binding), - role, - selection, - binding_profile, - ) - { - return Ok(false); - } - } - if spec.routes.len() != binding.routes.len() { - return Ok(false); - } - for (setup_route, binding_route) in spec.routes.iter().zip(binding.routes.iter()) { - if setup_route.work_type != binding_route.work_type - || setup_route.role != binding_route.role - || setup_route.fallbacks != binding_route.fallback_roles - { - return Ok(false); - } - } - Ok(match (&spec.route_default, &binding.default_role) { - (None, None) => true, - (Some(setup), Some(binding_role)) => { - setup.role == *binding_role && setup.fallbacks.is_empty() - } - _ => false, - }) -} - -fn setup_artifacts_from_intent( - runtime_host: &str, - roles: &BTreeMap, - binding: &HostBinding, - binding_artifacts: &[SourceArtifact], -) -> Result> { - roles - .iter() - .map(|(role, selection)| { - if runtime_host == "codex" - && selection.spawn.is_none() - && selection_matches_binding_profile(role, selection, binding) - { - return binding_artifact_for_role(binding, binding_artifacts, role); - } - let file_role = identifier_token(role); - let path = setup_artifact_path(runtime_host, role, selection)?; - let (kind, content) = match runtime_host { - "codex" => { - let spawn = selection.spawn.as_ref().ok_or_else(|| { - anyhow::anyhow!("setup role `{role}` must declare Codex spawn policy") - })?; - let agent_type = spawn.agent_type.clone(); - let effort = selection - .effort - .clone() - .unwrap_or_else(|| "medium".to_string()); - ( - "codex_agent", - format!( - "name = \"{agent_type}\"\ndescription = \"Switchloom custom {role} role.\"\nmodel = \"{}\"\nmodel_reasoning_effort = \"{effort}\"\n\ndeveloper_instructions = \"\"\"\nSpawn with agent_type `{agent_type}`, task_name `{}`, and fork_turns `{}`. The live parent permission profile remains authoritative; this role declares routing intent and expected ownership evidence, not filesystem permission enforcement.\n\"\"\"\n", - selection.model, spawn.task_name, spawn.fork_turns.mode - ), - ) - } - "claude-code" => { - let effort = selection - .effort - .clone() - .unwrap_or_else(|| "medium".to_string()); - ( - "claude_agent", - format!( - "---\nname: switchloom-{file_role}\nmodel: {}\neffort: {effort}\n---\nFollow the repository-local Switchloom setup role `{role}` and preserve routing evidence.\n", - selection.model - ), - ) - } - "cursor" => { - ( - "cursor_agent", - format!( - "---\nname: switchloom-{file_role}\nmodel: {}\n---\nFollow the repository-local Switchloom setup role `{role}` and preserve routing evidence.\n", - selection.model - ), - ) - } - "opencode" => { - let effort = selection - .effort - .clone() - .unwrap_or_else(|| "medium".to_string()); - ( - "opencode_agent", - format!( - "---\ndescription: Switchloom custom {role} role.\nmode: subagent\nmodel: {}\nvariant: {effort}\npermission:\n edit: allow\n bash: ask\n task:\n \"*\": deny\n---\nFollow the repository-local Switchloom setup role `{role}` and preserve routing evidence.\n", - selection.model - ), - ) - } - "pi" => { - let effort = selection - .effort - .clone() - .unwrap_or_else(|| "medium".to_string()); - let (provider, model) = selection.model.split_once('/').ok_or_else(|| { - anyhow::anyhow!( - "setup role `{role}` Pi model `{}` must use provider/model form", - selection.model - ) - })?; - let agent_type = format!("switchloom-pi-{file_role}"); - ( - "pi_workflow", - format!( - "{{\n \"schema_version\": 1,\n \"workflow\": \"switchloom-{file_role}\",\n \"runner\": \"pi\",\n \"runtime_class\": \"external-runner\",\n \"arguments\": {{\n \"agent_type\": \"{agent_type}\",\n \"provider_model\": \"{}\",\n \"thinking\": \"{effort}\",\n \"isolation\": {{\n \"session\": \"none\",\n \"tools\": \"none\",\n \"extensions\": \"none\",\n \"skills\": \"none\",\n \"agent_dir\": \"report-workdir/.pi-agent\"\n }},\n \"task\": {{\n \"semantic_role\": \"{role}\",\n \"profile\": \"{agent_type}\",\n \"returns\": \"nonce-only\"\n }}\n }},\n \"process\": {{\n \"argv\": [\"pi\", \"--print\", \"--no-session\", \"--no-tools\", \"--no-extensions\", \"--no-skills\", \"--provider\", \"{provider}\", \"--model\", \"{model}\", \"--thinking\", \"{effort}\"],\n \"state_boundary\": \"PI_CODING_AGENT_DIR is set to a report-local directory for every certification run\"\n }},\n \"security\": {{\n \"filesystem_tools\": \"disabled\",\n \"session_persistence\": \"disabled\",\n \"native_subagents\": \"not used\",\n \"certification_requirement\": \"A persisted workflow receipt must include the dynamic nonce returned by a live Pi child process before advisory runtime evidence is accepted.\"\n }}\n}}\n", - selection.model - ), - ) - } - "mixed-host" => { - ( - "routing_role", - format!( - "role = \"{role}\"\nmodel = \"{}\"\n{}\n", - selection.model, - selection - .effort - .as_ref() - .map(|effort| format!("effort = \"{effort}\"")) - .unwrap_or_default() - ), - ) - } - other => bail!("unsupported setup runtime host `{other}`"), - }; - let media_type = media_type_for(&path, kind); - Ok(SourceArtifact { - path, - media_type, - mode: "replace".to_string(), - content, - }) - }) - .collect() -} - -fn profile_from_binding_profile(profile: &BindingProfile) -> Profile { - Profile { - client: profile.client.clone(), - model: profile.model.clone(), - agent_type: profile.agent_type.clone(), - effort: profile.effort.clone(), - cost_tier: profile.cost_tier.clone(), - capabilities: Vec::new(), - skill: None, - notes: None, - fork_turns: profile.fork_turns.clone(), - } -} - -fn binding_artifact_for_role( - binding: &HostBinding, - artifacts: &[SourceArtifact], - role: &str, -) -> Result { - let profile = binding - .profiles - .get(role) - .ok_or_else(|| anyhow::anyhow!("setup role `{role}` is missing from binding"))?; - let agent_type = profile - .agent_type - .as_ref() - .ok_or_else(|| anyhow::anyhow!("setup role `{role}` has no binding agent_type"))?; - artifacts - .iter() - .find(|artifact| { - artifact - .content - .contains(&format!("name = \"{agent_type}\"")) - }) - .cloned() - .ok_or_else(|| anyhow::anyhow!("binding role `{role}` has no generated host artifact")) -} - -fn binding_artifact_path_for_role(binding: &HostBinding, role: &str) -> Result { - let profile = binding - .profiles - .get(role) - .ok_or_else(|| anyhow::anyhow!("setup role `{role}` is missing from binding"))?; - let agent_type = profile - .agent_type - .as_ref() - .ok_or_else(|| anyhow::anyhow!("setup role `{role}` has no binding agent_type"))?; - binding - .artifacts - .iter() - .find(|artifact| { - artifact - .content - .contains(&format!("name = \"{agent_type}\"")) - }) - .map(|artifact| artifact.path.clone()) - .ok_or_else(|| anyhow::anyhow!("binding role `{role}` has no generated host artifact")) -} - -fn validate_profile_fork_policy(profile: &Profile) -> Result<()> { - let requires_explicit_fork = profile.client == "codex" - && profile.agent_type.is_some() - && profile.agent_type.as_deref() != Some("model_routing_sol_medium"); - if !requires_explicit_fork { - return Ok(()); - } - let Some(fork_turns) = &profile.fork_turns else { - bail!( - "codex profile `{}` must declare fork_turns none or positive bounded when overriding model or effort", - profile.agent_type.as_deref().unwrap_or("") - ); - }; - match fork_turns.mode.as_str() { - "none" => Ok(()), - "bounded" => match fork_turns.turns { - Some(turns) if turns > 0 => Ok(()), - _ => bail!( - "codex profile `{}` bounded fork_turns must use positive turns", - profile.agent_type.as_deref().unwrap_or("") - ), - }, - "all" => bail!( - "codex profile `{}` must not use fork_turns all with model or effort overrides", - profile.agent_type.as_deref().unwrap_or("") - ), - other => bail!( - "codex profile `{}` has unsupported fork_turns mode `{other}`", - profile.agent_type.as_deref().unwrap_or("") - ), - } -} - -fn binding_profile_id<'a>(binding: &'a HostBinding, role: &str) -> Result<&'a str> { - binding - .profiles - .get(role) - .map(|profile| profile.profile.as_str()) - .ok_or_else(|| anyhow::anyhow!("binding route references unknown role `{role}`")) -} - -fn binding_for_selector(selector: &str) -> Result { - let binding_id = canonical_binding_id(selector); - let raw = BINDINGS - .iter() - .find(|(id, _)| *id == binding_id) - .map(|(_, raw)| *raw) - .ok_or_else(|| anyhow::anyhow!("unknown setup host `{selector}`"))?; - Ok(toml::from_str(raw)?) -} - -fn canonical_binding_id(selector: &str) -> &str { - match selector { - "codex" => "codex-openai", - "claude-code" => "claude-native", - "cursor" => "cursor-openai", - "opencode" => "opencode-native", - "pi" => "pi-external", - other => other, - } -} - -fn setup_runtime_host(binding: &HostBinding) -> &str { - binding.host.as_str() -} - -#[derive(Debug, Clone, Copy)] -struct SetupModelOption { - id: &'static str, - efforts: &'static [&'static str], - tier: &'static str, -} - -fn setup_model_catalog(host: &str) -> Vec { - match host { - "codex" => vec![ - SetupModelOption { - id: "gpt-5.6-sol", - efforts: &["low", "medium", "high", "xhigh", "ultra"], - tier: "premium", - }, - SetupModelOption { - id: "gpt-5.6-terra", - efforts: &["low", "medium", "high", "xhigh", "ultra"], - tier: "standard", - }, - SetupModelOption { - id: "gpt-5.6-luna", - efforts: &["low", "medium", "high", "xhigh"], - tier: "standard", - }, - ], - "cursor" => vec![ - SetupModelOption { - id: "gpt-5.6-sol", - efforts: &["low", "medium", "high", "xhigh", "max"], - tier: "premium", - }, - SetupModelOption { - id: "gpt-5.6-terra", - efforts: &["low", "medium", "high", "xhigh", "max"], - tier: "standard", - }, - SetupModelOption { - id: "gpt-5.6-luna", - efforts: &["low", "medium", "high", "xhigh", "max"], - tier: "standard", - }, - SetupModelOption { - id: "fable-5", - efforts: &["low", "medium", "high", "xhigh", "max"], - tier: "premium", - }, - SetupModelOption { - id: "claude-opus-4-8", - efforts: &["low", "medium", "high", "xhigh", "max"], - tier: "premium", - }, - SetupModelOption { - id: "claude-sonnet-5", - efforts: &["low", "medium", "high", "xhigh", "max"], - tier: "standard", - }, - SetupModelOption { - id: "grok-4.5", - efforts: &["low", "medium", "high"], - tier: "premium", - }, - SetupModelOption { - id: "composer-2.5", - efforts: &[], - tier: "standard", - }, - ], - "claude-code" => vec![ - SetupModelOption { - id: "opus", - efforts: &["medium", "high"], - tier: "premium", - }, - SetupModelOption { - id: "sonnet", - efforts: &["medium", "high"], - tier: "standard", - }, - ], - "opencode" => vec![ - SetupModelOption { - id: "opencode/gpt-5-nano", - efforts: &["low", "medium", "high", "max"], - tier: "standard", - }, - SetupModelOption { - id: "anthropic/claude-sonnet-4-5", - efforts: &["low", "medium", "high"], - tier: "standard", - }, - SetupModelOption { - id: "anthropic/claude-opus-4-5", - efforts: &["high", "max"], - tier: "premium", - }, - ], - "pi" => vec![ - SetupModelOption { - id: "openai/gpt-4o-mini", - efforts: &["low", "medium", "high", "xhigh"], - tier: "standard", - }, - SetupModelOption { - id: "google/gemini-2.5-flash", - efforts: &["low", "medium", "high", "xhigh"], - tier: "standard", - }, - SetupModelOption { - id: "anthropic/claude-sonnet-4-5", - efforts: &["low", "medium", "high", "xhigh"], - tier: "premium", - }, - ], - "mixed-host" => vec![ - SetupModelOption { - id: "gpt-5.6-sol", - efforts: &["medium", "high", "xhigh"], - tier: "premium", - }, - SetupModelOption { - id: "gpt-5.6-terra", - efforts: &["low", "medium", "high"], - tier: "standard", - }, - SetupModelOption { - id: "opus", - efforts: &["high"], - tier: "premium", - }, - SetupModelOption { - id: "sonnet", - efforts: &["medium"], - tier: "standard", - }, - ], - _ => Vec::new(), - } -} - -fn validate_model_effort( - host: &str, - role: &str, - selection: &SetupRoleSelection, - catalog: &[SetupModelOption], -) -> Result<()> { - let option = catalog - .iter() - .find(|option| option.id == selection.model) - .ok_or_else(|| { - anyhow::anyhow!( - "setup role `{role}` model `{}` is not supported by host `{host}`", - selection.model - ) - })?; - match (&selection.effort, option.efforts.is_empty()) { - (None, true) => Ok(()), - (Some(_), true) => bail!( - "setup role `{role}` model `{}` does not accept effort", - selection.model - ), - (None, false) => bail!( - "setup role `{role}` model `{}` requires effort", - selection.model - ), - (Some(effort), false) if option.efforts.contains(&effort.as_str()) => Ok(()), - (Some(effort), false) => bail!( - "setup role `{role}` effort `{effort}` is not supported for model `{}` on host `{host}`", - selection.model - ), - } -} - -fn selection_matches_binding_profile( - role: &str, - selection: &SetupRoleSelection, - binding: &HostBinding, -) -> bool { - binding.profiles.get(role).is_some_and(|profile| { - selection.model == profile.model - && selection.effort == profile.effort - && selection_spawn_matches_binding( - setup_runtime_host(binding), - role, - selection, - profile, - ) - }) -} - -fn setup_spawn_policy_for_binding_role( - runtime_host: &str, - role: &str, - profile: &BindingProfile, -) -> Option { - if runtime_host != "codex" { - return None; - } - Some(SetupSpawnPolicy { - agent_type: profile.agent_type.clone()?, - task_name: identifier_token(role), - fork_turns: profile.fork_turns.clone()?, - }) -} - -fn selection_spawn_matches_binding( - runtime_host: &str, - role: &str, - selection: &SetupRoleSelection, - profile: &BindingProfile, -) -> bool { - if runtime_host != "codex" { - return selection.spawn.is_none(); - } - match (&selection.spawn, &profile.agent_type, &profile.fork_turns) { - (None, None, None) => true, - (None, Some(_), None) => true, - (Some(spawn), Some(agent_type), Some(fork_turns)) => { - spawn.agent_type == *agent_type - && spawn.task_name == identifier_token(role) - && spawn.fork_turns == *fork_turns - } - _ => false, - } -} - -fn validate_setup_spawn_policy( - runtime_host: &str, - role: &str, - selection: &SetupRoleSelection, - matches_binding: bool, -) -> Result<()> { - if runtime_host != "codex" { - if selection.spawn.is_some() { - bail!("setup role `{role}` spawn policy is only supported for Codex hosts"); - } - return Ok(()); - } - if matches_binding && selection.spawn.is_none() { - return Ok(()); - } - let Some(spawn) = &selection.spawn else { - bail!( - "setup role `{role}` must declare Codex spawn policy with exact agent_type, task_name, and fork_turns" - ); - }; - if spawn.task_name.contains('/') || spawn.task_name.starts_with('.') { - bail!( - "setup role `{role}` task_name must be a local lowercase identifier, not a canonical task path" - ); - } - validate_setup_snake_identifier("agent_type", &spawn.agent_type)?; - validate_setup_snake_identifier("task_name", &spawn.task_name)?; - let expected_task_name = identifier_token(role); - if spawn.task_name != expected_task_name { - bail!( - "setup role `{role}` task_name `{}` must match `{expected_task_name}`", - spawn.task_name - ); - } - if spawn.agent_type.trim().is_empty() { - bail!("setup role `{role}` agent_type must not be blank"); - } - let fork_turns = &spawn.fork_turns; - { - match fork_turns.mode.as_str() { - "none" => { - if fork_turns.turns.is_some() { - bail!("setup role `{role}` fork_turns none must not declare turns"); - } - } - "bounded" => match fork_turns.turns { - Some(turns) if turns > 0 => {} - _ => bail!("setup role `{role}` bounded fork_turns must use positive turns"), - }, - "all" => { - bail!("setup role `{role}` must not use fork_turns all for Codex role overrides") - } - other => bail!("setup role `{role}` has unsupported fork_turns mode `{other}`"), - } - } - Ok(()) -} - -fn validate_setup_snake_identifier(kind: &str, value: &str) -> Result<()> { - let valid = !value.is_empty() - && value.len() <= 64 - && value - .bytes() - .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_'); - if !valid { - bail!("setup {kind} `{value}` must use lowercase ASCII letters, digits, or `_`"); - } - reject_setup_secret_like(kind, value) -} - -fn validate_setup_identifier(kind: &str, value: &str) -> Result<()> { - let valid = !value.is_empty() - && value.len() <= 64 - && value.bytes().all(|byte| { - byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-' || byte == b'_' - }); - if !valid { - bail!("setup {kind} `{value}` must use lowercase ASCII letters, digits, `_`, or `-`"); - } - reject_setup_secret_like(kind, value) -} - -fn validate_setup_identity_collisions( - spec: &SetupSpecV1, - runtime_host: &str, - binding: &HostBinding, -) -> Result<()> { - let mut normalized_roles = BTreeMap::::new(); - let mut artifact_paths = BTreeMap::::new(); - let mut codex_agent_types = BTreeMap::::new(); - let mut codex_task_names = BTreeMap::::new(); - for (role, selection) in &spec.selected_roles { - let normalized = identifier_token(role); - if let Some(existing) = normalized_roles.insert(normalized.clone(), role.clone()) { - bail!("setup roles `{existing}` and `{role}` both normalize to `{normalized}`"); - } - if runtime_host == "codex" { - let agent_type = if let Some(spawn) = &selection.spawn { - spawn.agent_type.clone() - } else if selection_matches_binding_profile(role, selection, binding) { - binding - .profiles - .get(role) - .and_then(|profile| profile.agent_type.clone()) - .unwrap_or_default() - } else { - String::new() - }; - if !agent_type.is_empty() { - if let Some(existing) = codex_agent_types.insert(agent_type.clone(), role.clone()) { - bail!( - "setup roles `{existing}` and `{role}` both declare Codex agent_type `{agent_type}`" - ); - } - } - if let Some(spawn) = &selection.spawn { - if let Some(existing) = - codex_task_names.insert(spawn.task_name.clone(), role.clone()) - { - bail!( - "setup roles `{existing}` and `{role}` both declare Codex task_name `{}`", - spawn.task_name - ); - } - } - } - let artifact_path = if runtime_host == "codex" - && selection.spawn.is_none() - && selection_matches_binding_profile(role, selection, binding) - { - Some(binding_artifact_path_for_role(binding, role)?) - } else if runtime_host != "codex" || selection.spawn.is_some() { - Some(setup_artifact_path(runtime_host, role, selection)?) - } else { - None - }; - if let Some(artifact_path) = artifact_path { - if let Some(existing) = artifact_paths.insert(artifact_path.clone(), role.clone()) { - bail!( - "setup roles `{existing}` and `{role}` both generate artifact path `{artifact_path}`" - ); - } - } - } - Ok(()) -} - -fn setup_artifact_path( - runtime_host: &str, - role: &str, - selection: &SetupRoleSelection, -) -> Result { - let file_role = identifier_token(role); - Ok(match runtime_host { - "codex" => { - let spawn = selection.spawn.as_ref().ok_or_else(|| { - anyhow::anyhow!("setup role `{role}` must declare Codex spawn policy") - })?; - format!(".codex/agents/{}.toml", spawn.agent_type) - } - "claude-code" => format!(".claude/agents/switchloom-{file_role}.md"), - "cursor" => format!(".cursor/agents/switchloom-{file_role}.md"), - "opencode" => format!(".opencode/agents/switchloom-{file_role}.md"), - "pi" => format!(".pi/workflows/switchloom-{file_role}.json"), - "mixed-host" => format!(".model-routing/roles/{file_role}.toml"), - other => bail!("unsupported setup runtime host `{other}`"), - }) -} - -fn validate_setup_route_role( - roles: &BTreeMap, - role: &str, -) -> Result<()> { - if !roles.contains_key(role) { - bail!("setup route references unknown role `{role}`"); - } - Ok(()) -} - -fn reject_setup_secret_like(kind: &str, value: &str) -> Result<()> { - let lower = value.to_ascii_lowercase(); - for token in [ - "api_key", - "apikey", - "token", - "secret", - "credential", - "password", - ] { - if lower.contains(token) { - bail!("setup {kind} must not contain credential-like token `{token}`"); - } - } - Ok(()) -} - -fn identifier_token(value: &str) -> String { - value - .chars() - .map(|ch| { - if ch.is_ascii_alphanumeric() { - ch.to_ascii_lowercase() - } else { - '_' - } - }) - .collect() -} - -fn media_type_for(path: &str, kind: &str) -> String { - if path.ends_with(".toml") { - "application/toml" - } else if path.ends_with(".json") { - "application/json" - } else if path.ends_with(".md") || kind.ends_with("_skill") || kind.ends_with("_agent") { - "text/markdown" - } else { - "text/plain" - } - .to_string() -} - -fn include_artifact_for_integration(artifact: &SourceArtifact, integration: Integration) -> bool { - if artifact.path.contains("/skills/") - || artifact - .content - .contains("name: model-routing-native-routing") - { - return false; - } - integration == Integration::Planr || !artifact.path.starts_with(".planr/") -} - -fn artifact_for_integration( - mut artifact: SourceArtifact, - integration: Integration, -) -> SourceArtifact { - if integration == Integration::Planr { - artifact.content = render_planr_native_role(&artifact); - } - artifact -} - -fn render_codex_agent_registration_artifact( - artifacts: &[SourceArtifact], -) -> Result> { - #[derive(Serialize)] - struct CodexAgentRegistrationConfig { - agents: BTreeMap, - } - - #[derive(Serialize)] - struct CodexAgentRegistration { - config_file: String, - } - - let mut agents = BTreeMap::new(); - for artifact in artifacts - .iter() - .filter(|artifact| artifact.path.starts_with(".codex/agents/")) - { - let parsed: toml::Value = toml::from_str(&artifact.content) - .with_context(|| format!("Codex agent artifact `{}` must be TOML", artifact.path))?; - let agent_type = parsed - .get("name") - .and_then(toml::Value::as_str) - .ok_or_else(|| { - anyhow::anyhow!("Codex agent artifact `{}` must declare name", artifact.path) - })?; - let Some(file_name) = artifact.path.strip_prefix(".codex/") else { - bail!( - "Codex agent artifact `{}` must be relative to .codex", - artifact.path - ); - }; - if let Some(existing) = agents.insert( - agent_type.to_string(), - CodexAgentRegistration { - config_file: format!("./{file_name}"), - }, - ) { - bail!( - "Codex agent_type `{agent_type}` is registered by multiple artifacts, including `{}`", - existing.config_file - ); - } - } - if agents.is_empty() { - return Ok(None); - } - let mut content = toml::to_string_pretty(&CodexAgentRegistrationConfig { agents })?; - if !content.ends_with('\n') { - content.push('\n'); - } - Ok(Some(SourceArtifact { - path: ".codex/config.toml".to_string(), - media_type: "application/toml".to_string(), - mode: "replace".to_string(), - content, - })) -} - -fn render_planr_native_role(artifact: &SourceArtifact) -> String { - let protocol = if is_reviewer_role(artifact) { - Some(( - "$planr-review", - "Use the existing Planr internal review protocol for exactly one Planr review item. Read the pick packet, audit the target item and evidence, report findings first, and return the review verdict through Planr. Do not create or invoke any routing-specific, goal, or loop workflow skill. Planr users enter only through $planr-goal and $planr-loop.", - )) - } else if is_worker_role(artifact) { - Some(( - "$planr-work", - "Use the existing Planr internal worker protocol for exactly one picked Planr item. Read the pick packet, implement only that item, log changed files and real verification commands, request review through Planr, and stop. Do not create or invoke any routing-specific, goal, or loop workflow skill. Planr users enter only through $planr-goal and $planr-loop.", - )) - } else { - None - }; - let Some((protocol_name, instructions)) = protocol else { - return artifact.content.clone(); - }; - if artifact.path.starts_with(".pi/workflows/") { - rewrite_json_workflow_protocol_preload(&artifact.content, protocol_name, instructions) - } else if artifact.path.starts_with(".codex/agents/") { - rewrite_codex_developer_instructions(&artifact.content, protocol_name, instructions) - } else { - rewrite_markdown_agent_body(&artifact.content, protocol_name, instructions) - } -} - -fn is_worker_role(artifact: &SourceArtifact) -> bool { - artifact.path.contains("terra-high") - || artifact.path.contains("luna-xhigh") - || artifact.path.contains("preset-worker") - || artifact.path.starts_with(".pi/workflows/") - || artifact.path.contains("implementer") - || artifact.content.contains("Normal implementation") - || artifact.content.contains("Bounded checklist") - || artifact.content.contains("custom implementer role") -} - -fn is_reviewer_role(artifact: &SourceArtifact) -> bool { - artifact.path.contains("sol-high") - || artifact.path.contains("reviewer") - || artifact.path.contains("verifier") - || artifact.content.contains("Independent final review") - || artifact.content.contains("custom reviewer role") - || artifact.content.contains("custom verifier role") -} - -fn rewrite_codex_developer_instructions( - content: &str, - protocol_name: &str, - instructions: &str, -) -> String { - let marker = "developer_instructions = \"\"\"\n"; - if let Some(start) = content.find(marker) { - let body_start = start + marker.len(); - if let Some(end) = content[body_start..].find("\n\"\"\"") { - let body_end = body_start + end; - let mut output = String::new(); - output.push_str(&content[..body_start]); - output.push_str(instructions); - output.push_str("\n\nProtocol preload: "); - output.push_str(protocol_name); - output.push_str(&content[body_end..]); - return output; - } - } - format!("{content}\n\nProtocol preload: {protocol_name}\n{instructions}\n") -} - -fn rewrite_markdown_agent_body(content: &str, protocol_name: &str, instructions: &str) -> String { - if let Some(rest) = content.strip_prefix("---\n") { - if let Some(end) = rest.find("\n---\n") { - let split = "---\n".len() + end + "\n---\n".len(); - return format!( - "{}Protocol preload: {}\n\n{}\n", - &content[..split], - protocol_name, - instructions - ); - } - } - format!("Protocol preload: {protocol_name}\n\n{instructions}\n") -} - -fn rewrite_json_workflow_protocol_preload( - content: &str, - protocol_name: &str, - instructions: &str, -) -> String { - let mut value: Value = serde_json::from_str(content).unwrap_or_else(|_| json!({})); - if let Some(object) = value.as_object_mut() { - object.insert( - "protocol_preload".to_string(), - json!({ - "marker": format!("Protocol preload: {protocol_name}"), - "instructions": instructions - }), - ); - } - let mut output = serde_json::to_string_pretty(&value).unwrap_or_else(|_| { - format!("{content}\n\nProtocol preload: {protocol_name}\n{instructions}\n") - }); - output.push('\n'); - output -} - -fn render_registry(source: &PolicySource) -> Result { - #[derive(Serialize)] - struct Registry { - profiles: BTreeMap, - routes: Vec, - #[serde(skip_serializing_if = "Option::is_none")] - route_default: Option, - } - #[derive(Serialize)] - struct PlanrRegistryProfile { - client: String, - model: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - effort: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - cost_tier: Option, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - capabilities: Vec, - #[serde(default, skip_serializing_if = "Option::is_none")] - skill: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - notes: Option, - } - let profile_key = |profile_id: &str| -> String { - source - .profiles - .get(profile_id) - .and_then(|profile| profile.agent_type.clone()) - .unwrap_or_else(|| profile_id.to_string()) - }; - let profiles = source - .profiles - .iter() - .map(|(id, profile)| { - ( - profile_key(id), - PlanrRegistryProfile { - client: profile.client.clone(), - model: profile.model.clone(), - effort: profile.effort.clone(), - cost_tier: profile.cost_tier.clone(), - capabilities: profile.capabilities.clone(), - skill: profile.skill.clone(), - notes: profile - .agent_type - .as_ref() - .map(|agent_type| format!("native_agent_type={agent_type}")) - .or_else(|| profile.notes.clone()), - }, - ) - }) - .collect::>(); - let routes = source - .routes - .iter() - .map(|route| Route { - selector: route.selector.clone(), - profile: profile_key(&route.profile), - fallbacks: route - .fallbacks - .iter() - .map(|fallback| profile_key(fallback)) - .collect(), - }) - .collect::>(); - let route_default = source.route_default.as_ref().map(|default| DefaultRoute { - profile: profile_key(&default.profile), - fallbacks: default - .fallbacks - .iter() - .map(|fallback| profile_key(fallback)) - .collect(), - }); - Ok(toml::to_string_pretty(&Registry { - profiles, - routes, - route_default, - })?) -} - -fn bundle_artifact(source: SourceArtifact) -> BundleArtifact { - BundleArtifact { - sha256: sha256(source.content.as_bytes()), - path: source.path, - media_type: source.media_type, - mode: source.mode, - content: source.content, - } -} - -fn sha256(bytes: &[u8]) -> String { - format!("{:x}", Sha256::digest(bytes)) -} - -fn encode_hex(bytes: &[u8]) -> String { - bytes.iter().fold(String::new(), |mut output, byte| { - write!(&mut output, "{byte:02x}").expect("writing to String cannot fail"); - output - }) -} - -fn decode_hex(value: &str) -> Option<[u8; N]> { - if value.len() != N * 2 { - return None; - } - let mut decoded = [0_u8; N]; - for (index, output) in decoded.iter_mut().enumerate() { - *output = u8::from_str_radix(&value[index * 2..index * 2 + 2], 16).ok()?; - } - Some(decoded) -} - -fn encode_base64url(bytes: &[u8]) -> String { - const TABLE: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; - let mut output = String::with_capacity(bytes.len().div_ceil(3) * 4); - for chunk in bytes.chunks(3) { - let first = chunk[0]; - let second = *chunk.get(1).unwrap_or(&0); - let third = *chunk.get(2).unwrap_or(&0); - output.push(TABLE[(first >> 2) as usize] as char); - output.push(TABLE[(((first & 0b0000_0011) << 4) | (second >> 4)) as usize] as char); - if chunk.len() > 1 { - output.push(TABLE[(((second & 0b0000_1111) << 2) | (third >> 6)) as usize] as char); - } - if chunk.len() > 2 { - output.push(TABLE[(third & 0b0011_1111) as usize] as char); - } - } - output -} - -const fn encoded_base64url_len(decoded_len: usize) -> usize { - let full_chunks = decoded_len / 3; - match decoded_len % 3 { - 0 => full_chunks * 4, - 1 => full_chunks * 4 + 2, - _ => full_chunks * 4 + 3, - } -} - -fn validate_base64url_payload_len(input: &str) -> Result<()> { - if input.len() > MAX_SETUP_RECIPE_ENCODED_BYTES { - bail!( - "setup recipe payload exceeds {MAX_SETUP_RECIPE_ENCODED_BYTES} base64url characters for {MAX_SETUP_RECIPE_BYTES} decoded bytes" - ); - } - Ok(()) -} - -fn decode_base64url(input: &str) -> Result> { - validate_base64url_payload_len(input)?; - if input - .bytes() - .any(|byte| !(byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'_')) - { - bail!("setup recipe payload must be unpadded base64url"); - } - let mut sextets = Vec::with_capacity(input.len()); - for byte in input.bytes() { - sextets.push(match byte { - b'A'..=b'Z' => byte - b'A', - b'a'..=b'z' => byte - b'a' + 26, - b'0'..=b'9' => byte - b'0' + 52, - b'-' => 62, - b'_' => 63, - _ => unreachable!(), - }); - } - if sextets.len() % 4 == 1 { - bail!("setup recipe payload has invalid base64url length"); - } - let mut output = Vec::with_capacity(sextets.len() / 4 * 3); - for chunk in sextets.chunks(4) { - let a = chunk[0]; - let b = *chunk - .get(1) - .ok_or_else(|| anyhow::anyhow!("invalid base64url payload"))?; - output.push((a << 2) | (b >> 4)); - if let Some(c) = chunk.get(2) { - output.push(((b & 0b0000_1111) << 4) | (c >> 2)); - if let Some(d) = chunk.get(3) { - output.push(((c & 0b0000_0011) << 6) | d); - } - } - } - Ok(output) -} +//! Standalone Switchloom policy compiler and repository lifecycle library. + +pub mod cli; +pub mod config; +pub mod contracts; +pub mod error; +pub mod evidence; +pub mod hosts; +pub mod integrations; +pub mod lifecycle; +pub mod registry; +pub mod routing; + +pub use config::*; +pub use contracts::*; +pub use error::{Error, Result}; +pub use evidence::*; +pub use hosts::*; +pub use lifecycle::*; +pub use registry::*; +pub use routing::*; #[cfg(test)] -mod tests { - use super::*; - use std::time::{SystemTime, UNIX_EPOCH}; - - fn temp_repo(name: &str) -> PathBuf { - let unique = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_nanos(); - let path = std::env::temp_dir().join(format!("model-routing-{name}-{unique}")); - fs::create_dir_all(&path).unwrap(); - path - } - - #[test] - fn complete_policy_binding_pool_compiles_deterministically() { - let summaries = list_policies().unwrap(); - assert_eq!(summaries.len(), 28); - for summary in summaries { - let first = - compile_json(&summary.policy_id, &summary.host, Integration::Standalone).unwrap(); - let second = - compile_json(&summary.policy_id, &summary.host, Integration::Standalone).unwrap(); - assert_eq!(first, second); - assert!(!first.contains(".planr/policy.toml")); - assert!(first.contains("\"package\": \"model-routing\"")); - } - } - - #[test] - fn planr_integration_is_explicit_and_adds_planr_declarations() { - let standalone = compile_json("balanced", "codex-openai", Integration::Standalone).unwrap(); - let planr = compile_json("balanced", "codex-openai", Integration::Planr).unwrap(); - assert!(!standalone.contains(".planr/agents.toml")); - assert!(planr.contains(".planr/agents.toml")); - assert!(planr.contains(".planr/policy.toml")); - } - - #[test] - fn compiled_bundle_carries_typed_adapter_contract() { - let bundle = compile_policy("balanced", "codex-openai", Integration::Planr).unwrap(); - let contract = bundle.adapter_contract.as_ref().unwrap(); - assert_eq!(contract.schema_version, 1); - assert_eq!( - contract.capability.runtime_class, - RuntimeClass::NativeSubagent - ); - assert_eq!(contract.adapter.runtime_class, RuntimeClass::NativeSubagent); - assert_eq!(contract.routing_intent.integration, Integration::Planr); - assert!( - contract - .routing_intent - .semantic_roles - .contains(&"worker".to_string()) - ); - assert!( - !contract - .routing_intent - .semantic_roles - .contains(&"codex-terra-high".to_string()) - ); - assert_eq!( - contract - .capability - .guarantees - .get("model_selection") - .unwrap() - .level, - GuaranteeLevel::Deterministic - ); - assert!( - contract - .capability - .guarantees - .values() - .any(|guarantee| guarantee.level == GuaranteeLevel::Advisory) - ); - assert!( - contract - .dispatch_evidence - .required_verdicts - .contains(&GuaranteeLevel::Unsupported) - ); - assert_eq!( - contract.dispatch_evidence.receipt_schema, - "DispatchEvidenceV1" - ); - assert!( - contract - .routing_intent - .role_requests - .iter() - .any(|request| request.semantic_role == "worker" - && request.requested_model == "gpt-5.6-terra" - && request.requested_effort.as_deref() == Some("high")) - ); - assert_eq!( - contract.adapter.dispatch_recipe.invocation, - "host-native-subagent" - ); - assert_eq!( - contract.capability.runtime_behavior.capability_version, - codex_v2_runtime_evidence().unwrap().evidence_id - ); - assert_eq!( - contract - .capability - .runtime_behavior - .installed_host_version_source, - "codex-cli 0.144.5 via codex --version" - ); - assert_eq!( - contract - .capability - .host_version_constraints - .minimum - .as_deref(), - Some(codex_v2_host_version(&codex_v2_runtime_evidence().unwrap()).as_str()) - ); - assert_eq!( - contract - .capability - .host_version_constraints - .maximum - .as_deref(), - Some(codex_v2_host_version(&codex_v2_runtime_evidence().unwrap()).as_str()) - ); - assert!( - contract - .capability - .runtime_behavior - .backend_selection_source - .contains("authenticated host account") - ); - assert!( - contract - .capability - .runtime_behavior - .discovery_behavior - .contains(".codex/config.toml") - ); - assert!( - contract - .capability - .runtime_behavior - .role_precedence - .iter() - .any(|entry| entry.contains("agent file")) - ); - assert!(contract.capability.runtime_behavior.shared_filesystem); - assert_eq!(contract.capability.parallelism.max_parallel_children, 3); - assert!( - contract - .capability - .runtime_behavior - .delegation_modes - .explicit_agent_type_dispatch - ); - assert!( - contract - .capability - .runtime_behavior - .delegation_modes - .ultra_auto_delegation - ); - assert!( - contract - .capability - .runtime_behavior - .source_references - .contains(&codex_v2_runtime_evidence_reference()) - ); - } - - #[test] - fn codex_runtime_evidence_fixtures_fail_for_named_provenance_reasons() { - for (fixture, expected) in [ - ( - include_str!( - "../fixtures/codex-v2-runtime-evidence/invalid-prose-only-provenance.json" - ), - "must include raw output", - ), - ( - include_str!( - "../fixtures/codex-v2-runtime-evidence/invalid-arbitrary-prose-provenance.json" - ), - "raw capture does not support", - ), - ( - include_str!( - "../fixtures/codex-v2-runtime-evidence/invalid-tampered-raw-digest.json" - ), - "raw output digest mismatch", - ), - ( - include_str!( - "../fixtures/codex-v2-runtime-evidence/invalid-unsupported-provenance-kind.json" - ), - "unsupported provenance kind", - ), - ] { - let evidence: CodexV2RuntimeEvidence = serde_json::from_str(fixture).unwrap(); - let error = validate_codex_v2_runtime_evidence(&evidence) - .unwrap_err() - .to_string(); - assert!( - error.contains(expected), - "expected `{expected}` in `{error}`" - ); - } - } - - #[test] - fn adapter_contract_distinguishes_external_runner_runtime_class() { - let binding = HostBinding { - id: "pi-runner".to_string(), - version: "1.0.0".to_string(), - host: "pi".to_string(), - runtime_class: RuntimeClass::ExternalRunner, - default_role: Some("worker".to_string()), - capability_evidence: vec!["pi-runner-contract".to_string()], - known_limitations: vec!["process isolation is runner-owned".to_string()], - capabilities: BindingCapabilities { - model_override: true, - effort_override: true, - fork_none: true, - fork_all: false, - }, - profiles: BTreeMap::from([( - "worker".to_string(), - BindingProfile { - profile: "pi-worker".to_string(), - client: "pi".to_string(), - model: "gpt-5.6-terra".to_string(), - agent_type: None, - effort: Some("high".to_string()), - cost_tier: Some("standard".to_string()), - fork_turns: Some(ForkPolicy { - mode: "none".to_string(), - turns: None, - }), - }, - )]), - routes: Vec::new(), - verification: BindingVerification { - id: "pi-smoke-v1".to_string(), - max_age_seconds: Some(60), - }, - artifacts: Vec::new(), - }; - let contract = - adapter_contract_for_binding("balanced", &binding, Integration::Standalone).unwrap(); - assert_eq!( - contract.capability.runtime_class, - RuntimeClass::ExternalRunner - ); - assert_eq!(contract.adapter.runtime_class, RuntimeClass::ExternalRunner); - assert_eq!( - contract.adapter.dispatch_recipe.invocation, - "external-runner-process" - ); - } - - #[test] - fn adapter_contract_handoff_names_planr_consumer_boundaries() { - let binding = binding_for_selector("codex-openai").unwrap(); - let contract = - adapter_contract_for_binding("balanced", &binding, Integration::Planr).unwrap(); - let handoff = &contract.planr_handoff; - let package_json: Value = serde_json::from_str(NPM_PACKAGE_JSON).unwrap(); - let expected_package = format!( - "{}@{}", - package_json["name"].as_str().unwrap(), - package_json["version"].as_str().unwrap() - ); - assert_eq!(handoff.switchloom_package, expected_package); - assert_ne!( - handoff.switchloom_package, - format!("{}@{PACKAGE_VERSION}", env!("CARGO_PKG_NAME")) - ); - assert!( - handoff - .semantic_role_contract - .contains("usage policy `balanced`") - ); - assert!( - handoff - .required_consumer_behavior - .iter() - .any(|behavior| behavior.contains("RoutingIntentV1")) - ); - assert!( - handoff - .required_consumer_behavior - .iter() - .any(|behavior| behavior.contains("package digest")) - ); - assert!( - handoff - .forbidden_duplicate_ownership - .iter() - .any(|behavior| behavior.contains("model catalog")) - ); - assert!( - handoff - .certification_report_reference - .contains("reports/native-host-certification") - ); - } - - #[test] - fn pi_external_adapter_declares_typed_runner_contract() { - let bundle = compile_policy("balanced", "pi-external", Integration::Standalone).unwrap(); - let contract = bundle.adapter_contract.as_ref().unwrap(); - assert_eq!( - contract.capability.runtime_class, - RuntimeClass::ExternalRunner - ); - assert_eq!(contract.adapter.runtime_class, RuntimeClass::ExternalRunner); - assert_eq!( - contract.adapter.dispatch_recipe.invocation, - "external-runner-process" - ); - for field in [ - "agent_type", - "provider", - "model", - "effort", - "fork_turns", - "isolation", - "task", - ] { - assert!( - contract - .adapter - .dispatch_recipe - .required_fields - .contains(&field.to_string()), - "Pi dispatch recipe should require {field}" - ); - } - assert_eq!( - contract.capability.observability.effective_model, - GuaranteeLevel::Advisory - ); - assert!( - contract - .capability - .known_limitations - .iter() - .any(|limitation| limitation.contains("process-isolated")) - ); - - let workflow = bundle - .artifacts - .iter() - .find(|artifact| artifact.path == ".pi/workflows/model-routing-preset-runner.json") - .unwrap(); - assert!( - workflow - .content - .contains("\"runtime_class\": \"external-runner\"") - ); - assert!( - workflow - .content - .contains("\"agent_type\": \"switchloom-pi-worker\"") - ); - assert!( - workflow - .content - .contains("\"provider_model\": \"openai/gpt-4o-mini\"") - ); - assert!(workflow.content.contains("\"thinking\": \"low\"")); - assert!(workflow.content.contains("\"session\": \"none\"")); - assert!(workflow.content.contains("\"task\"")); - } - - #[test] - fn host_binding_runtime_class_is_required_and_explicit() { - let missing_runtime_class = r#" -id = "pi-runner" -version = "1.0.0" -host = "pi" -default_role = "worker" - -[capabilities] -model_override = true -effort_override = true -fork_none = true -fork_all = false - -[profiles.worker] -profile = "pi-worker" -client = "pi" -model = "gpt-5.6-terra" - -[verification] -id = "pi-smoke-v1" -"#; - let error = toml::from_str::(missing_runtime_class) - .unwrap_err() - .to_string(); - assert!(error.contains("runtime_class")); - } - - #[test] - fn adapter_contract_rejects_unsupported_required_guarantees() { - let mut contract = compile_policy("balanced", "cursor-openai", Integration::Standalone) - .unwrap() - .adapter_contract - .unwrap(); - contract - .routing_intent - .required_guarantees - .push("effort_selection".to_string()); - let error = validate_adapter_contract(&contract) - .unwrap_err() - .to_string(); - assert!(error.contains("unsupported")); - } - - #[test] - fn shared_adapter_validation_rejects_invalid_routes_before_rendering() { - let binding = HostBinding { - id: "cursor-test".to_string(), - version: "1.0.0".to_string(), - host: "cursor".to_string(), - runtime_class: RuntimeClass::NativeSubagent, - default_role: None, - capability_evidence: Vec::new(), - known_limitations: Vec::new(), - capabilities: BindingCapabilities { - model_override: true, - effort_override: false, - fork_none: true, - fork_all: false, - }, - profiles: BTreeMap::from([( - "worker".to_string(), - BindingProfile { - profile: "cursor-worker".to_string(), - client: "cursor".to_string(), - model: "gpt-5.4-mini".to_string(), - agent_type: None, - effort: None, - cost_tier: Some("standard".to_string()), - fork_turns: Some(ForkPolicy { - mode: "none".to_string(), - turns: None, - }), - }, - )]), - routes: vec![BindingRoute { - work_type: "code".to_string(), - role: "missing".to_string(), - fallback_roles: Vec::new(), - }], - verification: BindingVerification { - id: "cursor-test-v1".to_string(), - max_age_seconds: Some(60), - }, - artifacts: Vec::new(), - }; - let error = compile_host_adapter("balanced", &binding, Integration::Standalone) - .unwrap_err() - .to_string(); - assert!(error.contains("unknown role `missing`")); - } - - #[test] - fn shared_adapter_validation_rejects_duplicate_profile_ids_before_rendering() { - let binding = HostBinding { - id: "cursor-test".to_string(), - version: "1.0.0".to_string(), - host: "cursor".to_string(), - runtime_class: RuntimeClass::NativeSubagent, - default_role: Some("first".to_string()), - capability_evidence: Vec::new(), - known_limitations: Vec::new(), - capabilities: BindingCapabilities { - model_override: true, - effort_override: false, - fork_none: true, - fork_all: false, - }, - profiles: BTreeMap::from([ - ( - "first".to_string(), - BindingProfile { - profile: "cursor-worker".to_string(), - client: "cursor".to_string(), - model: "gpt-5.4-mini".to_string(), - agent_type: None, - effort: None, - cost_tier: Some("standard".to_string()), - fork_turns: Some(ForkPolicy { - mode: "none".to_string(), - turns: None, - }), - }, - ), - ( - "second".to_string(), - BindingProfile { - profile: "cursor-worker".to_string(), - client: "cursor".to_string(), - model: "gpt-5.5".to_string(), - agent_type: None, - effort: None, - cost_tier: Some("premium".to_string()), - fork_turns: Some(ForkPolicy { - mode: "none".to_string(), - turns: None, - }), - }, - ), - ]), - routes: vec![BindingRoute { - work_type: "code".to_string(), - role: "first".to_string(), - fallback_roles: vec!["second".to_string()], - }], - verification: BindingVerification { - id: "cursor-test-v1".to_string(), - max_age_seconds: Some(60), - }, - artifacts: Vec::new(), - }; - let error = compile_host_adapter("balanced", &binding, Integration::Standalone) - .unwrap_err() - .to_string(); - assert!(error.contains("both normalize to profile `cursor-worker`")); - } - - #[test] - fn dispatch_recipe_artifact_paths_match_final_bundle_artifacts() { - let bundle = compile_policy("balanced", "mixed-host", Integration::Planr).unwrap(); - let contract_paths = bundle - .adapter_contract - .as_ref() - .unwrap() - .adapter - .dispatch_recipe - .artifact_paths - .iter() - .cloned() - .collect::>(); - let artifact_paths = bundle - .artifacts - .iter() - .map(|artifact| artifact.path.clone()) - .collect::>(); - assert_eq!(contract_paths, artifact_paths); - assert!( - !contract_paths - .iter() - .any(|path| path.contains("model-routing-native-routing")) - ); - } - - #[test] - fn dispatch_evidence_requires_persisted_requested_and_effective_receipt_fields() { - let mut valid = DispatchEvidenceV1 { - schema_version: 1, - package_digest: "sha256:abc".to_string(), - host_version: "codex 0.144.0".to_string(), - requested_dispatch: RequestedDispatchEvidence { - semantic_role: "worker".to_string(), - profile: "codex-terra-high".to_string(), - model: "gpt-5.6-terra".to_string(), - effort: Some("high".to_string()), - agent_type: Some("model_routing_terra_high".to_string()), - fork_turns: Some(ForkPolicy { - mode: "none".to_string(), - turns: None, - }), - }, - child_identity: ChildIdentityEvidence { - host: "codex".to_string(), - role: "worker".to_string(), - agent_role: "model_routing_terra_high".to_string(), - agent_type: Some("model_routing_terra_high".to_string()), - task_name: Some("worker".to_string()), - }, - effective_model: Some("gpt-5.6-terra".to_string()), - effective_effort: Some("high".to_string()), - nonce: "nonce-123".to_string(), - raw_evidence_refs: vec!["receipt.json".to_string()], - verdict: GuaranteeLevel::Deterministic, - }; - let encoded = serde_json::to_string(&valid).unwrap(); - let decoded: DispatchEvidenceV1 = serde_json::from_str(&encoded).unwrap(); - validate_dispatch_evidence(&decoded).unwrap(); - - let missing_nonce = r#"{ - "schema_version": 1, - "package_digest": "sha256:abc", - "host_version": "codex 0.144.0", - "requested_dispatch": { - "semantic_role": "worker", - "profile": "codex-terra-high", - "model": "gpt-5.6-terra" - }, - "child_identity": { - "host": "codex", - "role": "worker", - "agent_role": "model_routing_terra_high" - }, - "raw_evidence_refs": ["receipt.json"], - "verdict": "deterministic" -}"#; - let error = serde_json::from_str::(missing_nonce) - .unwrap_err() - .to_string(); - assert!(error.contains("nonce")); - - valid.effective_model = None; - let error = validate_dispatch_evidence(&valid).unwrap_err().to_string(); - assert!(error.contains("effective_model")); - - valid.effective_model = Some("gpt-5.6-sol".to_string()); - let error = validate_dispatch_evidence(&valid).unwrap_err().to_string(); - assert!(error.contains("does not match requested model")); - - valid.effective_model = Some("gpt-5.6-terra".to_string()); - valid.effective_effort = Some("medium".to_string()); - let error = validate_dispatch_evidence(&valid).unwrap_err().to_string(); - assert!(error.contains("does not match requested effort")); - - valid.effective_effort = None; - valid.verdict = GuaranteeLevel::Advisory; - validate_dispatch_evidence(&valid).unwrap(); - } - - #[test] - fn adapter_validation_blocks_unproven_deterministic_claude_and_cursor_evidence() { - for (host, role, profile, model, effort) in [ - ( - "claude-native", - "worker", - "claude-native-worker", - "sonnet", - Some("medium"), - ), - ( - "cursor-openai", - "worker", - "cursor-openai-worker", - "gpt-5.4-mini", - None, - ), - ] { - let contract = compile_policy("balanced", host, Integration::Standalone) - .unwrap() - .adapter_contract - .unwrap(); - let mut evidence = DispatchEvidenceV1 { - schema_version: 1, - package_digest: "sha256:abc".to_string(), - host_version: format!("{} cli 1.0.0", contract.capability.host), - requested_dispatch: RequestedDispatchEvidence { - semantic_role: role.to_string(), - profile: profile.to_string(), - model: model.to_string(), - effort: effort.map(str::to_string), - agent_type: None, - fork_turns: Some(ForkPolicy { - mode: "none".to_string(), - turns: None, - }), - }, - child_identity: ChildIdentityEvidence { - host: contract.capability.host.clone(), - role: role.to_string(), - agent_role: "model-routing-preset-worker".to_string(), - agent_type: None, - task_name: Some("model-routing-preset-worker".to_string()), - }, - effective_model: Some(model.to_string()), - effective_effort: effort.map(str::to_string), - nonce: "nonce-456".to_string(), - raw_evidence_refs: vec!["host-output.json".to_string()], - verdict: GuaranteeLevel::Deterministic, - }; - let error = validate_dispatch_evidence_for_adapter(&evidence, &contract) - .unwrap_err() - .to_string(); - assert!( - error.contains("effective model observability is Advisory"), - "{host}: {error}" - ); - - evidence.verdict = GuaranteeLevel::Advisory; - validate_dispatch_evidence_for_adapter(&evidence, &contract).unwrap(); - - evidence.verdict = GuaranteeLevel::Deterministic; - evidence.raw_evidence_refs.push(format!( - "host-authenticated-effective-model:{}:host-output.json#model", - contract.capability.host - )); - if effort.is_some() { - evidence.raw_evidence_refs.push(format!( - "host-authenticated-effective-effort:{}:host-output.json#effort", - contract.capability.host - )); - } - let error = validate_dispatch_evidence_for_adapter(&evidence, &contract) - .unwrap_err() - .to_string(); - assert!( - error.contains("effective model observability is Advisory"), - "forged refs should not upgrade {host}: {error}" - ); - } - } - - #[test] - fn setup_spec_roundtrips_through_canonical_toml_json_and_recipe() { - let spec = - setup_spec_for_policy("balanced", "codex-openai", Integration::Standalone).unwrap(); - let json = setup_spec_to_canonical_json(&spec).unwrap(); - let toml = setup_spec_to_canonical_toml(&spec).unwrap(); - let recipe = setup_spec_to_recipe(&spec).unwrap(); - assert!(json.contains("\"schema_version\": 1")); - assert!(toml.contains("schema_version = 1")); - assert!(recipe.starts_with(SETUP_RECIPE_PREFIX)); - assert_eq!(setup_spec_from_json(&json).unwrap(), spec); - assert_eq!(setup_spec_from_toml(&toml).unwrap(), spec); - assert_eq!(setup_spec_from_recipe(&recipe).unwrap(), spec); - } - - #[test] - fn setup_recipe_enforces_exact_pre_decode_size_boundaries() { - assert_eq!(MAX_SETUP_RECIPE_BYTES % 3, 1); - assert_eq!( - MAX_SETUP_RECIPE_ENCODED_BYTES, - (MAX_SETUP_RECIPE_BYTES / 3) * 4 + 2 - ); - - let boundary_payload = encode_base64url(&vec![0_u8; MAX_SETUP_RECIPE_BYTES]); - assert_eq!(boundary_payload.len(), MAX_SETUP_RECIPE_ENCODED_BYTES); - assert_eq!( - decode_base64url(&boundary_payload).unwrap().len(), - MAX_SETUP_RECIPE_BYTES - ); - let boundary_error = - setup_spec_from_recipe(&format!("{SETUP_RECIPE_PREFIX}{boundary_payload}")) - .unwrap_err() - .to_string(); - assert!(!boundary_error.contains("exceeds")); - - let first_oversized_payload = encode_base64url(&vec![0_u8; MAX_SETUP_RECIPE_BYTES + 1]); - assert_eq!( - first_oversized_payload.len(), - MAX_SETUP_RECIPE_ENCODED_BYTES + 1 - ); - assert!( - setup_spec_from_recipe(&format!("{SETUP_RECIPE_PREFIX}{first_oversized_payload}")) - .unwrap_err() - .to_string() - .contains("exceeds") - ); - - let very_large_payload = "A".repeat(MAX_SETUP_RECIPE_ENCODED_BYTES * 4); - assert!( - setup_spec_from_recipe(&format!("{SETUP_RECIPE_PREFIX}{very_large_payload}")) - .unwrap_err() - .to_string() - .contains("base64url characters") - ); - } - - #[test] - fn built_in_presets_compile_through_setup_spec_without_output_drift() { - let spec = setup_spec_for_policy("balanced", "codex-openai", Integration::Planr).unwrap(); - assert_eq!( - compile_setup_spec(&spec).unwrap(), - compile_builtin_policy_direct("balanced", "codex-openai", Integration::Planr).unwrap() - ); - } - - #[test] - fn public_host_aliases_preserve_built_in_preset_output() { - for (alias, binding) in [ - ("codex", "codex-openai"), - ("cursor", "cursor-openai"), - ("claude-code", "claude-native"), - ] { - let spec = setup_spec_for_policy("balanced", alias, Integration::Standalone).unwrap(); - assert_eq!(spec.host, binding); - assert_eq!( - compile_setup_spec(&spec).unwrap(), - compile_builtin_policy_direct("balanced", binding, Integration::Standalone) - .unwrap() - ); - } - } - - #[test] - fn codex_default_spec_can_be_partially_tuned_into_custom_standalone_bundle() { - let mut spec = setup_spec_for_policy("balanced", "codex", Integration::Standalone).unwrap(); - let worker = spec.selected_roles.get_mut("worker").unwrap(); - worker.model = "gpt-5.6-sol".to_string(); - worker.effort = Some("medium".to_string()); - worker.spawn = Some(SetupSpawnPolicy { - agent_type: "switchloom_worker".to_string(), - task_name: "worker".to_string(), - fork_turns: ForkPolicy { - mode: "none".to_string(), - turns: None, - }, - }); - - let bundle = compile_setup_spec(&spec).unwrap(); - validate_bundle(&bundle).unwrap(); - assert_eq!(bundle.source.integration, Integration::Standalone); - assert_eq!(bundle.evidence.status, "custom-unverified"); - assert_eq!( - bundle.profiles.get("driver").unwrap().agent_type.as_deref(), - Some("model_routing_sol_medium") - ); - assert!(bundle.artifacts.iter().any(|artifact| { - artifact.path == ".codex/agents/model-routing-sol-medium.toml" - && artifact - .content - .contains("name = \"model_routing_sol_medium\"") - })); - assert!(bundle.artifacts.iter().any(|artifact| { - artifact.path == ".codex/agents/switchloom_worker.toml" - && artifact.content.contains("name = \"switchloom_worker\"") - })); - assert!( - bundle - .artifacts - .iter() - .all(|artifact| !artifact.path.starts_with(".planr/")) - ); - } - - #[test] - fn codex_default_spec_can_be_partially_tuned_into_custom_planr_bundle() { - let mut spec = setup_spec_for_policy("balanced", "codex", Integration::Planr).unwrap(); - let reviewer = spec.selected_roles.get_mut("reviewer").unwrap(); - reviewer.model = "gpt-5.6-terra".to_string(); - reviewer.effort = Some("high".to_string()); - reviewer.spawn = Some(SetupSpawnPolicy { - agent_type: "switchloom_reviewer".to_string(), - task_name: "reviewer".to_string(), - fork_turns: ForkPolicy { - mode: "none".to_string(), - turns: None, - }, - }); - - let bundle = compile_setup_spec(&spec).unwrap(); - validate_bundle(&bundle).unwrap(); - assert_eq!(bundle.source.integration, Integration::Planr); - assert_eq!(bundle.evidence.status, "custom-unverified"); - assert_eq!( - bundle.profiles.get("driver").unwrap().agent_type.as_deref(), - Some("model_routing_sol_medium") - ); - assert!(bundle.artifacts.iter().any(|artifact| { - artifact.path == ".codex/agents/model-routing-sol-medium.toml" - && artifact - .content - .contains("name = \"model_routing_sol_medium\"") - })); - assert!(bundle.artifacts.iter().any(|artifact| { - artifact.path == ".codex/agents/switchloom_reviewer.toml" - && artifact.content.contains("name = \"switchloom_reviewer\"") - })); - assert!( - bundle - .artifacts - .iter() - .any(|artifact| artifact.path == ".planr/agents.toml") - ); - } - - #[test] - fn fully_custom_setup_compiles_as_unverified_host_native_bundle() { - let spec = SetupSpecV1 { - schema_version: 1, - host: "codex".to_string(), - integration: Integration::Standalone, - usage_policy: "balanced".to_string(), - selected_roles: BTreeMap::from([ - ( - "orchestrator".to_string(), - SetupRoleSelection { - model: "gpt-5.6-sol".to_string(), - effort: Some("medium".to_string()), - spawn: Some(SetupSpawnPolicy { - agent_type: "switchloom_orchestrator".to_string(), - task_name: "orchestrator".to_string(), - fork_turns: ForkPolicy { - mode: "none".to_string(), - turns: None, - }, - }), - }, - ), - ( - "implementer".to_string(), - SetupRoleSelection { - model: "gpt-5.6-terra".to_string(), - effort: Some("high".to_string()), - spawn: Some(SetupSpawnPolicy { - agent_type: "switchloom_implementer".to_string(), - task_name: "implementer".to_string(), - fork_turns: ForkPolicy { - mode: "none".to_string(), - turns: None, - }, - }), - }, - ), - ]), - routes: vec![SetupRouteMapping { - work_type: "code".to_string(), - role: "implementer".to_string(), - fallbacks: vec!["orchestrator".to_string()], - }], - route_default: Some(SetupDefaultRoute { - role: "orchestrator".to_string(), - fallbacks: Vec::new(), - }), - }; - let bundle = compile_setup_spec(&spec).unwrap(); - assert_eq!(bundle.source.integration, Integration::Standalone); - assert_eq!(bundle.evidence.status, "custom-unverified"); - assert!(bundle.profiles.contains_key("implementer")); - assert_eq!( - bundle.profiles.get("implementer").unwrap().model, - "gpt-5.6-terra" - ); - assert!(bundle.artifacts.iter().any(|artifact| artifact.path - == ".codex/agents/switchloom_implementer.toml" - && artifact.content.contains("model = \"gpt-5.6-terra\""))); - let adapter_contract = bundle.adapter_contract.as_ref().unwrap(); - assert!( - adapter_contract - .routing_intent - .role_requests - .iter() - .any(|request| request.semantic_role == "implementer" - && request.requested_model == "gpt-5.6-terra" - && request.requested_effort.as_deref() == Some("high")) - ); - assert!( - adapter_contract - .adapter - .dispatch_recipe - .artifact_paths - .contains(&".codex/agents/switchloom_implementer.toml".to_string()) - ); - assert!(bundle.artifacts.iter().any(|artifact| { - artifact.content.contains("task_name `implementer`") - && !artifact.content.contains("sandbox_mode") - })); - assert!( - bundle - .artifacts - .iter() - .all(|artifact| !artifact.path.starts_with(".planr/")) - ); - validate_bundle(&bundle).unwrap(); - } - - #[test] - fn custom_setup_rejects_duplicate_codex_spawn_identities() { - let duplicate_agent_type = SetupSpecV1 { - schema_version: 1, - host: "codex".to_string(), - integration: Integration::Standalone, - usage_policy: "balanced".to_string(), - selected_roles: BTreeMap::from([ - ( - "implementer".to_string(), - SetupRoleSelection { - model: "gpt-5.6-terra".to_string(), - effort: Some("high".to_string()), - spawn: Some(SetupSpawnPolicy { - agent_type: "switchloom_shared".to_string(), - task_name: "implementer".to_string(), - fork_turns: ForkPolicy { - mode: "none".to_string(), - turns: None, - }, - }), - }, - ), - ( - "reviewer".to_string(), - SetupRoleSelection { - model: "gpt-5.6-sol".to_string(), - effort: Some("high".to_string()), - spawn: Some(SetupSpawnPolicy { - agent_type: "switchloom_shared".to_string(), - task_name: "reviewer".to_string(), - fork_turns: ForkPolicy { - mode: "none".to_string(), - turns: None, - }, - }), - }, - ), - ]), - routes: vec![SetupRouteMapping { - work_type: "code".to_string(), - role: "implementer".to_string(), - fallbacks: vec!["reviewer".to_string()], - }], - route_default: Some(SetupDefaultRoute { - role: "implementer".to_string(), - fallbacks: Vec::new(), - }), - }; - assert!( - compile_setup_spec(&duplicate_agent_type) - .unwrap_err() - .to_string() - .contains("both declare Codex agent_type `switchloom_shared`") - ); - - let duplicate_task_name = SetupSpecV1 { - schema_version: 1, - host: "codex".to_string(), - integration: Integration::Standalone, - usage_policy: "balanced".to_string(), - selected_roles: BTreeMap::from([ - ( - "foo-bar".to_string(), - SetupRoleSelection { - model: "gpt-5.6-terra".to_string(), - effort: Some("high".to_string()), - spawn: Some(SetupSpawnPolicy { - agent_type: "switchloom_foo_bar".to_string(), - task_name: "foo_bar".to_string(), - fork_turns: ForkPolicy { - mode: "none".to_string(), - turns: None, - }, - }), - }, - ), - ( - "foo_bar".to_string(), - SetupRoleSelection { - model: "gpt-5.6-sol".to_string(), - effort: Some("high".to_string()), - spawn: Some(SetupSpawnPolicy { - agent_type: "switchloom_foo_bar_alt".to_string(), - task_name: "foo_bar".to_string(), - fork_turns: ForkPolicy { - mode: "none".to_string(), - turns: None, - }, - }), - }, - ), - ]), - routes: vec![SetupRouteMapping { - work_type: "code".to_string(), - role: "foo-bar".to_string(), - fallbacks: vec!["foo_bar".to_string()], - }], - route_default: None, - }; - assert!( - compile_setup_spec(&duplicate_task_name) - .unwrap_err() - .to_string() - .contains("both normalize to `foo_bar`") - ); - } - - #[test] - fn custom_setup_rejects_normalized_artifact_path_collisions() { - for (host, model, effort, expected_path) in [ - ( - "claude-code", - "sonnet", - Some("medium"), - ".claude/agents/switchloom-foo_bar.md", - ), - ( - "cursor", - "composer-2.5", - None, - ".cursor/agents/switchloom-foo_bar.md", - ), - ( - "mixed-host", - "sonnet", - Some("medium"), - ".model-routing/roles/foo_bar.toml", - ), - ] { - let spec = SetupSpecV1 { - schema_version: 1, - host: host.to_string(), - integration: Integration::Standalone, - usage_policy: "balanced".to_string(), - selected_roles: BTreeMap::from([ - ( - "foo-bar".to_string(), - SetupRoleSelection { - model: model.to_string(), - effort: effort.map(ToOwned::to_owned), - spawn: None, - }, - ), - ( - "foo_bar".to_string(), - SetupRoleSelection { - model: model.to_string(), - effort: effort.map(ToOwned::to_owned), - spawn: None, - }, - ), - ]), - routes: vec![SetupRouteMapping { - work_type: "code".to_string(), - role: "foo-bar".to_string(), - fallbacks: vec!["foo_bar".to_string()], - }], - route_default: None, - }; - let error = compile_setup_spec(&spec).unwrap_err().to_string(); - assert!( - error.contains("both normalize to `foo_bar`") || error.contains(expected_path), - "expected normalized collision for {host}, got {error}" - ); - } - } - - #[test] - fn successful_custom_setups_validate_final_bundles_for_each_host_family() { - for (host, role, model, effort) in [ - ("claude-code", "implementer", "sonnet", Some("medium")), - ("cursor", "implementer", "composer-2.5", None), - ( - "opencode", - "implementer", - "opencode/gpt-5-nano", - Some("medium"), - ), - ("mixed-host", "implementer", "sonnet", Some("medium")), - ] { - let spec = SetupSpecV1 { - schema_version: 1, - host: host.to_string(), - integration: Integration::Standalone, - usage_policy: "balanced".to_string(), - selected_roles: BTreeMap::from([( - role.to_string(), - SetupRoleSelection { - model: model.to_string(), - effort: effort.map(ToOwned::to_owned), - spawn: None, - }, - )]), - routes: vec![SetupRouteMapping { - work_type: "code".to_string(), - role: role.to_string(), - fallbacks: Vec::new(), - }], - route_default: None, - }; - let bundle = compile_setup_spec(&spec).unwrap(); - validate_bundle(&bundle).unwrap(); - assert_eq!(bundle.evidence.status, "custom-unverified"); - assert_eq!(bundle.profiles.get(role).unwrap().model, model); - let artifact_path = match host { - "claude-code" => ".claude/agents/switchloom-implementer.md", - "cursor" => ".cursor/agents/switchloom-implementer.md", - "opencode" => ".opencode/agents/switchloom-implementer.md", - "mixed-host" => ".model-routing/roles/implementer.toml", - _ => unreachable!(), - }; - assert!( - bundle - .artifacts - .iter() - .any(|artifact| artifact.path == artifact_path) - ); - let contract = bundle.adapter_contract.as_ref().unwrap(); - assert!(contract.routing_intent.role_requests.iter().any( - |request| request.semantic_role == role - && request.requested_model == model - && request.requested_effort.as_deref() == effort - )); - assert!( - contract - .adapter - .dispatch_recipe - .artifact_paths - .contains(&artifact_path.to_string()) - ); - } - } - - #[test] - fn setup_config_lifecycle_persists_normalized_config_and_reuses_manifest_flow() { - let repository = temp_repo("setup-config-lifecycle"); - let config_file = repository.join("input.setup.toml"); - let original = setup_spec_for_policy("balanced", "codex", Integration::Standalone).unwrap(); - let original_toml = setup_spec_to_canonical_toml(&original).unwrap(); - fs::write(&config_file, &original_toml).unwrap(); - - let preview = preview_setup_config_file(&repository, &config_file).unwrap(); - assert_eq!(preview.action, "preview"); - assert!( - preview.artifacts.iter().any(|artifact| { - artifact.path == SETUP_CONFIG_PATH && artifact.status == "create" - }) - ); - - let applied = apply_setup_config_file(&repository, &config_file).unwrap(); - assert_eq!(applied.action, "apply"); - assert_eq!( - fs::read_to_string(repository.join(SETUP_CONFIG_PATH)).unwrap(), - original_toml - ); - assert!( - !repository.join(".planr").exists(), - "standalone setup must not create .planr" - ); - let status = status_repository(&repository).unwrap(); - assert!(status.artifacts.iter().any(|artifact| { - artifact.path == SETUP_CONFIG_PATH && artifact.status == "managed" - })); - let saved_preview = preview_saved_setup(&repository).unwrap(); - assert!(saved_preview.artifacts.iter().any(|artifact| { - artifact.path == SETUP_CONFIG_PATH && artifact.status == "unchanged" - })); - - let mut updated = - setup_spec_for_policy("balanced", "codex", Integration::Standalone).unwrap(); - let worker = updated.selected_roles.get_mut("worker").unwrap(); - worker.model = "gpt-5.6-sol".to_string(); - worker.effort = Some("medium".to_string()); - worker.spawn = Some(SetupSpawnPolicy { - agent_type: "switchloom_worker".to_string(), - task_name: "worker".to_string(), - fork_turns: ForkPolicy { - mode: "none".to_string(), - turns: None, - }, - }); - let updated_file = repository.join("updated.setup.toml"); - let updated_toml = setup_spec_to_canonical_toml(&updated).unwrap(); - fs::write(&updated_file, &updated_toml).unwrap(); - let update = update_setup_config_file(&repository, &updated_file).unwrap(); - assert_eq!(update.action, "update"); - assert_eq!( - fs::read_to_string(repository.join(SETUP_CONFIG_PATH)).unwrap(), - updated_toml - ); - assert!( - repository - .join(".codex/agents/switchloom_worker.toml") - .exists() - ); - - let rollback = rollback_repository(&repository).unwrap(); - assert_eq!(rollback.action, "rollback"); - assert_eq!( - fs::read_to_string(repository.join(SETUP_CONFIG_PATH)).unwrap(), - original_toml - ); - assert!( - !repository - .join(".codex/agents/switchloom_worker.toml") - .exists() - ); - - let uninstall = uninstall_repository(&repository).unwrap(); - assert_eq!(uninstall.action, "uninstall"); - assert!(!repository.join(SETUP_CONFIG_PATH).exists()); - assert!(!repository.join(".model-routing/manifest.json").exists()); - } - - #[test] - fn setup_recipe_apply_persists_config_and_rejects_existing_conflicts() { - let repository = temp_repo("setup-recipe-lifecycle"); - let spec = setup_spec_for_policy("balanced", "codex", Integration::Planr).unwrap(); - let recipe = setup_spec_to_recipe(&spec).unwrap(); - - let preview = preview_setup_recipe(&repository, &recipe).unwrap(); - assert_eq!(preview.action, "preview"); - assert!(preview.artifacts.iter().any(|artifact| { - artifact.path == ".planr/agents.toml" && artifact.status == "create" - })); - apply_setup_recipe(&repository, &recipe).unwrap(); - assert_eq!( - fs::read_to_string(repository.join(SETUP_CONFIG_PATH)).unwrap(), - setup_spec_to_canonical_toml(&spec).unwrap() - ); - assert!(repository.join(".planr/agents.toml").exists()); - - let conflict_repo = temp_repo("setup-recipe-conflict"); - fs::create_dir_all(conflict_repo.join(".switchloom")).unwrap(); - fs::write(conflict_repo.join(SETUP_CONFIG_PATH), "not managed\n").unwrap(); - let error = apply_setup_recipe(&conflict_repo, &recipe) - .unwrap_err() - .to_string(); - assert!(error.contains(SETUP_CONFIG_PATH)); - } - - #[test] - fn prepared_setup_apply_aborts_when_repository_plan_changes_after_preview() { - let repository = temp_repo("prepared-setup-toctou"); - let spec = setup_spec_for_policy("balanced", "codex", Integration::Standalone).unwrap(); - let prepared = prepare_setup_lifecycle(&spec).unwrap(); - let preview = preview_prepared_setup(&repository, &prepared).unwrap(); - fs::create_dir_all(repository.join(".switchloom")).unwrap(); - fs::write(repository.join(SETUP_CONFIG_PATH), "external change\n").unwrap(); - let error = apply_prepared_setup(&repository, &prepared, &preview) - .unwrap_err() - .to_string(); - assert!(error.contains("repository state changed after preview")); - assert_eq!( - fs::read_to_string(repository.join(SETUP_CONFIG_PATH)).unwrap(), - "external change\n" - ); - assert!(!repository.join(".model-routing/manifest.json").exists()); - } - - #[cfg(unix)] - #[test] - fn prepared_setup_apply_aborts_when_repository_symlink_retargets_after_preview() { - use std::os::unix::fs::symlink; - - let root = temp_repo("prepared-setup-symlink"); - let repo_a = root.join("repo-a"); - let repo_b = root.join("repo-b"); - let link = root.join("repo-link"); - fs::create_dir_all(&repo_a).unwrap(); - fs::create_dir_all(&repo_b).unwrap(); - symlink(&repo_a, &link).unwrap(); - - let spec = setup_spec_for_policy("balanced", "codex", Integration::Standalone).unwrap(); - let prepared = prepare_setup_lifecycle(&spec).unwrap(); - let preview = preview_prepared_setup(&link, &prepared).unwrap(); - assert_eq!( - preview.repository, - repo_a.canonicalize().unwrap().display().to_string() - ); - - fs::remove_file(&link).unwrap(); - symlink(&repo_b, &link).unwrap(); - let error = apply_prepared_setup(&link, &prepared, &preview) - .unwrap_err() - .to_string(); - assert!(error.contains("repository state changed after preview")); - assert!(!repo_a.join(SETUP_CONFIG_PATH).exists()); - assert!(!repo_b.join(SETUP_CONFIG_PATH).exists()); - assert!(!repo_a.join(".model-routing/manifest.json").exists()); - assert!(!repo_b.join(".model-routing/manifest.json").exists()); - } - - #[test] - fn setup_spec_rejects_unknown_fields_and_invalid_combinations() { - let unknown = r#"{ - "schema_version": 1, - "host": "codex", - "integration": "standalone", - "usage_policy": "balanced", - "selected_roles": {}, - "routes": [], - "unexpected": true -}"#; - assert!( - format!("{:#}", setup_spec_from_json(unknown).unwrap_err()).contains("unknown field") - ); - - let invalid_effort = SetupSpecV1 { - schema_version: 1, - host: "codex".to_string(), - integration: Integration::Standalone, - usage_policy: "balanced".to_string(), - selected_roles: BTreeMap::from([( - "implementer".to_string(), - SetupRoleSelection { - model: "gpt-5.6-luna".to_string(), - effort: Some("ultra".to_string()), - spawn: Some(SetupSpawnPolicy { - agent_type: "switchloom_implementer".to_string(), - task_name: "implementer".to_string(), - fork_turns: ForkPolicy { - mode: "none".to_string(), - turns: None, - }, - }), - }, - )]), - routes: vec![SetupRouteMapping { - work_type: "code".to_string(), - role: "implementer".to_string(), - fallbacks: Vec::new(), - }], - route_default: None, - }; - assert!( - validate_setup_spec(&invalid_effort) - .unwrap_err() - .to_string() - .contains("is not supported") - ); - - let mut invalid_fork = invalid_effort; - invalid_fork - .selected_roles - .get_mut("implementer") - .unwrap() - .model = "gpt-5.6-terra".to_string(); - invalid_fork - .selected_roles - .get_mut("implementer") - .unwrap() - .effort = Some("high".to_string()); - invalid_fork - .selected_roles - .get_mut("implementer") - .unwrap() - .spawn - .as_mut() - .unwrap() - .fork_turns = ForkPolicy { - mode: "all".to_string(), - turns: None, - }; - assert!( - validate_setup_spec(&invalid_fork) - .unwrap_err() - .to_string() - .contains("must not use fork_turns all") - ); - - let mut missing_spawn = invalid_fork.clone(); - missing_spawn - .selected_roles - .get_mut("implementer") - .unwrap() - .spawn = None; - assert!( - validate_setup_spec(&missing_spawn) - .unwrap_err() - .to_string() - .contains("must declare Codex spawn policy") - ); - - let mut name_mismatch = invalid_fork.clone(); - let spawn = name_mismatch - .selected_roles - .get_mut("implementer") - .unwrap() - .spawn - .as_mut() - .unwrap(); - spawn.fork_turns = ForkPolicy { - mode: "none".to_string(), - turns: None, - }; - spawn.task_name = "wrong_name".to_string(); - assert!( - validate_setup_spec(&name_mismatch) - .unwrap_err() - .to_string() - .contains("must match `implementer`") - ); - - let mut task_path = name_mismatch; - task_path - .selected_roles - .get_mut("implementer") - .unwrap() - .spawn - .as_mut() - .unwrap() - .task_name = "/root/task".to_string(); - assert!( - validate_setup_spec(&task_path) - .unwrap_err() - .to_string() - .contains("not a canonical task path") - ); - - let legacy_fork_context = r#"{ - "schema_version": 1, - "host": "codex", - "integration": "standalone", - "usage_policy": "balanced", - "selected_roles": { - "implementer": { - "model": "gpt-5.6-terra", - "effort": "high", - "fork_context": "none" - } - }, - "routes": [{"work_type": "code", "role": "implementer"}] -}"#; - assert!( - format!( - "{:#}", - setup_spec_from_json(legacy_fork_context).unwrap_err() - ) - .contains("fork_context") - ); - } - - #[test] - fn setup_contract_catalog_exposes_transport_and_host_options() { - let catalog = setup_contract_catalog_value().unwrap(); - assert_eq!(catalog["configPath"], SETUP_CONFIG_PATH); - assert_eq!(catalog["recipePrefix"], SETUP_RECIPE_PREFIX); - assert!(catalog["hosts"].as_array().unwrap().iter().any(|host| { - host["id"] == "codex" - && host["models"] - .as_array() - .unwrap() - .iter() - .any(|model| model["id"] == "gpt-5.6-sol") - })); - assert!(catalog["hosts"].as_array().unwrap().iter().any(|host| { - host["id"] == "opencode" - && host["binding"] == "opencode-native" - && host["models"] - .as_array() - .unwrap() - .iter() - .any(|model| model["id"] == "opencode/gpt-5-nano") - })); - } - - #[test] - fn planr_integration_is_skill_free_and_preloads_existing_protocols() { - for host in BINDINGS.map(|(host, _)| host) { - let bundle = compile_policy("balanced", host, Integration::Planr).unwrap(); - assert!( - bundle - .artifacts - .iter() - .any(|artifact| artifact.path == ".planr/agents.toml") - ); - assert!( - bundle - .artifacts - .iter() - .any(|artifact| artifact.path == ".planr/policy.toml") - ); - assert!( - bundle - .artifacts - .iter() - .all(|artifact| !artifact.path.contains("/skills/")) - ); - let content = serde_json::to_string(&bundle).unwrap(); - assert!(!content.contains("model-routing-native-routing")); - assert!(!content.contains("planr-native-routing")); - assert!(!content.contains("Protocol preload: $planr-goal")); - assert!(!content.contains("Protocol preload: $planr-loop")); - - let worker_protocol = bundle.artifacts.iter().any(|artifact| { - (artifact.path.contains("terra-high") - || artifact.path.contains("luna-xhigh") - || artifact.path.contains("preset-worker") - || artifact.path.starts_with(".pi/workflows/")) - && artifact.content.contains("Protocol preload: $planr-work") - }); - assert!(worker_protocol, "missing Planr worker preload for {host}"); - - if host == "codex-openai" || host == "mixed-host" { - assert!( - bundle.artifacts.iter().any(|artifact| artifact - .content - .contains("Protocol preload: $planr-review")), - "missing Planr review preload for {host}" - ); - } - - if host == "codex-openai" { - let registry = bundle - .artifacts - .iter() - .find(|artifact| artifact.path == ".planr/agents.toml") - .expect("missing Planr registry"); - assert!(!registry.content.contains("agent_type =")); - assert!(!registry.content.contains("fork_turns")); - assert!( - registry - .content - .contains("[profiles.model_routing_terra_high]") - ); - assert!( - registry - .content - .contains("[profiles.model_routing_sol_high]") - ); - assert!( - registry - .content - .contains("profile = \"model_routing_terra_high\"") - ); - assert!( - registry - .content - .contains("profile = \"model_routing_sol_high\"") - ); - } - } - } - - #[test] - fn valid_generated_bundles_pass_strict_inspection() { - for integration in [Integration::Standalone, Integration::Planr] { - let bundle = compile_json("balanced", "codex-openai", integration).unwrap(); - let inspection = inspect_bundle_json(&bundle).unwrap(); - assert!(inspection.valid); - assert_eq!(inspection.integration, integration); - assert_eq!(inspection.policy_id, "balanced"); - } - } - - #[test] - fn invalid_contract_fixtures_fail_for_named_reasons() { - for (fixture, expected) in [ - ( - include_str!("../fixtures/routing-bundle-v1/invalid-unsupported-version.json"), - "unsupported schema_version 2", - ), - ( - include_str!("../fixtures/routing-bundle-v1/invalid-dual-artifact-payload.json"), - "cannot define both content and content_ref", - ), - ( - include_str!("../fixtures/routing-bundle-v1/invalid-artifact-hash.json"), - "sha256 mismatch", - ), - ( - include_str!("../fixtures/routing-bundle-v1/invalid-unknown-source-field.json"), - "unknown field `unexpected`", - ), - ( - include_str!( - "../fixtures/routing-bundle-v1/invalid-unknown-policy-usage-field.json" - ), - "unknown field `unexpected`", - ), - ( - include_str!("../fixtures/routing-bundle-v1/invalid-unknown-profile-field.json"), - "unknown field `unexpected`", - ), - ( - include_str!( - "../fixtures/routing-bundle-v1/invalid-runtime-missing-source-reference.json" - ), - "runtime behavior must declare source references", - ), - ( - include_str!( - "../fixtures/routing-bundle-v1/invalid-runtime-bogus-source-reference.json" - ), - "source reference must match the digest-bound evidence artifact", - ), - ( - include_str!( - "../fixtures/routing-bundle-v1/invalid-runtime-capability-mismatch.json" - ), - "capability_version must match parsed evidence_id", - ), - ( - include_str!("../fixtures/routing-bundle-v1/invalid-runtime-slot-count.json"), - "exactly the parsed evidence child slots", - ), - ( - include_str!("../fixtures/routing-bundle-v1/invalid-runtime-version-drift.json"), - "installed host version must match parsed evidence command output", - ), - ( - include_str!("../fixtures/routing-bundle-v1/invalid-runtime-ultra-delegation.json"), - "delegation modes must match parsed evidence", - ), - ] { - let error = inspect_bundle_json(fixture).unwrap_err().to_string(); - assert!( - error.contains(expected), - "expected `{expected}` in `{error}`" - ); - } - } - - #[test] - fn standalone_policy_contract_changes_enforceable_output() { - let balanced = compile_policy("balanced", "codex-openai", Integration::Standalone).unwrap(); - let read_only = - compile_policy("read-only-audit", "codex-openai", Integration::Standalone).unwrap(); - - assert_ne!(balanced.policy, read_only.policy); - assert_eq!(read_only.policy.usage.max_parallel_writers, 0); - assert_eq!( - read_only - .policy - .execution - .roles - .get("worker") - .unwrap() - .filesystem - .write_roots, - Vec::::new() - ); - - let mut balanced_value: Value = serde_json::from_str( - &compile_json("balanced", "codex-openai", Integration::Standalone).unwrap(), - ) - .unwrap(); - let mut read_only_value: Value = serde_json::from_str( - &compile_json("read-only-audit", "codex-openai", Integration::Standalone).unwrap(), - ) - .unwrap(); - for value in [&mut balanced_value, &mut read_only_value] { - let object = value.as_object_mut().unwrap(); - object.remove("bundle_id"); - object.remove("policy_id"); - } - assert_ne!(balanced_value, read_only_value); - } - - #[test] - fn codex_and_mixed_bindings_keep_native_bounded_fork_topology() { - let codex = compile_json("balanced", "codex-openai", Integration::Standalone).unwrap(); - let mixed = compile_json("balanced", "mixed-host", Integration::Standalone).unwrap(); - assert!(codex.contains("gpt-5.6-sol")); - assert!(codex.contains("\"fork_turns\"")); - assert!(codex.contains("\"mode\": \"none\"")); - assert!(!codex.contains("fork_turns: \\\"all\\\"")); - assert!(mixed.contains("fable-5")); - assert!(mixed.contains("gpt-5.6-terra")); - assert_ne!(codex, mixed); - } - - #[test] - fn built_in_codex_presets_do_not_route_to_ultra() { - for policy in ["low-usage", "balanced", "max-quality"] { - let bundle = compile_policy(policy, "codex-openai", Integration::Standalone).unwrap(); - assert_ne!( - bundle.route_default.as_ref().unwrap().profile, - "codex-sol-ultra", - "{policy} must not default to Ultra" - ); - assert!( - bundle - .routes - .iter() - .all(|route| route.profile != "codex-sol-ultra"), - "{policy} must not route any default work type to Ultra" - ); - assert!( - bundle.profiles.contains_key("codex-sol-ultra"), - "{policy} keeps Ultra available as an explicit manual profile" - ); - assert!( - bundle - .artifacts - .iter() - .any(|artifact| artifact.path == ".codex/agents/model-routing-sol-ultra.toml"), - "{policy} keeps the manual Ultra role artifact" - ); - } - } - - #[test] - fn claude_and_cursor_native_adapters_emit_artifacts_with_advisory_effective_routing() { - for (host, expected_path, requested_model) in [ - ( - "claude-native", - ".claude/agents/model-routing-preset-worker.md", - "sonnet", - ), - ( - "cursor-openai", - ".cursor/agents/model-routing-preset-worker.md", - "gpt-5.4-mini", - ), - ( - "cursor-fable-grok", - ".cursor/agents/model-routing-preset-worker.md", - "cursor-grok-4.5-medium", - ), - ( - "opencode-native", - ".opencode/agents/model-routing-preset-worker.md", - "opencode/gpt-5-nano", - ), - ] { - let bundle = compile_policy("balanced", host, Integration::Standalone).unwrap(); - let contract = bundle.adapter_contract.as_ref().unwrap(); - assert_eq!( - contract.capability.runtime_class, - RuntimeClass::NativeSubagent - ); - assert_eq!(contract.adapter.runtime_class, RuntimeClass::NativeSubagent); - assert_eq!( - contract.capability.observability.effective_model, - GuaranteeLevel::Advisory - ); - assert_eq!( - contract - .capability - .guarantees - .get("model_selection") - .unwrap() - .level, - GuaranteeLevel::Advisory - ); - assert!( - contract - .capability - .known_limitations - .iter() - .any(|limitation| limitation.contains("override")) - || contract - .capability - .known_limitations - .iter() - .any(|limitation| limitation.contains("preempt")) - || contract - .capability - .known_limitations - .iter() - .any(|limitation| limitation.contains("provider")) - ); - assert!( - contract - .adapter - .dispatch_recipe - .artifact_paths - .contains(&expected_path.to_string()) - ); - - let artifact = bundle - .artifacts - .iter() - .find(|artifact| artifact.path == expected_path) - .unwrap(); - assert!(artifact.content.contains(requested_model)); - assert!(artifact.content.contains("preserve routing evidence")); - } - } - - #[test] - fn codex_child_overrides_require_bounded_fork_policy() { - let mut profile = Profile { - client: "codex".to_string(), - model: "gpt-5.6-terra".to_string(), - agent_type: Some("model_routing_terra_high".to_string()), - effort: Some("high".to_string()), - cost_tier: None, - capabilities: Vec::new(), - skill: None, - notes: None, - fork_turns: None, - }; - assert!( - validate_profile_fork_policy(&profile) - .unwrap_err() - .to_string() - .contains("must declare fork_turns") - ); - - profile.fork_turns = Some(ForkPolicy { - mode: "all".to_string(), - turns: None, - }); - assert!( - validate_profile_fork_policy(&profile) - .unwrap_err() - .to_string() - .contains("must not use fork_turns all") - ); - - profile.fork_turns = Some(ForkPolicy { - mode: "bounded".to_string(), - turns: Some(2), - }); - validate_profile_fork_policy(&profile).unwrap(); - } - - #[test] - fn lifecycle_preview_apply_status_and_uninstall_are_repository_safe() { - let repository = temp_repo("lifecycle"); - let bundle = compile_policy("balanced", "codex-openai", Integration::Standalone).unwrap(); - assert!(bundle.artifacts.iter().all(|artifact| { - artifact.path == CODEX_CONFIG_PATH || artifact.path.starts_with(".codex/agents/") - })); - assert!( - bundle - .artifacts - .iter() - .all(|artifact| !artifact.content.contains("codex exec")) - ); - let preview = preview_bundle(&repository, &bundle).unwrap(); - assert_eq!(preview.action, "preview"); - assert_eq!(preview.artifacts.len(), 7); - assert!( - preview - .artifacts - .iter() - .all(|artifact| artifact.status == "create") - ); - - let bundle_file = repository.join("bundle.json"); - fs::write( - &bundle_file, - compile_json("balanced", "codex-openai", Integration::Standalone).unwrap(), - ) - .unwrap(); - let applied = apply_bundle_file(&repository, &bundle_file).unwrap(); - assert_eq!(applied.action, "apply"); - assert!(repository.join(MANIFEST_PATH).exists()); - assert!( - repository - .join(".codex/agents/model-routing-sol-medium.toml") - .exists() - ); - let codex_config = fs::read_to_string(repository.join(".codex/config.toml")).unwrap(); - assert!(codex_config.contains("[agents.model_routing_terra_high]")); - assert!(codex_config.contains("config_file = \"./agents/model-routing-terra-high.toml\"")); - assert!(codex_config.contains("[agents.model_routing_sol_high]")); - assert!(codex_config.contains("config_file = \"./agents/model-routing-sol-high.toml\"")); - assert!( - repository - .join(".codex/agents/model-routing-terra-high.toml") - .exists() - ); - assert!( - repository - .join(".codex/agents/model-routing-sol-high.toml") - .exists() - ); - - let status = status_repository(&repository).unwrap(); - assert_eq!(status.action, "status"); - assert!( - status - .artifacts - .iter() - .all(|artifact| artifact.status == "managed") - ); - - let uninstalled = uninstall_repository(&repository).unwrap(); - assert_eq!(uninstalled.action, "uninstall"); - assert!( - uninstalled - .artifacts - .iter() - .all(|artifact| artifact.status == "removed") - ); - assert!(!repository.join(MANIFEST_PATH).exists()); - assert!( - !repository - .join(".codex/agents/model-routing-sol-medium.toml") - .exists() - ); - } - - #[test] - fn opencode_native_lifecycle_manages_project_agents() { - let repository = temp_repo("opencode-lifecycle"); - let bundle = - compile_policy("balanced", "opencode-native", Integration::Standalone).unwrap(); - let preview = preview_bundle(&repository, &bundle).unwrap(); - assert!(preview.artifacts.iter().any(|artifact| { - artifact.path == ".opencode/agents/model-routing-preset-worker.md" - && artifact.status == "create" - })); - - apply_bundle_file_with_bundle(&repository, &bundle).unwrap(); - let worker = repository.join(".opencode/agents/model-routing-preset-worker.md"); - let driver = repository.join(".opencode/agents/model-routing-preset-driver.md"); - assert!(worker.exists()); - assert!(driver.exists()); - let driver_content = fs::read_to_string(&driver).unwrap(); - assert!(driver_content.contains("permission:")); - assert!(driver_content.contains("model-routing-preset-worker: allow")); - - let status = status_repository(&repository).unwrap(); - assert!(status.artifacts.iter().any(|artifact| { - artifact.path == ".opencode/agents/model-routing-preset-worker.md" - && artifact.status == "managed" - })); - uninstall_repository(&repository).unwrap(); - assert!(!worker.exists()); - assert!(!driver.exists()); - assert!(!repository.join(MANIFEST_PATH).exists()); - } - - #[test] - fn pi_external_lifecycle_manages_workflow_artifacts() { - let repository = temp_repo("pi-external-lifecycle"); - let bundle = compile_policy("balanced", "pi-external", Integration::Standalone).unwrap(); - let preview = preview_bundle(&repository, &bundle).unwrap(); - assert!(preview.artifacts.iter().any(|artifact| { - artifact.path == ".pi/workflows/model-routing-preset-runner.json" - && artifact.status == "create" - })); - - apply_bundle_file_with_bundle(&repository, &bundle).unwrap(); - let workflow = repository.join(".pi/workflows/model-routing-preset-runner.json"); - assert!(workflow.exists()); - let workflow_content = fs::read_to_string(&workflow).unwrap(); - assert!(workflow_content.contains("\"external-runner\"")); - assert!(workflow_content.contains("\"--no-tools\"")); - assert!(workflow_content.contains("\"PI_CODING_AGENT_DIR")); - - let status = status_repository(&repository).unwrap(); - assert!(status.artifacts.iter().any(|artifact| { - artifact.path == ".pi/workflows/model-routing-preset-runner.json" - && artifact.status == "managed" - })); - uninstall_repository(&repository).unwrap(); - assert!(!workflow.exists()); - assert!(!repository.join(MANIFEST_PATH).exists()); - } - - #[test] - fn lifecycle_rejects_unsafe_paths_and_conflicts() { - let repository = temp_repo("unsafe"); - let mut bundle = - compile_policy("balanced", "codex-openai", Integration::Standalone).unwrap(); - - bundle.artifacts[0].path = ".model-routing/unsafe.toml".to_string(); - assert!( - preview_bundle(&repository, &bundle) - .unwrap_err() - .to_string() - .contains("reserved path") - ); - - let mut bundle = - compile_policy("balanced", "codex-openai", Integration::Standalone).unwrap(); - bundle.artifacts[0].path = "../escape.toml".to_string(); - assert!( - preview_bundle(&repository, &bundle) - .unwrap_err() - .to_string() - .contains("must not traverse") - ); - - let bundle = compile_policy("balanced", "codex-openai", Integration::Standalone).unwrap(); - let target = repository.join(&bundle.artifacts[0].path); - fs::create_dir_all(target.parent().unwrap()).unwrap(); - fs::write(&target, "user edit").unwrap(); - let error = apply_bundle_file_with_bundle(&repository, &bundle) - .unwrap_err() - .to_string(); - assert!(error.contains("already exists with different content")); - } - - #[test] - fn lifecycle_rejects_parent_child_targets_without_partial_apply() { - let repository = temp_repo("parent-child"); - let mut bundle = - compile_policy("balanced", "codex-openai", Integration::Standalone).unwrap(); - bundle.artifacts.truncate(2); - bundle.artifacts[0].path = ".codex/agents/collision".to_string(); - bundle.artifacts[0].content = "parent".to_string(); - bundle.artifacts[0].sha256 = sha256(bundle.artifacts[0].content.as_bytes()); - bundle.artifacts[1].path = ".codex/agents/collision/child.toml".to_string(); - bundle.artifacts[1].content = "child".to_string(); - bundle.artifacts[1].sha256 = sha256(bundle.artifacts[1].content.as_bytes()); - - let error = apply_bundle_file_with_bundle(&repository, &bundle) - .unwrap_err() - .to_string(); - assert!(error.contains("parent-child collision")); - assert!(!repository.join(".codex/agents/collision").exists()); - assert!(!repository.join(MANIFEST_PATH).exists()); - } - - #[test] - fn lifecycle_update_and_rollback_are_manifest_aware() { - let repository = temp_repo("update-rollback"); - let original = compile_policy("balanced", "codex-openai", Integration::Standalone).unwrap(); - apply_bundle_file_with_bundle(&repository, &original).unwrap(); - - let mut updated = original.clone(); - updated.bundle_id = "balanced-codex-openai@updated".to_string(); - updated.artifacts[0].content.push_str("\n# updated\n"); - updated.artifacts[0].sha256 = sha256(updated.artifacts[0].content.as_bytes()); - let bundle_file = write_bundle_file(&repository, "updated-bundle.json", &updated); - - let update = update_bundle_file(&repository, &bundle_file).unwrap(); - assert_eq!(update.action, "update"); - assert!( - update - .artifacts - .iter() - .any(|artifact| artifact.status == "update") - ); - assert_eq!( - sha256(&fs::read(repository.join(&updated.artifacts[0].path)).unwrap()), - updated.artifacts[0].sha256 - ); - - let rollback = rollback_repository(&repository).unwrap(); - assert_eq!(rollback.action, "rollback"); - assert!( - rollback - .artifacts - .iter() - .any(|artifact| artifact.status == "rollback") - ); - assert_eq!( - sha256(&fs::read(repository.join(&original.artifacts[0].path)).unwrap()), - original.artifacts[0].sha256 - ); - } - - #[test] - fn lifecycle_codex_config_merges_unrelated_entries_update_rollback_and_uninstall() { - let repository = temp_repo("codex-config-ownership"); - fs::create_dir_all(repository.join(".codex/agents")).unwrap(); - fs::write( - repository.join(CODEX_CONFIG_PATH), - "[agents.local_reviewer]\nconfig_file = \"./agents/local-reviewer.toml\"\n\n[features]\nlocal = true\n", - ) - .unwrap(); - fs::write( - repository.join(".codex/agents/local-reviewer.toml"), - "name = \"local_reviewer\"\n", - ) - .unwrap(); - let global_codex_home = temp_repo("global-codex-home"); - fs::write( - global_codex_home.join("config.toml"), - "[agents.global_reviewer]\nconfig_file = \"./agents/global-reviewer.toml\"\n", - ) - .unwrap(); - let global_config_before = fs::read(global_codex_home.join("config.toml")).unwrap(); - - let codex = compile_policy("balanced", "codex-openai", Integration::Standalone).unwrap(); - let applied = apply_bundle_file_with_bundle(&repository, &codex).unwrap(); - assert!( - applied.artifacts.iter().any(|artifact| { - artifact.path == CODEX_CONFIG_PATH && artifact.status == "update" - }) - ); - let config_after_apply = fs::read_to_string(repository.join(CODEX_CONFIG_PATH)).unwrap(); - assert_codex_config_entry( - &config_after_apply, - "local_reviewer", - "./agents/local-reviewer.toml", - ); - assert_codex_config_entry( - &config_after_apply, - "model_routing_terra_high", - "./agents/model-routing-terra-high.toml", - ); - assert_codex_config_entry( - &config_after_apply, - "model_routing_sol_high", - "./agents/model-routing-sol-high.toml", - ); - assert!(config_after_apply.contains("[features]")); - - let mixed = compile_policy("balanced", "mixed-host", Integration::Standalone).unwrap(); - let mixed_file = write_bundle_file(&repository, "mixed.json", &mixed); - let update = update_bundle_file(&repository, &mixed_file).unwrap(); - assert!( - update.artifacts.iter().any(|artifact| { - artifact.path == CODEX_CONFIG_PATH && artifact.status == "update" - }) - ); - let config_after_update = fs::read_to_string(repository.join(CODEX_CONFIG_PATH)).unwrap(); - assert_codex_config_entry( - &config_after_update, - "local_reviewer", - "./agents/local-reviewer.toml", - ); - assert_codex_config_entry( - &config_after_update, - "model_routing_terra_high", - "./agents/model-routing-terra-high.toml", - ); - assert_no_codex_config_entry(&config_after_update, "model_routing_sol_medium"); - assert_no_codex_config_entry(&config_after_update, "model_routing_sol_ultra"); - - let rollback = rollback_repository(&repository).unwrap(); - assert!(rollback.artifacts.iter().any(|artifact| { - artifact.path == CODEX_CONFIG_PATH && artifact.status == "rollback" - })); - let config_after_rollback = fs::read_to_string(repository.join(CODEX_CONFIG_PATH)).unwrap(); - assert_codex_config_entry( - &config_after_rollback, - "local_reviewer", - "./agents/local-reviewer.toml", - ); - assert_codex_config_entry( - &config_after_rollback, - "model_routing_sol_medium", - "./agents/model-routing-sol-medium.toml", - ); - assert_codex_config_entry( - &config_after_rollback, - "model_routing_sol_ultra", - "./agents/model-routing-sol-ultra.toml", - ); - - let uninstall = uninstall_repository(&repository).unwrap(); - assert!(uninstall.artifacts.iter().any(|artifact| { - artifact.path == CODEX_CONFIG_PATH && artifact.status == "removed" - })); - let config_after_uninstall = - fs::read_to_string(repository.join(CODEX_CONFIG_PATH)).unwrap(); - assert_codex_config_entry( - &config_after_uninstall, - "local_reviewer", - "./agents/local-reviewer.toml", - ); - assert_no_codex_config_entry(&config_after_uninstall, "model_routing_terra_high"); - assert_no_codex_config_entry(&config_after_uninstall, "model_routing_sol_high"); - assert!(config_after_uninstall.contains("[features]")); - assert_eq!( - fs::read_to_string(repository.join(".codex/agents/local-reviewer.toml")).unwrap(), - "name = \"local_reviewer\"\n" - ); - assert_eq!( - fs::read(global_codex_home.join("config.toml")).unwrap(), - global_config_before - ); - assert!(!repository.join(MANIFEST_PATH).exists()); - } - - #[test] - fn lifecycle_codex_config_modified_managed_entry_is_preserved_with_repair() { - let repository = temp_repo("codex-config-modified-entry"); - let codex = compile_policy("balanced", "codex-openai", Integration::Standalone).unwrap(); - apply_bundle_file_with_bundle(&repository, &codex).unwrap(); - fs::write( - repository.join(CODEX_CONFIG_PATH), - "[agents.model_routing_terra_high]\nconfig_file = \"./agents/hacked.toml\"\n", - ) - .unwrap(); - - let status = status_repository(&repository).unwrap(); - assert!(status.artifacts.iter().any(|artifact| { - artifact.path == CODEX_CONFIG_PATH - && artifact.status == "modified" - && artifact.repair.is_some() - })); - - let uninstall = uninstall_repository(&repository).unwrap(); - assert!(uninstall.artifacts.iter().any(|artifact| { - artifact.path == CODEX_CONFIG_PATH - && artifact.status == "preserved-modified" - && artifact.repair.is_some() - })); - assert!(repository.join(CODEX_CONFIG_PATH).exists()); - assert!(repository.join(MANIFEST_PATH).exists()); - } - - #[test] - fn lifecycle_recovers_interrupted_transaction_before_next_entrypoint() { - let repository = temp_repo("interrupted-transaction"); - let original = compile_policy("balanced", "codex-openai", Integration::Standalone).unwrap(); - apply_bundle_file_with_bundle(&repository, &original).unwrap(); - - let mut updated = original.clone(); - updated.bundle_id = "balanced-codex-openai@interrupted".to_string(); - updated.artifacts[0] - .content - .push_str("\n# interrupted update\n"); - updated.artifacts[0].sha256 = sha256(updated.artifacts[0].content.as_bytes()); - let updated_file = write_bundle_file(&repository, "interrupted.json", &updated); - - TRANSACTION_FAIL_AFTER_WRITES.with(|fail_after| fail_after.set(1)); - let interrupted = std::panic::catch_unwind(|| { - update_bundle_file(&repository, &updated_file).unwrap(); - }); - TRANSACTION_FAIL_AFTER_WRITES.with(|fail_after| fail_after.set(0)); - assert!(interrupted.is_err()); - assert!(has_transaction_directory(&repository)); - - let status = status_repository(&repository).unwrap(); - assert_eq!( - status.bundle_id.as_deref(), - Some(original.bundle_id.as_str()) - ); - assert_eq!( - sha256(&fs::read(repository.join(&original.artifacts[0].path)).unwrap()), - original.artifacts[0].sha256 - ); - assert!( - status - .artifacts - .iter() - .all(|artifact| artifact.status == "managed") - ); - assert!(!has_transaction_directory(&repository)); - - let update = update_bundle_file(&repository, &updated_file).unwrap(); - assert!( - update - .artifacts - .iter() - .any(|artifact| artifact.status == "update") - ); - } - - #[test] - fn lifecycle_recovers_interrupted_atomic_journal_replacement() { - let repository = temp_repo("journal-replace"); - let original = compile_policy("balanced", "codex-openai", Integration::Standalone).unwrap(); - apply_bundle_file_with_bundle(&repository, &original).unwrap(); - - let mut updated = original.clone(); - updated.bundle_id = "balanced-codex-openai@journal-replace".to_string(); - updated.artifacts[0] - .content - .push_str("\n# journal replace interruption\n"); - updated.artifacts[0].sha256 = sha256(updated.artifacts[0].content.as_bytes()); - let updated_file = write_bundle_file(&repository, "journal-replace.json", &updated); - - TRANSACTION_FAIL_JOURNAL_REPLACE_AFTER.with(|fail_after| fail_after.set(2)); - let interrupted = std::panic::catch_unwind(|| { - update_bundle_file(&repository, &updated_file).unwrap(); - }); - TRANSACTION_FAIL_JOURNAL_REPLACE_AFTER.with(|fail_after| fail_after.set(0)); - assert!(interrupted.is_err()); - assert!(has_transaction_directory(&repository)); - - let status = status_repository(&repository).unwrap(); - assert_eq!( - status.bundle_id.as_deref(), - Some(original.bundle_id.as_str()) - ); - assert_eq!( - sha256(&fs::read(repository.join(&original.artifacts[0].path)).unwrap()), - original.artifacts[0].sha256 - ); - assert!(!has_transaction_directory(&repository)); - } - - #[test] - fn lifecycle_restore_failure_preserves_recoverable_transaction_data() { - let repository = temp_repo("restore-failure"); - let original = compile_policy("balanced", "codex-openai", Integration::Standalone).unwrap(); - apply_bundle_file_with_bundle(&repository, &original).unwrap(); - - let mut updated = original.clone(); - updated.bundle_id = "balanced-codex-openai@restore-failure".to_string(); - updated.artifacts[0] - .content - .push_str("\n# restore failure\n"); - updated.artifacts[0].sha256 = sha256(updated.artifacts[0].content.as_bytes()); - let updated_file = write_bundle_file(&repository, "restore-failure.json", &updated); - - TRANSACTION_FAIL_AFTER_WRITES.with(|fail_after| fail_after.set(1)); - let interrupted = std::panic::catch_unwind(|| { - update_bundle_file(&repository, &updated_file).unwrap(); - }); - TRANSACTION_FAIL_AFTER_WRITES.with(|fail_after| fail_after.set(0)); - assert!(interrupted.is_err()); - assert!(has_transaction_directory(&repository)); - - TRANSACTION_FAIL_RESTORE.with(|fail| fail.set(true)); - let recovery_error = status_repository(&repository).unwrap_err().to_string(); - TRANSACTION_FAIL_RESTORE.with(|fail| fail.set(false)); - assert!(recovery_error.contains("failed to recover transaction write")); - assert!(has_transaction_directory(&repository)); - - let status = status_repository(&repository).unwrap(); - assert_eq!( - status.bundle_id.as_deref(), - Some(original.bundle_id.as_str()) - ); - assert_eq!( - sha256(&fs::read(repository.join(&original.artifacts[0].path)).unwrap()), - original.artifacts[0].sha256 - ); - assert!(!has_transaction_directory(&repository)); - } - - #[test] - fn lifecycle_returned_journal_error_retains_backup_when_immediate_rollback_fails() { - let repository = temp_repo("rollback-retains-backup"); - let original = compile_policy("balanced", "codex-openai", Integration::Standalone).unwrap(); - apply_bundle_file_with_bundle(&repository, &original).unwrap(); - - let mut updated = original.clone(); - updated.bundle_id = "balanced-codex-openai@rollback-retains-backup".to_string(); - updated.artifacts[0] - .content - .push_str("\n# returned journal error\n"); - updated.artifacts[0].sha256 = sha256(updated.artifacts[0].content.as_bytes()); - let updated_file = write_bundle_file(&repository, "rollback-retains-backup.json", &updated); - - TRANSACTION_RETURN_JOURNAL_ERROR_AFTER.with(|fail_after| fail_after.set(2)); - TRANSACTION_FAIL_RESTORE.with(|fail| fail.set(true)); - let error = update_bundle_file(&repository, &updated_file) - .unwrap_err() - .to_string(); - TRANSACTION_RETURN_JOURNAL_ERROR_AFTER.with(|fail_after| fail_after.set(0)); - TRANSACTION_FAIL_RESTORE.with(|fail| fail.set(false)); - assert!(error.contains("transaction rollback incomplete")); - assert!(has_transaction_directory(&repository)); - assert!(has_transaction_backup(&repository)); - - let status = status_repository(&repository).unwrap(); - assert_eq!( - status.bundle_id.as_deref(), - Some(original.bundle_id.as_str()) - ); - assert_eq!( - sha256(&fs::read(repository.join(&original.artifacts[0].path)).unwrap()), - original.artifacts[0].sha256 - ); - assert!(!has_transaction_directory(&repository)); - } - - #[test] - fn lifecycle_staged_rename_error_restores_backup_before_commit_mark() { - let repository = temp_repo("staged-rename"); - let original = compile_policy("balanced", "codex-openai", Integration::Standalone).unwrap(); - apply_bundle_file_with_bundle(&repository, &original).unwrap(); - - let mut updated = original.clone(); - updated.bundle_id = "balanced-codex-openai@staged-rename".to_string(); - updated.artifacts[0] - .content - .push_str("\n# staged rename failure\n"); - updated.artifacts[0].sha256 = sha256(updated.artifacts[0].content.as_bytes()); - let updated_file = write_bundle_file(&repository, "staged-rename.json", &updated); - - TRANSACTION_RETURN_STAGED_RENAME_ERROR_AFTER.with(|fail_after| fail_after.set(1)); - let error = update_bundle_file(&repository, &updated_file) - .unwrap_err() - .to_string(); - TRANSACTION_RETURN_STAGED_RENAME_ERROR_AFTER.with(|fail_after| fail_after.set(0)); - assert!(error.contains("injected staged rename error after backup")); - assert!(!has_transaction_directory(&repository)); - - let status = status_repository(&repository).unwrap(); - assert_eq!( - status.bundle_id.as_deref(), - Some(original.bundle_id.as_str()) - ); - assert_eq!( - sha256(&fs::read(repository.join(&original.artifacts[0].path)).unwrap()), - original.artifacts[0].sha256 - ); - } - - #[test] - fn lifecycle_preserves_modified_files_and_residual_manifest() { - let repository = temp_repo("preserve-residual"); - let mut bundle = - compile_policy("balanced", "codex-openai", Integration::Standalone).unwrap(); - bundle.artifacts.truncate(1); - apply_bundle_file_with_bundle(&repository, &bundle).unwrap(); - - let target = repository.join(&bundle.artifacts[0].path); - fs::write(&target, "user modified").unwrap(); - let uninstall = uninstall_repository(&repository).unwrap(); - assert_eq!(uninstall.artifacts[0].status, "preserved-modified"); - assert!(uninstall.artifacts[0].repair.is_some()); - assert!(target.exists()); - assert!(repository.join(MANIFEST_PATH).exists()); - - let status = status_repository(&repository).unwrap(); - assert_eq!(status.artifacts[0].status, "modified"); - assert!(status.artifacts[0].repair.is_some()); - } - - #[test] - fn lifecycle_cross_host_update_and_rollback_remove_old_managed_artifacts() { - let repository = temp_repo("cross-host"); - let codex = compile_policy("balanced", "codex-openai", Integration::Standalone).unwrap(); - let claude = compile_policy("balanced", "claude-native", Integration::Standalone).unwrap(); - apply_bundle_file_with_bundle(&repository, &codex).unwrap(); - let codex_artifact = repository.join(".codex/agents/model-routing-sol-medium.toml"); - assert!(codex_artifact.exists()); - - let claude_file = write_bundle_file(&repository, "claude.json", &claude); - let update = update_bundle_file(&repository, &claude_file).unwrap(); - assert!( - update - .artifacts - .iter() - .any(|artifact| artifact.mode == "delete" && artifact.status == "removed") - ); - assert!(!codex_artifact.exists()); - let status = status_repository(&repository).unwrap(); - assert!( - status - .artifacts - .iter() - .all(|artifact| artifact.path.starts_with(".claude/")) - ); - - let claude_artifact = repository.join(".claude/agents/model-routing-preset-worker.md"); - assert!(claude_artifact.exists()); - let rollback = rollback_repository(&repository).unwrap(); - assert!( - rollback - .artifacts - .iter() - .any(|artifact| artifact.mode == "delete" && artifact.status == "removed") - ); - assert!(!claude_artifact.exists()); - assert!(codex_artifact.exists()); - let status = status_repository(&repository).unwrap(); - assert!( - status - .artifacts - .iter() - .all(|artifact| artifact.path.starts_with(".codex/")) - ); - - uninstall_repository(&repository).unwrap(); - assert!(!repository.join(MANIFEST_PATH).exists()); - assert!(!codex_artifact.exists()); - } - - #[test] - fn lifecycle_cross_host_update_preserves_modified_removed_paths() { - let repository = temp_repo("cross-host-preserve"); - let codex = compile_policy("balanced", "codex-openai", Integration::Standalone).unwrap(); - let claude = compile_policy("balanced", "claude-native", Integration::Standalone).unwrap(); - apply_bundle_file_with_bundle(&repository, &codex).unwrap(); - let codex_artifact = repository.join(".codex/agents/model-routing-sol-medium.toml"); - fs::write(&codex_artifact, "user modified codex artifact").unwrap(); - - let claude_file = write_bundle_file(&repository, "claude.json", &claude); - let update = update_bundle_file(&repository, &claude_file).unwrap(); - let preserved = update - .artifacts - .iter() - .find(|artifact| artifact.path == ".codex/agents/model-routing-sol-medium.toml") - .unwrap(); - assert_eq!(preserved.mode, "delete"); - assert_eq!(preserved.status, "preserved-modified"); - assert!(preserved.repair.is_some()); - assert!(codex_artifact.exists()); - - let status = status_repository(&repository).unwrap(); - assert!(status.artifacts.iter().any(|artifact| { - artifact.path == ".codex/agents/model-routing-sol-medium.toml" - && artifact.status == "modified" - && artifact.repair.is_some() - })); - assert!( - status - .artifacts - .iter() - .any(|artifact| artifact.path.starts_with(".claude/")) - ); - } - - fn apply_bundle_file_with_bundle( - repository: &Path, - bundle: &RoutingBundleV1, - ) -> Result { - let bundle_file = write_bundle_file(repository, "bundle.json", bundle); - apply_bundle_file(repository, &bundle_file) - } - - fn write_bundle_file(repository: &Path, name: &str, bundle: &RoutingBundleV1) -> PathBuf { - let bundle_file = repository.join(name); - fs::write(&bundle_file, serde_json::to_vec_pretty(bundle).unwrap()).unwrap(); - bundle_file - } - - fn assert_codex_config_entry(content: &str, agent_type: &str, config_file: &str) { - let parsed: toml::Value = toml::from_str(content).unwrap(); - assert_eq!( - parsed["agents"][agent_type]["config_file"].as_str(), - Some(config_file) - ); - } - - fn assert_no_codex_config_entry(content: &str, agent_type: &str) { - let parsed: toml::Value = toml::from_str(content).unwrap(); - assert!(parsed["agents"].get(agent_type).is_none()); - } - - fn has_transaction_directory(repository: &Path) -> bool { - fs::read_dir(repository.join(".model-routing")) - .unwrap() - .any(|entry| { - entry - .unwrap() - .file_name() - .to_str() - .is_some_and(|name| name.starts_with("txn-")) - }) - } - - fn has_transaction_backup(repository: &Path) -> bool { - fs::read_dir(repository.join(".model-routing")) - .unwrap() - .filter_map(Result::ok) - .any(|entry| { - entry - .file_name() - .to_str() - .is_some_and(|name| name.starts_with("txn-")) - && entry.path().join("backup").exists() - }) - } - - #[test] - fn codex_agent_types_match_registered_toml_names() { - for host in ["codex-openai", "mixed-host"] { - let source = show_policy("balanced", host).unwrap(); - assert!( - source - .artifacts - .iter() - .all(|artifact| !artifact.path.starts_with(".codex/skills/")) - ); - assert!( - source - .artifacts - .iter() - .all(|artifact| !artifact.content.contains("model-routing-native-routing")) - ); - let bundle = compile_policy("balanced", host, Integration::Standalone).unwrap(); - let config = bundle - .artifacts - .iter() - .find(|artifact| artifact.path == ".codex/config.toml") - .expect("Codex role config should be generated"); - let parsed_config: toml::Value = toml::from_str(&config.content).unwrap(); - let config_agents = parsed_config["agents"].as_table().unwrap(); - let registered_names = bundle - .artifacts - .iter() - .filter(|artifact| artifact.path.starts_with(".codex/agents/")) - .map(|artifact| { - let agent_type = - toml::from_str::(&artifact.content).unwrap()["name"] - .as_str() - .unwrap() - .to_string(); - let relative_config_file = - artifact.path.strip_prefix(".codex/").unwrap().to_string(); - assert_eq!( - config_agents[&agent_type]["config_file"].as_str(), - Some(format!("./{relative_config_file}").as_str()) - ); - agent_type - }) - .collect::>(); - for profile in bundle - .profiles - .values() - .filter(|profile| profile.client == "codex") - { - let agent_type = profile.agent_type.as_deref().unwrap(); - assert!(registered_names.contains(agent_type)); - } - } - } - - #[test] - fn fresh_repository_registers_codex_native_role_discovery_config() { - let repository = temp_repo("codex-native-discovery-config"); - let bundle = compile_policy("balanced", "codex-openai", Integration::Standalone).unwrap(); - assert!(bundle.artifacts.iter().all(|artifact| { - artifact.path == ".codex/config.toml" || artifact.path.starts_with(".codex/agents/") - })); - assert!( - bundle - .artifacts - .iter() - .all(|artifact| !artifact.path.starts_with(".codex/skills/")) - ); - apply_bundle_file_with_bundle(&repository, &bundle).unwrap(); - - for role in ["model-routing-terra-high", "model-routing-sol-high"] { - assert!( - repository - .join(format!(".codex/agents/{role}.toml")) - .exists(), - "generated native Codex role file {role} should exist" - ); - } - - let config = bundle - .artifacts - .iter() - .find(|artifact| artifact.path == ".codex/config.toml") - .expect("repository-local Codex role discovery config should be generated"); - let parsed: toml::Value = toml::from_str(&config.content).unwrap(); - assert_eq!( - parsed["agents"]["model_routing_terra_high"]["config_file"].as_str(), - Some("./agents/model-routing-terra-high.toml") - ); - assert_eq!( - parsed["agents"]["model_routing_sol_high"]["config_file"].as_str(), - Some("./agents/model-routing-sol-high.toml") - ); - assert_eq!( - fs::read_to_string(repository.join(".codex/config.toml")).unwrap(), - config.content - ); - } - - #[test] - fn generated_registry_is_derived_from_binding_profiles_and_routes() { - for host in BINDINGS.map(|(host, _)| host) { - let bundle = compile_policy("balanced", host, Integration::Planr).unwrap(); - let registry = bundle - .artifacts - .iter() - .find(|artifact| artifact.path == ".planr/agents.toml") - .unwrap(); - let parsed: toml::Value = toml::from_str(®istry.content).unwrap(); - assert_eq!( - parsed["profiles"].as_table().unwrap().len(), - bundle.profiles.len() - ); - assert_eq!( - parsed["routes"].as_array().unwrap().len(), - bundle.routes.len() - ); - } - } - - #[test] - fn checked_in_planr_contract_fixtures_are_generated_outputs() { - for (host, fixture) in [ - ( - "codex-openai", - include_str!("../fixtures/routing-bundle-v1/valid-balanced-codex.json"), - ), - ( - "mixed-host", - include_str!("../fixtures/routing-bundle-v1/valid-balanced-mixed.json"), - ), - ] { - let generated: serde_json::Value = - serde_json::from_str(&compile_json("balanced", host, Integration::Planr).unwrap()) - .unwrap(); - let checked_in: serde_json::Value = serde_json::from_str(fixture).unwrap(); - assert_eq!(generated, checked_in, "regenerate fixture for {host}"); - } - } - - #[test] - fn offline_evaluation_never_claims_live_verification_or_recommendation() { - let report = evaluate_policy("balanced", "codex-openai").unwrap(); - assert!(report.offline_reproducible); - assert!(report.scenario_count >= 7); - assert_eq!(report.status, "experimental"); - assert!(!report.recommended); - } - - #[test] - fn no_in_memory_claim_can_promote_offline_evaluation() { - let report = evaluate_policy("balanced", "codex-openai").unwrap(); - assert!(report.live_evidence.is_none()); - assert_eq!(report.status, "experimental"); - assert!(!report.recommended); - } - - #[test] - fn catalog_is_reproducible_and_contains_the_full_pool() { - let first = catalog_json().unwrap(); - let second = catalog_json().unwrap(); - assert_eq!(first, second); - let value: Value = serde_json::from_str(&first).unwrap(); - assert_eq!(value["compositions"].as_array().unwrap().len(), 28); - assert!( - value["compositions"] - .as_array() - .unwrap() - .iter() - .all(|entry| entry["recommended"] == false) - ); - } - - #[test] - fn registry_signatures_are_content_bound() { - let signing_key = SigningKey::from_bytes(&[7_u8; 32]); - let trusted_public_key = encode_hex(signing_key.verifying_key().as_bytes()); - let signature = sign_registry(b"catalog", "fixture", &"07".repeat(32)).unwrap(); - verify_registry_signature(b"catalog", &signature, "fixture", &trusted_public_key).unwrap(); - assert!( - verify_registry_signature(b"tampered", &signature, "fixture", &trusted_public_key) - .is_err() - ); - let attacker_key = encode_hex( - SigningKey::from_bytes(&[8_u8; 32]) - .verifying_key() - .as_bytes(), - ); - assert!( - verify_registry_signature(b"catalog", &signature, "fixture", &attacker_key).is_err() - ); - assert!( - verify_registry_signature(b"catalog", &signature, "attacker", &trusted_public_key) - .is_err() - ); - } - - #[test] - fn probe_does_not_infer_authentication_from_version_availability() { - let report = probe_host( - "codex-openai", - Some("definitely-not-a-model-routing-host-command"), - ) - .unwrap(); - assert!(!report.available); - assert_eq!(report.authentication, "not_tested"); - } -} +#[path = "tests/architecture.rs"] +mod architecture_tests; diff --git a/src/lifecycle.rs b/src/lifecycle.rs new file mode 100644 index 0000000..695c12a --- /dev/null +++ b/src/lifecycle.rs @@ -0,0 +1,1629 @@ +use crate::contracts::*; +use crate::error::{Result, ResultContext}; +use crate::{bail, product_error}; +use crate::{config::*, registry::*, routing::*}; +use serde::{Deserialize, Serialize}; +use std::cell::Cell; +use std::collections::{BTreeMap, BTreeSet}; +use std::fs; +use std::path::{Component, Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; + +pub(crate) const CODEX_CONFIG_PATH: &str = ".codex/config.toml"; +pub(crate) const MANIFEST_PATH: &str = ".model-routing/manifest.json"; +pub(crate) const TRANSACTION_JOURNAL: &str = "journal.json"; +thread_local! { + pub(crate) static TRANSACTION_FAIL_AFTER_WRITES: Cell = const { Cell::new(0) }; + pub(crate) static TRANSACTION_FAIL_JOURNAL_REPLACE_AFTER: Cell = const { Cell::new(0) }; + pub(crate) static TRANSACTION_RETURN_JOURNAL_ERROR_AFTER: Cell = const { Cell::new(0) }; + pub(crate) static TRANSACTION_RETURN_STAGED_RENAME_ERROR_AFTER: Cell = const { Cell::new(0) }; + pub(crate) static TRANSACTION_FAIL_RESTORE: Cell = const { Cell::new(false) }; +} + +#[cfg(test)] +#[path = "tests/lifecycle.rs"] +mod tests; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct LifecycleReport { + pub action: String, + pub bundle_id: Option, + pub repository: String, + pub artifacts: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct LifecycleArtifactReport { + pub path: String, + pub mode: String, + pub status: String, + pub sha256: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub repair: Option, +} + +pub struct PreparedSetupLifecycle { + bundle: RoutingBundleV1, + bundle_input: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub(crate) struct ManagedManifest { + pub(crate) schema_version: u32, + pub(crate) bundle_id: String, + pub(crate) bundle_sha256: String, + pub(crate) artifacts: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) previous: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub(crate) struct ManagedArtifact { + pub(crate) path: String, + pub(crate) sha256: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) content: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub(crate) struct ManagedSnapshot { + pub(crate) bundle_id: String, + pub(crate) bundle_sha256: String, + pub(crate) artifacts: Vec, +} + +pub fn preview_setup_config_file(repository: &Path, config_file: &Path) -> Result { + let spec = read_setup_config_file(config_file)?; + preview_setup(repository, &spec) +} + +pub fn preview_setup_recipe(repository: &Path, recipe: &str) -> Result { + let spec = setup_spec_from_recipe(recipe)?; + preview_setup(repository, &spec) +} + +pub fn preview_saved_setup(repository: &Path) -> Result { + let spec = read_saved_setup_config(repository)?; + preview_setup(repository, &spec) +} + +pub fn apply_setup_config_file(repository: &Path, config_file: &Path) -> Result { + let spec = read_setup_config_file(config_file)?; + apply_setup(repository, &spec) +} + +pub fn apply_setup_recipe(repository: &Path, recipe: &str) -> Result { + let spec = setup_spec_from_recipe(recipe)?; + apply_setup(repository, &spec) +} + +pub fn apply_saved_setup(repository: &Path) -> Result { + let spec = read_saved_setup_config(repository)?; + apply_setup(repository, &spec) +} + +pub fn update_setup_config_file(repository: &Path, config_file: &Path) -> Result { + let spec = read_setup_config_file(config_file)?; + update_setup(repository, &spec) +} + +pub fn update_setup_recipe(repository: &Path, recipe: &str) -> Result { + let spec = setup_spec_from_recipe(recipe)?; + update_setup(repository, &spec) +} + +pub fn update_saved_setup(repository: &Path) -> Result { + let spec = read_saved_setup_config(repository)?; + update_setup(repository, &spec) +} + +pub fn prepare_setup_config_file(config_file: &Path) -> Result { + prepare_setup_lifecycle(&read_setup_config_file(config_file)?) +} + +pub fn prepare_setup_recipe(recipe: &str) -> Result { + prepare_setup_lifecycle(&setup_spec_from_recipe(recipe)?) +} + +pub fn prepare_saved_setup(repository: &Path) -> Result { + prepare_setup_lifecycle(&read_saved_setup_config(repository)?) +} + +pub fn preview_prepared_setup( + repository: &Path, + prepared: &PreparedSetupLifecycle, +) -> Result { + preview_bundle(repository, &prepared.bundle) +} + +pub fn apply_prepared_setup( + repository: &Path, + prepared: &PreparedSetupLifecycle, + confirmed_preview: &LifecycleReport, +) -> Result { + let current_preview = preview_prepared_setup(repository, prepared)?; + if !same_lifecycle_plan(¤t_preview, confirmed_preview) { + bail!("repository state changed after preview; rerun preview/apply and confirm again"); + } + apply_bundle_json( + Path::new(&confirmed_preview.repository), + &prepared.bundle, + &prepared.bundle_input, + ) +} + +pub fn preview_bundle_file(repository: &Path, bundle_file: &Path) -> Result { + let bundle = read_bundle_file(bundle_file)?; + preview_bundle(repository, &bundle) +} + +pub fn apply_bundle_file(repository: &Path, bundle_file: &Path) -> Result { + let bundle_input = fs::read_to_string(bundle_file) + .with_context(|| format!("failed to read bundle `{}`", bundle_file.display()))?; + let bundle = validate_bundle_json(&bundle_input)?; + apply_bundle_json(repository, &bundle, &bundle_input) +} + +pub(crate) fn apply_bundle_json( + repository: &Path, + bundle: &RoutingBundleV1, + bundle_input: &str, +) -> Result { + let repository = canonicalize_existing_repository(repository)?; + recover_pending_transactions(&repository)?; + let planned = plan_artifacts(&repository, bundle, None)?; + ensure_apply_is_safe(&planned)?; + let manifest = manifest_from_bundle(bundle, sha256(bundle_input.as_bytes()), None); + commit_transaction(&repository, &planned, &manifest)?; + Ok(report_from_plan( + "apply", + &repository, + Some(&bundle.bundle_id), + &planned, + )) +} + +pub fn update_bundle_file(repository: &Path, bundle_file: &Path) -> Result { + let bundle_input = fs::read_to_string(bundle_file) + .with_context(|| format!("failed to read bundle `{}`", bundle_file.display()))?; + let bundle = validate_bundle_json(&bundle_input)?; + update_bundle_json(repository, &bundle, &bundle_input) +} + +pub(crate) fn update_bundle_json( + repository: &Path, + bundle: &RoutingBundleV1, + bundle_input: &str, +) -> Result { + let repository = canonicalize_existing_repository(repository)?; + recover_pending_transactions(&repository)?; + let current = read_manifest(&repository)? + .ok_or_else(|| product_error!("no model-routing manifest found"))?; + let planned = plan_artifacts(&repository, bundle, Some(¤t))?; + ensure_update_is_safe(&planned)?; + let manifest = manifest_from_plan( + &bundle.bundle_id, + sha256(bundle_input.as_bytes()), + &planned, + Some(snapshot_from_manifest(¤t)), + ); + commit_transaction(&repository, &planned, &manifest)?; + Ok(report_from_plan( + "update", + &repository, + Some(&bundle.bundle_id), + &planned, + )) +} + +pub fn status_repository(repository: &Path) -> Result { + let repository = canonicalize_existing_repository(repository)?; + recover_pending_transactions(&repository)?; + let Some(manifest) = read_manifest(&repository)? else { + return Ok(LifecycleReport { + action: "status".to_string(), + bundle_id: None, + repository: repository.display().to_string(), + artifacts: Vec::new(), + }); + }; + let mut reports = Vec::new(); + for artifact in &manifest.artifacts { + let target = resolve_repository_target(&repository, &artifact.path)?; + let status = status_for_managed_artifact(&target, artifact)?; + reports.push(LifecycleArtifactReport { + path: artifact.path.clone(), + mode: "managed".to_string(), + status: status.to_string(), + sha256: artifact.sha256.clone(), + repair: repair_for_status(status), + }); + } + Ok(LifecycleReport { + action: "status".to_string(), + bundle_id: Some(manifest.bundle_id), + repository: repository.display().to_string(), + artifacts: reports, + }) +} + +pub fn uninstall_repository(repository: &Path) -> Result { + let repository = canonicalize_existing_repository(repository)?; + recover_pending_transactions(&repository)?; + let manifest = read_manifest(&repository)? + .ok_or_else(|| product_error!("no model-routing manifest found"))?; + let mut reports = Vec::new(); + for artifact in &manifest.artifacts { + let target = resolve_repository_target(&repository, &artifact.path)?; + let status = uninstall_managed_artifact(&target, artifact)?; + reports.push(LifecycleArtifactReport { + path: artifact.path.clone(), + mode: "managed".to_string(), + status: status.to_string(), + sha256: artifact.sha256.clone(), + repair: repair_for_status(status), + }); + } + let residual_artifacts = manifest + .artifacts + .iter() + .zip(reports.iter()) + .filter(|(_, report)| report.status != "removed") + .map(|(artifact, _)| ManagedArtifact { + path: artifact.path.clone(), + sha256: artifact.sha256.clone(), + content: artifact.content.clone(), + }) + .collect::>(); + if residual_artifacts.is_empty() { + remove_manifest(&repository)?; + } else { + let residual = ManagedManifest { + schema_version: 1, + bundle_id: manifest.bundle_id.clone(), + bundle_sha256: manifest.bundle_sha256.clone(), + artifacts: residual_artifacts, + previous: manifest.previous.clone(), + }; + write_manifest_file(&repository, &residual)?; + } + Ok(LifecycleReport { + action: "uninstall".to_string(), + bundle_id: Some(manifest.bundle_id), + repository: repository.display().to_string(), + artifacts: reports, + }) +} + +pub fn rollback_repository(repository: &Path) -> Result { + let repository = canonicalize_existing_repository(repository)?; + recover_pending_transactions(&repository)?; + let manifest = read_manifest(&repository)? + .ok_or_else(|| product_error!("no model-routing manifest found"))?; + let previous = manifest + .previous + .clone() + .ok_or_else(|| product_error!("no rollback snapshot found"))?; + let planned = plan_rollback_artifacts(&repository, &manifest, &previous)?; + ensure_update_is_safe(&planned)?; + let rollback_manifest = manifest_from_plan( + &previous.bundle_id, + previous.bundle_sha256.clone(), + &planned, + None, + ); + commit_transaction(&repository, &planned, &rollback_manifest)?; + Ok(report_from_plan( + "rollback", + &repository, + Some(&rollback_manifest.bundle_id), + &planned, + )) +} + +pub(crate) fn read_bundle_file(bundle_file: &Path) -> Result { + let input = fs::read_to_string(bundle_file) + .with_context(|| format!("failed to read bundle `{}`", bundle_file.display()))?; + validate_bundle_json(&input) +} + +pub(crate) fn read_setup_config_file(config_file: &Path) -> Result { + let input = fs::read_to_string(config_file) + .with_context(|| format!("failed to read setup config `{}`", config_file.display()))?; + setup_spec_from_toml(&input) +} + +pub(crate) fn read_saved_setup_config(repository: &Path) -> Result { + let repository = canonicalize_existing_repository(repository)?; + let config_path = repository.join(SETUP_CONFIG_PATH); + read_setup_config_file(&config_path) +} + +pub(crate) fn preview_setup(repository: &Path, spec: &SetupSpecV1) -> Result { + let prepared = prepare_setup_lifecycle(spec)?; + preview_bundle(repository, &prepared.bundle) +} + +pub(crate) fn apply_setup(repository: &Path, spec: &SetupSpecV1) -> Result { + let prepared = prepare_setup_lifecycle(spec)?; + apply_bundle_json(repository, &prepared.bundle, &prepared.bundle_input) +} + +pub(crate) fn update_setup(repository: &Path, spec: &SetupSpecV1) -> Result { + let prepared = prepare_setup_lifecycle(spec)?; + update_bundle_json(repository, &prepared.bundle, &prepared.bundle_input) +} + +pub(crate) fn prepare_setup_lifecycle(spec: &SetupSpecV1) -> Result { + let normalized_config = setup_spec_to_canonical_toml(spec)?; + let mut bundle = compile_setup_spec(spec)?; + bundle.artifacts.push(bundle_artifact(SourceArtifact { + path: SETUP_CONFIG_PATH.to_string(), + media_type: "application/toml".to_string(), + mode: "replace".to_string(), + content: normalized_config, + })); + bundle + .artifacts + .sort_by(|left, right| left.path.cmp(&right.path)); + validate_bundle(&bundle)?; + let mut bundle_input = serde_json::to_string_pretty(&bundle)?; + bundle_input.push('\n'); + Ok(PreparedSetupLifecycle { + bundle, + bundle_input, + }) +} + +pub(crate) fn same_lifecycle_plan(left: &LifecycleReport, right: &LifecycleReport) -> bool { + left.action == right.action + && left.bundle_id == right.bundle_id + && left.repository == right.repository + && left.artifacts == right.artifacts +} + +#[derive(Debug)] +pub(crate) struct PlannedArtifact { + pub(crate) path: String, + pub(crate) target: PathBuf, + pub(crate) mode: String, + pub(crate) content: Option, + pub(crate) managed_content: Option, + pub(crate) sha256: String, + pub(crate) status: String, +} + +pub(crate) fn preview_bundle( + repository: &Path, + bundle: &RoutingBundleV1, +) -> Result { + let repository = canonicalize_existing_repository(repository)?; + recover_pending_transactions(&repository)?; + let planned = plan_artifacts(&repository, bundle, None)?; + Ok(report_from_plan( + "preview", + &repository, + Some(&bundle.bundle_id), + &planned, + )) +} + +pub(crate) fn plan_artifacts( + repository: &Path, + bundle: &RoutingBundleV1, + current_manifest: Option<&ManagedManifest>, +) -> Result> { + let mut seen_targets = BTreeSet::new(); + let mut planned = Vec::new(); + for artifact in &bundle.artifacts { + let target = resolve_repository_target(repository, &artifact.path)?; + let key = target.display().to_string(); + if !seen_targets.insert(key) { + bail!("duplicate resolved artifact target `{}`", artifact.path); + } + let managed_entry = current_manifest.and_then(|manifest| { + manifest + .artifacts + .iter() + .find(|managed| managed.path == artifact.path) + }); + if artifact.path == CODEX_CONFIG_PATH { + planned.push(plan_codex_config_artifact( + repository, + artifact, + target, + managed_entry, + )?); + continue; + } + let status = if target.exists() { + let metadata = fs::symlink_metadata(&target) + .with_context(|| format!("failed to inspect `{}`", target.display()))?; + if metadata.file_type().is_symlink() { + bail!("artifact target `{}` is a symlink", artifact.path); + } + let current = fs::read(&target) + .with_context(|| format!("failed to read `{}`", target.display()))?; + let current_sha = sha256(¤t); + if current_sha == artifact.sha256 { + "unchanged" + } else if let Some(managed) = managed_entry { + if current_sha == managed.sha256 { + "update" + } else { + "preserved-modified" + } + } else { + "conflict" + } + } else { + ensure_parent_is_safe(repository, &target)?; + "create" + }; + planned.push(PlannedArtifact { + path: artifact.path.clone(), + target, + mode: artifact.mode.clone(), + content: Some(artifact.content.clone()), + managed_content: Some(artifact.content.clone()), + sha256: artifact.sha256.clone(), + status: status.to_string(), + }); + } + if let Some(manifest) = current_manifest { + for artifact in &manifest.artifacts { + if bundle + .artifacts + .iter() + .any(|bundle_artifact| bundle_artifact.path == artifact.path) + { + continue; + } + let target = resolve_repository_target(repository, &artifact.path)?; + let (status, content) = if artifact.path == CODEX_CONFIG_PATH { + let status = preserved_or_removed_status(&target, artifact)?; + let content = if status == "removed" { + remove_managed_codex_config_entries(&target, artifact)? + } else { + artifact.content.clone() + }; + (status, content) + } else { + let status = preserved_or_removed_status(&target, artifact)?; + let content = if status == "removed" { + None + } else { + artifact.content.clone() + }; + (status, content) + }; + planned.push(PlannedArtifact { + path: artifact.path.clone(), + target, + mode: "delete".to_string(), + content, + managed_content: artifact.content.clone(), + sha256: artifact.sha256.clone(), + status, + }); + } + } + reject_parent_child_targets(&planned)?; + Ok(planned) +} + +pub(crate) fn ensure_apply_is_safe(planned: &[PlannedArtifact]) -> Result<()> { + for artifact in planned { + if artifact.status == "conflict" || artifact.status == "preserved-modified" { + bail!( + "artifact target `{}` already exists with different content", + artifact.path + ); + } + } + Ok(()) +} + +pub(crate) fn ensure_update_is_safe(planned: &[PlannedArtifact]) -> Result<()> { + for artifact in planned { + if artifact.status == "conflict" { + bail!( + "artifact target `{}` already exists with unmanaged content", + artifact.path + ); + } + } + Ok(()) +} + +pub(crate) fn reject_parent_child_targets(planned: &[PlannedArtifact]) -> Result<()> { + for (index, left) in planned.iter().enumerate() { + let left_relative = Path::new(&left.path); + for right in planned.iter().skip(index + 1) { + let right_relative = Path::new(&right.path); + if left_relative.starts_with(right_relative) + || right_relative.starts_with(left_relative) + { + bail!( + "artifact targets `{}` and `{}` have a parent-child collision", + left.path, + right.path + ); + } + } + } + Ok(()) +} + +pub(crate) fn plan_rollback_artifacts( + repository: &Path, + current_manifest: &ManagedManifest, + previous: &ManagedSnapshot, +) -> Result> { + let mut planned = Vec::new(); + for artifact in &previous.artifacts { + let content = artifact.content.clone().ok_or_else(|| { + product_error!( + "rollback artifact `{}` has no stored content", + artifact.path + ) + })?; + let target = resolve_repository_target(repository, &artifact.path)?; + let current = current_manifest + .artifacts + .iter() + .find(|managed| managed.path == artifact.path); + if artifact.path == CODEX_CONFIG_PATH { + planned.push(plan_codex_config_rollback_artifact( + repository, artifact, content, target, current, + )?); + continue; + } + let status = if target.exists() { + let current_content = fs::read(&target) + .with_context(|| format!("failed to read `{}`", target.display()))?; + let current_sha = sha256(¤t_content); + if current_sha == artifact.sha256 { + "unchanged" + } else if let Some(managed) = current { + if current_sha == managed.sha256 { + "rollback" + } else { + "preserved-modified" + } + } else { + "rollback" + } + } else { + ensure_parent_is_safe(repository, &target)?; + "create" + }; + planned.push(PlannedArtifact { + path: artifact.path.clone(), + target, + mode: "replace".to_string(), + content: Some(content), + managed_content: artifact.content.clone(), + sha256: artifact.sha256.clone(), + status: status.to_string(), + }); + } + for artifact in ¤t_manifest.artifacts { + if previous + .artifacts + .iter() + .any(|previous_artifact| previous_artifact.path == artifact.path) + { + continue; + } + let target = resolve_repository_target(repository, &artifact.path)?; + let (status, content) = if artifact.path == CODEX_CONFIG_PATH { + let status = preserved_or_removed_status(&target, artifact)?; + let content = if status == "removed" { + remove_managed_codex_config_entries(&target, artifact)? + } else { + artifact.content.clone() + }; + (status, content) + } else { + let status = preserved_or_removed_status(&target, artifact)?; + let content = if status == "removed" { + None + } else { + artifact.content.clone() + }; + (status, content) + }; + planned.push(PlannedArtifact { + path: artifact.path.clone(), + target, + mode: "delete".to_string(), + content, + managed_content: artifact.content.clone(), + sha256: artifact.sha256.clone(), + status, + }); + } + reject_parent_child_targets(&planned)?; + Ok(planned) +} + +pub(crate) fn plan_codex_config_artifact( + repository: &Path, + artifact: &BundleArtifact, + target: PathBuf, + managed_entry: Option<&ManagedArtifact>, +) -> Result { + let (status, content) = if target.exists() { + ensure_artifact_target_is_regular(&target, &artifact.path)?; + let current = fs::read_to_string(&target) + .with_context(|| format!("failed to read `{}`", target.display()))?; + if let Some(managed) = managed_entry { + if codex_config_contains_managed_entries(¤t, managed)? { + if codex_config_has_unmanaged_conflict(¤t, &artifact.content, Some(managed))? + { + ("conflict".to_string(), Some(current)) + } else if managed.content.as_deref() == Some(artifact.content.as_str()) + && codex_config_contains_desired_entries(¤t, &artifact.content)? + { + ("unchanged".to_string(), Some(current)) + } else { + ( + "update".to_string(), + merge_codex_config_entries( + Some(¤t), + Some(managed), + &artifact.content, + )?, + ) + } + } else { + ("preserved-modified".to_string(), Some(current)) + } + } else if codex_config_has_unmanaged_conflict(¤t, &artifact.content, None)? { + ("conflict".to_string(), Some(current)) + } else if codex_config_contains_desired_entries(¤t, &artifact.content)? { + ("unchanged".to_string(), Some(current)) + } else { + ( + "update".to_string(), + merge_codex_config_entries(Some(¤t), None, &artifact.content)?, + ) + } + } else { + ensure_parent_is_safe(repository, &target)?; + ("create".to_string(), Some(artifact.content.clone())) + }; + Ok(PlannedArtifact { + path: artifact.path.clone(), + target, + mode: artifact.mode.clone(), + content, + managed_content: Some(artifact.content.clone()), + sha256: artifact.sha256.clone(), + status, + }) +} + +pub(crate) fn plan_codex_config_rollback_artifact( + repository: &Path, + artifact: &ManagedArtifact, + desired_content: String, + target: PathBuf, + current: Option<&ManagedArtifact>, +) -> Result { + let (status, content) = if target.exists() { + ensure_artifact_target_is_regular(&target, &artifact.path)?; + let current_text = fs::read_to_string(&target) + .with_context(|| format!("failed to read `{}`", target.display()))?; + if let Some(managed) = current { + if codex_config_contains_managed_entries(¤t_text, managed)? { + if codex_config_has_unmanaged_conflict( + ¤t_text, + &desired_content, + Some(managed), + )? { + ("conflict".to_string(), Some(current_text)) + } else if managed.content.as_deref() == Some(desired_content.as_str()) + && codex_config_contains_desired_entries(¤t_text, &desired_content)? + { + ("unchanged".to_string(), Some(current_text)) + } else { + ( + "rollback".to_string(), + merge_codex_config_entries( + Some(¤t_text), + Some(managed), + &desired_content, + )?, + ) + } + } else { + ("preserved-modified".to_string(), Some(current_text)) + } + } else if codex_config_has_unmanaged_conflict(¤t_text, &desired_content, None)? { + ("conflict".to_string(), Some(current_text)) + } else { + ( + "rollback".to_string(), + merge_codex_config_entries(Some(¤t_text), None, &desired_content)?, + ) + } + } else { + ensure_parent_is_safe(repository, &target)?; + ("create".to_string(), Some(desired_content.clone())) + }; + Ok(PlannedArtifact { + path: artifact.path.clone(), + target, + mode: "replace".to_string(), + content, + managed_content: Some(desired_content), + sha256: artifact.sha256.clone(), + status, + }) +} + +pub(crate) fn ensure_artifact_target_is_regular(target: &Path, artifact_path: &str) -> Result<()> { + let metadata = fs::symlink_metadata(target) + .with_context(|| format!("failed to inspect `{}`", target.display()))?; + if metadata.file_type().is_symlink() { + bail!("artifact target `{artifact_path}` is a symlink"); + } + if !metadata.is_file() { + bail!("artifact target `{artifact_path}` is not a file"); + } + Ok(()) +} + +pub(crate) fn preserved_or_removed_status( + target: &Path, + artifact: &ManagedArtifact, +) -> Result { + if !target.exists() { + return Ok("missing".to_string()); + } + let metadata = fs::symlink_metadata(target) + .with_context(|| format!("failed to inspect `{}`", target.display()))?; + if metadata.file_type().is_symlink() { + bail!("artifact target `{}` is a symlink", artifact.path); + } + if artifact.path == CODEX_CONFIG_PATH { + let current = fs::read_to_string(target) + .with_context(|| format!("failed to read `{}`", target.display()))?; + return if codex_config_contains_managed_entries(¤t, artifact)? { + Ok("removed".to_string()) + } else { + Ok("preserved-modified".to_string()) + }; + } + let current = + fs::read(target).with_context(|| format!("failed to read `{}`", target.display()))?; + if sha256(¤t) == artifact.sha256 { + Ok("removed".to_string()) + } else { + Ok("preserved-modified".to_string()) + } +} + +pub(crate) fn status_for_managed_artifact( + target: &Path, + artifact: &ManagedArtifact, +) -> Result<&'static str> { + if !target.exists() { + return Ok("missing"); + } + let metadata = fs::symlink_metadata(target) + .with_context(|| format!("failed to inspect `{}`", target.display()))?; + if metadata.file_type().is_symlink() { + bail!("artifact target `{}` is a symlink", artifact.path); + } + if artifact.path == CODEX_CONFIG_PATH { + let current = fs::read_to_string(target) + .with_context(|| format!("failed to read `{}`", target.display()))?; + return if codex_config_contains_managed_entries(¤t, artifact)? { + Ok("managed") + } else { + Ok("modified") + }; + } + let content = + fs::read(target).with_context(|| format!("failed to read `{}`", target.display()))?; + if sha256(&content) == artifact.sha256 { + Ok("managed") + } else { + Ok("modified") + } +} + +pub(crate) fn uninstall_managed_artifact( + target: &Path, + artifact: &ManagedArtifact, +) -> Result<&'static str> { + if !target.exists() { + return Ok("missing"); + } + let metadata = fs::symlink_metadata(target) + .with_context(|| format!("failed to inspect `{}`", target.display()))?; + if metadata.file_type().is_symlink() { + bail!("artifact target `{}` is a symlink", artifact.path); + } + if artifact.path == CODEX_CONFIG_PATH { + let current = fs::read_to_string(target) + .with_context(|| format!("failed to read `{}`", target.display()))?; + if !codex_config_contains_managed_entries(¤t, artifact)? { + return Ok("preserved-modified"); + } + match remove_managed_codex_config_entries(target, artifact)? { + Some(content) => fs::write(target, content.as_bytes()) + .with_context(|| format!("failed to write `{}`", target.display()))?, + None => fs::remove_file(target) + .with_context(|| format!("failed to remove `{}`", target.display()))?, + } + return Ok("removed"); + } + let content = + fs::read(target).with_context(|| format!("failed to read `{}`", target.display()))?; + if sha256(&content) != artifact.sha256 { + Ok("preserved-modified") + } else { + fs::remove_file(target) + .with_context(|| format!("failed to remove `{}`", target.display()))?; + Ok("removed") + } +} + +pub(crate) fn codex_config_contains_managed_entries( + current_content: &str, + managed: &ManagedArtifact, +) -> Result { + let managed_content = managed.content.as_deref().ok_or_else(|| { + product_error!("managed artifact `{}` has no stored content", managed.path) + })?; + Ok( + !codex_config_has_unmanaged_conflict(current_content, managed_content, None)? + && codex_config_contains_desired_entries(current_content, managed_content)?, + ) +} + +pub(crate) fn codex_config_contains_desired_entries( + current_content: &str, + desired_content: &str, +) -> Result { + let current = codex_agent_entries(current_content)?; + let desired = codex_agent_entries(desired_content)?; + Ok(desired + .iter() + .all(|(name, desired_entry)| current.get(name) == Some(desired_entry))) +} + +pub(crate) fn codex_config_has_unmanaged_conflict( + current_content: &str, + desired_content: &str, + previously_managed: Option<&ManagedArtifact>, +) -> Result { + let current = codex_agent_entries(current_content)?; + let desired = codex_agent_entries(desired_content)?; + let old_keys = previously_managed + .and_then(|managed| managed.content.as_deref()) + .map(codex_agent_entry_names) + .transpose()? + .unwrap_or_default(); + Ok(desired.iter().any(|(name, desired_entry)| { + !old_keys.contains(name) + && current + .get(name) + .is_some_and(|entry| entry != desired_entry) + })) +} + +pub(crate) fn merge_codex_config_entries( + current_content: Option<&str>, + previously_managed: Option<&ManagedArtifact>, + desired_content: &str, +) -> Result> { + let mut root = match current_content { + Some(content) => parse_toml_root(content)?, + None => toml::value::Table::new(), + }; + if let Some(managed) = previously_managed { + let managed_content = managed.content.as_deref().ok_or_else(|| { + product_error!("managed artifact `{}` has no stored content", managed.path) + })?; + remove_codex_agent_entries(&mut root, &codex_agent_entry_names(managed_content)?)?; + } + upsert_codex_agent_entries(&mut root, codex_agent_entries(desired_content)?)?; + render_toml_root(root) +} + +pub(crate) fn remove_managed_codex_config_entries( + target: &Path, + managed: &ManagedArtifact, +) -> Result> { + let current = fs::read_to_string(target) + .with_context(|| format!("failed to read `{}`", target.display()))?; + let managed_content = managed.content.as_deref().ok_or_else(|| { + product_error!("managed artifact `{}` has no stored content", managed.path) + })?; + let mut root = parse_toml_root(¤t)?; + remove_codex_agent_entries(&mut root, &codex_agent_entry_names(managed_content)?)?; + render_toml_root(root) +} + +pub(crate) fn parse_toml_root(content: &str) -> Result { + match toml::from_str::(content)? { + toml::Value::Table(table) => Ok(table), + _ => bail!("Codex config must be a TOML table"), + } +} + +pub(crate) fn codex_agent_entry_names(content: &str) -> Result> { + Ok(codex_agent_entries(content)?.into_keys().collect()) +} + +pub(crate) fn codex_agent_entries(content: &str) -> Result> { + let root = parse_toml_root(content)?; + let Some(agents) = root.get("agents") else { + return Ok(BTreeMap::new()); + }; + let agents = agents + .as_table() + .ok_or_else(|| product_error!("Codex config `agents` must be a table"))?; + Ok(agents + .iter() + .map(|(name, value)| (name.clone(), value.clone())) + .collect()) +} + +pub(crate) fn remove_codex_agent_entries( + root: &mut toml::value::Table, + names: &BTreeSet, +) -> Result<()> { + let Some(agents_value) = root.get_mut("agents") else { + return Ok(()); + }; + let agents = agents_value + .as_table_mut() + .ok_or_else(|| product_error!("Codex config `agents` must be a table"))?; + for name in names { + agents.remove(name); + } + if agents.is_empty() { + root.remove("agents"); + } + Ok(()) +} + +pub(crate) fn upsert_codex_agent_entries( + root: &mut toml::value::Table, + entries: BTreeMap, +) -> Result<()> { + if entries.is_empty() { + return Ok(()); + } + if !root.contains_key("agents") { + root.insert( + "agents".to_string(), + toml::Value::Table(toml::value::Table::new()), + ); + } + let agents = root + .get_mut("agents") + .and_then(toml::Value::as_table_mut) + .ok_or_else(|| product_error!("Codex config `agents` must be a table"))?; + for (name, value) in entries { + agents.insert(name, value); + } + Ok(()) +} + +pub(crate) fn render_toml_root(root: toml::value::Table) -> Result> { + if root.is_empty() { + return Ok(None); + } + let mut content = toml::to_string_pretty(&toml::Value::Table(root))?; + if !content.ends_with('\n') { + content.push('\n'); + } + Ok(Some(content)) +} + +pub(crate) fn commit_transaction( + repository: &Path, + planned: &[PlannedArtifact], + manifest: &ManagedManifest, +) -> Result<()> { + let txn_root = repository.join(".model-routing").join(format!( + "txn-{}-{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() + )); + let stage_root = txn_root.join("stage"); + let backup_root = txn_root.join("backup"); + fs::create_dir_all(&stage_root) + .with_context(|| format!("failed to create `{}`", stage_root.display()))?; + fs::create_dir_all(&backup_root) + .with_context(|| format!("failed to create `{}`", backup_root.display()))?; + + let mut writes = Vec::new(); + for (index, artifact) in planned.iter().enumerate() { + if artifact.status == "unchanged" + || artifact.status == "preserved-modified" + || artifact.status == "missing" + { + continue; + } + let staged = match &artifact.content { + Some(content) => { + let staged = stage_root.join(format!("artifact-{index}")); + fs::write(&staged, content.as_bytes()) + .with_context(|| format!("failed to stage `{}`", artifact.path))?; + Some(staged) + } + None => None, + }; + writes.push(TransactionalWrite { + label: artifact.path.clone(), + target: artifact.target.clone(), + staged, + backup: backup_root.join(format!("artifact-{index}")), + committed: false, + backup_created: false, + had_original: artifact.target.exists(), + }); + } + + let manifest_path = repository.join(MANIFEST_PATH); + let manifest_stage = stage_root.join("manifest.json"); + fs::write(&manifest_stage, serde_json::to_vec_pretty(manifest)?) + .with_context(|| format!("failed to stage `{MANIFEST_PATH}`"))?; + writes.push(TransactionalWrite { + label: MANIFEST_PATH.to_string(), + target: manifest_path.clone(), + staged: Some(manifest_stage), + backup: backup_root.join("manifest.json"), + committed: false, + backup_created: false, + had_original: manifest_path.exists(), + }); + + write_transaction_journal(repository, &txn_root, &writes)?; + let result = commit_writes(&mut writes); + if let Err(error) = result { + if let Err(rollback_error) = rollback_writes(&writes) { + return Err(error).with_context(|| { + format!( + "transaction rollback incomplete; retained `{}` for recovery: {rollback_error:#}", + txn_root.display() + ) + }); + } + fs::remove_dir_all(&txn_root) + .with_context(|| format!("failed to remove `{}`", txn_root.display()))?; + return Err(error); + } + fs::remove_dir_all(&txn_root) + .with_context(|| format!("failed to remove `{}`", txn_root.display()))?; + Ok(()) +} + +#[derive(Debug)] +pub(crate) struct TransactionalWrite { + pub(crate) label: String, + pub(crate) target: PathBuf, + pub(crate) staged: Option, + pub(crate) backup: PathBuf, + pub(crate) committed: bool, + pub(crate) backup_created: bool, + pub(crate) had_original: bool, +} + +pub(crate) fn commit_writes(writes: &mut [TransactionalWrite]) -> Result<()> { + let txn_root = writes + .first() + .and_then(|write| write.backup.parent()) + .and_then(Path::parent) + .map(Path::to_path_buf) + .ok_or_else(|| product_error!("transaction has no writes"))?; + let repository = txn_root + .parent() + .and_then(Path::parent) + .ok_or_else(|| product_error!("transaction root is outside repository metadata"))? + .to_path_buf(); + for index in 0..writes.len() { + if let Some(parent) = writes[index].target.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("failed to create `{}`", parent.display()))?; + } + if writes[index].target.exists() { + fs::rename(&writes[index].target, &writes[index].backup) + .with_context(|| format!("failed to backup `{}`", writes[index].label))?; + writes[index].backup_created = true; + write_transaction_journal(&repository, &txn_root, writes)?; + } + if let Some(staged) = &writes[index].staged { + maybe_return_staged_rename_error()?; + fs::rename(staged, &writes[index].target) + .with_context(|| format!("failed to commit `{}`", writes[index].label))?; + } + writes[index].committed = true; + write_transaction_journal(&repository, &txn_root, writes)?; + maybe_fail_after_transaction_write()?; + } + Ok(()) +} + +pub(crate) fn rollback_writes(writes: &[TransactionalWrite]) -> Result<()> { + for write in writes.iter().rev() { + if !write.committed && !write.backup_created { + continue; + } + maybe_fail_during_restore()?; + if write.target.exists() { + fs::remove_file(&write.target) + .with_context(|| format!("failed to remove `{}`", write.target.display()))?; + } + if write.had_original { + if let Some(parent) = write.target.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("failed to create `{}`", parent.display()))?; + } + fs::rename(&write.backup, &write.target).with_context(|| { + format!( + "failed to restore `{}` from `{}`", + write.target.display(), + write.backup.display() + ) + })?; + } + } + Ok(()) +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct TransactionJournal { + pub(crate) schema_version: u32, + pub(crate) writes: Vec, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct TransactionJournalWrite { + pub(crate) label: String, + pub(crate) target: String, + pub(crate) staged: Option, + pub(crate) backup: String, + pub(crate) committed: bool, + #[serde(default)] + pub(crate) backup_created: bool, + pub(crate) had_original: bool, +} + +pub(crate) fn write_transaction_journal( + repository: &Path, + txn_root: &Path, + writes: &[TransactionalWrite], +) -> Result<()> { + let journal = TransactionJournal { + schema_version: 1, + writes: writes + .iter() + .map(|write| { + Ok(TransactionJournalWrite { + label: write.label.clone(), + target: repository_relative(repository, &write.target)?, + staged: write + .staged + .as_ref() + .map(|staged| repository_relative(repository, staged)) + .transpose()?, + backup: repository_relative(repository, &write.backup)?, + committed: write.committed, + backup_created: write.backup_created, + had_original: write.had_original, + }) + }) + .collect::>>()?, + }; + let journal_path = txn_root.join(TRANSACTION_JOURNAL); + let temp_path = txn_root.join(format!("{TRANSACTION_JOURNAL}.tmp")); + fs::write(&temp_path, serde_json::to_vec_pretty(&journal)?).with_context(|| { + format!( + "failed to write transaction journal temp `{}`", + temp_path.display() + ) + })?; + sync_file(&temp_path)?; + maybe_return_journal_error()?; + maybe_fail_during_journal_replace(); + fs::rename(&temp_path, &journal_path).with_context(|| { + format!( + "failed to replace transaction journal `{}`", + journal_path.display() + ) + })?; + sync_directory(txn_root)?; + Ok(()) +} + +pub(crate) fn recover_pending_transactions(repository: &Path) -> Result<()> { + let metadata_dir = repository.join(".model-routing"); + if !metadata_dir.exists() { + return Ok(()); + } + for entry in fs::read_dir(&metadata_dir) + .with_context(|| format!("failed to read `{}`", metadata_dir.display()))? + { + let entry = entry?; + if !entry.file_type()?.is_dir() { + continue; + } + let name = entry.file_name(); + let Some(name) = name.to_str() else { + continue; + }; + if !name.starts_with("txn-") { + continue; + } + recover_transaction(repository, &entry.path())?; + } + Ok(()) +} + +pub(crate) fn recover_transaction(repository: &Path, txn_root: &Path) -> Result<()> { + let journal_path = txn_root.join(TRANSACTION_JOURNAL); + if journal_path.exists() { + let input = fs::read(&journal_path) + .with_context(|| format!("failed to read `{}`", journal_path.display()))?; + let journal: TransactionJournal = serde_json::from_slice(&input) + .with_context(|| format!("failed to parse `{}`", journal_path.display()))?; + for write in journal.writes.iter().rev() { + recover_transaction_write(repository, write).with_context(|| { + format!("failed to recover transaction write `{}`", write.label) + })?; + } + } + fs::remove_dir_all(txn_root) + .with_context(|| format!("failed to remove `{}`", txn_root.display()))?; + Ok(()) +} + +pub(crate) fn recover_transaction_write( + repository: &Path, + write: &TransactionJournalWrite, +) -> Result<()> { + maybe_fail_during_restore()?; + let target = repository.join(&write.target); + let backup = repository.join(&write.backup); + if backup.exists() { + if target.exists() { + fs::remove_file(&target) + .with_context(|| format!("failed to remove `{}`", target.display()))?; + } + if let Some(parent) = target.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("failed to create `{}`", parent.display()))?; + } + fs::rename(&backup, &target).with_context(|| { + format!( + "failed to restore `{}` from `{}`", + target.display(), + backup.display() + ) + })?; + return Ok(()); + } + if !write.had_original + && write + .staged + .as_ref() + .is_some_and(|staged| !repository.join(staged).exists()) + && target.exists() + { + fs::remove_file(&target) + .with_context(|| format!("failed to remove partial `{}`", target.display()))?; + } + Ok(()) +} + +pub(crate) fn repository_relative(repository: &Path, path: &Path) -> Result { + Ok(path + .strip_prefix(repository) + .with_context(|| format!("`{}` is outside repository", path.display()))? + .to_str() + .ok_or_else(|| product_error!("path `{}` is not UTF-8", path.display()))? + .to_string()) +} + +pub(crate) fn sync_file(path: &Path) -> Result<()> { + fs::File::open(path) + .with_context(|| format!("failed to open `{}` for sync", path.display()))? + .sync_all() + .with_context(|| format!("failed to sync `{}`", path.display()))?; + Ok(()) +} + +pub(crate) fn sync_directory(path: &Path) -> Result<()> { + fs::File::open(path) + .with_context(|| format!("failed to open directory `{}` for sync", path.display()))? + .sync_all() + .with_context(|| format!("failed to sync directory `{}`", path.display()))?; + Ok(()) +} + +pub(crate) fn maybe_fail_after_transaction_write() -> Result<()> { + TRANSACTION_FAIL_AFTER_WRITES.with(|fail_after| { + let remaining = fail_after.get(); + if remaining == 0 { + return; + } + fail_after.set(remaining - 1); + if remaining == 1 { + panic!("injected transaction interruption after committed write"); + } + }); + Ok(()) +} + +pub(crate) fn maybe_fail_during_journal_replace() { + TRANSACTION_FAIL_JOURNAL_REPLACE_AFTER.with(|fail_after| { + let remaining = fail_after.get(); + if remaining == 0 { + return; + } + fail_after.set(remaining - 1); + if remaining == 1 { + panic!("injected transaction interruption during journal replacement"); + } + }); +} + +pub(crate) fn maybe_return_journal_error() -> Result<()> { + TRANSACTION_RETURN_JOURNAL_ERROR_AFTER.with(|fail_after| { + let remaining = fail_after.get(); + if remaining == 0 { + return Ok(()); + } + fail_after.set(remaining - 1); + if remaining == 1 { + bail!("injected transaction journal update error"); + } + Ok(()) + }) +} + +pub(crate) fn maybe_return_staged_rename_error() -> Result<()> { + TRANSACTION_RETURN_STAGED_RENAME_ERROR_AFTER.with(|fail_after| { + let remaining = fail_after.get(); + if remaining == 0 { + return Ok(()); + } + fail_after.set(remaining - 1); + if remaining == 1 { + bail!("injected staged rename error after backup"); + } + Ok(()) + }) +} + +pub(crate) fn maybe_fail_during_restore() -> Result<()> { + TRANSACTION_FAIL_RESTORE.with(|fail| { + if fail.replace(false) { + bail!("injected transaction restore failure"); + } + Ok(()) + }) +} + +pub(crate) fn canonicalize_existing_repository(repository: &Path) -> Result { + let canonical = repository + .canonicalize() + .with_context(|| format!("repository `{}` does not exist", repository.display()))?; + if !canonical.is_dir() { + bail!("repository `{}` is not a directory", canonical.display()); + } + Ok(canonical) +} + +pub(crate) fn resolve_repository_target(repository: &Path, artifact_path: &str) -> Result { + if artifact_path.trim().is_empty() { + bail!("artifact path must not be blank"); + } + let path = Path::new(artifact_path); + if path.is_absolute() { + bail!("artifact path `{artifact_path}` must be repository-relative"); + } + let mut normalized = PathBuf::new(); + for component in path.components() { + match component { + Component::Normal(part) => normalized.push(part), + Component::CurDir => {} + Component::ParentDir => bail!("artifact path `{artifact_path}` must not traverse"), + _ => bail!("artifact path `{artifact_path}` is unsupported"), + } + } + let normalized_text = normalized + .to_str() + .ok_or_else(|| product_error!("artifact path `{artifact_path}` is not UTF-8"))?; + if normalized_text.starts_with(".model-routing/") { + bail!("artifact path `{artifact_path}` targets a reserved path"); + } + if normalized_text == SETUP_CONFIG_PATH { + return Ok(repository.join(normalized)); + } + if !allowed_repository_target(normalized_text) { + bail!("artifact path `{artifact_path}` is not an allowed host artifact path"); + } + Ok(repository.join(normalized)) +} + +pub(crate) fn allowed_repository_target(path: &str) -> bool { + if path == ".codex/config.toml" { + return true; + } + [ + ".codex/agents/", + ".claude/agents/", + ".cursor/agents/", + ".opencode/agents/", + ".pi/workflows/", + ".planr/", + ] + .iter() + .any(|prefix| path.starts_with(prefix)) +} + +pub(crate) fn ensure_parent_is_safe(repository: &Path, target: &Path) -> Result<()> { + let mut current = repository.to_path_buf(); + let relative = target + .strip_prefix(repository) + .map_err(|_| product_error!("target escaped repository"))?; + if let Some(parent) = relative.parent() { + for component in parent.components() { + let Component::Normal(part) = component else { + bail!("artifact parent contains unsupported component"); + }; + current.push(part); + if current.exists() { + let metadata = fs::symlink_metadata(¤t) + .with_context(|| format!("failed to inspect `{}`", current.display()))?; + if metadata.file_type().is_symlink() { + bail!("artifact parent `{}` is a symlink", current.display()); + } + if !metadata.is_dir() { + bail!("artifact parent `{}` is not a directory", current.display()); + } + } + } + } + Ok(()) +} + +pub(crate) fn manifest_from_bundle( + bundle: &RoutingBundleV1, + bundle_sha256: String, + previous: Option, +) -> ManagedManifest { + ManagedManifest { + schema_version: 1, + bundle_id: bundle.bundle_id.clone(), + bundle_sha256, + artifacts: bundle + .artifacts + .iter() + .map(|artifact| ManagedArtifact { + path: artifact.path.clone(), + sha256: artifact.sha256.clone(), + content: Some(artifact.content.clone()), + }) + .collect(), + previous, + } +} + +pub(crate) fn manifest_from_plan( + bundle_id: &str, + bundle_sha256: String, + planned: &[PlannedArtifact], + previous: Option, +) -> ManagedManifest { + ManagedManifest { + schema_version: 1, + bundle_id: bundle_id.to_string(), + bundle_sha256, + artifacts: planned + .iter() + .filter(|artifact| artifact.status != "removed") + .map(|artifact| ManagedArtifact { + path: artifact.path.clone(), + sha256: artifact.sha256.clone(), + content: artifact.managed_content.clone(), + }) + .collect(), + previous, + } +} + +pub(crate) fn snapshot_from_manifest(manifest: &ManagedManifest) -> ManagedSnapshot { + ManagedSnapshot { + bundle_id: manifest.bundle_id.clone(), + bundle_sha256: manifest.bundle_sha256.clone(), + artifacts: manifest.artifacts.clone(), + } +} + +pub(crate) fn write_manifest_file(repository: &Path, manifest: &ManagedManifest) -> Result<()> { + let manifest_path = repository.join(MANIFEST_PATH); + if let Some(parent) = manifest_path.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("failed to create `{}`", parent.display()))?; + } + fs::write(&manifest_path, serde_json::to_vec_pretty(&manifest)?) + .with_context(|| format!("failed to write `{}`", manifest_path.display()))?; + Ok(()) +} + +pub(crate) fn remove_manifest(repository: &Path) -> Result<()> { + let manifest_path = repository.join(MANIFEST_PATH); + if manifest_path.exists() { + fs::remove_file(&manifest_path) + .with_context(|| format!("failed to remove `{}`", manifest_path.display()))?; + } + Ok(()) +} + +pub(crate) fn read_manifest(repository: &Path) -> Result> { + let manifest_path = repository.join(MANIFEST_PATH); + if !manifest_path.exists() { + return Ok(None); + } + let input = fs::read(&manifest_path) + .with_context(|| format!("failed to read `{}`", manifest_path.display()))?; + Ok(Some(serde_json::from_slice(&input).with_context(|| { + format!("failed to parse `{}`", manifest_path.display()) + })?)) +} + +pub(crate) fn report_from_plan( + action: &str, + repository: &Path, + bundle_id: Option<&str>, + planned: &[PlannedArtifact], +) -> LifecycleReport { + LifecycleReport { + action: action.to_string(), + bundle_id: bundle_id.map(ToOwned::to_owned), + repository: repository.display().to_string(), + artifacts: planned + .iter() + .map(|artifact| LifecycleArtifactReport { + path: artifact.path.clone(), + mode: artifact.mode.clone(), + status: artifact.status.clone(), + sha256: artifact.sha256.clone(), + repair: repair_for_status(&artifact.status), + }) + .collect(), + } +} + +pub(crate) fn repair_for_status(status: &str) -> Option { + match status { + "modified" | "preserved-modified" => Some( + "user-modified file preserved; run update or rollback after reconciling local edits" + .to_string(), + ), + "missing" => Some( + "managed file is missing; run update to recreate or uninstall to drop ownership" + .to_string(), + ), + _ => None, + } +} diff --git a/src/main.rs b/src/main.rs index aadc409..f352ddb 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,460 +1,3 @@ -use anyhow::{Result, bail}; -use clap::{Args, Parser, Subcommand, ValueEnum}; -use model_routing::{ - Integration, RegistrySignature, apply_bundle_file, apply_saved_setup, apply_setup_config_file, - apply_setup_recipe, catalog_json, compile_json, evaluate_policy, inspect_bundle_json, - list_policies, prepare_saved_setup, prepare_setup_config_file, prepare_setup_recipe, - preview_bundle_file, preview_prepared_setup, preview_saved_setup, preview_setup_config_file, - preview_setup_recipe, probe_host, rollback_repository, show_policy, sign_registry, - status_repository, uninstall_repository, update_bundle_file, update_saved_setup, - update_setup_config_file, update_setup_recipe, validate_dispatch_evidence_json_for_bundle, - verify_registry_signature, -}; -use std::fs; -use std::io::{self, Write}; -use std::path::PathBuf; - -#[derive(Parser)] -#[command(name = "model-routing", version, about)] -struct Cli { - #[command(subcommand)] - command: Command, -} - -#[derive(Subcommand)] -enum Command { - Policy(PolicyArgs), - Compile(CompileArgs), - Inspect(FileArgs), - Preview(LifecycleSourceArgs), - Apply(LifecycleApplyArgs), - Update(LifecycleSourceArgs), - Status(RepositoryArgs), - Uninstall(RepositoryArgs), - Rollback(RepositoryArgs), - /// Check whether the selected host CLI is installed and report its version. - Doctor(ProbeArgs), - Probe(ProbeArgs), - /// Validate a dispatch-evidence receipt against its generated bundle. - Certify(EvidenceValidateArgs), - Evaluate(PolicySelector), - Evidence(EvidenceArgs), - Catalog(CatalogArgs), - Registry(RegistryArgs), -} - -#[derive(Args)] -struct PolicyArgs { - #[command(subcommand)] - command: PolicyCommand, -} - -#[derive(Subcommand)] -enum PolicyCommand { - List, - Show(PolicySelector), -} - -#[derive(Args)] -struct PolicySelector { - policy: String, - #[arg(long)] - host: String, -} - -#[derive(Args)] -struct CompileArgs { - policy: String, - #[arg(long)] - host: String, - #[arg(long, value_enum, default_value_t = CliIntegration::Standalone)] - integration: CliIntegration, - #[arg(long)] - output: Option, -} - -#[derive(Clone, Copy, Debug, ValueEnum)] -enum CliIntegration { - Standalone, - Planr, -} - -impl From for Integration { - fn from(value: CliIntegration) -> Self { - match value { - CliIntegration::Standalone => Self::Standalone, - CliIntegration::Planr => Self::Planr, - } - } -} - -#[derive(Args)] -struct ProbeArgs { - host: String, - #[arg(long)] - command: Option, -} - -#[derive(Args)] -struct CatalogArgs { - #[command(subcommand)] - command: CatalogCommand, -} - -#[derive(Args)] -struct EvidenceArgs { - #[command(subcommand)] - command: EvidenceCommand, -} - -#[derive(Subcommand)] -enum EvidenceCommand { - Validate(EvidenceValidateArgs), -} - -#[derive(Args)] -struct EvidenceValidateArgs { - receipt: PathBuf, - #[arg(long)] - bundle: PathBuf, -} - -#[derive(Subcommand)] -enum CatalogCommand { - Build(OutputArgs), - Verify(FileArgs), -} - -#[derive(Args)] -struct RegistryArgs { - #[command(subcommand)] - command: RegistryCommand, -} - -#[derive(Subcommand)] -enum RegistryCommand { - Sign(SignArgs), - Verify(VerifyArgs), -} - -#[derive(Args)] -struct OutputArgs { - #[arg(long)] - output: Option, -} - -#[derive(Args)] -struct FileArgs { - file: PathBuf, -} - -#[derive(Args)] -struct LifecycleSourceArgs { - bundle: Option, - #[arg(long)] - config: Option, - #[arg(long)] - recipe: Option, - #[arg(long, default_value = ".")] - repository: PathBuf, -} - -#[derive(Args)] -struct LifecycleApplyArgs { - #[command(flatten)] - source: LifecycleSourceArgs, - #[arg(long)] - yes: bool, -} - -#[derive(Args)] -struct RepositoryArgs { - #[arg(long, default_value = ".")] - repository: PathBuf, -} - -#[derive(Args)] -struct SignArgs { - file: PathBuf, - #[arg(long)] - signer: String, - #[arg(long)] - private_key_file: PathBuf, - #[arg(long)] - output: PathBuf, -} - -#[derive(Args)] -struct VerifyArgs { - file: PathBuf, - #[arg(long)] - signature: PathBuf, - #[arg(long)] - trusted_signer: String, - #[arg(long)] - trusted_public_key_file: PathBuf, -} - fn main() { - if let Err(error) = run() { - eprintln!("error: {error:#}"); - std::process::exit(1); - } -} - -fn run() -> Result<()> { - match Cli::parse().command { - Command::Policy(args) => match args.command { - PolicyCommand::List => println!("{}", serde_json::to_string_pretty(&list_policies()?)?), - PolicyCommand::Show(selector) => println!( - "{}", - serde_json::to_string_pretty(&show_policy(&selector.policy, &selector.host)?)? - ), - }, - Command::Compile(args) => { - let output = compile_json(&args.policy, &args.host, args.integration.into())?; - if let Some(path) = args.output { - fs::write(path, output)?; - } else { - print!("{output}"); - } - } - Command::Inspect(args) => { - let current = fs::read_to_string(args.file)?; - println!( - "{}", - serde_json::to_string_pretty(&inspect_bundle_json(¤t)?)? - ); - } - Command::Preview(args) => println!( - "{}", - serde_json::to_string_pretty(&preview_lifecycle_source(&args)?)? - ), - Command::Apply(args) => { - if lifecycle_source_kind(&args.source)? == LifecycleSourceKind::Bundle { - println!( - "{}", - serde_json::to_string_pretty(&apply_lifecycle_source(&args.source)?)? - ); - } else { - let prepared = prepare_lifecycle_source(&args.source)?; - let preview = preview_prepared_setup(&args.source.repository, &prepared)?; - eprintln!("{}", serde_json::to_string_pretty(&preview)?); - confirm_setup_apply(args.yes)?; - println!( - "{}", - serde_json::to_string_pretty(&model_routing::apply_prepared_setup( - &args.source.repository, - &prepared, - &preview, - )?)? - ); - } - } - Command::Update(args) => println!( - "{}", - serde_json::to_string_pretty(&update_lifecycle_source(&args)?)? - ), - Command::Status(args) => println!( - "{}", - serde_json::to_string_pretty(&status_repository(&args.repository)?)? - ), - Command::Uninstall(args) => println!( - "{}", - serde_json::to_string_pretty(&uninstall_repository(&args.repository)?)? - ), - Command::Rollback(args) => println!( - "{}", - serde_json::to_string_pretty(&rollback_repository(&args.repository)?)? - ), - Command::Doctor(args) => println!( - "{}", - serde_json::to_string_pretty(&probe_host(&args.host, args.command.as_deref())?)? - ), - Command::Probe(args) => println!( - "{}", - serde_json::to_string_pretty(&probe_host(&args.host, args.command.as_deref())?)? - ), - Command::Certify(args) => { - validate_evidence_receipt(args.receipt, args.bundle)?; - } - Command::Evaluate(selector) => println!( - "{}", - serde_json::to_string_pretty(&evaluate_policy(&selector.policy, &selector.host)?)? - ), - Command::Evidence(args) => match args.command { - EvidenceCommand::Validate(args) => { - validate_evidence_receipt(args.receipt, args.bundle)?; - } - }, - Command::Catalog(args) => match args.command { - CatalogCommand::Build(args) => { - let catalog = catalog_json()?; - if let Some(output) = args.output { - fs::write(output, catalog)?; - } else { - print!("{catalog}"); - } - } - CatalogCommand::Verify(args) => { - let current = fs::read_to_string(args.file)?; - if current != catalog_json()? { - anyhow::bail!("catalog does not match package-owned generated sources"); - } - println!("catalog verified"); - } - }, - Command::Registry(args) => match args.command { - RegistryCommand::Sign(args) => { - let content = fs::read(&args.file)?; - let private_key = fs::read_to_string(args.private_key_file)?; - let signature = sign_registry(&content, &args.signer, &private_key)?; - fs::write(args.output, serde_json::to_vec_pretty(&signature)?)?; - } - RegistryCommand::Verify(args) => { - let content = fs::read(args.file)?; - let signature: RegistrySignature = - serde_json::from_slice(&fs::read(args.signature)?)?; - let trusted_public_key = fs::read_to_string(args.trusted_public_key_file)?; - verify_registry_signature( - &content, - &signature, - &args.trusted_signer, - &trusted_public_key, - )?; - println!("registry signature verified"); - } - }, - } - Ok(()) -} - -fn validate_evidence_receipt(receipt_path: PathBuf, bundle_path: PathBuf) -> Result<()> { - let receipt = fs::read_to_string(receipt_path)?; - let bundle = fs::read_to_string(bundle_path)?; - validate_dispatch_evidence_json_for_bundle(&receipt, &bundle)?; - println!("dispatch evidence validated"); - Ok(()) -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum LifecycleSourceKind { - Bundle, - Config, - Recipe, - SavedConfig, -} - -fn lifecycle_source_kind(args: &LifecycleSourceArgs) -> Result { - let selected = [ - args.bundle.is_some(), - args.config.is_some(), - args.recipe.is_some(), - ] - .into_iter() - .filter(|selected| *selected) - .count(); - if selected > 1 { - bail!("choose only one of bundle, --config, or --recipe"); - } - Ok(if args.bundle.is_some() { - LifecycleSourceKind::Bundle - } else if args.config.is_some() { - LifecycleSourceKind::Config - } else if args.recipe.is_some() { - LifecycleSourceKind::Recipe - } else { - LifecycleSourceKind::SavedConfig - }) -} - -fn preview_lifecycle_source(args: &LifecycleSourceArgs) -> Result { - match lifecycle_source_kind(args)? { - LifecycleSourceKind::Bundle => preview_bundle_file( - &args.repository, - args.bundle.as_ref().expect("bundle source checked"), - ), - LifecycleSourceKind::Config => preview_setup_config_file( - &args.repository, - args.config.as_ref().expect("config source checked"), - ), - LifecycleSourceKind::Recipe => preview_setup_recipe( - &args.repository, - args.recipe.as_deref().expect("recipe checked"), - ), - LifecycleSourceKind::SavedConfig => preview_saved_setup(&args.repository), - } -} - -fn apply_lifecycle_source(args: &LifecycleSourceArgs) -> Result { - match lifecycle_source_kind(args)? { - LifecycleSourceKind::Bundle => apply_bundle_file( - &args.repository, - args.bundle.as_ref().expect("bundle source checked"), - ), - LifecycleSourceKind::Config => apply_setup_config_file( - &args.repository, - args.config.as_ref().expect("config source checked"), - ), - LifecycleSourceKind::Recipe => apply_setup_recipe( - &args.repository, - args.recipe.as_deref().expect("recipe checked"), - ), - LifecycleSourceKind::SavedConfig => apply_saved_setup(&args.repository), - } -} - -fn prepare_lifecycle_source( - args: &LifecycleSourceArgs, -) -> Result { - match lifecycle_source_kind(args)? { - LifecycleSourceKind::Bundle => bail!("bundle lifecycle source cannot be prepared as setup"), - LifecycleSourceKind::Config => { - prepare_setup_config_file(args.config.as_ref().expect("config source checked")) - } - LifecycleSourceKind::Recipe => { - prepare_setup_recipe(args.recipe.as_deref().expect("recipe checked")) - } - LifecycleSourceKind::SavedConfig => prepare_saved_setup(&args.repository), - } -} - -fn update_lifecycle_source(args: &LifecycleSourceArgs) -> Result { - match lifecycle_source_kind(args)? { - LifecycleSourceKind::Bundle => update_bundle_file( - &args.repository, - args.bundle.as_ref().expect("bundle source checked"), - ), - LifecycleSourceKind::Config => update_setup_config_file( - &args.repository, - args.config.as_ref().expect("config source checked"), - ), - LifecycleSourceKind::Recipe => update_setup_recipe( - &args.repository, - args.recipe.as_deref().expect("recipe checked"), - ), - LifecycleSourceKind::SavedConfig => update_saved_setup(&args.repository), - } -} - -fn confirm_setup_apply(yes: bool) -> Result<()> { - if yes { - return Ok(()); - } - if !atty_stdin() { - bail!("setup apply requires --yes when stdin is not interactive"); - } - eprint!("Apply these repository-local setup changes? Type yes to continue: "); - io::stderr().flush()?; - let mut input = String::new(); - io::stdin().read_line(&mut input)?; - if input.trim() != "yes" { - bail!("setup apply cancelled"); - } - Ok(()) -} - -fn atty_stdin() -> bool { - use std::io::IsTerminal; - io::stdin().is_terminal() + model_routing::cli::main(); } diff --git a/src/registry.rs b/src/registry.rs new file mode 100644 index 0000000..a67e166 --- /dev/null +++ b/src/registry.rs @@ -0,0 +1,163 @@ +use crate::contracts::*; +use crate::error::Result; +use crate::{bail, product_error}; +use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::collections::BTreeMap; +use std::fmt::Write; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct RegistrySignature { + pub algorithm: String, + pub signer: String, + pub content_sha256: String, + pub value: String, +} + +#[cfg(test)] +#[path = "tests/registry.rs"] +mod tests; + +pub fn sign_registry( + content: &[u8], + signer: &str, + private_key_hex: &str, +) -> Result { + if signer.trim().is_empty() { + bail!("registry signer must not be blank"); + } + let seed = decode_hex::<32>(private_key_hex.trim()).ok_or_else(|| { + product_error!("private key file must contain exactly 64 hexadecimal characters") + })?; + let key = SigningKey::from_bytes(&seed); + let signature = key.sign(content); + Ok(RegistrySignature { + algorithm: "ed25519".to_string(), + signer: signer.to_string(), + content_sha256: sha256(content), + value: encode_hex(&signature.to_bytes()), + }) +} + +pub fn verify_registry_signature( + content: &[u8], + signature: &RegistrySignature, + trusted_signer: &str, + trusted_public_key_hex: &str, +) -> Result<()> { + if signature.algorithm != "ed25519" || signature.content_sha256 != sha256(content) { + bail!("registry signature metadata does not match content"); + } + if trusted_signer.trim().is_empty() || signature.signer != trusted_signer { + bail!("registry signature signer does not match the trusted signer"); + } + let public_key = decode_hex::<32>(trusted_public_key_hex.trim()) + .ok_or_else(|| product_error!("trusted registry public key is invalid"))?; + let signature_bytes = decode_hex::<64>(&signature.value) + .ok_or_else(|| product_error!("registry signature value is invalid"))?; + let key = VerifyingKey::from_bytes(&public_key)?; + key.verify(content, &Signature::from_bytes(&signature_bytes))?; + Ok(()) +} +pub(crate) fn render_registry(source: &PolicySource) -> Result { + #[derive(Serialize)] + struct Registry { + profiles: BTreeMap, + routes: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + route_default: Option, + } + #[derive(Serialize)] + struct PlanrRegistryProfile { + client: String, + model: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + effort: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + cost_tier: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + capabilities: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + skill: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + notes: Option, + } + let profile_key = |profile_id: &str| -> String { + source + .profiles + .get(profile_id) + .and_then(|profile| profile.agent_type.clone()) + .unwrap_or_else(|| profile_id.to_string()) + }; + let profiles = source + .profiles + .iter() + .map(|(id, profile)| { + ( + profile_key(id), + PlanrRegistryProfile { + client: profile.client.clone(), + model: profile.model.clone(), + effort: profile.effort.clone(), + cost_tier: profile.cost_tier.clone(), + capabilities: profile.capabilities.clone(), + skill: profile.skill.clone(), + notes: profile + .agent_type + .as_ref() + .map(|agent_type| format!("native_agent_type={agent_type}")) + .or_else(|| profile.notes.clone()), + }, + ) + }) + .collect::>(); + let routes = source + .routes + .iter() + .map(|route| Route { + selector: route.selector.clone(), + profile: profile_key(&route.profile), + fallbacks: route + .fallbacks + .iter() + .map(|fallback| profile_key(fallback)) + .collect(), + }) + .collect::>(); + let route_default = source.route_default.as_ref().map(|default| DefaultRoute { + profile: profile_key(&default.profile), + fallbacks: default + .fallbacks + .iter() + .map(|fallback| profile_key(fallback)) + .collect(), + }); + Ok(toml::to_string_pretty(&Registry { + profiles, + routes, + route_default, + })?) +} + +pub(crate) fn sha256(bytes: &[u8]) -> String { + format!("{:x}", Sha256::digest(bytes)) +} + +pub(crate) fn encode_hex(bytes: &[u8]) -> String { + bytes.iter().fold(String::new(), |mut output, byte| { + write!(&mut output, "{byte:02x}").expect("writing to String cannot fail"); + output + }) +} + +pub(crate) fn decode_hex(value: &str) -> Option<[u8; N]> { + if value.len() != N * 2 { + return None; + } + let mut decoded = [0_u8; N]; + for (index, output) in decoded.iter_mut().enumerate() { + *output = u8::from_str_radix(&value[index * 2..index * 2 + 2], 16).ok()?; + } + Some(decoded) +} diff --git a/src/routing.rs b/src/routing.rs new file mode 100644 index 0000000..33f50e5 --- /dev/null +++ b/src/routing.rs @@ -0,0 +1,1179 @@ +use crate::contracts::*; +use crate::error::{Result, ResultContext}; +use crate::evidence::{validate_adapter_contract, validate_dispatch_evidence_for_adapter}; +use crate::integrations::render_planr_native_role; +use crate::{bail, product_error}; +use crate::{config::*, hosts::*, registry::*}; +use serde_json::{Value, json}; +use std::collections::{BTreeMap, BTreeSet}; + +pub(crate) const PACKAGE_VERSION: &str = env!("CARGO_PKG_VERSION"); +pub(crate) const GENERATED_AT: &str = "2026-07-16T00:00:00Z"; +pub(crate) const GENERATED_AT_UNIX: i64 = 1_784_160_000; +pub(crate) const EVALUATION_SUITE: &str = include_str!("../evaluations/preset-suite-v1.toml"); +pub(crate) const POLICIES: [(&str, &str); 4] = [ + ("balanced", include_str!("../usage-policies/balanced.toml")), + ( + "low-usage", + include_str!("../usage-policies/low-usage.toml"), + ), + ( + "max-quality", + include_str!("../usage-policies/max-quality.toml"), + ), + ( + "read-only-audit", + include_str!("../usage-policies/read-only-audit.toml"), + ), +]; + +pub fn list_policies() -> Result> { + let mut summaries = Vec::new(); + for (policy, _) in POLICIES { + for (host, _) in BINDINGS { + let source = show_policy(policy, host)?; + summaries.push(PolicySummary { + policy_id: source.policy_id, + host: source.host, + policy_version: source.policy_version, + binding_id: source.binding_id, + binding_version: source.binding_version, + profile_count: source.profiles.len(), + artifact_count: source.artifacts.len() + 2, + evidence_status: source.evidence.status, + }); + } + } + Ok(summaries) +} + +#[cfg(test)] +#[path = "tests/routing.rs"] +mod tests; + +pub fn show_policy(policy: &str, host: &str) -> Result { + let binding_id = canonical_binding_id(host); + let policy_raw = POLICIES + .iter() + .find(|(id, _)| *id == policy) + .map(|(_, raw)| *raw) + .ok_or_else(|| product_error!("unknown routing policy `{policy}`"))?; + let binding_raw = BINDINGS + .iter() + .find(|(id, _)| *id == binding_id) + .map(|(_, raw)| *raw) + .ok_or_else(|| product_error!("unknown routing host `{host}`"))?; + let policy_contract: PolicyContract = toml::from_str(policy_raw)?; + validate_policy_contract(&policy_contract)?; + let binding: HostBinding = toml::from_str(binding_raw)?; + let adapter = compile_host_adapter( + policy_contract.id.as_str(), + &binding, + Integration::Standalone, + )?; + Ok(PolicySource { + policy_id: policy_contract.id.clone(), + host: host.to_string(), + policy_version: policy_contract.version.clone(), + binding_id: binding.id.clone(), + binding_version: binding.version.clone(), + generated_at: GENERATED_AT.to_string(), + requirements: adapter.requirements, + profiles: adapter.profiles, + routes: adapter.routes, + route_default: adapter.route_default, + artifacts: adapter.artifacts, + evidence: EvaluationEvidence { + evaluation_ids: vec![binding.verification.id.clone()], + status: "experimental".to_string(), + }, + adapter_contract: adapter.adapter_contract, + policy: policy_contract, + policy_toml: policy_raw.to_string(), + }) +} + +pub fn compile_policy( + policy: &str, + host: &str, + integration: Integration, +) -> Result { + compile_setup_spec(&setup_spec_for_policy(policy, host, integration)?) +} + +#[cfg(test)] +pub(crate) fn compile_builtin_policy_direct( + policy: &str, + host: &str, + integration: Integration, +) -> Result { + compile_source(show_policy(policy, host)?, integration) +} + +pub(crate) fn compile_source( + source: PolicySource, + integration: Integration, +) -> Result { + validate_source(&source)?; + let mut adapter_contract = adapter_contract_for_source(&source, integration)?; + let mut artifacts = Vec::new(); + if integration == Integration::Planr { + let registry = render_registry(&source)?; + artifacts.push(bundle_artifact(SourceArtifact { + path: ".planr/agents.toml".to_string(), + media_type: "application/toml".to_string(), + mode: "replace".to_string(), + content: registry, + })); + artifacts.push(bundle_artifact(SourceArtifact { + path: ".planr/policy.toml".to_string(), + media_type: "application/toml".to_string(), + mode: "replace".to_string(), + content: source.policy_toml.clone(), + })); + } + let mut host_artifacts = source + .artifacts + .iter() + .filter(|artifact| include_artifact_for_integration(artifact, integration)) + .cloned() + .map(|artifact| artifact_for_integration(artifact, integration)) + .collect::>(); + if let Some(codex_config) = render_codex_agent_registration_artifact(&host_artifacts)? { + host_artifacts.push(codex_config); + } + artifacts.extend(host_artifacts.into_iter().map(bundle_artifact)); + artifacts.sort_by(|left, right| left.path.cmp(&right.path)); + adapter_contract.adapter.emitted_artifact_modes = artifacts + .iter() + .map(|artifact| artifact.mode.clone()) + .collect::>() + .into_iter() + .collect(); + adapter_contract.adapter.dispatch_recipe.artifact_paths = artifacts + .iter() + .map(|artifact| artifact.path.clone()) + .collect(); + validate_adapter_contract(&adapter_contract)?; + + Ok(RoutingBundleV1 { + schema_version: 1, + bundle_id: format!( + "{}-{}@{}+{}", + source.policy_id, source.host, source.policy_version, source.binding_version + ), + policy_id: source.policy_id, + policy_version: source.policy_version, + generated_at: source.generated_at, + source: BundleSource { + package: "model-routing".to_string(), + package_version: PACKAGE_VERSION.to_string(), + integration, + }, + policy: source.policy, + requirements: source.requirements, + profiles: source.profiles, + routes: source.routes, + route_default: source.route_default, + artifacts, + evidence: source.evidence, + adapter_contract: Some(adapter_contract), + }) +} + +pub fn compile_json(policy: &str, host: &str, integration: Integration) -> Result { + let mut json = serde_json::to_string_pretty(&compile_policy(policy, host, integration)?)?; + json.push('\n'); + Ok(json) +} + +pub fn evaluate_policy(policy: &str, host: &str) -> Result { + let suite: toml::Value = toml::from_str(EVALUATION_SUITE)?; + let suite_id = suite + .get("id") + .and_then(toml::Value::as_str) + .ok_or_else(|| product_error!("evaluation suite is missing id"))?; + let suite_version = suite + .get("version") + .and_then(toml::Value::as_str) + .ok_or_else(|| product_error!("evaluation suite is missing version"))?; + let scenario_count = suite + .get("tasks") + .and_then(toml::Value::as_array) + .map_or(0, Vec::len); + let bundle = compile_json(policy, host, Integration::Standalone)?; + Ok(EvaluationReport { + schema_version: 1, + suite_id: suite_id.to_string(), + suite_version: suite_version.to_string(), + suite_sha256: sha256(EVALUATION_SUITE.as_bytes()), + policy_id: policy.to_string(), + host: show_policy(policy, host)?.host, + bundle_sha256: sha256(bundle.as_bytes()), + scenario_count, + offline_reproducible: scenario_count > 0, + live_evidence: None, + status: "experimental".to_string(), + recommended: false, + }) +} + +pub fn catalog_value() -> Result { + let mut compositions = Vec::new(); + for summary in list_policies()? { + let source = show_policy(&summary.policy_id, &summary.host)?; + let report = evaluate_policy(&summary.policy_id, &summary.host)?; + let bundle = compile_policy(&summary.policy_id, &summary.host, Integration::Standalone)?; + compositions.push(json!({ + "id": format!("{}-{}@{}+{}", summary.policy_id, summary.binding_id, summary.policy_version, summary.binding_version), + "entryId": format!("{}-{}", summary.policy_id, summary.binding_id), + "entryVersion": format!("{}+{}", summary.policy_version, summary.binding_version), + "status": report.status, + "statusLabel": "Experimental", + "recommended": false, + "freshness": "current", + "lifecycle": "published", + "replacement": Value::Null, + "policy": { + "id": summary.policy_id, + "version": summary.policy_version, + "usage": source.policy.usage, + "transitions": source.policy.transitions, + "materiality": source.policy.materiality, + "execution": source.policy.execution, + }, + "binding": { + "id": summary.binding_id, + "selector": summary.host, + "version": summary.binding_version, + "host": source.requirements.first().map(|requirement| requirement.host.clone()), + "profiles": bundle.profiles, + "dispatch": bundle.routes, + }, + "compatibility": { + "hosts": source.requirements.iter().map(|requirement| requirement.host.clone()).collect::>(), + "minModelRoutingVersion": "0.1.0", + "maxModelRoutingVersion": Value::Null, + }, + "enforcement": [ + {"dimension": "Repository writes", "state": "verified", "detail": "Core previews and applies only allowlisted repository-local bundle artifacts."}, + {"dimension": "Model and effort", "state": "host_enforced", "detail": "The package generates exact host roles; the host remains execution authority."}, + {"dimension": "Effective route evidence", "state": "unavailable", "detail": "No authenticated live-host evidence is published for this generated catalog entry."} + ], + "evaluation": { + "suiteId": report.suite_id, + "suiteVersion": report.suite_version, + "evaluatedAtUnix": GENERATED_AT_UNIX, + "reviewAtUnix": Value::Null, + "status": report.status, + "metrics": {"runs": 0, "oracle_passes": 0, "average_quality_score_bps": Value::Null}, + "thresholds": {}, + "resultHashes": [], + "fixtureSha256": report.suite_sha256, + }, + "registry": { + "id": "model-routing-official", + "version": PACKAGE_VERSION, + "manifestSha256": report.bundle_sha256, + "signer": Value::Null, + "signatureVerified": false, + "trustedMaintainer": false, + "artifacts": bundle.artifacts.iter().map(|artifact| json!({"path": artifact.path, "sha256": artifact.sha256})).collect::>(), + }, + "command": format!("model-routing compile {} --host {} --output routing-bundle.json", source.policy_id, source.host), + })); + } + Ok(json!({ + "schemaVersion": 1, + "generatedAtUnix": GENERATED_AT_UNIX, + "setupContract": setup_contract_catalog_value()?, + "source": { + "state": "package_generated", + "entryCount": compositions.len(), + "trust": "model_routing_unsigned_catalog_v1", + "message": "Entries stay experimental until authenticated live evidence and an offline maintainer signature pass." + }, + "compositions": compositions, + })) +} + +pub fn catalog_json() -> Result { + let mut output = serde_json::to_string_pretty(&catalog_value()?)?; + output.push('\n'); + Ok(output) +} + +pub fn inspect_bundle_json(input: &str) -> Result { + let bundle = validate_bundle_json(input)?; + Ok(BundleInspection { + schema_version: bundle.schema_version, + bundle_id: bundle.bundle_id, + policy_id: bundle.policy_id, + integration: bundle.source.integration, + profile_count: bundle.profiles.len(), + route_count: bundle.routes.len(), + artifact_count: bundle.artifacts.len(), + valid: true, + }) +} + +pub fn validate_bundle_json(input: &str) -> Result { + let value: Value = serde_json::from_str(input).context("bundle is not valid JSON")?; + validate_raw_bundle_shape(&value)?; + let bundle: RoutingBundleV1 = serde_json::from_value(value).map_err(|error| { + product_error!("bundle does not match RoutingBundle v1 schema: {error}") + })?; + validate_bundle(&bundle)?; + Ok(bundle) +} + +pub fn validate_bundle(bundle: &RoutingBundleV1) -> Result<()> { + if bundle.schema_version != 1 { + bail!("unsupported schema_version {}", bundle.schema_version); + } + if bundle.bundle_id.trim().is_empty() + || bundle.policy_id.trim().is_empty() + || bundle.policy_version.trim().is_empty() + { + bail!("bundle id, policy id, and policy version must not be blank"); + } + if bundle.source.package != "model-routing" { + bail!("bundle source package must be model-routing"); + } + if bundle.policy.id != bundle.policy_id || bundle.policy.version != bundle.policy_version { + bail!("bundle policy contract does not match bundle policy identifiers"); + } + validate_policy_contract(&bundle.policy)?; + for route in &bundle.routes { + if !bundle.profiles.contains_key(&route.profile) { + bail!("route references unknown profile `{}`", route.profile); + } + for fallback in &route.fallbacks { + if !bundle.profiles.contains_key(fallback) { + bail!("route fallback references unknown profile `{fallback}`"); + } + } + } + if let Some(default) = &bundle.route_default { + if !bundle.profiles.contains_key(&default.profile) { + bail!( + "default route references unknown profile `{}`", + default.profile + ); + } + for fallback in &default.fallbacks { + if !bundle.profiles.contains_key(fallback) { + bail!("default route fallback references unknown profile `{fallback}`"); + } + } + } + let mut artifact_paths = BTreeSet::new(); + for artifact in &bundle.artifacts { + if artifact.path.trim().is_empty() { + bail!("artifact path must not be blank"); + } + if !artifact_paths.insert(artifact.path.clone()) { + bail!("duplicate artifact path `{}`", artifact.path); + } + if artifact.mode != "create" && artifact.mode != "replace" { + bail!( + "artifact `{}` has unsupported mode `{}`", + artifact.path, + artifact.mode + ); + } + let expected = sha256(artifact.content.as_bytes()); + if artifact.sha256 != expected { + bail!("artifact `{}` sha256 mismatch", artifact.path); + } + } + if let Some(contract) = &bundle.adapter_contract { + validate_adapter_contract(contract)?; + } + Ok(()) +} + +pub(crate) fn validate_raw_bundle_shape(value: &Value) -> Result<()> { + let object = value + .as_object() + .ok_or_else(|| product_error!("bundle root must be a JSON object"))?; + let schema_version = object + .get("schema_version") + .and_then(Value::as_u64) + .ok_or_else(|| product_error!("bundle schema_version must be an integer"))?; + if schema_version != 1 { + bail!("unsupported schema_version {schema_version}"); + } + let allowed_root = BTreeSet::from([ + "schema_version", + "bundle_id", + "policy_id", + "policy_version", + "generated_at", + "source", + "policy", + "requirements", + "profiles", + "routes", + "route_default", + "artifacts", + "evidence", + "adapter_contract", + ]); + for key in object.keys() { + if !allowed_root.contains(key.as_str()) { + bail!("unknown bundle field `{key}`"); + } + } + let source = object + .get("source") + .and_then(Value::as_object) + .ok_or_else(|| product_error!("bundle source must be an object"))?; + if !source.contains_key("integration") { + bail!("bundle source.integration is required"); + } + let artifacts = object + .get("artifacts") + .and_then(Value::as_array) + .ok_or_else(|| product_error!("bundle artifacts must be an array"))?; + let allowed_artifact = BTreeSet::from(["path", "media_type", "mode", "content", "sha256"]); + for artifact in artifacts { + let artifact_object = artifact + .as_object() + .ok_or_else(|| product_error!("bundle artifact must be an object"))?; + let path = artifact_object + .get("path") + .and_then(Value::as_str) + .unwrap_or(""); + if artifact_object.contains_key("content") && artifact_object.contains_key("content_ref") { + bail!("artifact `{path}` cannot define both content and content_ref"); + } + for key in artifact_object.keys() { + if !allowed_artifact.contains(key.as_str()) { + bail!("artifact `{path}` has unknown field `{key}`"); + } + } + } + Ok(()) +} + +pub(crate) fn validate_policy_contract(policy: &PolicyContract) -> Result<()> { + if policy.schema_version != 1 { + bail!( + "unsupported policy schema_version {}", + policy.schema_version + ); + } + if policy.id.trim().is_empty() || policy.version.trim().is_empty() { + bail!("policy id and version must not be blank"); + } + if policy.usage.max_parallel_writers > policy.usage.max_active_agents { + bail!("policy max_parallel_writers cannot exceed max_active_agents"); + } + if policy.usage.max_parallel_readers > policy.usage.max_active_agents { + bail!("policy max_parallel_readers cannot exceed max_active_agents"); + } + if policy.usage.review_reserve_percent > 100 { + bail!("policy review_reserve_percent cannot exceed 100"); + } + if policy.execution.max_write_scope_entries == 0 { + for role in policy.execution.roles.values() { + if !role.filesystem.write_roots.is_empty() { + bail!("policy with zero write scope cannot declare writable roots"); + } + } + } + Ok(()) +} + +pub(crate) fn validate_source(source: &PolicySource) -> Result<()> { + if source.policy_id.trim().is_empty() || source.host.trim().is_empty() { + bail!("routing policy id and host must not be blank"); + } + for route in &source.routes { + if !source.profiles.contains_key(&route.profile) { + bail!("route references unknown profile `{}`", route.profile); + } + } + if let Some(default) = &source.route_default { + if !source.profiles.contains_key(&default.profile) { + bail!( + "default route references unknown profile `{}`", + default.profile + ); + } + } + if source.evidence.status == "recommended" { + bail!("policy sources cannot claim recommended without the evaluation gate"); + } + for profile in source.profiles.values() { + validate_profile_fork_policy(profile)?; + } + validate_adapter_contract(&source.adapter_contract)?; + validate_policy_contract(&source.policy)?; + Ok(()) +} + +pub(crate) fn bundle_artifact(source: SourceArtifact) -> BundleArtifact { + BundleArtifact { + sha256: sha256(source.content.as_bytes()), + path: source.path, + media_type: source.media_type, + mode: source.mode, + content: source.content, + } +} + +pub fn compile_setup_spec(spec: &SetupSpecV1) -> Result { + let source = source_from_setup_spec(spec)?; + let bundle = compile_source(source, spec.integration)?; + validate_bundle(&bundle)?; + Ok(bundle) +} + +pub fn compile_setup_json(input: &str) -> Result { + let spec = setup_spec_from_json(input)?; + let mut json = serde_json::to_string_pretty(&compile_setup_spec(&spec)?)?; + json.push('\n'); + Ok(json) +} + +pub(crate) fn source_from_setup_spec(spec: &SetupSpecV1) -> Result { + validate_setup_spec(spec)?; + let binding = binding_for_selector(&spec.host)?; + let mut source = show_policy(&spec.usage_policy, &binding.id)?; + if setup_matches_binding(spec, &binding)? { + return Ok(source); + } + let adapter = + compile_setup_adapter(source.policy_id.as_str(), &binding, spec, &source.artifacts)?; + source.requirements = adapter.requirements; + source.profiles = adapter.profiles; + source.routes = adapter.routes; + source.route_default = adapter.route_default; + source.artifacts = adapter.artifacts; + source.adapter_contract = adapter.adapter_contract; + source.evidence = EvaluationEvidence { + evaluation_ids: Vec::new(), + status: "custom-unverified".to_string(), + }; + Ok(source) +} + +pub(crate) fn compile_setup_adapter( + policy_id: &str, + binding: &HostBinding, + spec: &SetupSpecV1, + binding_artifacts: &[SourceArtifact], +) -> Result { + validate_host_adapter(binding)?; + let runtime_host = setup_runtime_host(binding); + let profiles = setup_profiles_from_intent(spec, binding)?; + let routes = setup_routes_from_intent(spec); + let route_default = setup_default_route_from_intent(spec); + let artifacts = setup_artifacts_from_intent( + runtime_host, + &spec.selected_roles, + binding, + binding_artifacts, + )?; + let mut adapter_contract = adapter_contract_for_binding(policy_id, binding, spec.integration)?; + adapter_contract.routing_intent.semantic_roles = profiles.keys().cloned().collect(); + adapter_contract.routing_intent.role_requests = role_intents_for_profiles(&profiles); + adapter_contract.adapter.emitted_artifact_modes = artifacts + .iter() + .map(|artifact| artifact.mode.clone()) + .collect::>() + .into_iter() + .collect(); + adapter_contract.adapter.dispatch_recipe.artifact_paths = artifacts + .iter() + .map(|artifact| artifact.path.clone()) + .collect(); + validate_adapter_contract(&adapter_contract)?; + Ok(CompiledHostAdapter { + requirements: vec![HostRequirement { + host: binding.host.clone(), + capabilities: requirement_capabilities_for_binding(binding), + }], + profiles, + routes, + route_default, + artifacts, + adapter_contract, + }) +} + +pub(crate) fn setup_profiles_from_intent( + spec: &SetupSpecV1, + binding: &HostBinding, +) -> Result> { + let runtime_host = setup_runtime_host(binding); + let model_catalog = setup_model_catalog(runtime_host); + spec.selected_roles + .iter() + .map(|(role, selection)| { + let option = model_catalog + .iter() + .find(|option| option.id == selection.model) + .ok_or_else(|| { + product_error!( + "setup role `{role}` model `{}` is not supported by host `{}`", + selection.model, + spec.host + ) + })?; + if runtime_host == "codex" + && selection.spawn.is_none() + && selection_matches_binding_profile(role, selection, binding) + { + return Ok(( + role.clone(), + profile_from_binding_profile(binding.profiles.get(role).ok_or_else(|| { + product_error!("setup role `{role}` is missing from binding") + })?), + )); + } + let agent_type = if runtime_host == "codex" { + Some( + selection + .spawn + .as_ref() + .ok_or_else(|| { + product_error!("setup role `{role}` must declare Codex spawn policy") + })? + .agent_type + .clone(), + ) + } else { + None + }; + Ok(( + role.clone(), + Profile { + client: runtime_host.to_string(), + model: selection.model.clone(), + agent_type, + effort: selection.effort.clone(), + cost_tier: Some(option.tier.to_string()), + capabilities: Vec::new(), + skill: None, + notes: Some("custom SetupSpecV1 role".to_string()), + fork_turns: selection + .spawn + .as_ref() + .map(|spawn| spawn.fork_turns.clone()), + }, + )) + }) + .collect() +} + +pub(crate) fn setup_routes_from_intent(spec: &SetupSpecV1) -> Vec { + spec.routes + .iter() + .map(|route| Route { + selector: RouteSelector { + work_type: Some(route.work_type.clone()), + plan: None, + }, + profile: route.role.clone(), + fallbacks: route.fallbacks.clone(), + }) + .collect() +} + +pub(crate) fn setup_default_route_from_intent(spec: &SetupSpecV1) -> Option { + spec.route_default.as_ref().map(|default| DefaultRoute { + profile: default.role.clone(), + fallbacks: default.fallbacks.clone(), + }) +} + +pub(crate) fn setup_matches_binding(spec: &SetupSpecV1, binding: &HostBinding) -> Result { + if canonical_binding_id(&spec.host) != binding.id { + return Ok(false); + } + if spec.selected_roles.len() != binding.profiles.len() { + return Ok(false); + } + for (role, binding_profile) in &binding.profiles { + let Some(selection) = spec.selected_roles.get(role) else { + return Ok(false); + }; + if selection.model != binding_profile.model + || selection.effort != binding_profile.effort + || !selection_spawn_matches_binding( + setup_runtime_host(binding), + role, + selection, + binding_profile, + ) + { + return Ok(false); + } + } + if spec.routes.len() != binding.routes.len() { + return Ok(false); + } + for (setup_route, binding_route) in spec.routes.iter().zip(binding.routes.iter()) { + if setup_route.work_type != binding_route.work_type + || setup_route.role != binding_route.role + || setup_route.fallbacks != binding_route.fallback_roles + { + return Ok(false); + } + } + Ok(match (&spec.route_default, &binding.default_role) { + (None, None) => true, + (Some(setup), Some(binding_role)) => { + setup.role == *binding_role && setup.fallbacks.is_empty() + } + _ => false, + }) +} + +pub(crate) fn setup_artifacts_from_intent( + runtime_host: &str, + roles: &BTreeMap, + binding: &HostBinding, + binding_artifacts: &[SourceArtifact], +) -> Result> { + roles + .iter() + .map(|(role, selection)| { + if runtime_host == "codex" + && selection.spawn.is_none() + && selection_matches_binding_profile(role, selection, binding) + { + return binding_artifact_for_role(binding, binding_artifacts, role); + } + let file_role = identifier_token(role); + let path = setup_artifact_path(runtime_host, role, selection)?; + let (kind, content) = match runtime_host { + "codex" => { + let spawn = selection.spawn.as_ref().ok_or_else(|| { + product_error!("setup role `{role}` must declare Codex spawn policy") + })?; + let agent_type = spawn.agent_type.clone(); + let effort = selection + .effort + .clone() + .unwrap_or_else(|| "medium".to_string()); + ( + "codex_agent", + format!( + "name = \"{agent_type}\"\ndescription = \"Switchloom custom {role} role.\"\nmodel = \"{}\"\nmodel_reasoning_effort = \"{effort}\"\n\ndeveloper_instructions = \"\"\"\nSpawn with agent_type `{agent_type}`, task_name `{}`, and fork_turns `{}`. The live parent permission profile remains authoritative; this role declares routing intent and expected ownership evidence, not filesystem permission enforcement.\n\"\"\"\n", + selection.model, spawn.task_name, spawn.fork_turns.mode + ), + ) + } + "claude-code" => { + let effort = selection + .effort + .clone() + .unwrap_or_else(|| "medium".to_string()); + ( + "claude_agent", + format!( + "---\nname: switchloom-{file_role}\nmodel: {}\neffort: {effort}\n---\nFollow the repository-local Switchloom setup role `{role}` and preserve routing evidence.\n", + selection.model + ), + ) + } + "cursor" => { + ( + "cursor_agent", + format!( + "---\nname: switchloom-{file_role}\nmodel: {}\n---\nFollow the repository-local Switchloom setup role `{role}` and preserve routing evidence.\n", + selection.model + ), + ) + } + "opencode" => { + let effort = selection + .effort + .clone() + .unwrap_or_else(|| "medium".to_string()); + ( + "opencode_agent", + format!( + "---\ndescription: Switchloom custom {role} role.\nmode: subagent\nmodel: {}\nvariant: {effort}\npermission:\n edit: allow\n bash: ask\n task:\n \"*\": deny\n---\nFollow the repository-local Switchloom setup role `{role}` and preserve routing evidence.\n", + selection.model + ), + ) + } + "pi" => { + let effort = selection + .effort + .clone() + .unwrap_or_else(|| "medium".to_string()); + let (provider, model) = selection.model.split_once('/').ok_or_else(|| { + product_error!( + "setup role `{role}` Pi model `{}` must use provider/model form", + selection.model + ) + })?; + let agent_type = format!("switchloom-pi-{file_role}"); + ( + "pi_workflow", + format!( + "{{\n \"schema_version\": 1,\n \"workflow\": \"switchloom-{file_role}\",\n \"runner\": \"pi\",\n \"runtime_class\": \"external-runner\",\n \"arguments\": {{\n \"agent_type\": \"{agent_type}\",\n \"provider_model\": \"{}\",\n \"thinking\": \"{effort}\",\n \"isolation\": {{\n \"session\": \"none\",\n \"tools\": \"none\",\n \"extensions\": \"none\",\n \"skills\": \"none\",\n \"agent_dir\": \"report-workdir/.pi-agent\"\n }},\n \"task\": {{\n \"semantic_role\": \"{role}\",\n \"profile\": \"{agent_type}\",\n \"returns\": \"nonce-only\"\n }}\n }},\n \"process\": {{\n \"argv\": [\"pi\", \"--print\", \"--no-session\", \"--no-tools\", \"--no-extensions\", \"--no-skills\", \"--provider\", \"{provider}\", \"--model\", \"{model}\", \"--thinking\", \"{effort}\"],\n \"state_boundary\": \"PI_CODING_AGENT_DIR is set to a report-local directory for every certification run\"\n }},\n \"security\": {{\n \"filesystem_tools\": \"disabled\",\n \"session_persistence\": \"disabled\",\n \"native_subagents\": \"not used\",\n \"certification_requirement\": \"A persisted workflow receipt must include the dynamic nonce returned by a live Pi child process before advisory runtime evidence is accepted.\"\n }}\n}}\n", + selection.model + ), + ) + } + "mixed-host" => { + ( + "routing_role", + format!( + "role = \"{role}\"\nmodel = \"{}\"\n{}\n", + selection.model, + selection + .effort + .as_ref() + .map(|effort| format!("effort = \"{effort}\"")) + .unwrap_or_default() + ), + ) + } + other => bail!("unsupported setup runtime host `{other}`"), + }; + let media_type = media_type_for(&path, kind); + Ok(SourceArtifact { + path, + media_type, + mode: "replace".to_string(), + content, + }) + }) + .collect() +} + +pub(crate) fn compile_host_adapter( + policy_id: &str, + binding: &HostBinding, + integration: Integration, +) -> Result { + validate_host_adapter(binding)?; + Ok(CompiledHostAdapter { + requirements: vec![HostRequirement { + host: binding.host.clone(), + capabilities: requirement_capabilities_for_binding(binding), + }], + profiles: profiles_for_binding(binding), + routes: routes_for_binding(binding)?, + route_default: default_route_for_binding(binding)?, + artifacts: artifacts_for_binding(binding), + adapter_contract: adapter_contract_for_binding(policy_id, binding, integration)?, + }) +} + +pub(crate) fn profiles_for_binding(binding: &HostBinding) -> BTreeMap { + binding + .profiles + .values() + .map(|profile| { + ( + profile.profile.clone(), + profile_from_binding_profile(profile), + ) + }) + .collect() +} + +pub(crate) fn routes_for_binding(binding: &HostBinding) -> Result> { + binding + .routes + .iter() + .map(|route| { + Ok(Route { + selector: RouteSelector { + work_type: Some(route.work_type.clone()), + plan: None, + }, + profile: binding_profile_id(binding, &route.role)?.to_string(), + fallbacks: route + .fallback_roles + .iter() + .map(|role| binding_profile_id(binding, role).map(ToOwned::to_owned)) + .collect::>>()?, + }) + }) + .collect() +} + +pub(crate) fn default_route_for_binding(binding: &HostBinding) -> Result> { + binding + .default_role + .as_deref() + .map(|role| -> Result { + Ok(DefaultRoute { + profile: binding_profile_id(binding, role)?.to_string(), + fallbacks: Vec::new(), + }) + }) + .transpose() +} + +pub(crate) fn adapter_contract_for_binding( + policy_id: &str, + binding: &HostBinding, + integration: Integration, +) -> Result { + let runtime_class = binding.runtime_class; + let semantic_roles = binding.profiles.keys().cloned().collect::>(); + let artifact_modes = if binding.artifacts.is_empty() { + Vec::new() + } else { + vec!["create".to_string(), "replace".to_string()] + }; + let dispatch_fields = dispatch_fields_for_binding(binding); + let artifact_paths = binding + .artifacts + .iter() + .map(|artifact| artifact.path.clone()) + .collect::>(); + Ok(AdapterContractV1 { + schema_version: 1, + routing_intent: RoutingIntentV1 { + schema_version: 1, + integration, + semantic_roles, + role_requests: role_intents_for_binding(binding), + required_guarantees: vec![ + "artifact_lifecycle".to_string(), + "dispatch_identity".to_string(), + ], + }, + capability: HostCapabilityV1 { + schema_version: 1, + host: binding.host.clone(), + host_version_constraints: host_version_constraints_for_binding(binding)?, + runtime_class, + runtime_behavior: runtime_behavior_for_binding(binding)?, + discovery_artifacts: binding.capability_evidence.clone(), + dispatch_fields: dispatch_fields.clone(), + model_control: ControlCapability { + level: control_level(binding.capabilities.model_override, binding.host == "codex"), + field: "model".to_string(), + evidence_required: binding.capabilities.model_override, + }, + effort_control: ControlCapability { + level: control_level(binding.capabilities.effort_override, binding.host == "codex"), + field: "effort".to_string(), + evidence_required: binding.capabilities.effort_override, + }, + context_semantics: ContextSemantics { + supports_fork_none: binding.capabilities.fork_none, + supports_fork_all: binding.capabilities.fork_all, + requires_bounded_context_for_overrides: binding.host == "codex", + }, + nesting: NestingCapability { + max_depth: 1, + level: if binding.capabilities.fork_none { + GuaranteeLevel::Deterministic + } else { + GuaranteeLevel::Unsupported + }, + }, + parallelism: ParallelismCapability { + max_parallel_children: max_parallel_children_for_binding(binding)?, + level: GuaranteeLevel::Advisory, + }, + observability: ObservabilityCapability { + requested_dispatch: GuaranteeLevel::Deterministic, + effective_identity: if binding.host == "codex" { + GuaranteeLevel::Deterministic + } else { + GuaranteeLevel::Advisory + }, + effective_model: if binding.host == "codex" { + GuaranteeLevel::Deterministic + } else { + GuaranteeLevel::Advisory + }, + raw_evidence_refs: binding.capability_evidence.clone(), + }, + guarantees: capability_guarantees_for_binding(binding), + known_limitations: binding.known_limitations.clone(), + }, + adapter: HostAdapterV1 { + schema_version: 1, + adapter_id: binding.id.clone(), + adapter_version: binding.version.clone(), + runtime_class, + accepts_intent_schema: "RoutingIntentV1".to_string(), + emitted_artifact_modes: artifact_modes, + dispatch_recipe: DispatchRecipeV1 { + invocation: match runtime_class { + RuntimeClass::NativeSubagent => "host-native-subagent".to_string(), + RuntimeClass::ExternalRunner => "external-runner-process".to_string(), + }, + required_fields: dispatch_fields, + artifact_paths, + }, + lifecycle_owner: "switchloom-managed".to_string(), + }, + dispatch_evidence: DispatchEvidenceContractV1 { + schema_version: 1, + required_verdicts: vec![ + GuaranteeLevel::Deterministic, + GuaranteeLevel::Advisory, + GuaranteeLevel::Unsupported, + ], + receipt_schema: "DispatchEvidenceV1".to_string(), + }, + planr_handoff: PlanrHandoffV1 { + schema_version: 1, + switchloom_package: npm_package_identity()?, + semantic_role_contract: format!( + "Planr supplies usage policy `{policy_id}`, work_type routes, and semantic roles; Switchloom owns the selected host binding, model, effort, fork/context policy, and generated dispatch artifacts." + ), + required_consumer_behavior: vec![ + "Consume RoutingIntentV1 as the only source for semantic_role, work_type, selected profile, model, effort, agent_type, and fork_turns inputs.".to_string(), + "Use the CLI lifecycle or SetupSpecV1 recipe commands to preview, apply, update, status, rollback, and uninstall repository-local artifacts.".to_string(), + "Record Switchloom package version, package digest, bundle_id, host version, requested dispatch, effective child identity, nonce, and receipt paths before claiming certification.".to_string(), + "Treat advisory or unsupported guarantees as uncertified until nonce-bearing live host evidence upgrades them.".to_string(), + "For the available-host release gate, Codex may be certified from deterministic effective-routing evidence; Cursor profiles may only claim advisory nonce-correlated requested-routing evidence unless the host exposes authenticated effective role/model telemetry. Claude Code, OpenCode, and Pi remain unavailable or unverified until authentic receipts exist.".to_string(), + ], + forbidden_duplicate_ownership: vec![ + "Do not maintain a Planr-side model catalog, effort catalog, preset compiler, host adapter, or fork policy normalizer for Switchloom-owned inputs.".to_string(), + "Do not re-normalize Switchloom model, effort, role, agent_type, profile, or fork policy identifiers in Planr.".to_string(), + "Do not overwrite Switchloom-managed artifacts outside preview/apply/update/rollback/uninstall.".to_string(), + "Do not mark Claude Code, OpenCode, Pi, or any advisory receipt as certified without live nonce-bearing child evidence.".to_string(), + ], + certification_report_reference: + "reports/native-host-certification///workdir/dispatch-evidence.json plus the matching bundle.json, invocation receipt, package digest, and validator stdout".to_string(), + }, + }) +} + +pub(crate) fn adapter_contract_for_source( + source: &PolicySource, + integration: Integration, +) -> Result { + let mut contract = source.adapter_contract.clone(); + contract.routing_intent.integration = integration; + contract.adapter.emitted_artifact_modes = source + .artifacts + .iter() + .map(|artifact| artifact.mode.clone()) + .collect::>() + .into_iter() + .collect(); + validate_adapter_contract(&contract)?; + Ok(contract) +} + +pub(crate) fn role_intents_for_binding(binding: &HostBinding) -> Vec { + binding + .profiles + .iter() + .map(|(role, profile)| RoutingRoleIntentV1 { + semantic_role: role.clone(), + requested_model: profile.model.clone(), + requested_effort: profile.effort.clone(), + instructions: format!("Route `{role}` through `{}`.", profile.profile), + }) + .collect() +} + +pub(crate) fn role_intents_for_profiles( + profiles: &BTreeMap, +) -> Vec { + profiles + .iter() + .map(|(role, profile)| RoutingRoleIntentV1 { + semantic_role: role.clone(), + requested_model: profile.model.clone(), + requested_effort: profile.effort.clone(), + instructions: format!("Route `{role}` through `{}`.", profile.client), + }) + .collect() +} + +pub(crate) fn profile_from_binding_profile(profile: &BindingProfile) -> Profile { + Profile { + client: profile.client.clone(), + model: profile.model.clone(), + agent_type: profile.agent_type.clone(), + effort: profile.effort.clone(), + cost_tier: profile.cost_tier.clone(), + capabilities: Vec::new(), + skill: None, + notes: None, + fork_turns: profile.fork_turns.clone(), + } +} + +pub(crate) fn validate_profile_fork_policy(profile: &Profile) -> Result<()> { + let requires_explicit_fork = profile.client == "codex" + && profile.agent_type.is_some() + && profile.agent_type.as_deref() != Some("model_routing_sol_medium"); + if !requires_explicit_fork { + return Ok(()); + } + let Some(fork_turns) = &profile.fork_turns else { + bail!( + "codex profile `{}` must declare fork_turns none or positive bounded when overriding model or effort", + profile.agent_type.as_deref().unwrap_or("") + ); + }; + match fork_turns.mode.as_str() { + "none" => Ok(()), + "bounded" => match fork_turns.turns { + Some(turns) if turns > 0 => Ok(()), + _ => bail!( + "codex profile `{}` bounded fork_turns must use positive turns", + profile.agent_type.as_deref().unwrap_or("") + ), + }, + "all" => bail!( + "codex profile `{}` must not use fork_turns all with model or effort overrides", + profile.agent_type.as_deref().unwrap_or("") + ), + other => bail!( + "codex profile `{}` has unsupported fork_turns mode `{other}`", + profile.agent_type.as_deref().unwrap_or("") + ), + } +} + +pub(crate) fn include_artifact_for_integration( + artifact: &SourceArtifact, + integration: Integration, +) -> bool { + if artifact.path.contains("/skills/") + || artifact + .content + .contains("name: model-routing-native-routing") + { + return false; + } + integration == Integration::Planr || !artifact.path.starts_with(".planr/") +} + +pub(crate) fn artifact_for_integration( + mut artifact: SourceArtifact, + integration: Integration, +) -> SourceArtifact { + if integration == Integration::Planr { + artifact.content = render_planr_native_role(&artifact); + } + artifact +} + +pub fn validate_dispatch_evidence_json_for_bundle( + evidence_json: &str, + bundle_json: &str, +) -> Result<()> { + let bundle: RoutingBundleV1 = serde_json::from_str(bundle_json)?; + validate_bundle(&bundle)?; + let contract = bundle + .adapter_contract + .as_ref() + .ok_or_else(|| product_error!("bundle is missing adapter_contract"))?; + let evidence: DispatchEvidenceV1 = serde_json::from_str(evidence_json)?; + validate_dispatch_evidence_for_adapter(&evidence, contract) +} diff --git a/src/tests/architecture.rs b/src/tests/architecture.rs new file mode 100644 index 0000000..1a65924 --- /dev/null +++ b/src/tests/architecture.rs @@ -0,0 +1,90 @@ +#[test] +fn product_module_dependencies_are_acyclic_and_routing_owned() { + const MODULES: &[&str] = &[ + "config", + "contracts", + "error", + "evidence", + "hosts", + "integrations", + "lifecycle", + "registry", + "routing", + ]; + const SOURCES: &[(&str, &str, &[&str])] = &[ + ("contracts", include_str!("../contracts.rs"), &[]), + ( + "registry", + include_str!("../registry.rs"), + &["contracts", "error"], + ), + ( + "evidence", + include_str!("../evidence.rs"), + &["contracts", "error", "registry"], + ), + ( + "hosts", + include_str!("../hosts.rs"), + &["contracts", "error", "evidence"], + ), + ( + "config", + include_str!("../config.rs"), + &["contracts", "error", "hosts"], + ), + ( + "integrations", + include_str!("../integrations.rs"), + &["contracts"], + ), + ( + "routing", + include_str!("../routing.rs"), + &[ + "config", + "contracts", + "error", + "evidence", + "hosts", + "integrations", + "registry", + ], + ), + ( + "lifecycle", + include_str!("../lifecycle.rs"), + &["config", "contracts", "error", "registry", "routing"], + ), + ]; + + for (owner, source, allowed_dependencies) in SOURCES { + assert!( + !source.contains("anyhow"), + "{owner} must return the concrete product error, not anyhow" + ); + for line in source.lines().filter(|line| line.starts_with("use crate")) { + for module in MODULES { + if line.contains(&format!("{module}::")) { + assert!( + allowed_dependencies.contains(module), + "{owner} must not depend on {module}: {line}" + ); + } + } + } + } + + let hosts = include_str!("../hosts.rs"); + let routing = include_str!("../routing.rs"); + for routing_owner in [ + "fn profiles_for_binding", + "fn routes_for_binding", + "fn default_route_for_binding", + "fn role_intents_for_binding", + "fn role_intents_for_profiles", + ] { + assert!(!hosts.contains(routing_owner)); + assert!(routing.contains(routing_owner)); + } +} diff --git a/src/tests/config.rs b/src/tests/config.rs new file mode 100644 index 0000000..2a0443d --- /dev/null +++ b/src/tests/config.rs @@ -0,0 +1,397 @@ +use crate::*; +use std::collections::BTreeMap; + +#[test] +fn setup_spec_roundtrips_through_canonical_toml_json_and_recipe() { + let spec = setup_spec_for_policy("balanced", "codex-openai", Integration::Standalone).unwrap(); + let json = setup_spec_to_canonical_json(&spec).unwrap(); + let toml = setup_spec_to_canonical_toml(&spec).unwrap(); + let recipe = setup_spec_to_recipe(&spec).unwrap(); + assert!(json.contains("\"schema_version\": 1")); + assert!(toml.contains("schema_version = 1")); + assert!(recipe.starts_with(SETUP_RECIPE_PREFIX)); + assert_eq!(setup_spec_from_json(&json).unwrap(), spec); + assert_eq!(setup_spec_from_toml(&toml).unwrap(), spec); + assert_eq!(setup_spec_from_recipe(&recipe).unwrap(), spec); +} + +#[test] +fn setup_recipe_enforces_exact_pre_decode_size_boundaries() { + assert_eq!(MAX_SETUP_RECIPE_BYTES % 3, 1); + assert_eq!( + MAX_SETUP_RECIPE_ENCODED_BYTES, + (MAX_SETUP_RECIPE_BYTES / 3) * 4 + 2 + ); + + let boundary_payload = encode_base64url(&vec![0_u8; MAX_SETUP_RECIPE_BYTES]); + assert_eq!(boundary_payload.len(), MAX_SETUP_RECIPE_ENCODED_BYTES); + assert_eq!( + decode_base64url(&boundary_payload).unwrap().len(), + MAX_SETUP_RECIPE_BYTES + ); + let boundary_error = + setup_spec_from_recipe(&format!("{SETUP_RECIPE_PREFIX}{boundary_payload}")) + .unwrap_err() + .to_string(); + assert!(!boundary_error.contains("exceeds")); + + let first_oversized_payload = encode_base64url(&vec![0_u8; MAX_SETUP_RECIPE_BYTES + 1]); + assert_eq!( + first_oversized_payload.len(), + MAX_SETUP_RECIPE_ENCODED_BYTES + 1 + ); + assert!( + setup_spec_from_recipe(&format!("{SETUP_RECIPE_PREFIX}{first_oversized_payload}")) + .unwrap_err() + .to_string() + .contains("exceeds") + ); + + let very_large_payload = "A".repeat(MAX_SETUP_RECIPE_ENCODED_BYTES * 4); + assert!( + setup_spec_from_recipe(&format!("{SETUP_RECIPE_PREFIX}{very_large_payload}")) + .unwrap_err() + .to_string() + .contains("base64url characters") + ); +} + +#[test] +fn custom_setup_rejects_duplicate_codex_spawn_identities() { + let duplicate_agent_type = SetupSpecV1 { + schema_version: 1, + host: "codex".to_string(), + integration: Integration::Standalone, + usage_policy: "balanced".to_string(), + selected_roles: BTreeMap::from([ + ( + "implementer".to_string(), + SetupRoleSelection { + model: "gpt-5.6-terra".to_string(), + effort: Some("high".to_string()), + spawn: Some(SetupSpawnPolicy { + agent_type: "switchloom_shared".to_string(), + task_name: "implementer".to_string(), + fork_turns: ForkPolicy { + mode: "none".to_string(), + turns: None, + }, + }), + }, + ), + ( + "reviewer".to_string(), + SetupRoleSelection { + model: "gpt-5.6-sol".to_string(), + effort: Some("high".to_string()), + spawn: Some(SetupSpawnPolicy { + agent_type: "switchloom_shared".to_string(), + task_name: "reviewer".to_string(), + fork_turns: ForkPolicy { + mode: "none".to_string(), + turns: None, + }, + }), + }, + ), + ]), + routes: vec![SetupRouteMapping { + work_type: "code".to_string(), + role: "implementer".to_string(), + fallbacks: vec!["reviewer".to_string()], + }], + route_default: Some(SetupDefaultRoute { + role: "implementer".to_string(), + fallbacks: Vec::new(), + }), + }; + assert!( + compile_setup_spec(&duplicate_agent_type) + .unwrap_err() + .to_string() + .contains("both declare Codex agent_type `switchloom_shared`") + ); + + let duplicate_task_name = SetupSpecV1 { + schema_version: 1, + host: "codex".to_string(), + integration: Integration::Standalone, + usage_policy: "balanced".to_string(), + selected_roles: BTreeMap::from([ + ( + "foo-bar".to_string(), + SetupRoleSelection { + model: "gpt-5.6-terra".to_string(), + effort: Some("high".to_string()), + spawn: Some(SetupSpawnPolicy { + agent_type: "switchloom_foo_bar".to_string(), + task_name: "foo_bar".to_string(), + fork_turns: ForkPolicy { + mode: "none".to_string(), + turns: None, + }, + }), + }, + ), + ( + "foo_bar".to_string(), + SetupRoleSelection { + model: "gpt-5.6-sol".to_string(), + effort: Some("high".to_string()), + spawn: Some(SetupSpawnPolicy { + agent_type: "switchloom_foo_bar_alt".to_string(), + task_name: "foo_bar".to_string(), + fork_turns: ForkPolicy { + mode: "none".to_string(), + turns: None, + }, + }), + }, + ), + ]), + routes: vec![SetupRouteMapping { + work_type: "code".to_string(), + role: "foo-bar".to_string(), + fallbacks: vec!["foo_bar".to_string()], + }], + route_default: None, + }; + assert!( + compile_setup_spec(&duplicate_task_name) + .unwrap_err() + .to_string() + .contains("both normalize to `foo_bar`") + ); +} + +#[test] +fn custom_setup_rejects_normalized_artifact_path_collisions() { + for (host, model, effort, expected_path) in [ + ( + "claude-code", + "sonnet", + Some("medium"), + ".claude/agents/switchloom-foo_bar.md", + ), + ( + "cursor", + "composer-2.5", + None, + ".cursor/agents/switchloom-foo_bar.md", + ), + ( + "mixed-host", + "sonnet", + Some("medium"), + ".model-routing/roles/foo_bar.toml", + ), + ] { + let spec = SetupSpecV1 { + schema_version: 1, + host: host.to_string(), + integration: Integration::Standalone, + usage_policy: "balanced".to_string(), + selected_roles: BTreeMap::from([ + ( + "foo-bar".to_string(), + SetupRoleSelection { + model: model.to_string(), + effort: effort.map(ToOwned::to_owned), + spawn: None, + }, + ), + ( + "foo_bar".to_string(), + SetupRoleSelection { + model: model.to_string(), + effort: effort.map(ToOwned::to_owned), + spawn: None, + }, + ), + ]), + routes: vec![SetupRouteMapping { + work_type: "code".to_string(), + role: "foo-bar".to_string(), + fallbacks: vec!["foo_bar".to_string()], + }], + route_default: None, + }; + let error = compile_setup_spec(&spec).unwrap_err().to_string(); + assert!( + error.contains("both normalize to `foo_bar`") || error.contains(expected_path), + "expected normalized collision for {host}, got {error}" + ); + } +} + +#[test] +fn setup_spec_rejects_unknown_fields_and_invalid_combinations() { + let unknown = r#"{ + "schema_version": 1, + "host": "codex", + "integration": "standalone", + "usage_policy": "balanced", + "selected_roles": {}, + "routes": [], + "unexpected": true +}"#; + assert!(format!("{:#}", setup_spec_from_json(unknown).unwrap_err()).contains("unknown field")); + + let invalid_effort = SetupSpecV1 { + schema_version: 1, + host: "codex".to_string(), + integration: Integration::Standalone, + usage_policy: "balanced".to_string(), + selected_roles: BTreeMap::from([( + "implementer".to_string(), + SetupRoleSelection { + model: "gpt-5.6-luna".to_string(), + effort: Some("ultra".to_string()), + spawn: Some(SetupSpawnPolicy { + agent_type: "switchloom_implementer".to_string(), + task_name: "implementer".to_string(), + fork_turns: ForkPolicy { + mode: "none".to_string(), + turns: None, + }, + }), + }, + )]), + routes: vec![SetupRouteMapping { + work_type: "code".to_string(), + role: "implementer".to_string(), + fallbacks: Vec::new(), + }], + route_default: None, + }; + assert!( + validate_setup_spec(&invalid_effort) + .unwrap_err() + .to_string() + .contains("is not supported") + ); + + let mut invalid_fork = invalid_effort; + invalid_fork + .selected_roles + .get_mut("implementer") + .unwrap() + .model = "gpt-5.6-terra".to_string(); + invalid_fork + .selected_roles + .get_mut("implementer") + .unwrap() + .effort = Some("high".to_string()); + invalid_fork + .selected_roles + .get_mut("implementer") + .unwrap() + .spawn + .as_mut() + .unwrap() + .fork_turns = ForkPolicy { + mode: "all".to_string(), + turns: None, + }; + assert!( + validate_setup_spec(&invalid_fork) + .unwrap_err() + .to_string() + .contains("must not use fork_turns all") + ); + + let mut missing_spawn = invalid_fork.clone(); + missing_spawn + .selected_roles + .get_mut("implementer") + .unwrap() + .spawn = None; + assert!( + validate_setup_spec(&missing_spawn) + .unwrap_err() + .to_string() + .contains("must declare Codex spawn policy") + ); + + let mut name_mismatch = invalid_fork.clone(); + let spawn = name_mismatch + .selected_roles + .get_mut("implementer") + .unwrap() + .spawn + .as_mut() + .unwrap(); + spawn.fork_turns = ForkPolicy { + mode: "none".to_string(), + turns: None, + }; + spawn.task_name = "wrong_name".to_string(); + assert!( + validate_setup_spec(&name_mismatch) + .unwrap_err() + .to_string() + .contains("must match `implementer`") + ); + + let mut task_path = name_mismatch; + task_path + .selected_roles + .get_mut("implementer") + .unwrap() + .spawn + .as_mut() + .unwrap() + .task_name = "/root/task".to_string(); + assert!( + validate_setup_spec(&task_path) + .unwrap_err() + .to_string() + .contains("not a canonical task path") + ); + + let legacy_fork_context = r#"{ + "schema_version": 1, + "host": "codex", + "integration": "standalone", + "usage_policy": "balanced", + "selected_roles": { + "implementer": { + "model": "gpt-5.6-terra", + "effort": "high", + "fork_context": "none" + } + }, + "routes": [{"work_type": "code", "role": "implementer"}] +}"#; + assert!( + format!( + "{:#}", + setup_spec_from_json(legacy_fork_context).unwrap_err() + ) + .contains("fork_context") + ); +} + +#[test] +fn setup_contract_catalog_exposes_transport_and_host_options() { + let catalog = setup_contract_catalog_value().unwrap(); + assert_eq!(catalog["configPath"], SETUP_CONFIG_PATH); + assert_eq!(catalog["recipePrefix"], SETUP_RECIPE_PREFIX); + assert!(catalog["hosts"].as_array().unwrap().iter().any(|host| { + host["id"] == "codex" + && host["models"] + .as_array() + .unwrap() + .iter() + .any(|model| model["id"] == "gpt-5.6-sol") + })); + assert!(catalog["hosts"].as_array().unwrap().iter().any(|host| { + host["id"] == "opencode" + && host["binding"] == "opencode-native" + && host["models"] + .as_array() + .unwrap() + .iter() + .any(|model| model["id"] == "opencode/gpt-5-nano") + })); +} diff --git a/src/tests/evidence.rs b/src/tests/evidence.rs new file mode 100644 index 0000000..ad72b72 --- /dev/null +++ b/src/tests/evidence.rs @@ -0,0 +1,196 @@ +use crate::*; + +#[test] +fn codex_runtime_evidence_fixtures_fail_for_named_provenance_reasons() { + for (fixture, expected) in [ + ( + include_str!( + "../../fixtures/codex-v2-runtime-evidence/invalid-prose-only-provenance.json" + ), + "must include raw output", + ), + ( + include_str!( + "../../fixtures/codex-v2-runtime-evidence/invalid-arbitrary-prose-provenance.json" + ), + "raw capture does not support", + ), + ( + include_str!( + "../../fixtures/codex-v2-runtime-evidence/invalid-tampered-raw-digest.json" + ), + "raw output digest mismatch", + ), + ( + include_str!( + "../../fixtures/codex-v2-runtime-evidence/invalid-unsupported-provenance-kind.json" + ), + "unsupported provenance kind", + ), + ] { + let evidence: CodexV2RuntimeEvidence = serde_json::from_str(fixture).unwrap(); + let error = validate_codex_v2_runtime_evidence(&evidence) + .unwrap_err() + .to_string(); + assert!( + error.contains(expected), + "expected `{expected}` in `{error}`" + ); + } +} + +#[test] +fn dispatch_evidence_requires_persisted_requested_and_effective_receipt_fields() { + let mut valid = DispatchEvidenceV1 { + schema_version: 1, + package_digest: "sha256:abc".to_string(), + host_version: "codex 0.144.0".to_string(), + requested_dispatch: RequestedDispatchEvidence { + semantic_role: "worker".to_string(), + profile: "codex-terra-high".to_string(), + model: "gpt-5.6-terra".to_string(), + effort: Some("high".to_string()), + agent_type: Some("model_routing_terra_high".to_string()), + fork_turns: Some(ForkPolicy { + mode: "none".to_string(), + turns: None, + }), + }, + child_identity: ChildIdentityEvidence { + host: "codex".to_string(), + role: "worker".to_string(), + agent_role: "model_routing_terra_high".to_string(), + agent_type: Some("model_routing_terra_high".to_string()), + task_name: Some("worker".to_string()), + }, + effective_model: Some("gpt-5.6-terra".to_string()), + effective_effort: Some("high".to_string()), + nonce: "nonce-123".to_string(), + raw_evidence_refs: vec!["receipt.json".to_string()], + verdict: GuaranteeLevel::Deterministic, + }; + let encoded = serde_json::to_string(&valid).unwrap(); + let decoded: DispatchEvidenceV1 = serde_json::from_str(&encoded).unwrap(); + validate_dispatch_evidence(&decoded).unwrap(); + + let missing_nonce = r#"{ + "schema_version": 1, + "package_digest": "sha256:abc", + "host_version": "codex 0.144.0", + "requested_dispatch": { + "semantic_role": "worker", + "profile": "codex-terra-high", + "model": "gpt-5.6-terra" + }, + "child_identity": { + "host": "codex", + "role": "worker", + "agent_role": "model_routing_terra_high" + }, + "raw_evidence_refs": ["receipt.json"], + "verdict": "deterministic" +}"#; + let error = serde_json::from_str::(missing_nonce) + .unwrap_err() + .to_string(); + assert!(error.contains("nonce")); + + valid.effective_model = None; + let error = validate_dispatch_evidence(&valid).unwrap_err().to_string(); + assert!(error.contains("effective_model")); + + valid.effective_model = Some("gpt-5.6-sol".to_string()); + let error = validate_dispatch_evidence(&valid).unwrap_err().to_string(); + assert!(error.contains("does not match requested model")); + + valid.effective_model = Some("gpt-5.6-terra".to_string()); + valid.effective_effort = Some("medium".to_string()); + let error = validate_dispatch_evidence(&valid).unwrap_err().to_string(); + assert!(error.contains("does not match requested effort")); + + valid.effective_effort = None; + valid.verdict = GuaranteeLevel::Advisory; + validate_dispatch_evidence(&valid).unwrap(); +} + +#[test] +fn adapter_validation_blocks_unproven_deterministic_claude_and_cursor_evidence() { + for (host, role, profile, model, effort) in [ + ( + "claude-native", + "worker", + "claude-native-worker", + "sonnet", + Some("medium"), + ), + ( + "cursor-openai", + "worker", + "cursor-openai-worker", + "gpt-5.4-mini", + None, + ), + ] { + let contract = compile_policy("balanced", host, Integration::Standalone) + .unwrap() + .adapter_contract + .unwrap(); + let mut evidence = DispatchEvidenceV1 { + schema_version: 1, + package_digest: "sha256:abc".to_string(), + host_version: format!("{} cli 1.0.0", contract.capability.host), + requested_dispatch: RequestedDispatchEvidence { + semantic_role: role.to_string(), + profile: profile.to_string(), + model: model.to_string(), + effort: effort.map(str::to_string), + agent_type: None, + fork_turns: Some(ForkPolicy { + mode: "none".to_string(), + turns: None, + }), + }, + child_identity: ChildIdentityEvidence { + host: contract.capability.host.clone(), + role: role.to_string(), + agent_role: "model-routing-preset-worker".to_string(), + agent_type: None, + task_name: Some("model-routing-preset-worker".to_string()), + }, + effective_model: Some(model.to_string()), + effective_effort: effort.map(str::to_string), + nonce: "nonce-456".to_string(), + raw_evidence_refs: vec!["host-output.json".to_string()], + verdict: GuaranteeLevel::Deterministic, + }; + let error = validate_dispatch_evidence_for_adapter(&evidence, &contract) + .unwrap_err() + .to_string(); + assert!( + error.contains("effective model observability is Advisory"), + "{host}: {error}" + ); + + evidence.verdict = GuaranteeLevel::Advisory; + validate_dispatch_evidence_for_adapter(&evidence, &contract).unwrap(); + + evidence.verdict = GuaranteeLevel::Deterministic; + evidence.raw_evidence_refs.push(format!( + "host-authenticated-effective-model:{}:host-output.json#model", + contract.capability.host + )); + if effort.is_some() { + evidence.raw_evidence_refs.push(format!( + "host-authenticated-effective-effort:{}:host-output.json#effort", + contract.capability.host + )); + } + let error = validate_dispatch_evidence_for_adapter(&evidence, &contract) + .unwrap_err() + .to_string(); + assert!( + error.contains("effective model observability is Advisory"), + "forged refs should not upgrade {host}: {error}" + ); + } +} diff --git a/src/tests/hosts.rs b/src/tests/hosts.rs new file mode 100644 index 0000000..dfbe119 --- /dev/null +++ b/src/tests/hosts.rs @@ -0,0 +1,436 @@ +use crate::*; +use std::collections::{BTreeMap, BTreeSet}; + +#[test] +fn adapter_contract_distinguishes_external_runner_runtime_class() { + let binding = HostBinding { + id: "pi-runner".to_string(), + version: "1.0.0".to_string(), + host: "pi".to_string(), + runtime_class: RuntimeClass::ExternalRunner, + default_role: Some("worker".to_string()), + capability_evidence: vec!["pi-runner-contract".to_string()], + known_limitations: vec!["process isolation is runner-owned".to_string()], + capabilities: BindingCapabilities { + model_override: true, + effort_override: true, + fork_none: true, + fork_all: false, + }, + profiles: BTreeMap::from([( + "worker".to_string(), + BindingProfile { + profile: "pi-worker".to_string(), + client: "pi".to_string(), + model: "gpt-5.6-terra".to_string(), + agent_type: None, + effort: Some("high".to_string()), + cost_tier: Some("standard".to_string()), + fork_turns: Some(ForkPolicy { + mode: "none".to_string(), + turns: None, + }), + }, + )]), + routes: Vec::new(), + verification: BindingVerification { + id: "pi-smoke-v1".to_string(), + max_age_seconds: Some(60), + }, + artifacts: Vec::new(), + }; + let contract = + adapter_contract_for_binding("balanced", &binding, Integration::Standalone).unwrap(); + assert_eq!( + contract.capability.runtime_class, + RuntimeClass::ExternalRunner + ); + assert_eq!(contract.adapter.runtime_class, RuntimeClass::ExternalRunner); + assert_eq!( + contract.adapter.dispatch_recipe.invocation, + "external-runner-process" + ); +} + +#[test] +fn pi_external_adapter_declares_typed_runner_contract() { + let bundle = compile_policy("balanced", "pi-external", Integration::Standalone).unwrap(); + let contract = bundle.adapter_contract.as_ref().unwrap(); + assert_eq!( + contract.capability.runtime_class, + RuntimeClass::ExternalRunner + ); + assert_eq!(contract.adapter.runtime_class, RuntimeClass::ExternalRunner); + assert_eq!( + contract.adapter.dispatch_recipe.invocation, + "external-runner-process" + ); + for field in [ + "agent_type", + "provider", + "model", + "effort", + "fork_turns", + "isolation", + "task", + ] { + assert!( + contract + .adapter + .dispatch_recipe + .required_fields + .contains(&field.to_string()), + "Pi dispatch recipe should require {field}" + ); + } + assert_eq!( + contract.capability.observability.effective_model, + GuaranteeLevel::Advisory + ); + assert!( + contract + .capability + .known_limitations + .iter() + .any(|limitation| limitation.contains("process-isolated")) + ); + + let workflow = bundle + .artifacts + .iter() + .find(|artifact| artifact.path == ".pi/workflows/model-routing-preset-runner.json") + .unwrap(); + assert!( + workflow + .content + .contains("\"runtime_class\": \"external-runner\"") + ); + assert!( + workflow + .content + .contains("\"agent_type\": \"switchloom-pi-worker\"") + ); + assert!( + workflow + .content + .contains("\"provider_model\": \"openai/gpt-4o-mini\"") + ); + assert!(workflow.content.contains("\"thinking\": \"low\"")); + assert!(workflow.content.contains("\"session\": \"none\"")); + assert!(workflow.content.contains("\"task\"")); +} + +#[test] +fn host_binding_runtime_class_is_required_and_explicit() { + let missing_runtime_class = r#" +id = "pi-runner" +version = "1.0.0" +host = "pi" +default_role = "worker" + +[capabilities] +model_override = true +effort_override = true +fork_none = true +fork_all = false + +[profiles.worker] +profile = "pi-worker" +client = "pi" +model = "gpt-5.6-terra" + +[verification] +id = "pi-smoke-v1" +"#; + let error = toml::from_str::(missing_runtime_class) + .unwrap_err() + .to_string(); + assert!(error.contains("runtime_class")); +} + +#[test] +fn adapter_contract_rejects_unsupported_required_guarantees() { + let mut contract = compile_policy("balanced", "cursor-openai", Integration::Standalone) + .unwrap() + .adapter_contract + .unwrap(); + contract + .routing_intent + .required_guarantees + .push("effort_selection".to_string()); + let error = validate_adapter_contract(&contract) + .unwrap_err() + .to_string(); + assert!(error.contains("unsupported")); +} + +#[test] +fn shared_adapter_validation_rejects_invalid_routes_before_rendering() { + let binding = HostBinding { + id: "cursor-test".to_string(), + version: "1.0.0".to_string(), + host: "cursor".to_string(), + runtime_class: RuntimeClass::NativeSubagent, + default_role: None, + capability_evidence: Vec::new(), + known_limitations: Vec::new(), + capabilities: BindingCapabilities { + model_override: true, + effort_override: false, + fork_none: true, + fork_all: false, + }, + profiles: BTreeMap::from([( + "worker".to_string(), + BindingProfile { + profile: "cursor-worker".to_string(), + client: "cursor".to_string(), + model: "gpt-5.4-mini".to_string(), + agent_type: None, + effort: None, + cost_tier: Some("standard".to_string()), + fork_turns: Some(ForkPolicy { + mode: "none".to_string(), + turns: None, + }), + }, + )]), + routes: vec![BindingRoute { + work_type: "code".to_string(), + role: "missing".to_string(), + fallback_roles: Vec::new(), + }], + verification: BindingVerification { + id: "cursor-test-v1".to_string(), + max_age_seconds: Some(60), + }, + artifacts: Vec::new(), + }; + let error = compile_host_adapter("balanced", &binding, Integration::Standalone) + .unwrap_err() + .to_string(); + assert!(error.contains("unknown role `missing`")); +} + +#[test] +fn shared_adapter_validation_rejects_duplicate_profile_ids_before_rendering() { + let binding = HostBinding { + id: "cursor-test".to_string(), + version: "1.0.0".to_string(), + host: "cursor".to_string(), + runtime_class: RuntimeClass::NativeSubagent, + default_role: Some("first".to_string()), + capability_evidence: Vec::new(), + known_limitations: Vec::new(), + capabilities: BindingCapabilities { + model_override: true, + effort_override: false, + fork_none: true, + fork_all: false, + }, + profiles: BTreeMap::from([ + ( + "first".to_string(), + BindingProfile { + profile: "cursor-worker".to_string(), + client: "cursor".to_string(), + model: "gpt-5.4-mini".to_string(), + agent_type: None, + effort: None, + cost_tier: Some("standard".to_string()), + fork_turns: Some(ForkPolicy { + mode: "none".to_string(), + turns: None, + }), + }, + ), + ( + "second".to_string(), + BindingProfile { + profile: "cursor-worker".to_string(), + client: "cursor".to_string(), + model: "gpt-5.5".to_string(), + agent_type: None, + effort: None, + cost_tier: Some("premium".to_string()), + fork_turns: Some(ForkPolicy { + mode: "none".to_string(), + turns: None, + }), + }, + ), + ]), + routes: vec![BindingRoute { + work_type: "code".to_string(), + role: "first".to_string(), + fallback_roles: vec!["second".to_string()], + }], + verification: BindingVerification { + id: "cursor-test-v1".to_string(), + max_age_seconds: Some(60), + }, + artifacts: Vec::new(), + }; + let error = compile_host_adapter("balanced", &binding, Integration::Standalone) + .unwrap_err() + .to_string(); + assert!(error.contains("both normalize to profile `cursor-worker`")); +} + +#[test] +fn dispatch_recipe_artifact_paths_match_final_bundle_artifacts() { + let bundle = compile_policy("balanced", "mixed-host", Integration::Planr).unwrap(); + let contract_paths = bundle + .adapter_contract + .as_ref() + .unwrap() + .adapter + .dispatch_recipe + .artifact_paths + .iter() + .cloned() + .collect::>(); + let artifact_paths = bundle + .artifacts + .iter() + .map(|artifact| artifact.path.clone()) + .collect::>(); + assert_eq!(contract_paths, artifact_paths); + assert!( + !contract_paths + .iter() + .any(|path| path.contains("model-routing-native-routing")) + ); +} + +#[test] +fn claude_and_cursor_native_adapters_emit_artifacts_with_advisory_effective_routing() { + for (host, expected_path, requested_model) in [ + ( + "claude-native", + ".claude/agents/model-routing-preset-worker.md", + "sonnet", + ), + ( + "cursor-openai", + ".cursor/agents/model-routing-preset-worker.md", + "gpt-5.4-mini", + ), + ( + "cursor-fable-grok", + ".cursor/agents/model-routing-preset-worker.md", + "cursor-grok-4.5-medium", + ), + ( + "opencode-native", + ".opencode/agents/model-routing-preset-worker.md", + "opencode/gpt-5-nano", + ), + ] { + let bundle = compile_policy("balanced", host, Integration::Standalone).unwrap(); + let contract = bundle.adapter_contract.as_ref().unwrap(); + assert_eq!( + contract.capability.runtime_class, + RuntimeClass::NativeSubagent + ); + assert_eq!(contract.adapter.runtime_class, RuntimeClass::NativeSubagent); + assert_eq!( + contract.capability.observability.effective_model, + GuaranteeLevel::Advisory + ); + assert_eq!( + contract + .capability + .guarantees + .get("model_selection") + .unwrap() + .level, + GuaranteeLevel::Advisory + ); + assert!( + contract + .capability + .known_limitations + .iter() + .any(|limitation| limitation.contains("override")) + || contract + .capability + .known_limitations + .iter() + .any(|limitation| limitation.contains("preempt")) + || contract + .capability + .known_limitations + .iter() + .any(|limitation| limitation.contains("provider")) + ); + assert!( + contract + .adapter + .dispatch_recipe + .artifact_paths + .contains(&expected_path.to_string()) + ); + + let artifact = bundle + .artifacts + .iter() + .find(|artifact| artifact.path == expected_path) + .unwrap(); + assert!(artifact.content.contains(requested_model)); + assert!(artifact.content.contains("preserve routing evidence")); + } +} + +#[test] +fn codex_agent_types_match_registered_toml_names() { + for host in ["codex-openai", "mixed-host"] { + let source = show_policy("balanced", host).unwrap(); + assert!( + source + .artifacts + .iter() + .all(|artifact| !artifact.path.starts_with(".codex/skills/")) + ); + assert!( + source + .artifacts + .iter() + .all(|artifact| !artifact.content.contains("model-routing-native-routing")) + ); + let bundle = compile_policy("balanced", host, Integration::Standalone).unwrap(); + let config = bundle + .artifacts + .iter() + .find(|artifact| artifact.path == ".codex/config.toml") + .expect("Codex role config should be generated"); + let parsed_config: toml::Value = toml::from_str(&config.content).unwrap(); + let config_agents = parsed_config["agents"].as_table().unwrap(); + let registered_names = bundle + .artifacts + .iter() + .filter(|artifact| artifact.path.starts_with(".codex/agents/")) + .map(|artifact| { + let agent_type = toml::from_str::(&artifact.content).unwrap()["name"] + .as_str() + .unwrap() + .to_string(); + let relative_config_file = + artifact.path.strip_prefix(".codex/").unwrap().to_string(); + assert_eq!( + config_agents[&agent_type]["config_file"].as_str(), + Some(format!("./{relative_config_file}").as_str()) + ); + agent_type + }) + .collect::>(); + for profile in bundle + .profiles + .values() + .filter(|profile| profile.client == "codex") + { + let agent_type = profile.agent_type.as_deref().unwrap(); + assert!(registered_names.contains(agent_type)); + } + } +} diff --git a/src/tests/integrations.rs b/src/tests/integrations.rs new file mode 100644 index 0000000..e070eb8 --- /dev/null +++ b/src/tests/integrations.rs @@ -0,0 +1,136 @@ +use crate::*; +use serde_json::Value; + +#[test] +fn planr_integration_is_explicit_and_adds_planr_declarations() { + let standalone = compile_json("balanced", "codex-openai", Integration::Standalone).unwrap(); + let planr = compile_json("balanced", "codex-openai", Integration::Planr).unwrap(); + assert!(!standalone.contains(".planr/agents.toml")); + assert!(planr.contains(".planr/agents.toml")); + assert!(planr.contains(".planr/policy.toml")); +} + +#[test] +fn adapter_contract_handoff_names_planr_consumer_boundaries() { + let binding = binding_for_selector("codex-openai").unwrap(); + let contract = adapter_contract_for_binding("balanced", &binding, Integration::Planr).unwrap(); + let handoff = &contract.planr_handoff; + let package_json: Value = serde_json::from_str(NPM_PACKAGE_JSON).unwrap(); + let expected_package = format!( + "{}@{}", + package_json["name"].as_str().unwrap(), + package_json["version"].as_str().unwrap() + ); + assert_eq!(handoff.switchloom_package, expected_package); + assert_ne!( + handoff.switchloom_package, + format!("{}@{PACKAGE_VERSION}", env!("CARGO_PKG_NAME")) + ); + assert!( + handoff + .semantic_role_contract + .contains("usage policy `balanced`") + ); + assert!( + handoff + .required_consumer_behavior + .iter() + .any(|behavior| behavior.contains("RoutingIntentV1")) + ); + assert!( + handoff + .required_consumer_behavior + .iter() + .any(|behavior| behavior.contains("package digest")) + ); + assert!( + handoff + .forbidden_duplicate_ownership + .iter() + .any(|behavior| behavior.contains("model catalog")) + ); + assert!( + handoff + .certification_report_reference + .contains("reports/native-host-certification") + ); +} + +#[test] +fn planr_integration_is_skill_free_and_preloads_existing_protocols() { + for host in BINDINGS.map(|(host, _)| host) { + let bundle = compile_policy("balanced", host, Integration::Planr).unwrap(); + assert!( + bundle + .artifacts + .iter() + .any(|artifact| artifact.path == ".planr/agents.toml") + ); + assert!( + bundle + .artifacts + .iter() + .any(|artifact| artifact.path == ".planr/policy.toml") + ); + assert!( + bundle + .artifacts + .iter() + .all(|artifact| !artifact.path.contains("/skills/")) + ); + let content = serde_json::to_string(&bundle).unwrap(); + assert!(!content.contains("model-routing-native-routing")); + assert!(!content.contains("planr-native-routing")); + assert!(!content.contains("Protocol preload: $planr-goal")); + assert!(!content.contains("Protocol preload: $planr-loop")); + + let worker_protocol = bundle.artifacts.iter().any(|artifact| { + (artifact.path.contains("terra-high") + || artifact.path.contains("luna-xhigh") + || artifact.path.contains("preset-worker") + || artifact.path.starts_with(".pi/workflows/")) + && artifact.content.contains("Protocol preload: $planr-work") + }); + assert!(worker_protocol, "missing Planr worker preload for {host}"); + + if host == "codex-openai" || host == "mixed-host" { + assert!( + bundle + .artifacts + .iter() + .any(|artifact| artifact.content.contains("Protocol preload: $planr-review")), + "missing Planr review preload for {host}" + ); + } + + if host == "codex-openai" { + let registry = bundle + .artifacts + .iter() + .find(|artifact| artifact.path == ".planr/agents.toml") + .expect("missing Planr registry"); + assert!(!registry.content.contains("agent_type =")); + assert!(!registry.content.contains("fork_turns")); + assert!( + registry + .content + .contains("[profiles.model_routing_terra_high]") + ); + assert!( + registry + .content + .contains("[profiles.model_routing_sol_high]") + ); + assert!( + registry + .content + .contains("profile = \"model_routing_terra_high\"") + ); + assert!( + registry + .content + .contains("profile = \"model_routing_sol_high\"") + ); + } + } +} diff --git a/src/tests/lifecycle.rs b/src/tests/lifecycle.rs new file mode 100644 index 0000000..ea20ea6 --- /dev/null +++ b/src/tests/lifecycle.rs @@ -0,0 +1,56 @@ +use crate::*; +use std::fs; +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; + +#[path = "lifecycle_transactions.rs"] +mod transactions; + +fn temp_repo(name: &str) -> PathBuf { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let path = std::env::temp_dir().join(format!("model-routing-{name}-{unique}")); + fs::create_dir_all(&path).unwrap(); + path +} + +fn apply_bundle_file_with_bundle( + repository: &Path, + bundle: &RoutingBundleV1, +) -> Result { + let bundle_file = write_bundle_file(repository, "bundle.json", bundle); + apply_bundle_file(repository, &bundle_file) +} + +fn write_bundle_file(repository: &Path, name: &str, bundle: &RoutingBundleV1) -> PathBuf { + let bundle_file = repository.join(name); + fs::write(&bundle_file, serde_json::to_vec_pretty(bundle).unwrap()).unwrap(); + bundle_file +} + +fn has_transaction_directory(repository: &Path) -> bool { + fs::read_dir(repository.join(".model-routing")) + .unwrap() + .any(|entry| { + entry + .unwrap() + .file_name() + .to_str() + .is_some_and(|name| name.starts_with("txn-")) + }) +} + +fn has_transaction_backup(repository: &Path) -> bool { + fs::read_dir(repository.join(".model-routing")) + .unwrap() + .filter_map(std::result::Result::ok) + .any(|entry| { + entry + .file_name() + .to_str() + .is_some_and(|name| name.starts_with("txn-")) + && entry.path().join("backup").exists() + }) +} diff --git a/src/tests/lifecycle_transactions.rs b/src/tests/lifecycle_transactions.rs new file mode 100644 index 0000000..7895ae8 --- /dev/null +++ b/src/tests/lifecycle_transactions.rs @@ -0,0 +1,193 @@ +use super::*; + +#[test] +fn lifecycle_recovers_interrupted_transaction_before_next_entrypoint() { + let repository = temp_repo("interrupted-transaction"); + let original = compile_policy("balanced", "codex-openai", Integration::Standalone).unwrap(); + apply_bundle_file_with_bundle(&repository, &original).unwrap(); + + let mut updated = original.clone(); + updated.bundle_id = "balanced-codex-openai@interrupted".to_string(); + updated.artifacts[0] + .content + .push_str("\n# interrupted update\n"); + updated.artifacts[0].sha256 = sha256(updated.artifacts[0].content.as_bytes()); + let updated_file = write_bundle_file(&repository, "interrupted.json", &updated); + + TRANSACTION_FAIL_AFTER_WRITES.with(|fail_after| fail_after.set(1)); + let interrupted = std::panic::catch_unwind(|| { + update_bundle_file(&repository, &updated_file).unwrap(); + }); + TRANSACTION_FAIL_AFTER_WRITES.with(|fail_after| fail_after.set(0)); + assert!(interrupted.is_err()); + assert!(has_transaction_directory(&repository)); + + let status = status_repository(&repository).unwrap(); + assert_eq!( + status.bundle_id.as_deref(), + Some(original.bundle_id.as_str()) + ); + assert_eq!( + sha256(&fs::read(repository.join(&original.artifacts[0].path)).unwrap()), + original.artifacts[0].sha256 + ); + assert!( + status + .artifacts + .iter() + .all(|artifact| artifact.status == "managed") + ); + assert!(!has_transaction_directory(&repository)); + + let update = update_bundle_file(&repository, &updated_file).unwrap(); + assert!( + update + .artifacts + .iter() + .any(|artifact| artifact.status == "update") + ); +} + +#[test] +fn lifecycle_recovers_interrupted_atomic_journal_replacement() { + let repository = temp_repo("journal-replace"); + let original = compile_policy("balanced", "codex-openai", Integration::Standalone).unwrap(); + apply_bundle_file_with_bundle(&repository, &original).unwrap(); + + let mut updated = original.clone(); + updated.bundle_id = "balanced-codex-openai@journal-replace".to_string(); + updated.artifacts[0] + .content + .push_str("\n# journal replace interruption\n"); + updated.artifacts[0].sha256 = sha256(updated.artifacts[0].content.as_bytes()); + let updated_file = write_bundle_file(&repository, "journal-replace.json", &updated); + + TRANSACTION_FAIL_JOURNAL_REPLACE_AFTER.with(|fail_after| fail_after.set(2)); + let interrupted = std::panic::catch_unwind(|| { + update_bundle_file(&repository, &updated_file).unwrap(); + }); + TRANSACTION_FAIL_JOURNAL_REPLACE_AFTER.with(|fail_after| fail_after.set(0)); + assert!(interrupted.is_err()); + assert!(has_transaction_directory(&repository)); + + let status = status_repository(&repository).unwrap(); + assert_eq!( + status.bundle_id.as_deref(), + Some(original.bundle_id.as_str()) + ); + assert_eq!( + sha256(&fs::read(repository.join(&original.artifacts[0].path)).unwrap()), + original.artifacts[0].sha256 + ); + assert!(!has_transaction_directory(&repository)); +} + +#[test] +fn lifecycle_restore_failure_preserves_recoverable_transaction_data() { + let repository = temp_repo("restore-failure"); + let original = compile_policy("balanced", "codex-openai", Integration::Standalone).unwrap(); + apply_bundle_file_with_bundle(&repository, &original).unwrap(); + + let mut updated = original.clone(); + updated.bundle_id = "balanced-codex-openai@restore-failure".to_string(); + updated.artifacts[0] + .content + .push_str("\n# restore failure\n"); + updated.artifacts[0].sha256 = sha256(updated.artifacts[0].content.as_bytes()); + let updated_file = write_bundle_file(&repository, "restore-failure.json", &updated); + + TRANSACTION_FAIL_AFTER_WRITES.with(|fail_after| fail_after.set(1)); + let interrupted = std::panic::catch_unwind(|| { + update_bundle_file(&repository, &updated_file).unwrap(); + }); + TRANSACTION_FAIL_AFTER_WRITES.with(|fail_after| fail_after.set(0)); + assert!(interrupted.is_err()); + assert!(has_transaction_directory(&repository)); + + TRANSACTION_FAIL_RESTORE.with(|fail| fail.set(true)); + let recovery_error = status_repository(&repository).unwrap_err().to_string(); + TRANSACTION_FAIL_RESTORE.with(|fail| fail.set(false)); + assert!(recovery_error.contains("failed to recover transaction write")); + assert!(has_transaction_directory(&repository)); + + let status = status_repository(&repository).unwrap(); + assert_eq!( + status.bundle_id.as_deref(), + Some(original.bundle_id.as_str()) + ); + assert_eq!( + sha256(&fs::read(repository.join(&original.artifacts[0].path)).unwrap()), + original.artifacts[0].sha256 + ); + assert!(!has_transaction_directory(&repository)); +} + +#[test] +fn lifecycle_returned_journal_error_retains_backup_when_immediate_rollback_fails() { + let repository = temp_repo("rollback-retains-backup"); + let original = compile_policy("balanced", "codex-openai", Integration::Standalone).unwrap(); + apply_bundle_file_with_bundle(&repository, &original).unwrap(); + + let mut updated = original.clone(); + updated.bundle_id = "balanced-codex-openai@rollback-retains-backup".to_string(); + updated.artifacts[0] + .content + .push_str("\n# returned journal error\n"); + updated.artifacts[0].sha256 = sha256(updated.artifacts[0].content.as_bytes()); + let updated_file = write_bundle_file(&repository, "rollback-retains-backup.json", &updated); + + TRANSACTION_RETURN_JOURNAL_ERROR_AFTER.with(|fail_after| fail_after.set(2)); + TRANSACTION_FAIL_RESTORE.with(|fail| fail.set(true)); + let error = update_bundle_file(&repository, &updated_file) + .unwrap_err() + .to_string(); + TRANSACTION_RETURN_JOURNAL_ERROR_AFTER.with(|fail_after| fail_after.set(0)); + TRANSACTION_FAIL_RESTORE.with(|fail| fail.set(false)); + assert!(error.contains("transaction rollback incomplete")); + assert!(has_transaction_directory(&repository)); + assert!(has_transaction_backup(&repository)); + + let status = status_repository(&repository).unwrap(); + assert_eq!( + status.bundle_id.as_deref(), + Some(original.bundle_id.as_str()) + ); + assert_eq!( + sha256(&fs::read(repository.join(&original.artifacts[0].path)).unwrap()), + original.artifacts[0].sha256 + ); + assert!(!has_transaction_directory(&repository)); +} + +#[test] +fn lifecycle_staged_rename_error_restores_backup_before_commit_mark() { + let repository = temp_repo("staged-rename"); + let original = compile_policy("balanced", "codex-openai", Integration::Standalone).unwrap(); + apply_bundle_file_with_bundle(&repository, &original).unwrap(); + + let mut updated = original.clone(); + updated.bundle_id = "balanced-codex-openai@staged-rename".to_string(); + updated.artifacts[0] + .content + .push_str("\n# staged rename failure\n"); + updated.artifacts[0].sha256 = sha256(updated.artifacts[0].content.as_bytes()); + let updated_file = write_bundle_file(&repository, "staged-rename.json", &updated); + + TRANSACTION_RETURN_STAGED_RENAME_ERROR_AFTER.with(|fail_after| fail_after.set(1)); + let error = update_bundle_file(&repository, &updated_file) + .unwrap_err() + .to_string(); + TRANSACTION_RETURN_STAGED_RENAME_ERROR_AFTER.with(|fail_after| fail_after.set(0)); + assert!(error.contains("injected staged rename error after backup")); + assert!(!has_transaction_directory(&repository)); + + let status = status_repository(&repository).unwrap(); + assert_eq!( + status.bundle_id.as_deref(), + Some(original.bundle_id.as_str()) + ); + assert_eq!( + sha256(&fs::read(repository.join(&original.artifacts[0].path)).unwrap()), + original.artifacts[0].sha256 + ); +} diff --git a/src/tests/registry.rs b/src/tests/registry.rs new file mode 100644 index 0000000..29973d7 --- /dev/null +++ b/src/tests/registry.rs @@ -0,0 +1,22 @@ +use crate::*; +use ed25519_dalek::SigningKey; + +#[test] +fn registry_signatures_are_content_bound() { + let signing_key = SigningKey::from_bytes(&[7_u8; 32]); + let trusted_public_key = encode_hex(signing_key.verifying_key().as_bytes()); + let signature = sign_registry(b"catalog", "fixture", &"07".repeat(32)).unwrap(); + verify_registry_signature(b"catalog", &signature, "fixture", &trusted_public_key).unwrap(); + assert!( + verify_registry_signature(b"tampered", &signature, "fixture", &trusted_public_key).is_err() + ); + let attacker_key = encode_hex( + SigningKey::from_bytes(&[8_u8; 32]) + .verifying_key() + .as_bytes(), + ); + assert!(verify_registry_signature(b"catalog", &signature, "fixture", &attacker_key).is_err()); + assert!( + verify_registry_signature(b"catalog", &signature, "attacker", &trusted_public_key).is_err() + ); +} diff --git a/src/tests/routing.rs b/src/tests/routing.rs new file mode 100644 index 0000000..e65826f --- /dev/null +++ b/src/tests/routing.rs @@ -0,0 +1,557 @@ +use crate::*; +use serde_json::Value; +use std::collections::BTreeMap; + +#[test] +fn compiled_bundle_carries_typed_adapter_contract() { + let bundle = compile_policy("balanced", "codex-openai", Integration::Planr).unwrap(); + let contract = bundle.adapter_contract.as_ref().unwrap(); + assert_eq!(contract.schema_version, 1); + assert_eq!( + contract.capability.runtime_class, + RuntimeClass::NativeSubagent + ); + assert_eq!(contract.adapter.runtime_class, RuntimeClass::NativeSubagent); + assert_eq!(contract.routing_intent.integration, Integration::Planr); + assert!( + contract + .routing_intent + .semantic_roles + .contains(&"worker".to_string()) + ); + assert!( + !contract + .routing_intent + .semantic_roles + .contains(&"codex-terra-high".to_string()) + ); + assert_eq!( + contract + .capability + .guarantees + .get("model_selection") + .unwrap() + .level, + GuaranteeLevel::Deterministic + ); + assert!( + contract + .capability + .guarantees + .values() + .any(|guarantee| guarantee.level == GuaranteeLevel::Advisory) + ); + assert!( + contract + .dispatch_evidence + .required_verdicts + .contains(&GuaranteeLevel::Unsupported) + ); + assert_eq!( + contract.dispatch_evidence.receipt_schema, + "DispatchEvidenceV1" + ); + assert!( + contract + .routing_intent + .role_requests + .iter() + .any(|request| request.semantic_role == "worker" + && request.requested_model == "gpt-5.6-terra" + && request.requested_effort.as_deref() == Some("high")) + ); + assert_eq!( + contract.adapter.dispatch_recipe.invocation, + "host-native-subagent" + ); + assert_eq!( + contract.capability.runtime_behavior.capability_version, + codex_v2_runtime_evidence().unwrap().evidence_id + ); + assert_eq!( + contract + .capability + .runtime_behavior + .installed_host_version_source, + "codex-cli 0.144.5 via codex --version" + ); + assert_eq!( + contract + .capability + .host_version_constraints + .minimum + .as_deref(), + Some(codex_v2_host_version(&codex_v2_runtime_evidence().unwrap()).as_str()) + ); + assert_eq!( + contract + .capability + .host_version_constraints + .maximum + .as_deref(), + Some(codex_v2_host_version(&codex_v2_runtime_evidence().unwrap()).as_str()) + ); + assert!( + contract + .capability + .runtime_behavior + .backend_selection_source + .contains("authenticated host account") + ); + assert!( + contract + .capability + .runtime_behavior + .discovery_behavior + .contains(".codex/config.toml") + ); + assert!( + contract + .capability + .runtime_behavior + .role_precedence + .iter() + .any(|entry| entry.contains("agent file")) + ); + assert!(contract.capability.runtime_behavior.shared_filesystem); + assert_eq!(contract.capability.parallelism.max_parallel_children, 3); + assert!( + contract + .capability + .runtime_behavior + .delegation_modes + .explicit_agent_type_dispatch + ); + assert!( + contract + .capability + .runtime_behavior + .delegation_modes + .ultra_auto_delegation + ); + assert!( + contract + .capability + .runtime_behavior + .source_references + .contains(&codex_v2_runtime_evidence_reference()) + ); +} + +#[test] +fn codex_default_spec_can_be_partially_tuned_into_custom_standalone_bundle() { + let mut spec = setup_spec_for_policy("balanced", "codex", Integration::Standalone).unwrap(); + let worker = spec.selected_roles.get_mut("worker").unwrap(); + worker.model = "gpt-5.6-sol".to_string(); + worker.effort = Some("medium".to_string()); + worker.spawn = Some(SetupSpawnPolicy { + agent_type: "switchloom_worker".to_string(), + task_name: "worker".to_string(), + fork_turns: ForkPolicy { + mode: "none".to_string(), + turns: None, + }, + }); + + let bundle = compile_setup_spec(&spec).unwrap(); + validate_bundle(&bundle).unwrap(); + assert_eq!(bundle.source.integration, Integration::Standalone); + assert_eq!(bundle.evidence.status, "custom-unverified"); + assert_eq!( + bundle.profiles.get("driver").unwrap().agent_type.as_deref(), + Some("model_routing_sol_medium") + ); + assert!(bundle.artifacts.iter().any(|artifact| { + artifact.path == ".codex/agents/model-routing-sol-medium.toml" + && artifact + .content + .contains("name = \"model_routing_sol_medium\"") + })); + assert!(bundle.artifacts.iter().any(|artifact| { + artifact.path == ".codex/agents/switchloom_worker.toml" + && artifact.content.contains("name = \"switchloom_worker\"") + })); + assert!( + bundle + .artifacts + .iter() + .all(|artifact| !artifact.path.starts_with(".planr/")) + ); +} + +#[test] +fn codex_default_spec_can_be_partially_tuned_into_custom_planr_bundle() { + let mut spec = setup_spec_for_policy("balanced", "codex", Integration::Planr).unwrap(); + let reviewer = spec.selected_roles.get_mut("reviewer").unwrap(); + reviewer.model = "gpt-5.6-terra".to_string(); + reviewer.effort = Some("high".to_string()); + reviewer.spawn = Some(SetupSpawnPolicy { + agent_type: "switchloom_reviewer".to_string(), + task_name: "reviewer".to_string(), + fork_turns: ForkPolicy { + mode: "none".to_string(), + turns: None, + }, + }); + + let bundle = compile_setup_spec(&spec).unwrap(); + validate_bundle(&bundle).unwrap(); + assert_eq!(bundle.source.integration, Integration::Planr); + assert_eq!(bundle.evidence.status, "custom-unverified"); + assert_eq!( + bundle.profiles.get("driver").unwrap().agent_type.as_deref(), + Some("model_routing_sol_medium") + ); + assert!(bundle.artifacts.iter().any(|artifact| { + artifact.path == ".codex/agents/model-routing-sol-medium.toml" + && artifact + .content + .contains("name = \"model_routing_sol_medium\"") + })); + assert!(bundle.artifacts.iter().any(|artifact| { + artifact.path == ".codex/agents/switchloom_reviewer.toml" + && artifact.content.contains("name = \"switchloom_reviewer\"") + })); + assert!( + bundle + .artifacts + .iter() + .any(|artifact| artifact.path == ".planr/agents.toml") + ); +} + +#[test] +fn fully_custom_setup_compiles_as_unverified_host_native_bundle() { + let spec = SetupSpecV1 { + schema_version: 1, + host: "codex".to_string(), + integration: Integration::Standalone, + usage_policy: "balanced".to_string(), + selected_roles: BTreeMap::from([ + ( + "orchestrator".to_string(), + SetupRoleSelection { + model: "gpt-5.6-sol".to_string(), + effort: Some("medium".to_string()), + spawn: Some(SetupSpawnPolicy { + agent_type: "switchloom_orchestrator".to_string(), + task_name: "orchestrator".to_string(), + fork_turns: ForkPolicy { + mode: "none".to_string(), + turns: None, + }, + }), + }, + ), + ( + "implementer".to_string(), + SetupRoleSelection { + model: "gpt-5.6-terra".to_string(), + effort: Some("high".to_string()), + spawn: Some(SetupSpawnPolicy { + agent_type: "switchloom_implementer".to_string(), + task_name: "implementer".to_string(), + fork_turns: ForkPolicy { + mode: "none".to_string(), + turns: None, + }, + }), + }, + ), + ]), + routes: vec![SetupRouteMapping { + work_type: "code".to_string(), + role: "implementer".to_string(), + fallbacks: vec!["orchestrator".to_string()], + }], + route_default: Some(SetupDefaultRoute { + role: "orchestrator".to_string(), + fallbacks: Vec::new(), + }), + }; + let bundle = compile_setup_spec(&spec).unwrap(); + assert_eq!(bundle.source.integration, Integration::Standalone); + assert_eq!(bundle.evidence.status, "custom-unverified"); + assert!(bundle.profiles.contains_key("implementer")); + assert_eq!( + bundle.profiles.get("implementer").unwrap().model, + "gpt-5.6-terra" + ); + assert!(bundle.artifacts.iter().any(|artifact| artifact.path + == ".codex/agents/switchloom_implementer.toml" + && artifact.content.contains("model = \"gpt-5.6-terra\""))); + let adapter_contract = bundle.adapter_contract.as_ref().unwrap(); + assert!(adapter_contract.routing_intent.role_requests.iter().any( + |request| request.semantic_role == "implementer" + && request.requested_model == "gpt-5.6-terra" + && request.requested_effort.as_deref() == Some("high") + )); + assert!( + adapter_contract + .adapter + .dispatch_recipe + .artifact_paths + .contains(&".codex/agents/switchloom_implementer.toml".to_string()) + ); + assert!(bundle.artifacts.iter().any(|artifact| { + artifact.content.contains("task_name `implementer`") + && !artifact.content.contains("sandbox_mode") + })); + assert!( + bundle + .artifacts + .iter() + .all(|artifact| !artifact.path.starts_with(".planr/")) + ); + validate_bundle(&bundle).unwrap(); +} + +#[test] +fn successful_custom_setups_validate_final_bundles_for_each_host_family() { + for (host, role, model, effort) in [ + ("claude-code", "implementer", "sonnet", Some("medium")), + ("cursor", "implementer", "composer-2.5", None), + ( + "opencode", + "implementer", + "opencode/gpt-5-nano", + Some("medium"), + ), + ("mixed-host", "implementer", "sonnet", Some("medium")), + ] { + let spec = SetupSpecV1 { + schema_version: 1, + host: host.to_string(), + integration: Integration::Standalone, + usage_policy: "balanced".to_string(), + selected_roles: BTreeMap::from([( + role.to_string(), + SetupRoleSelection { + model: model.to_string(), + effort: effort.map(ToOwned::to_owned), + spawn: None, + }, + )]), + routes: vec![SetupRouteMapping { + work_type: "code".to_string(), + role: role.to_string(), + fallbacks: Vec::new(), + }], + route_default: None, + }; + let bundle = compile_setup_spec(&spec).unwrap(); + validate_bundle(&bundle).unwrap(); + assert_eq!(bundle.evidence.status, "custom-unverified"); + assert_eq!(bundle.profiles.get(role).unwrap().model, model); + let artifact_path = match host { + "claude-code" => ".claude/agents/switchloom-implementer.md", + "cursor" => ".cursor/agents/switchloom-implementer.md", + "opencode" => ".opencode/agents/switchloom-implementer.md", + "mixed-host" => ".model-routing/roles/implementer.toml", + _ => unreachable!(), + }; + assert!( + bundle + .artifacts + .iter() + .any(|artifact| artifact.path == artifact_path) + ); + let contract = bundle.adapter_contract.as_ref().unwrap(); + assert!( + contract + .routing_intent + .role_requests + .iter() + .any(|request| request.semantic_role == role + && request.requested_model == model + && request.requested_effort.as_deref() == effort) + ); + assert!( + contract + .adapter + .dispatch_recipe + .artifact_paths + .contains(&artifact_path.to_string()) + ); + } +} + +#[test] +fn standalone_policy_contract_changes_enforceable_output() { + let balanced = compile_policy("balanced", "codex-openai", Integration::Standalone).unwrap(); + let read_only = + compile_policy("read-only-audit", "codex-openai", Integration::Standalone).unwrap(); + + assert_ne!(balanced.policy, read_only.policy); + assert_eq!(read_only.policy.usage.max_parallel_writers, 0); + assert_eq!( + read_only + .policy + .execution + .roles + .get("worker") + .unwrap() + .filesystem + .write_roots, + Vec::::new() + ); + + let mut balanced_value: Value = serde_json::from_str( + &compile_json("balanced", "codex-openai", Integration::Standalone).unwrap(), + ) + .unwrap(); + let mut read_only_value: Value = serde_json::from_str( + &compile_json("read-only-audit", "codex-openai", Integration::Standalone).unwrap(), + ) + .unwrap(); + for value in [&mut balanced_value, &mut read_only_value] { + let object = value.as_object_mut().unwrap(); + object.remove("bundle_id"); + object.remove("policy_id"); + } + assert_ne!(balanced_value, read_only_value); +} + +#[test] +fn codex_and_mixed_bindings_keep_native_bounded_fork_topology() { + let codex = compile_json("balanced", "codex-openai", Integration::Standalone).unwrap(); + let mixed = compile_json("balanced", "mixed-host", Integration::Standalone).unwrap(); + assert!(codex.contains("gpt-5.6-sol")); + assert!(codex.contains("\"fork_turns\"")); + assert!(codex.contains("\"mode\": \"none\"")); + assert!(!codex.contains("fork_turns: \\\"all\\\"")); + assert!(mixed.contains("fable-5")); + assert!(mixed.contains("gpt-5.6-terra")); + assert_ne!(codex, mixed); +} + +#[test] +fn built_in_codex_presets_do_not_route_to_ultra() { + for policy in ["low-usage", "balanced", "max-quality"] { + let bundle = compile_policy(policy, "codex-openai", Integration::Standalone).unwrap(); + assert_ne!( + bundle.route_default.as_ref().unwrap().profile, + "codex-sol-ultra", + "{policy} must not default to Ultra" + ); + assert!( + bundle + .routes + .iter() + .all(|route| route.profile != "codex-sol-ultra"), + "{policy} must not route any default work type to Ultra" + ); + assert!( + bundle.profiles.contains_key("codex-sol-ultra"), + "{policy} keeps Ultra available as an explicit manual profile" + ); + assert!( + bundle + .artifacts + .iter() + .any(|artifact| artifact.path == ".codex/agents/model-routing-sol-ultra.toml"), + "{policy} keeps the manual Ultra role artifact" + ); + } +} + +#[test] +fn codex_child_overrides_require_bounded_fork_policy() { + let mut profile = Profile { + client: "codex".to_string(), + model: "gpt-5.6-terra".to_string(), + agent_type: Some("model_routing_terra_high".to_string()), + effort: Some("high".to_string()), + cost_tier: None, + capabilities: Vec::new(), + skill: None, + notes: None, + fork_turns: None, + }; + assert!( + validate_profile_fork_policy(&profile) + .unwrap_err() + .to_string() + .contains("must declare fork_turns") + ); + + profile.fork_turns = Some(ForkPolicy { + mode: "all".to_string(), + turns: None, + }); + assert!( + validate_profile_fork_policy(&profile) + .unwrap_err() + .to_string() + .contains("must not use fork_turns all") + ); + + profile.fork_turns = Some(ForkPolicy { + mode: "bounded".to_string(), + turns: Some(2), + }); + validate_profile_fork_policy(&profile).unwrap(); +} + +#[test] +fn generated_registry_is_derived_from_binding_profiles_and_routes() { + for host in BINDINGS.map(|(host, _)| host) { + let bundle = compile_policy("balanced", host, Integration::Planr).unwrap(); + let registry = bundle + .artifacts + .iter() + .find(|artifact| artifact.path == ".planr/agents.toml") + .unwrap(); + let parsed: toml::Value = toml::from_str(®istry.content).unwrap(); + assert_eq!( + parsed["profiles"].as_table().unwrap().len(), + bundle.profiles.len() + ); + assert_eq!( + parsed["routes"].as_array().unwrap().len(), + bundle.routes.len() + ); + } +} + +#[test] +fn offline_evaluation_never_claims_live_verification_or_recommendation() { + let report = evaluate_policy("balanced", "codex-openai").unwrap(); + assert!(report.offline_reproducible); + assert!(report.scenario_count >= 7); + assert_eq!(report.status, "experimental"); + assert!(!report.recommended); +} + +#[test] +fn no_in_memory_claim_can_promote_offline_evaluation() { + let report = evaluate_policy("balanced", "codex-openai").unwrap(); + assert!(report.live_evidence.is_none()); + assert_eq!(report.status, "experimental"); + assert!(!report.recommended); +} + +#[test] +fn built_in_presets_compile_through_setup_spec_without_output_drift() { + let spec = setup_spec_for_policy("balanced", "codex-openai", Integration::Planr).unwrap(); + assert_eq!( + compile_setup_spec(&spec).unwrap(), + compile_builtin_policy_direct("balanced", "codex-openai", Integration::Planr).unwrap() + ); +} + +#[test] +fn public_host_aliases_preserve_built_in_preset_output() { + for (alias, binding) in [ + ("codex", "codex-openai"), + ("cursor", "cursor-openai"), + ("claude-code", "claude-native"), + ] { + let spec = setup_spec_for_policy("balanced", alias, Integration::Standalone).unwrap(); + assert_eq!(spec.host, binding); + assert_eq!( + compile_setup_spec(&spec).unwrap(), + compile_builtin_policy_direct("balanced", binding, Integration::Standalone).unwrap() + ); + } +} diff --git a/tests/host_contract.rs b/tests/host_contract.rs new file mode 100644 index 0000000..5d3edfb --- /dev/null +++ b/tests/host_contract.rs @@ -0,0 +1,12 @@ +use model_routing::*; + +#[test] +fn probe_does_not_infer_authentication_from_version_availability() { + let report = probe_host( + "codex-openai", + Some("definitely-not-a-model-routing-host-command"), + ) + .unwrap(); + assert!(!report.available); + assert_eq!(report.authentication, "not_tested"); +} diff --git a/tests/lifecycle_contract.rs b/tests/lifecycle_contract.rs new file mode 100644 index 0000000..b8991ec --- /dev/null +++ b/tests/lifecycle_contract.rs @@ -0,0 +1,746 @@ +use model_routing::*; +use sha2::{Digest, Sha256}; +use std::fmt::Write; +use std::fs; +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; + +fn temp_repo(name: &str) -> PathBuf { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let path = std::env::temp_dir().join(format!("model-routing-{name}-{unique}")); + fs::create_dir_all(&path).unwrap(); + path +} + +fn test_sha256(bytes: &[u8]) -> String { + let digest = Sha256::digest(bytes); + digest.iter().fold(String::new(), |mut output, byte| { + write!(output, "{byte:02x}").expect("writing to String cannot fail"); + output + }) +} + +fn apply_bundle_file_with_bundle( + repository: &Path, + bundle: &RoutingBundleV1, +) -> Result { + let bundle_file = write_bundle_file(repository, "bundle.json", bundle); + apply_bundle_file(repository, &bundle_file) +} + +fn preview_bundle_with_bundle( + repository: &Path, + bundle: &RoutingBundleV1, +) -> Result { + let bundle_file = write_bundle_file(repository, "preview-bundle.json", bundle); + preview_bundle_file(repository, &bundle_file) +} + +fn write_bundle_file(repository: &Path, name: &str, bundle: &RoutingBundleV1) -> PathBuf { + let bundle_file = repository.join(name); + fs::write(&bundle_file, serde_json::to_vec_pretty(bundle).unwrap()).unwrap(); + bundle_file +} + +fn assert_codex_config_entry(content: &str, agent_type: &str, config_file: &str) { + let parsed: toml::Value = toml::from_str(content).unwrap(); + assert_eq!( + parsed["agents"][agent_type]["config_file"].as_str(), + Some(config_file) + ); +} + +fn assert_no_codex_config_entry(content: &str, agent_type: &str) { + let parsed: toml::Value = toml::from_str(content).unwrap(); + assert!(parsed["agents"].get(agent_type).is_none()); +} + +#[test] +fn setup_config_lifecycle_persists_normalized_config_and_reuses_manifest_flow() { + let repository = temp_repo("setup-config-lifecycle"); + let config_file = repository.join("input.setup.toml"); + let original = setup_spec_for_policy("balanced", "codex", Integration::Standalone).unwrap(); + let original_toml = setup_spec_to_canonical_toml(&original).unwrap(); + fs::write(&config_file, &original_toml).unwrap(); + + let preview = preview_setup_config_file(&repository, &config_file).unwrap(); + assert_eq!(preview.action, "preview"); + assert!( + preview + .artifacts + .iter() + .any(|artifact| { artifact.path == SETUP_CONFIG_PATH && artifact.status == "create" }) + ); + + let applied = apply_setup_config_file(&repository, &config_file).unwrap(); + assert_eq!(applied.action, "apply"); + assert_eq!( + fs::read_to_string(repository.join(SETUP_CONFIG_PATH)).unwrap(), + original_toml + ); + assert!( + !repository.join(".planr").exists(), + "standalone setup must not create .planr" + ); + let status = status_repository(&repository).unwrap(); + assert!( + status + .artifacts + .iter() + .any(|artifact| { artifact.path == SETUP_CONFIG_PATH && artifact.status == "managed" }) + ); + let saved_preview = preview_saved_setup(&repository).unwrap(); + assert!( + saved_preview.artifacts.iter().any(|artifact| { + artifact.path == SETUP_CONFIG_PATH && artifact.status == "unchanged" + }) + ); + + let mut updated = setup_spec_for_policy("balanced", "codex", Integration::Standalone).unwrap(); + let worker = updated.selected_roles.get_mut("worker").unwrap(); + worker.model = "gpt-5.6-sol".to_string(); + worker.effort = Some("medium".to_string()); + worker.spawn = Some(SetupSpawnPolicy { + agent_type: "switchloom_worker".to_string(), + task_name: "worker".to_string(), + fork_turns: ForkPolicy { + mode: "none".to_string(), + turns: None, + }, + }); + let updated_file = repository.join("updated.setup.toml"); + let updated_toml = setup_spec_to_canonical_toml(&updated).unwrap(); + fs::write(&updated_file, &updated_toml).unwrap(); + let update = update_setup_config_file(&repository, &updated_file).unwrap(); + assert_eq!(update.action, "update"); + assert_eq!( + fs::read_to_string(repository.join(SETUP_CONFIG_PATH)).unwrap(), + updated_toml + ); + assert!( + repository + .join(".codex/agents/switchloom_worker.toml") + .exists() + ); + + let rollback = rollback_repository(&repository).unwrap(); + assert_eq!(rollback.action, "rollback"); + assert_eq!( + fs::read_to_string(repository.join(SETUP_CONFIG_PATH)).unwrap(), + original_toml + ); + assert!( + !repository + .join(".codex/agents/switchloom_worker.toml") + .exists() + ); + + let uninstall = uninstall_repository(&repository).unwrap(); + assert_eq!(uninstall.action, "uninstall"); + assert!(!repository.join(SETUP_CONFIG_PATH).exists()); + assert!(!repository.join(".model-routing/manifest.json").exists()); +} + +#[test] +fn setup_recipe_apply_persists_config_and_rejects_existing_conflicts() { + let repository = temp_repo("setup-recipe-lifecycle"); + let spec = setup_spec_for_policy("balanced", "codex", Integration::Planr).unwrap(); + let recipe = setup_spec_to_recipe(&spec).unwrap(); + + let preview = preview_setup_recipe(&repository, &recipe).unwrap(); + assert_eq!(preview.action, "preview"); + assert!( + preview.artifacts.iter().any(|artifact| { + artifact.path == ".planr/agents.toml" && artifact.status == "create" + }) + ); + apply_setup_recipe(&repository, &recipe).unwrap(); + assert_eq!( + fs::read_to_string(repository.join(SETUP_CONFIG_PATH)).unwrap(), + setup_spec_to_canonical_toml(&spec).unwrap() + ); + assert!(repository.join(".planr/agents.toml").exists()); + + let conflict_repo = temp_repo("setup-recipe-conflict"); + fs::create_dir_all(conflict_repo.join(".switchloom")).unwrap(); + fs::write(conflict_repo.join(SETUP_CONFIG_PATH), "not managed\n").unwrap(); + let error = apply_setup_recipe(&conflict_repo, &recipe) + .unwrap_err() + .to_string(); + assert!(error.contains(SETUP_CONFIG_PATH)); +} + +#[test] +fn opencode_native_lifecycle_manages_project_agents() { + let repository = temp_repo("opencode-lifecycle"); + let bundle = compile_policy("balanced", "opencode-native", Integration::Standalone).unwrap(); + let preview = preview_bundle_with_bundle(&repository, &bundle).unwrap(); + assert!(preview.artifacts.iter().any(|artifact| { + artifact.path == ".opencode/agents/model-routing-preset-worker.md" + && artifact.status == "create" + })); + + apply_bundle_file_with_bundle(&repository, &bundle).unwrap(); + let worker = repository.join(".opencode/agents/model-routing-preset-worker.md"); + let driver = repository.join(".opencode/agents/model-routing-preset-driver.md"); + assert!(worker.exists()); + assert!(driver.exists()); + let driver_content = fs::read_to_string(&driver).unwrap(); + assert!(driver_content.contains("permission:")); + assert!(driver_content.contains("model-routing-preset-worker: allow")); + + let status = status_repository(&repository).unwrap(); + assert!(status.artifacts.iter().any(|artifact| { + artifact.path == ".opencode/agents/model-routing-preset-worker.md" + && artifact.status == "managed" + })); + uninstall_repository(&repository).unwrap(); + assert!(!worker.exists()); + assert!(!driver.exists()); + assert!(!repository.join(".model-routing/manifest.json").exists()); +} + +#[test] +fn pi_external_lifecycle_manages_workflow_artifacts() { + let repository = temp_repo("pi-external-lifecycle"); + let bundle = compile_policy("balanced", "pi-external", Integration::Standalone).unwrap(); + let preview = preview_bundle_with_bundle(&repository, &bundle).unwrap(); + assert!(preview.artifacts.iter().any(|artifact| { + artifact.path == ".pi/workflows/model-routing-preset-runner.json" + && artifact.status == "create" + })); + + apply_bundle_file_with_bundle(&repository, &bundle).unwrap(); + let workflow = repository.join(".pi/workflows/model-routing-preset-runner.json"); + assert!(workflow.exists()); + let workflow_content = fs::read_to_string(&workflow).unwrap(); + assert!(workflow_content.contains("\"external-runner\"")); + assert!(workflow_content.contains("\"--no-tools\"")); + assert!(workflow_content.contains("\"PI_CODING_AGENT_DIR")); + + let status = status_repository(&repository).unwrap(); + assert!(status.artifacts.iter().any(|artifact| { + artifact.path == ".pi/workflows/model-routing-preset-runner.json" + && artifact.status == "managed" + })); + uninstall_repository(&repository).unwrap(); + assert!(!workflow.exists()); + assert!(!repository.join(".model-routing/manifest.json").exists()); +} + +#[test] +fn lifecycle_update_and_rollback_are_manifest_aware() { + let repository = temp_repo("update-rollback"); + let original = compile_policy("balanced", "codex-openai", Integration::Standalone).unwrap(); + apply_bundle_file_with_bundle(&repository, &original).unwrap(); + + let mut updated = original.clone(); + updated.bundle_id = "balanced-codex-openai@updated".to_string(); + updated.artifacts[0].content.push_str("\n# updated\n"); + updated.artifacts[0].sha256 = test_sha256(updated.artifacts[0].content.as_bytes()); + let bundle_file = write_bundle_file(&repository, "updated-bundle.json", &updated); + + let update = update_bundle_file(&repository, &bundle_file).unwrap(); + assert_eq!(update.action, "update"); + assert!( + update + .artifacts + .iter() + .any(|artifact| artifact.status == "update") + ); + assert_eq!( + test_sha256(&fs::read(repository.join(&updated.artifacts[0].path)).unwrap()), + updated.artifacts[0].sha256 + ); + + let rollback = rollback_repository(&repository).unwrap(); + assert_eq!(rollback.action, "rollback"); + assert!( + rollback + .artifacts + .iter() + .any(|artifact| artifact.status == "rollback") + ); + assert_eq!( + test_sha256(&fs::read(repository.join(&original.artifacts[0].path)).unwrap()), + original.artifacts[0].sha256 + ); +} + +#[test] +fn lifecycle_codex_config_merges_unrelated_entries_update_rollback_and_uninstall() { + let repository = temp_repo("codex-config-ownership"); + fs::create_dir_all(repository.join(".codex/agents")).unwrap(); + fs::write( + repository.join(".codex/config.toml"), + "[agents.local_reviewer]\nconfig_file = \"./agents/local-reviewer.toml\"\n\n[features]\nlocal = true\n", + ) + .unwrap(); + fs::write( + repository.join(".codex/agents/local-reviewer.toml"), + "name = \"local_reviewer\"\n", + ) + .unwrap(); + let global_codex_home = temp_repo("global-codex-home"); + fs::write( + global_codex_home.join("config.toml"), + "[agents.global_reviewer]\nconfig_file = \"./agents/global-reviewer.toml\"\n", + ) + .unwrap(); + let global_config_before = fs::read(global_codex_home.join("config.toml")).unwrap(); + + let codex = compile_policy("balanced", "codex-openai", Integration::Standalone).unwrap(); + let applied = apply_bundle_file_with_bundle(&repository, &codex).unwrap(); + assert!( + applied.artifacts.iter().any(|artifact| { + artifact.path == ".codex/config.toml" && artifact.status == "update" + }) + ); + let config_after_apply = fs::read_to_string(repository.join(".codex/config.toml")).unwrap(); + assert_codex_config_entry( + &config_after_apply, + "local_reviewer", + "./agents/local-reviewer.toml", + ); + assert_codex_config_entry( + &config_after_apply, + "model_routing_terra_high", + "./agents/model-routing-terra-high.toml", + ); + assert_codex_config_entry( + &config_after_apply, + "model_routing_sol_high", + "./agents/model-routing-sol-high.toml", + ); + assert!(config_after_apply.contains("[features]")); + + let mixed = compile_policy("balanced", "mixed-host", Integration::Standalone).unwrap(); + let mixed_file = write_bundle_file(&repository, "mixed.json", &mixed); + let update = update_bundle_file(&repository, &mixed_file).unwrap(); + assert!( + update.artifacts.iter().any(|artifact| { + artifact.path == ".codex/config.toml" && artifact.status == "update" + }) + ); + let config_after_update = fs::read_to_string(repository.join(".codex/config.toml")).unwrap(); + assert_codex_config_entry( + &config_after_update, + "local_reviewer", + "./agents/local-reviewer.toml", + ); + assert_codex_config_entry( + &config_after_update, + "model_routing_terra_high", + "./agents/model-routing-terra-high.toml", + ); + assert_no_codex_config_entry(&config_after_update, "model_routing_sol_medium"); + assert_no_codex_config_entry(&config_after_update, "model_routing_sol_ultra"); + + let rollback = rollback_repository(&repository).unwrap(); + assert!(rollback.artifacts.iter().any(|artifact| { + artifact.path == ".codex/config.toml" && artifact.status == "rollback" + })); + let config_after_rollback = fs::read_to_string(repository.join(".codex/config.toml")).unwrap(); + assert_codex_config_entry( + &config_after_rollback, + "local_reviewer", + "./agents/local-reviewer.toml", + ); + assert_codex_config_entry( + &config_after_rollback, + "model_routing_sol_medium", + "./agents/model-routing-sol-medium.toml", + ); + assert_codex_config_entry( + &config_after_rollback, + "model_routing_sol_ultra", + "./agents/model-routing-sol-ultra.toml", + ); + + let uninstall = uninstall_repository(&repository).unwrap(); + assert!( + uninstall.artifacts.iter().any(|artifact| { + artifact.path == ".codex/config.toml" && artifact.status == "removed" + }) + ); + let config_after_uninstall = fs::read_to_string(repository.join(".codex/config.toml")).unwrap(); + assert_codex_config_entry( + &config_after_uninstall, + "local_reviewer", + "./agents/local-reviewer.toml", + ); + assert_no_codex_config_entry(&config_after_uninstall, "model_routing_terra_high"); + assert_no_codex_config_entry(&config_after_uninstall, "model_routing_sol_high"); + assert!(config_after_uninstall.contains("[features]")); + assert_eq!( + fs::read_to_string(repository.join(".codex/agents/local-reviewer.toml")).unwrap(), + "name = \"local_reviewer\"\n" + ); + assert_eq!( + fs::read(global_codex_home.join("config.toml")).unwrap(), + global_config_before + ); + assert!(!repository.join(".model-routing/manifest.json").exists()); +} + +#[test] +fn lifecycle_codex_config_modified_managed_entry_is_preserved_with_repair() { + let repository = temp_repo("codex-config-modified-entry"); + let codex = compile_policy("balanced", "codex-openai", Integration::Standalone).unwrap(); + apply_bundle_file_with_bundle(&repository, &codex).unwrap(); + fs::write( + repository.join(".codex/config.toml"), + "[agents.model_routing_terra_high]\nconfig_file = \"./agents/hacked.toml\"\n", + ) + .unwrap(); + + let status = status_repository(&repository).unwrap(); + assert!(status.artifacts.iter().any(|artifact| { + artifact.path == ".codex/config.toml" + && artifact.status == "modified" + && artifact.repair.is_some() + })); + + let uninstall = uninstall_repository(&repository).unwrap(); + assert!(uninstall.artifacts.iter().any(|artifact| { + artifact.path == ".codex/config.toml" + && artifact.status == "preserved-modified" + && artifact.repair.is_some() + })); + assert!(repository.join(".codex/config.toml").exists()); + assert!(repository.join(".model-routing/manifest.json").exists()); +} + +#[test] +fn lifecycle_preserves_modified_files_and_residual_manifest() { + let repository = temp_repo("preserve-residual"); + let mut bundle = compile_policy("balanced", "codex-openai", Integration::Standalone).unwrap(); + bundle.artifacts.truncate(1); + apply_bundle_file_with_bundle(&repository, &bundle).unwrap(); + + let target = repository.join(&bundle.artifacts[0].path); + fs::write(&target, "user modified").unwrap(); + let uninstall = uninstall_repository(&repository).unwrap(); + assert_eq!(uninstall.artifacts[0].status, "preserved-modified"); + assert!(uninstall.artifacts[0].repair.is_some()); + assert!(target.exists()); + assert!(repository.join(".model-routing/manifest.json").exists()); + + let status = status_repository(&repository).unwrap(); + assert_eq!(status.artifacts[0].status, "modified"); + assert!(status.artifacts[0].repair.is_some()); +} + +#[test] +fn lifecycle_cross_host_update_and_rollback_remove_old_managed_artifacts() { + let repository = temp_repo("cross-host"); + let codex = compile_policy("balanced", "codex-openai", Integration::Standalone).unwrap(); + let claude = compile_policy("balanced", "claude-native", Integration::Standalone).unwrap(); + apply_bundle_file_with_bundle(&repository, &codex).unwrap(); + let codex_artifact = repository.join(".codex/agents/model-routing-sol-medium.toml"); + assert!(codex_artifact.exists()); + + let claude_file = write_bundle_file(&repository, "claude.json", &claude); + let update = update_bundle_file(&repository, &claude_file).unwrap(); + assert!( + update + .artifacts + .iter() + .any(|artifact| artifact.mode == "delete" && artifact.status == "removed") + ); + assert!(!codex_artifact.exists()); + let status = status_repository(&repository).unwrap(); + assert!( + status + .artifacts + .iter() + .all(|artifact| artifact.path.starts_with(".claude/")) + ); + + let claude_artifact = repository.join(".claude/agents/model-routing-preset-worker.md"); + assert!(claude_artifact.exists()); + let rollback = rollback_repository(&repository).unwrap(); + assert!( + rollback + .artifacts + .iter() + .any(|artifact| artifact.mode == "delete" && artifact.status == "removed") + ); + assert!(!claude_artifact.exists()); + assert!(codex_artifact.exists()); + let status = status_repository(&repository).unwrap(); + assert!( + status + .artifacts + .iter() + .all(|artifact| artifact.path.starts_with(".codex/")) + ); + + uninstall_repository(&repository).unwrap(); + assert!(!repository.join(".model-routing/manifest.json").exists()); + assert!(!codex_artifact.exists()); +} + +#[test] +fn lifecycle_cross_host_update_preserves_modified_removed_paths() { + let repository = temp_repo("cross-host-preserve"); + let codex = compile_policy("balanced", "codex-openai", Integration::Standalone).unwrap(); + let claude = compile_policy("balanced", "claude-native", Integration::Standalone).unwrap(); + apply_bundle_file_with_bundle(&repository, &codex).unwrap(); + let codex_artifact = repository.join(".codex/agents/model-routing-sol-medium.toml"); + fs::write(&codex_artifact, "user modified codex artifact").unwrap(); + + let claude_file = write_bundle_file(&repository, "claude.json", &claude); + let update = update_bundle_file(&repository, &claude_file).unwrap(); + let preserved = update + .artifacts + .iter() + .find(|artifact| artifact.path == ".codex/agents/model-routing-sol-medium.toml") + .unwrap(); + assert_eq!(preserved.mode, "delete"); + assert_eq!(preserved.status, "preserved-modified"); + assert!(preserved.repair.is_some()); + assert!(codex_artifact.exists()); + + let status = status_repository(&repository).unwrap(); + assert!(status.artifacts.iter().any(|artifact| { + artifact.path == ".codex/agents/model-routing-sol-medium.toml" + && artifact.status == "modified" + && artifact.repair.is_some() + })); + assert!( + status + .artifacts + .iter() + .any(|artifact| artifact.path.starts_with(".claude/")) + ); +} + +#[test] +fn fresh_repository_registers_codex_native_role_discovery_config() { + let repository = temp_repo("codex-native-discovery-config"); + let bundle = compile_policy("balanced", "codex-openai", Integration::Standalone).unwrap(); + assert!(bundle.artifacts.iter().all(|artifact| { + artifact.path == ".codex/config.toml" || artifact.path.starts_with(".codex/agents/") + })); + assert!( + bundle + .artifacts + .iter() + .all(|artifact| !artifact.path.starts_with(".codex/skills/")) + ); + apply_bundle_file_with_bundle(&repository, &bundle).unwrap(); + + for role in ["model-routing-terra-high", "model-routing-sol-high"] { + assert!( + repository + .join(format!(".codex/agents/{role}.toml")) + .exists(), + "generated native Codex role file {role} should exist" + ); + } + + let config = bundle + .artifacts + .iter() + .find(|artifact| artifact.path == ".codex/config.toml") + .expect("repository-local Codex role discovery config should be generated"); + let parsed: toml::Value = toml::from_str(&config.content).unwrap(); + assert_eq!( + parsed["agents"]["model_routing_terra_high"]["config_file"].as_str(), + Some("./agents/model-routing-terra-high.toml") + ); + assert_eq!( + parsed["agents"]["model_routing_sol_high"]["config_file"].as_str(), + Some("./agents/model-routing-sol-high.toml") + ); + assert_eq!( + fs::read_to_string(repository.join(".codex/config.toml")).unwrap(), + config.content + ); +} + +#[test] +fn lifecycle_preview_apply_status_and_uninstall_are_repository_safe() { + let repository = temp_repo("lifecycle"); + let bundle = compile_policy("balanced", "codex-openai", Integration::Standalone).unwrap(); + assert!(bundle.artifacts.iter().all(|artifact| { + artifact.path == ".codex/config.toml" || artifact.path.starts_with(".codex/agents/") + })); + assert!( + bundle + .artifacts + .iter() + .all(|artifact| !artifact.content.contains("codex exec")) + ); + let preview = preview_bundle_with_bundle(&repository, &bundle).unwrap(); + assert_eq!(preview.action, "preview"); + assert_eq!(preview.artifacts.len(), 7); + assert!( + preview + .artifacts + .iter() + .all(|artifact| artifact.status == "create") + ); + + let bundle_file = repository.join("bundle.json"); + fs::write( + &bundle_file, + compile_json("balanced", "codex-openai", Integration::Standalone).unwrap(), + ) + .unwrap(); + let applied = apply_bundle_file(&repository, &bundle_file).unwrap(); + assert_eq!(applied.action, "apply"); + assert!(repository.join(".model-routing/manifest.json").exists()); + assert!( + repository + .join(".codex/agents/model-routing-sol-medium.toml") + .exists() + ); + let codex_config = fs::read_to_string(repository.join(".codex/config.toml")).unwrap(); + assert!(codex_config.contains("[agents.model_routing_terra_high]")); + assert!(codex_config.contains("config_file = \"./agents/model-routing-terra-high.toml\"")); + assert!(codex_config.contains("[agents.model_routing_sol_high]")); + assert!(codex_config.contains("config_file = \"./agents/model-routing-sol-high.toml\"")); + assert!( + repository + .join(".codex/agents/model-routing-terra-high.toml") + .exists() + ); + assert!( + repository + .join(".codex/agents/model-routing-sol-high.toml") + .exists() + ); + + let status = status_repository(&repository).unwrap(); + assert_eq!(status.action, "status"); + assert!( + status + .artifacts + .iter() + .all(|artifact| artifact.status == "managed") + ); + + let uninstalled = uninstall_repository(&repository).unwrap(); + assert_eq!(uninstalled.action, "uninstall"); + assert!( + uninstalled + .artifacts + .iter() + .all(|artifact| artifact.status == "removed") + ); + assert!(!repository.join(".model-routing/manifest.json").exists()); + assert!( + !repository + .join(".codex/agents/model-routing-sol-medium.toml") + .exists() + ); +} + +#[test] +fn lifecycle_rejects_unsafe_paths_and_conflicts() { + let repository = temp_repo("unsafe"); + let mut bundle = compile_policy("balanced", "codex-openai", Integration::Standalone).unwrap(); + + bundle.artifacts[0].path = ".model-routing/unsafe.toml".to_string(); + assert!( + preview_bundle_with_bundle(&repository, &bundle) + .unwrap_err() + .to_string() + .contains("reserved path") + ); + + let mut bundle = compile_policy("balanced", "codex-openai", Integration::Standalone).unwrap(); + bundle.artifacts[0].path = "../escape.toml".to_string(); + assert!( + preview_bundle_with_bundle(&repository, &bundle) + .unwrap_err() + .to_string() + .contains("must not traverse") + ); + + let bundle = compile_policy("balanced", "codex-openai", Integration::Standalone).unwrap(); + let target = repository.join(&bundle.artifacts[0].path); + fs::create_dir_all(target.parent().unwrap()).unwrap(); + fs::write(&target, "user edit").unwrap(); + let error = apply_bundle_file_with_bundle(&repository, &bundle) + .unwrap_err() + .to_string(); + assert!(error.contains("already exists with different content")); +} + +#[test] +fn lifecycle_rejects_parent_child_targets_without_partial_apply() { + let repository = temp_repo("parent-child"); + let mut bundle = compile_policy("balanced", "codex-openai", Integration::Standalone).unwrap(); + bundle.artifacts.truncate(2); + bundle.artifacts[0].path = ".codex/agents/collision".to_string(); + bundle.artifacts[0].content = "parent".to_string(); + bundle.artifacts[0].sha256 = test_sha256(bundle.artifacts[0].content.as_bytes()); + bundle.artifacts[1].path = ".codex/agents/collision/child.toml".to_string(); + bundle.artifacts[1].content = "child".to_string(); + bundle.artifacts[1].sha256 = test_sha256(bundle.artifacts[1].content.as_bytes()); + + let error = apply_bundle_file_with_bundle(&repository, &bundle) + .unwrap_err() + .to_string(); + assert!(error.contains("parent-child collision")); + assert!(!repository.join(".codex/agents/collision").exists()); + assert!(!repository.join(".model-routing/manifest.json").exists()); +} + +#[test] +fn prepared_setup_apply_aborts_when_repository_plan_changes_after_preview() { + let repository = temp_repo("prepared-setup-toctou"); + let spec = setup_spec_for_policy("balanced", "codex", Integration::Standalone).unwrap(); + let prepared = prepare_setup_recipe(&setup_spec_to_recipe(&spec).unwrap()).unwrap(); + let preview = preview_prepared_setup(&repository, &prepared).unwrap(); + fs::create_dir_all(repository.join(".switchloom")).unwrap(); + fs::write(repository.join(SETUP_CONFIG_PATH), "external change\n").unwrap(); + let error = apply_prepared_setup(&repository, &prepared, &preview) + .unwrap_err() + .to_string(); + assert!(error.contains("repository state changed after preview")); + assert_eq!( + fs::read_to_string(repository.join(SETUP_CONFIG_PATH)).unwrap(), + "external change\n" + ); + assert!(!repository.join(".model-routing/manifest.json").exists()); +} + +#[cfg(unix)] +#[test] +fn prepared_setup_apply_aborts_when_repository_symlink_retargets_after_preview() { + use std::os::unix::fs::symlink; + + let root = temp_repo("prepared-setup-symlink"); + let repo_a = root.join("repo-a"); + let repo_b = root.join("repo-b"); + let link = root.join("repo-link"); + fs::create_dir_all(&repo_a).unwrap(); + fs::create_dir_all(&repo_b).unwrap(); + symlink(&repo_a, &link).unwrap(); + + let spec = setup_spec_for_policy("balanced", "codex", Integration::Standalone).unwrap(); + let prepared = prepare_setup_recipe(&setup_spec_to_recipe(&spec).unwrap()).unwrap(); + let preview = preview_prepared_setup(&link, &prepared).unwrap(); + assert_eq!( + preview.repository, + repo_a.canonicalize().unwrap().display().to_string() + ); + + fs::remove_file(&link).unwrap(); + symlink(&repo_b, &link).unwrap(); + let error = apply_prepared_setup(&link, &prepared, &preview) + .unwrap_err() + .to_string(); + assert!(error.contains("repository state changed after preview")); + assert!(!repo_a.join(SETUP_CONFIG_PATH).exists()); + assert!(!repo_b.join(SETUP_CONFIG_PATH).exists()); + assert!(!repo_a.join(".model-routing/manifest.json").exists()); + assert!(!repo_b.join(".model-routing/manifest.json").exists()); +} diff --git a/tests/routing_contract.rs b/tests/routing_contract.rs new file mode 100644 index 0000000..68e947b --- /dev/null +++ b/tests/routing_contract.rs @@ -0,0 +1,128 @@ +use model_routing::*; +use serde_json::Value; + +#[test] +fn complete_policy_binding_pool_compiles_deterministically() { + let summaries = list_policies().unwrap(); + assert_eq!(summaries.len(), 28); + for summary in summaries { + let first = + compile_json(&summary.policy_id, &summary.host, Integration::Standalone).unwrap(); + let second = + compile_json(&summary.policy_id, &summary.host, Integration::Standalone).unwrap(); + assert_eq!(first, second); + assert!(!first.contains(".planr/policy.toml")); + assert!(first.contains("\"package\": \"model-routing\"")); + } +} + +#[test] +fn valid_generated_bundles_pass_strict_inspection() { + for integration in [Integration::Standalone, Integration::Planr] { + let bundle = compile_json("balanced", "codex-openai", integration).unwrap(); + let inspection = inspect_bundle_json(&bundle).unwrap(); + assert!(inspection.valid); + assert_eq!(inspection.integration, integration); + assert_eq!(inspection.policy_id, "balanced"); + } +} + +#[test] +fn invalid_contract_fixtures_fail_for_named_reasons() { + for (fixture, expected) in [ + ( + include_str!("../fixtures/routing-bundle-v1/invalid-unsupported-version.json"), + "unsupported schema_version 2", + ), + ( + include_str!("../fixtures/routing-bundle-v1/invalid-dual-artifact-payload.json"), + "cannot define both content and content_ref", + ), + ( + include_str!("../fixtures/routing-bundle-v1/invalid-artifact-hash.json"), + "sha256 mismatch", + ), + ( + include_str!("../fixtures/routing-bundle-v1/invalid-unknown-source-field.json"), + "unknown field `unexpected`", + ), + ( + include_str!("../fixtures/routing-bundle-v1/invalid-unknown-policy-usage-field.json"), + "unknown field `unexpected`", + ), + ( + include_str!("../fixtures/routing-bundle-v1/invalid-unknown-profile-field.json"), + "unknown field `unexpected`", + ), + ( + include_str!( + "../fixtures/routing-bundle-v1/invalid-runtime-missing-source-reference.json" + ), + "runtime behavior must declare source references", + ), + ( + include_str!( + "../fixtures/routing-bundle-v1/invalid-runtime-bogus-source-reference.json" + ), + "source reference must match the digest-bound evidence artifact", + ), + ( + include_str!("../fixtures/routing-bundle-v1/invalid-runtime-capability-mismatch.json"), + "capability_version must match parsed evidence_id", + ), + ( + include_str!("../fixtures/routing-bundle-v1/invalid-runtime-slot-count.json"), + "exactly the parsed evidence child slots", + ), + ( + include_str!("../fixtures/routing-bundle-v1/invalid-runtime-version-drift.json"), + "installed host version must match parsed evidence command output", + ), + ( + include_str!("../fixtures/routing-bundle-v1/invalid-runtime-ultra-delegation.json"), + "delegation modes must match parsed evidence", + ), + ] { + let error = inspect_bundle_json(fixture).unwrap_err().to_string(); + assert!( + error.contains(expected), + "expected `{expected}` in `{error}`" + ); + } +} + +#[test] +fn checked_in_planr_contract_fixtures_are_generated_outputs() { + for (host, fixture) in [ + ( + "codex-openai", + include_str!("../fixtures/routing-bundle-v1/valid-balanced-codex.json"), + ), + ( + "mixed-host", + include_str!("../fixtures/routing-bundle-v1/valid-balanced-mixed.json"), + ), + ] { + let generated: serde_json::Value = + serde_json::from_str(&compile_json("balanced", host, Integration::Planr).unwrap()) + .unwrap(); + let checked_in: serde_json::Value = serde_json::from_str(fixture).unwrap(); + assert_eq!(generated, checked_in, "regenerate fixture for {host}"); + } +} + +#[test] +fn catalog_is_reproducible_and_contains_the_full_pool() { + let first = catalog_json().unwrap(); + let second = catalog_json().unwrap(); + assert_eq!(first, second); + let value: Value = serde_json::from_str(&first).unwrap(); + assert_eq!(value["compositions"].as_array().unwrap().len(), 28); + assert!( + value["compositions"] + .as_array() + .unwrap() + .iter() + .all(|entry| entry["recommended"] == false) + ); +} diff --git a/tsconfig.json b/tsconfig.json index c3b7c21..db0b9f2 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,11 +1,11 @@ { "extends": "astro/tsconfigs/strict", - "include": [".astro/types.d.ts", "src/**/*", "astro.config.ts"], + "include": [".astro/types.d.ts", "website/src/**/*", "astro.config.ts"], "exclude": ["dist", "target", "node_modules"], "compilerOptions": { "baseUrl": ".", "paths": { - "@/*": ["./src/*"] + "@/*": ["./website/src/*"] }, "jsx": "react-jsx", "jsxImportSource": "react" diff --git a/vitest.config.ts b/vitest.config.ts index ae847ff..6d8b16e 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -2,6 +2,6 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ test: { - include: ["src/**/*.test.ts"], + include: ["website/src/**/*.test.ts"], }, }); diff --git a/website/data/bundles/balanced-claude-native.json b/website/data/bundles/balanced-claude-native.json index 7f5539f..61731b1 100644 --- a/website/data/bundles/balanced-claude-native.json +++ b/website/data/bundles/balanced-claude-native.json @@ -6,7 +6,7 @@ "generated_at": "2026-07-16T00:00:00Z", "source": { "package": "model-routing", - "package_version": "0.2.2", + "package_version": "0.3.0", "integration": "standalone" }, "policy": { @@ -295,7 +295,7 @@ }, "planr_handoff": { "schema_version": 1, - "switchloom_package": "switchloom@0.2.2", + "switchloom_package": "switchloom@0.3.0", "semantic_role_contract": "Planr supplies usage policy `balanced`, work_type routes, and semantic roles; Switchloom owns the selected host binding, model, effort, fork/context policy, and generated dispatch artifacts.", "required_consumer_behavior": [ "Consume RoutingIntentV1 as the only source for semantic_role, work_type, selected profile, model, effort, agent_type, and fork_turns inputs.", diff --git a/website/data/bundles/balanced-codex-openai.json b/website/data/bundles/balanced-codex-openai.json index 6b96f34..652cd07 100644 --- a/website/data/bundles/balanced-codex-openai.json +++ b/website/data/bundles/balanced-codex-openai.json @@ -6,7 +6,7 @@ "generated_at": "2026-07-16T00:00:00Z", "source": { "package": "model-routing", - "package_version": "0.2.2", + "package_version": "0.3.0", "integration": "standalone" }, "policy": { @@ -447,7 +447,7 @@ }, "planr_handoff": { "schema_version": 1, - "switchloom_package": "switchloom@0.2.2", + "switchloom_package": "switchloom@0.3.0", "semantic_role_contract": "Planr supplies usage policy `balanced`, work_type routes, and semantic roles; Switchloom owns the selected host binding, model, effort, fork/context policy, and generated dispatch artifacts.", "required_consumer_behavior": [ "Consume RoutingIntentV1 as the only source for semantic_role, work_type, selected profile, model, effort, agent_type, and fork_turns inputs.", diff --git a/website/data/bundles/balanced-cursor-fable-grok.json b/website/data/bundles/balanced-cursor-fable-grok.json index 198fefd..121ccc7 100644 --- a/website/data/bundles/balanced-cursor-fable-grok.json +++ b/website/data/bundles/balanced-cursor-fable-grok.json @@ -6,7 +6,7 @@ "generated_at": "2026-07-16T00:00:00Z", "source": { "package": "model-routing", - "package_version": "0.2.2", + "package_version": "0.3.0", "integration": "standalone" }, "policy": { @@ -288,7 +288,7 @@ }, "planr_handoff": { "schema_version": 1, - "switchloom_package": "switchloom@0.2.2", + "switchloom_package": "switchloom@0.3.0", "semantic_role_contract": "Planr supplies usage policy `balanced`, work_type routes, and semantic roles; Switchloom owns the selected host binding, model, effort, fork/context policy, and generated dispatch artifacts.", "required_consumer_behavior": [ "Consume RoutingIntentV1 as the only source for semantic_role, work_type, selected profile, model, effort, agent_type, and fork_turns inputs.", diff --git a/website/data/bundles/balanced-cursor-openai.json b/website/data/bundles/balanced-cursor-openai.json index efd2718..5b0eb21 100644 --- a/website/data/bundles/balanced-cursor-openai.json +++ b/website/data/bundles/balanced-cursor-openai.json @@ -6,7 +6,7 @@ "generated_at": "2026-07-16T00:00:00Z", "source": { "package": "model-routing", - "package_version": "0.2.2", + "package_version": "0.3.0", "integration": "standalone" }, "policy": { @@ -288,7 +288,7 @@ }, "planr_handoff": { "schema_version": 1, - "switchloom_package": "switchloom@0.2.2", + "switchloom_package": "switchloom@0.3.0", "semantic_role_contract": "Planr supplies usage policy `balanced`, work_type routes, and semantic roles; Switchloom owns the selected host binding, model, effort, fork/context policy, and generated dispatch artifacts.", "required_consumer_behavior": [ "Consume RoutingIntentV1 as the only source for semantic_role, work_type, selected profile, model, effort, agent_type, and fork_turns inputs.", diff --git a/website/data/bundles/balanced-mixed-host.json b/website/data/bundles/balanced-mixed-host.json index ce178b1..0082b2d 100644 --- a/website/data/bundles/balanced-mixed-host.json +++ b/website/data/bundles/balanced-mixed-host.json @@ -6,7 +6,7 @@ "generated_at": "2026-07-16T00:00:00Z", "source": { "package": "model-routing", - "package_version": "0.2.2", + "package_version": "0.3.0", "integration": "standalone" }, "policy": { @@ -378,7 +378,7 @@ }, "planr_handoff": { "schema_version": 1, - "switchloom_package": "switchloom@0.2.2", + "switchloom_package": "switchloom@0.3.0", "semantic_role_contract": "Planr supplies usage policy `balanced`, work_type routes, and semantic roles; Switchloom owns the selected host binding, model, effort, fork/context policy, and generated dispatch artifacts.", "required_consumer_behavior": [ "Consume RoutingIntentV1 as the only source for semantic_role, work_type, selected profile, model, effort, agent_type, and fork_turns inputs.", diff --git a/website/data/bundles/balanced-opencode-native.json b/website/data/bundles/balanced-opencode-native.json index d919c7c..024dbcf 100644 --- a/website/data/bundles/balanced-opencode-native.json +++ b/website/data/bundles/balanced-opencode-native.json @@ -6,7 +6,7 @@ "generated_at": "2026-07-16T00:00:00Z", "source": { "package": "model-routing", - "package_version": "0.2.2", + "package_version": "0.3.0", "integration": "standalone" }, "policy": { @@ -307,7 +307,7 @@ }, "planr_handoff": { "schema_version": 1, - "switchloom_package": "switchloom@0.2.2", + "switchloom_package": "switchloom@0.3.0", "semantic_role_contract": "Planr supplies usage policy `balanced`, work_type routes, and semantic roles; Switchloom owns the selected host binding, model, effort, fork/context policy, and generated dispatch artifacts.", "required_consumer_behavior": [ "Consume RoutingIntentV1 as the only source for semantic_role, work_type, selected profile, model, effort, agent_type, and fork_turns inputs.", diff --git a/website/data/bundles/balanced-pi-external.json b/website/data/bundles/balanced-pi-external.json index 8887f3b..72b3a77 100644 --- a/website/data/bundles/balanced-pi-external.json +++ b/website/data/bundles/balanced-pi-external.json @@ -6,7 +6,7 @@ "generated_at": "2026-07-16T00:00:00Z", "source": { "package": "model-routing", - "package_version": "0.2.2", + "package_version": "0.3.0", "integration": "standalone" }, "policy": { @@ -309,7 +309,7 @@ }, "planr_handoff": { "schema_version": 1, - "switchloom_package": "switchloom@0.2.2", + "switchloom_package": "switchloom@0.3.0", "semantic_role_contract": "Planr supplies usage policy `balanced`, work_type routes, and semantic roles; Switchloom owns the selected host binding, model, effort, fork/context policy, and generated dispatch artifacts.", "required_consumer_behavior": [ "Consume RoutingIntentV1 as the only source for semantic_role, work_type, selected profile, model, effort, agent_type, and fork_turns inputs.", diff --git a/website/data/bundles/low-usage-claude-native.json b/website/data/bundles/low-usage-claude-native.json index 1ec8585..243ba4e 100644 --- a/website/data/bundles/low-usage-claude-native.json +++ b/website/data/bundles/low-usage-claude-native.json @@ -6,7 +6,7 @@ "generated_at": "2026-07-16T00:00:00Z", "source": { "package": "model-routing", - "package_version": "0.2.2", + "package_version": "0.3.0", "integration": "standalone" }, "policy": { @@ -295,7 +295,7 @@ }, "planr_handoff": { "schema_version": 1, - "switchloom_package": "switchloom@0.2.2", + "switchloom_package": "switchloom@0.3.0", "semantic_role_contract": "Planr supplies usage policy `low-usage`, work_type routes, and semantic roles; Switchloom owns the selected host binding, model, effort, fork/context policy, and generated dispatch artifacts.", "required_consumer_behavior": [ "Consume RoutingIntentV1 as the only source for semantic_role, work_type, selected profile, model, effort, agent_type, and fork_turns inputs.", diff --git a/website/data/bundles/low-usage-codex-openai.json b/website/data/bundles/low-usage-codex-openai.json index 501e1d5..60e1ef9 100644 --- a/website/data/bundles/low-usage-codex-openai.json +++ b/website/data/bundles/low-usage-codex-openai.json @@ -6,7 +6,7 @@ "generated_at": "2026-07-16T00:00:00Z", "source": { "package": "model-routing", - "package_version": "0.2.2", + "package_version": "0.3.0", "integration": "standalone" }, "policy": { @@ -447,7 +447,7 @@ }, "planr_handoff": { "schema_version": 1, - "switchloom_package": "switchloom@0.2.2", + "switchloom_package": "switchloom@0.3.0", "semantic_role_contract": "Planr supplies usage policy `low-usage`, work_type routes, and semantic roles; Switchloom owns the selected host binding, model, effort, fork/context policy, and generated dispatch artifacts.", "required_consumer_behavior": [ "Consume RoutingIntentV1 as the only source for semantic_role, work_type, selected profile, model, effort, agent_type, and fork_turns inputs.", diff --git a/website/data/bundles/low-usage-cursor-fable-grok.json b/website/data/bundles/low-usage-cursor-fable-grok.json index 372208e..7168cae 100644 --- a/website/data/bundles/low-usage-cursor-fable-grok.json +++ b/website/data/bundles/low-usage-cursor-fable-grok.json @@ -6,7 +6,7 @@ "generated_at": "2026-07-16T00:00:00Z", "source": { "package": "model-routing", - "package_version": "0.2.2", + "package_version": "0.3.0", "integration": "standalone" }, "policy": { @@ -288,7 +288,7 @@ }, "planr_handoff": { "schema_version": 1, - "switchloom_package": "switchloom@0.2.2", + "switchloom_package": "switchloom@0.3.0", "semantic_role_contract": "Planr supplies usage policy `low-usage`, work_type routes, and semantic roles; Switchloom owns the selected host binding, model, effort, fork/context policy, and generated dispatch artifacts.", "required_consumer_behavior": [ "Consume RoutingIntentV1 as the only source for semantic_role, work_type, selected profile, model, effort, agent_type, and fork_turns inputs.", diff --git a/website/data/bundles/low-usage-cursor-openai.json b/website/data/bundles/low-usage-cursor-openai.json index 9a26bb1..fba4991 100644 --- a/website/data/bundles/low-usage-cursor-openai.json +++ b/website/data/bundles/low-usage-cursor-openai.json @@ -6,7 +6,7 @@ "generated_at": "2026-07-16T00:00:00Z", "source": { "package": "model-routing", - "package_version": "0.2.2", + "package_version": "0.3.0", "integration": "standalone" }, "policy": { @@ -288,7 +288,7 @@ }, "planr_handoff": { "schema_version": 1, - "switchloom_package": "switchloom@0.2.2", + "switchloom_package": "switchloom@0.3.0", "semantic_role_contract": "Planr supplies usage policy `low-usage`, work_type routes, and semantic roles; Switchloom owns the selected host binding, model, effort, fork/context policy, and generated dispatch artifacts.", "required_consumer_behavior": [ "Consume RoutingIntentV1 as the only source for semantic_role, work_type, selected profile, model, effort, agent_type, and fork_turns inputs.", diff --git a/website/data/bundles/low-usage-mixed-host.json b/website/data/bundles/low-usage-mixed-host.json index 78e03ce..be219bf 100644 --- a/website/data/bundles/low-usage-mixed-host.json +++ b/website/data/bundles/low-usage-mixed-host.json @@ -6,7 +6,7 @@ "generated_at": "2026-07-16T00:00:00Z", "source": { "package": "model-routing", - "package_version": "0.2.2", + "package_version": "0.3.0", "integration": "standalone" }, "policy": { @@ -378,7 +378,7 @@ }, "planr_handoff": { "schema_version": 1, - "switchloom_package": "switchloom@0.2.2", + "switchloom_package": "switchloom@0.3.0", "semantic_role_contract": "Planr supplies usage policy `low-usage`, work_type routes, and semantic roles; Switchloom owns the selected host binding, model, effort, fork/context policy, and generated dispatch artifacts.", "required_consumer_behavior": [ "Consume RoutingIntentV1 as the only source for semantic_role, work_type, selected profile, model, effort, agent_type, and fork_turns inputs.", diff --git a/website/data/bundles/low-usage-opencode-native.json b/website/data/bundles/low-usage-opencode-native.json index 1a57a2b..82ef17d 100644 --- a/website/data/bundles/low-usage-opencode-native.json +++ b/website/data/bundles/low-usage-opencode-native.json @@ -6,7 +6,7 @@ "generated_at": "2026-07-16T00:00:00Z", "source": { "package": "model-routing", - "package_version": "0.2.2", + "package_version": "0.3.0", "integration": "standalone" }, "policy": { @@ -307,7 +307,7 @@ }, "planr_handoff": { "schema_version": 1, - "switchloom_package": "switchloom@0.2.2", + "switchloom_package": "switchloom@0.3.0", "semantic_role_contract": "Planr supplies usage policy `low-usage`, work_type routes, and semantic roles; Switchloom owns the selected host binding, model, effort, fork/context policy, and generated dispatch artifacts.", "required_consumer_behavior": [ "Consume RoutingIntentV1 as the only source for semantic_role, work_type, selected profile, model, effort, agent_type, and fork_turns inputs.", diff --git a/website/data/bundles/low-usage-pi-external.json b/website/data/bundles/low-usage-pi-external.json index 14a7ad1..a99aeef 100644 --- a/website/data/bundles/low-usage-pi-external.json +++ b/website/data/bundles/low-usage-pi-external.json @@ -6,7 +6,7 @@ "generated_at": "2026-07-16T00:00:00Z", "source": { "package": "model-routing", - "package_version": "0.2.2", + "package_version": "0.3.0", "integration": "standalone" }, "policy": { @@ -309,7 +309,7 @@ }, "planr_handoff": { "schema_version": 1, - "switchloom_package": "switchloom@0.2.2", + "switchloom_package": "switchloom@0.3.0", "semantic_role_contract": "Planr supplies usage policy `low-usage`, work_type routes, and semantic roles; Switchloom owns the selected host binding, model, effort, fork/context policy, and generated dispatch artifacts.", "required_consumer_behavior": [ "Consume RoutingIntentV1 as the only source for semantic_role, work_type, selected profile, model, effort, agent_type, and fork_turns inputs.", diff --git a/website/data/bundles/max-quality-claude-native.json b/website/data/bundles/max-quality-claude-native.json index 17d7d7c..e517e9b 100644 --- a/website/data/bundles/max-quality-claude-native.json +++ b/website/data/bundles/max-quality-claude-native.json @@ -6,7 +6,7 @@ "generated_at": "2026-07-16T00:00:00Z", "source": { "package": "model-routing", - "package_version": "0.2.2", + "package_version": "0.3.0", "integration": "standalone" }, "policy": { @@ -295,7 +295,7 @@ }, "planr_handoff": { "schema_version": 1, - "switchloom_package": "switchloom@0.2.2", + "switchloom_package": "switchloom@0.3.0", "semantic_role_contract": "Planr supplies usage policy `max-quality`, work_type routes, and semantic roles; Switchloom owns the selected host binding, model, effort, fork/context policy, and generated dispatch artifacts.", "required_consumer_behavior": [ "Consume RoutingIntentV1 as the only source for semantic_role, work_type, selected profile, model, effort, agent_type, and fork_turns inputs.", diff --git a/website/data/bundles/max-quality-codex-openai.json b/website/data/bundles/max-quality-codex-openai.json index ec6b51e..c9f1b99 100644 --- a/website/data/bundles/max-quality-codex-openai.json +++ b/website/data/bundles/max-quality-codex-openai.json @@ -6,7 +6,7 @@ "generated_at": "2026-07-16T00:00:00Z", "source": { "package": "model-routing", - "package_version": "0.2.2", + "package_version": "0.3.0", "integration": "standalone" }, "policy": { @@ -447,7 +447,7 @@ }, "planr_handoff": { "schema_version": 1, - "switchloom_package": "switchloom@0.2.2", + "switchloom_package": "switchloom@0.3.0", "semantic_role_contract": "Planr supplies usage policy `max-quality`, work_type routes, and semantic roles; Switchloom owns the selected host binding, model, effort, fork/context policy, and generated dispatch artifacts.", "required_consumer_behavior": [ "Consume RoutingIntentV1 as the only source for semantic_role, work_type, selected profile, model, effort, agent_type, and fork_turns inputs.", diff --git a/website/data/bundles/max-quality-cursor-fable-grok.json b/website/data/bundles/max-quality-cursor-fable-grok.json index aedad91..1a43d88 100644 --- a/website/data/bundles/max-quality-cursor-fable-grok.json +++ b/website/data/bundles/max-quality-cursor-fable-grok.json @@ -6,7 +6,7 @@ "generated_at": "2026-07-16T00:00:00Z", "source": { "package": "model-routing", - "package_version": "0.2.2", + "package_version": "0.3.0", "integration": "standalone" }, "policy": { @@ -288,7 +288,7 @@ }, "planr_handoff": { "schema_version": 1, - "switchloom_package": "switchloom@0.2.2", + "switchloom_package": "switchloom@0.3.0", "semantic_role_contract": "Planr supplies usage policy `max-quality`, work_type routes, and semantic roles; Switchloom owns the selected host binding, model, effort, fork/context policy, and generated dispatch artifacts.", "required_consumer_behavior": [ "Consume RoutingIntentV1 as the only source for semantic_role, work_type, selected profile, model, effort, agent_type, and fork_turns inputs.", diff --git a/website/data/bundles/max-quality-cursor-openai.json b/website/data/bundles/max-quality-cursor-openai.json index 4257f23..3291d26 100644 --- a/website/data/bundles/max-quality-cursor-openai.json +++ b/website/data/bundles/max-quality-cursor-openai.json @@ -6,7 +6,7 @@ "generated_at": "2026-07-16T00:00:00Z", "source": { "package": "model-routing", - "package_version": "0.2.2", + "package_version": "0.3.0", "integration": "standalone" }, "policy": { @@ -288,7 +288,7 @@ }, "planr_handoff": { "schema_version": 1, - "switchloom_package": "switchloom@0.2.2", + "switchloom_package": "switchloom@0.3.0", "semantic_role_contract": "Planr supplies usage policy `max-quality`, work_type routes, and semantic roles; Switchloom owns the selected host binding, model, effort, fork/context policy, and generated dispatch artifacts.", "required_consumer_behavior": [ "Consume RoutingIntentV1 as the only source for semantic_role, work_type, selected profile, model, effort, agent_type, and fork_turns inputs.", diff --git a/website/data/bundles/max-quality-mixed-host.json b/website/data/bundles/max-quality-mixed-host.json index 7a17611..e2ec467 100644 --- a/website/data/bundles/max-quality-mixed-host.json +++ b/website/data/bundles/max-quality-mixed-host.json @@ -6,7 +6,7 @@ "generated_at": "2026-07-16T00:00:00Z", "source": { "package": "model-routing", - "package_version": "0.2.2", + "package_version": "0.3.0", "integration": "standalone" }, "policy": { @@ -378,7 +378,7 @@ }, "planr_handoff": { "schema_version": 1, - "switchloom_package": "switchloom@0.2.2", + "switchloom_package": "switchloom@0.3.0", "semantic_role_contract": "Planr supplies usage policy `max-quality`, work_type routes, and semantic roles; Switchloom owns the selected host binding, model, effort, fork/context policy, and generated dispatch artifacts.", "required_consumer_behavior": [ "Consume RoutingIntentV1 as the only source for semantic_role, work_type, selected profile, model, effort, agent_type, and fork_turns inputs.", diff --git a/website/data/bundles/max-quality-opencode-native.json b/website/data/bundles/max-quality-opencode-native.json index 7272fe2..1f3841a 100644 --- a/website/data/bundles/max-quality-opencode-native.json +++ b/website/data/bundles/max-quality-opencode-native.json @@ -6,7 +6,7 @@ "generated_at": "2026-07-16T00:00:00Z", "source": { "package": "model-routing", - "package_version": "0.2.2", + "package_version": "0.3.0", "integration": "standalone" }, "policy": { @@ -307,7 +307,7 @@ }, "planr_handoff": { "schema_version": 1, - "switchloom_package": "switchloom@0.2.2", + "switchloom_package": "switchloom@0.3.0", "semantic_role_contract": "Planr supplies usage policy `max-quality`, work_type routes, and semantic roles; Switchloom owns the selected host binding, model, effort, fork/context policy, and generated dispatch artifacts.", "required_consumer_behavior": [ "Consume RoutingIntentV1 as the only source for semantic_role, work_type, selected profile, model, effort, agent_type, and fork_turns inputs.", diff --git a/website/data/bundles/max-quality-pi-external.json b/website/data/bundles/max-quality-pi-external.json index fe9b756..fa91d36 100644 --- a/website/data/bundles/max-quality-pi-external.json +++ b/website/data/bundles/max-quality-pi-external.json @@ -6,7 +6,7 @@ "generated_at": "2026-07-16T00:00:00Z", "source": { "package": "model-routing", - "package_version": "0.2.2", + "package_version": "0.3.0", "integration": "standalone" }, "policy": { @@ -309,7 +309,7 @@ }, "planr_handoff": { "schema_version": 1, - "switchloom_package": "switchloom@0.2.2", + "switchloom_package": "switchloom@0.3.0", "semantic_role_contract": "Planr supplies usage policy `max-quality`, work_type routes, and semantic roles; Switchloom owns the selected host binding, model, effort, fork/context policy, and generated dispatch artifacts.", "required_consumer_behavior": [ "Consume RoutingIntentV1 as the only source for semantic_role, work_type, selected profile, model, effort, agent_type, and fork_turns inputs.", diff --git a/website/data/bundles/read-only-audit-claude-native.json b/website/data/bundles/read-only-audit-claude-native.json index fbb10aa..956f5c8 100644 --- a/website/data/bundles/read-only-audit-claude-native.json +++ b/website/data/bundles/read-only-audit-claude-native.json @@ -6,7 +6,7 @@ "generated_at": "2026-07-16T00:00:00Z", "source": { "package": "model-routing", - "package_version": "0.2.2", + "package_version": "0.3.0", "integration": "standalone" }, "policy": { @@ -290,7 +290,7 @@ }, "planr_handoff": { "schema_version": 1, - "switchloom_package": "switchloom@0.2.2", + "switchloom_package": "switchloom@0.3.0", "semantic_role_contract": "Planr supplies usage policy `read-only-audit`, work_type routes, and semantic roles; Switchloom owns the selected host binding, model, effort, fork/context policy, and generated dispatch artifacts.", "required_consumer_behavior": [ "Consume RoutingIntentV1 as the only source for semantic_role, work_type, selected profile, model, effort, agent_type, and fork_turns inputs.", diff --git a/website/data/bundles/read-only-audit-codex-openai.json b/website/data/bundles/read-only-audit-codex-openai.json index 67b20c4..b322edb 100644 --- a/website/data/bundles/read-only-audit-codex-openai.json +++ b/website/data/bundles/read-only-audit-codex-openai.json @@ -6,7 +6,7 @@ "generated_at": "2026-07-16T00:00:00Z", "source": { "package": "model-routing", - "package_version": "0.2.2", + "package_version": "0.3.0", "integration": "standalone" }, "policy": { @@ -442,7 +442,7 @@ }, "planr_handoff": { "schema_version": 1, - "switchloom_package": "switchloom@0.2.2", + "switchloom_package": "switchloom@0.3.0", "semantic_role_contract": "Planr supplies usage policy `read-only-audit`, work_type routes, and semantic roles; Switchloom owns the selected host binding, model, effort, fork/context policy, and generated dispatch artifacts.", "required_consumer_behavior": [ "Consume RoutingIntentV1 as the only source for semantic_role, work_type, selected profile, model, effort, agent_type, and fork_turns inputs.", diff --git a/website/data/bundles/read-only-audit-cursor-fable-grok.json b/website/data/bundles/read-only-audit-cursor-fable-grok.json index ff22099..8dfcc20 100644 --- a/website/data/bundles/read-only-audit-cursor-fable-grok.json +++ b/website/data/bundles/read-only-audit-cursor-fable-grok.json @@ -6,7 +6,7 @@ "generated_at": "2026-07-16T00:00:00Z", "source": { "package": "model-routing", - "package_version": "0.2.2", + "package_version": "0.3.0", "integration": "standalone" }, "policy": { @@ -283,7 +283,7 @@ }, "planr_handoff": { "schema_version": 1, - "switchloom_package": "switchloom@0.2.2", + "switchloom_package": "switchloom@0.3.0", "semantic_role_contract": "Planr supplies usage policy `read-only-audit`, work_type routes, and semantic roles; Switchloom owns the selected host binding, model, effort, fork/context policy, and generated dispatch artifacts.", "required_consumer_behavior": [ "Consume RoutingIntentV1 as the only source for semantic_role, work_type, selected profile, model, effort, agent_type, and fork_turns inputs.", diff --git a/website/data/bundles/read-only-audit-cursor-openai.json b/website/data/bundles/read-only-audit-cursor-openai.json index 2f40284..c5051ca 100644 --- a/website/data/bundles/read-only-audit-cursor-openai.json +++ b/website/data/bundles/read-only-audit-cursor-openai.json @@ -6,7 +6,7 @@ "generated_at": "2026-07-16T00:00:00Z", "source": { "package": "model-routing", - "package_version": "0.2.2", + "package_version": "0.3.0", "integration": "standalone" }, "policy": { @@ -283,7 +283,7 @@ }, "planr_handoff": { "schema_version": 1, - "switchloom_package": "switchloom@0.2.2", + "switchloom_package": "switchloom@0.3.0", "semantic_role_contract": "Planr supplies usage policy `read-only-audit`, work_type routes, and semantic roles; Switchloom owns the selected host binding, model, effort, fork/context policy, and generated dispatch artifacts.", "required_consumer_behavior": [ "Consume RoutingIntentV1 as the only source for semantic_role, work_type, selected profile, model, effort, agent_type, and fork_turns inputs.", diff --git a/website/data/bundles/read-only-audit-mixed-host.json b/website/data/bundles/read-only-audit-mixed-host.json index a9b98e3..49ce4fb 100644 --- a/website/data/bundles/read-only-audit-mixed-host.json +++ b/website/data/bundles/read-only-audit-mixed-host.json @@ -6,7 +6,7 @@ "generated_at": "2026-07-16T00:00:00Z", "source": { "package": "model-routing", - "package_version": "0.2.2", + "package_version": "0.3.0", "integration": "standalone" }, "policy": { @@ -373,7 +373,7 @@ }, "planr_handoff": { "schema_version": 1, - "switchloom_package": "switchloom@0.2.2", + "switchloom_package": "switchloom@0.3.0", "semantic_role_contract": "Planr supplies usage policy `read-only-audit`, work_type routes, and semantic roles; Switchloom owns the selected host binding, model, effort, fork/context policy, and generated dispatch artifacts.", "required_consumer_behavior": [ "Consume RoutingIntentV1 as the only source for semantic_role, work_type, selected profile, model, effort, agent_type, and fork_turns inputs.", diff --git a/website/data/bundles/read-only-audit-opencode-native.json b/website/data/bundles/read-only-audit-opencode-native.json index 95d4326..0452a50 100644 --- a/website/data/bundles/read-only-audit-opencode-native.json +++ b/website/data/bundles/read-only-audit-opencode-native.json @@ -6,7 +6,7 @@ "generated_at": "2026-07-16T00:00:00Z", "source": { "package": "model-routing", - "package_version": "0.2.2", + "package_version": "0.3.0", "integration": "standalone" }, "policy": { @@ -302,7 +302,7 @@ }, "planr_handoff": { "schema_version": 1, - "switchloom_package": "switchloom@0.2.2", + "switchloom_package": "switchloom@0.3.0", "semantic_role_contract": "Planr supplies usage policy `read-only-audit`, work_type routes, and semantic roles; Switchloom owns the selected host binding, model, effort, fork/context policy, and generated dispatch artifacts.", "required_consumer_behavior": [ "Consume RoutingIntentV1 as the only source for semantic_role, work_type, selected profile, model, effort, agent_type, and fork_turns inputs.", diff --git a/website/data/bundles/read-only-audit-pi-external.json b/website/data/bundles/read-only-audit-pi-external.json index 02e5786..a92d544 100644 --- a/website/data/bundles/read-only-audit-pi-external.json +++ b/website/data/bundles/read-only-audit-pi-external.json @@ -6,7 +6,7 @@ "generated_at": "2026-07-16T00:00:00Z", "source": { "package": "model-routing", - "package_version": "0.2.2", + "package_version": "0.3.0", "integration": "standalone" }, "policy": { @@ -304,7 +304,7 @@ }, "planr_handoff": { "schema_version": 1, - "switchloom_package": "switchloom@0.2.2", + "switchloom_package": "switchloom@0.3.0", "semantic_role_contract": "Planr supplies usage policy `read-only-audit`, work_type routes, and semantic roles; Switchloom owns the selected host binding, model, effort, fork/context policy, and generated dispatch artifacts.", "required_consumer_behavior": [ "Consume RoutingIntentV1 as the only source for semantic_role, work_type, selected profile, model, effort, agent_type, and fork_turns inputs.", diff --git a/website/data/catalog.json b/website/data/catalog.json index 8f25d75..989186a 100644 --- a/website/data/catalog.json +++ b/website/data/catalog.json @@ -248,11 +248,11 @@ } ], "id": "model-routing-official", - "manifestSha256": "b31c37e9036b25f4ea413672eb227704830e4b4199de4041d0b36dc91d0c7980", + "manifestSha256": "3289f2d7231639a7084c4455c13448e89a1f596a2877672db5b1962d06f5f905", "signatureVerified": false, "signer": null, "trustedMaintainer": false, - "version": "0.2.2" + "version": "0.3.0" }, "replacement": null, "status": "experimental", @@ -412,11 +412,11 @@ } ], "id": "model-routing-official", - "manifestSha256": "6d5c41dff23a2f42175e1d80989bd0db8a7e39177b78c7172c96eb670976a981", + "manifestSha256": "0796e44507fabdefd9901ba47f7de06fa24396f5007c087541d0b83d9d47c296", "signatureVerified": false, "signer": null, "trustedMaintainer": false, - "version": "0.2.2" + "version": "0.3.0" }, "replacement": null, "status": "experimental", @@ -576,11 +576,11 @@ } ], "id": "model-routing-official", - "manifestSha256": "9d234c2429e6b0e456ffa0072075c288e46fb0f82db4e2803b8f6da9b6cf12aa", + "manifestSha256": "51daa89d66e3810faaf83dccee3f046e831c76ae896eaa54be6654e7a13bb8f1", "signatureVerified": false, "signer": null, "trustedMaintainer": false, - "version": "0.2.2" + "version": "0.3.0" }, "replacement": null, "status": "experimental", @@ -742,11 +742,11 @@ } ], "id": "model-routing-official", - "manifestSha256": "94383f684b05fad4d7623671be53488c328315dd988ed1c3d16964b048d3b4a9", + "manifestSha256": "b5e13c49520edfcab143d61e6dc923507d90f1c432e3816fcac0d5fe58cd329b", "signatureVerified": false, "signer": null, "trustedMaintainer": false, - "version": "0.2.2" + "version": "0.3.0" }, "replacement": null, "status": "experimental", @@ -912,11 +912,11 @@ } ], "id": "model-routing-official", - "manifestSha256": "a4774373c38542109924e459b1e655d62be8097ea3c9c1e98ab15e9d69bbc713", + "manifestSha256": "325868ffbf05720f2afd9057aca3d7af2cb4712e7ba48a80dd178268922dfe5d", "signatureVerified": false, "signer": null, "trustedMaintainer": false, - "version": "0.2.2" + "version": "0.3.0" }, "replacement": null, "status": "experimental", @@ -1080,11 +1080,11 @@ } ], "id": "model-routing-official", - "manifestSha256": "61264131f1497b84b69755b86dfbb97b1e2b1e85287548aa00c1573469f579c9", + "manifestSha256": "af47cdae77149e2a5320c839954dd6f25bef458a96073bed28a10871b4894f9e", "signatureVerified": false, "signer": null, "trustedMaintainer": false, - "version": "0.2.2" + "version": "0.3.0" }, "replacement": null, "status": "experimental", @@ -1294,11 +1294,11 @@ } ], "id": "model-routing-official", - "manifestSha256": "d014e16b6b5500c0742d5441e35e073ef3f22b620de89cda1cf5a225862c47ab", + "manifestSha256": "15723f5557aaa7e053dd407d0cb35ba6c7a5b66f0d169b920607abba6c6484b9", "signatureVerified": false, "signer": null, "trustedMaintainer": false, - "version": "0.2.2" + "version": "0.3.0" }, "replacement": null, "status": "experimental", @@ -1552,11 +1552,11 @@ } ], "id": "model-routing-official", - "manifestSha256": "c9ff0402c08b192341f911c4174718b7e0b12425e4365ebed6754b17131d12e0", + "manifestSha256": "a63f314c5e10464499ec9e7189f89dfa13437df4f203799616353ec611f65e01", "signatureVerified": false, "signer": null, "trustedMaintainer": false, - "version": "0.2.2" + "version": "0.3.0" }, "replacement": null, "status": "experimental", @@ -1716,11 +1716,11 @@ } ], "id": "model-routing-official", - "manifestSha256": "bb4beefb57f3d984a4a2af324333ca00f15a11842f36fe4b87528e8cd1648d74", + "manifestSha256": "392437a358761e9ecfca34a0047971296d866de48bec681a52342cf05a2dd9f8", "signatureVerified": false, "signer": null, "trustedMaintainer": false, - "version": "0.2.2" + "version": "0.3.0" }, "replacement": null, "status": "experimental", @@ -1880,11 +1880,11 @@ } ], "id": "model-routing-official", - "manifestSha256": "57c642145b6189fbcf9239f6b4238296af63238eb3253d0b754f482c1ebbcf8a", + "manifestSha256": "2ec35fcaa41ef746611d0cbc82289f6dee4bb83e5af8bda3680ab4e5af6d0d57", "signatureVerified": false, "signer": null, "trustedMaintainer": false, - "version": "0.2.2" + "version": "0.3.0" }, "replacement": null, "status": "experimental", @@ -2046,11 +2046,11 @@ } ], "id": "model-routing-official", - "manifestSha256": "d17d507a00e55cd0c81ab7739ac9d4f31984b1f3919cb985ce58d58e1f17cf0e", + "manifestSha256": "791f305571fa4ce2a373588ba2b284073f0f78a0adc5a5666be8278b73a2e1e7", "signatureVerified": false, "signer": null, "trustedMaintainer": false, - "version": "0.2.2" + "version": "0.3.0" }, "replacement": null, "status": "experimental", @@ -2216,11 +2216,11 @@ } ], "id": "model-routing-official", - "manifestSha256": "da143f532a10bc22e9b0daaadfa99dcbb91eea77eaa8176a38bf9fb1b735110f", + "manifestSha256": "6e44b7ffedfc66753c791a75c48362f552758038ff922d703ef1d86b2fb0eaaa", "signatureVerified": false, "signer": null, "trustedMaintainer": false, - "version": "0.2.2" + "version": "0.3.0" }, "replacement": null, "status": "experimental", @@ -2384,11 +2384,11 @@ } ], "id": "model-routing-official", - "manifestSha256": "65daf8e85ab5bc71ec2948b6291a7133fa6fb50dc77b3b87c38e048f95d3318b", + "manifestSha256": "becfb3251cb48c010c0857f2c5d84fb0005d509b10b73e736763cfb916be7d04", "signatureVerified": false, "signer": null, "trustedMaintainer": false, - "version": "0.2.2" + "version": "0.3.0" }, "replacement": null, "status": "experimental", @@ -2598,11 +2598,11 @@ } ], "id": "model-routing-official", - "manifestSha256": "b2deac0bec59bc1e42074ede972704e729695c7c262ed44a90b1fd5c0ae93f66", + "manifestSha256": "58003f86483ddfa424968240c8ca6ec2ef444e994d81d88f05c5fc00cea16009", "signatureVerified": false, "signer": null, "trustedMaintainer": false, - "version": "0.2.2" + "version": "0.3.0" }, "replacement": null, "status": "experimental", @@ -2856,11 +2856,11 @@ } ], "id": "model-routing-official", - "manifestSha256": "bc91dbc0f5a74fd12e29cf3b0ce69daee63af71bf38fe8d3d79473433e5a56ef", + "manifestSha256": "b89a0d7aca4ef2f34ecbaf4a663c85a4ef18f0f7bd2e3d09011b2a496b1d75a3", "signatureVerified": false, "signer": null, "trustedMaintainer": false, - "version": "0.2.2" + "version": "0.3.0" }, "replacement": null, "status": "experimental", @@ -3020,11 +3020,11 @@ } ], "id": "model-routing-official", - "manifestSha256": "186d935c025e33785d1419333614eda9f887dab2969069b71be2bbc57620c003", + "manifestSha256": "01d071da43e4b6f36d280f35a439cf0c63d2f36ab59383f8952eaf57d894f143", "signatureVerified": false, "signer": null, "trustedMaintainer": false, - "version": "0.2.2" + "version": "0.3.0" }, "replacement": null, "status": "experimental", @@ -3184,11 +3184,11 @@ } ], "id": "model-routing-official", - "manifestSha256": "f2c799497ffb4cbc1390c98078bcf6688bee301fabe65c9a0fb8998b7b5bc8dc", + "manifestSha256": "094b2a3a4746c8dbbf5d969cd658862c780fc12e58923ef1bdf666a57d9690f2", "signatureVerified": false, "signer": null, "trustedMaintainer": false, - "version": "0.2.2" + "version": "0.3.0" }, "replacement": null, "status": "experimental", @@ -3350,11 +3350,11 @@ } ], "id": "model-routing-official", - "manifestSha256": "3c40733f22cb2a8c701df55d6c002c95c0c2a56a881bd08fa5e3efeb216052bd", + "manifestSha256": "d2ee1036da30ce7958914ff738bd8ec142cc2bc0190a4903fc31dfa2774f6688", "signatureVerified": false, "signer": null, "trustedMaintainer": false, - "version": "0.2.2" + "version": "0.3.0" }, "replacement": null, "status": "experimental", @@ -3520,11 +3520,11 @@ } ], "id": "model-routing-official", - "manifestSha256": "2085b1cf480be6aeda30daf8ab354602251badf4f9a2119dacc36477c67bd6b0", + "manifestSha256": "278fa992a4ed6aecd952836904309aae026943a8d68b59f59b62fd60ef3ee711", "signatureVerified": false, "signer": null, "trustedMaintainer": false, - "version": "0.2.2" + "version": "0.3.0" }, "replacement": null, "status": "experimental", @@ -3688,11 +3688,11 @@ } ], "id": "model-routing-official", - "manifestSha256": "6dfee89c153875a66efa41696fc3ff9b00b796019f878a80c3fbef5fc4ac127e", + "manifestSha256": "711b3881b13535e3d4bf4b08d12026f82357cda881ef77fadc2547841b0ad66f", "signatureVerified": false, "signer": null, "trustedMaintainer": false, - "version": "0.2.2" + "version": "0.3.0" }, "replacement": null, "status": "experimental", @@ -3902,11 +3902,11 @@ } ], "id": "model-routing-official", - "manifestSha256": "0746f5ffe4ce160619274b665f54ccdbdc0ada62345e62b3d725784442c4b54e", + "manifestSha256": "c94d0730f8a2d12271217c9d1cbc1a6bb12753ba57370a1268e057b0076480f3", "signatureVerified": false, "signer": null, "trustedMaintainer": false, - "version": "0.2.2" + "version": "0.3.0" }, "replacement": null, "status": "experimental", @@ -4155,11 +4155,11 @@ } ], "id": "model-routing-official", - "manifestSha256": "94bf039d0fab3192bc5450fed887302984cbbd9a39a47ea0d935b0594702a78c", + "manifestSha256": "696c9d1ca75a8861f9339fc9e7a82340d424900bc8ffc42fc72e772f46cf6c4a", "signatureVerified": false, "signer": null, "trustedMaintainer": false, - "version": "0.2.2" + "version": "0.3.0" }, "replacement": null, "status": "experimental", @@ -4314,11 +4314,11 @@ } ], "id": "model-routing-official", - "manifestSha256": "87df329939bc6e8d697817f47f0c76ba96bb2a7e050ebc752c5c1083257b816e", + "manifestSha256": "580f77bf20241b24790a371710f2f13c435dafdf088a22fdbf520e2c7e69894d", "signatureVerified": false, "signer": null, "trustedMaintainer": false, - "version": "0.2.2" + "version": "0.3.0" }, "replacement": null, "status": "experimental", @@ -4473,11 +4473,11 @@ } ], "id": "model-routing-official", - "manifestSha256": "8fc4c2911efc54581031328db9e77a1843fcd672a4abc12b9ed54c73623ecbf5", + "manifestSha256": "f6a70384920cf457c07354fb1997c80922b1f857ed842a32ad8062828fdb4edf", "signatureVerified": false, "signer": null, "trustedMaintainer": false, - "version": "0.2.2" + "version": "0.3.0" }, "replacement": null, "status": "experimental", @@ -4634,11 +4634,11 @@ } ], "id": "model-routing-official", - "manifestSha256": "6ad98f1989aad6b3e414026b19d8828ee9d8b648f85b6f78d0ce67486e1df6d6", + "manifestSha256": "85295f7867ea9e147e35148f3f703b6acd61d10f3ad84aabe28cc04fed2656d1", "signatureVerified": false, "signer": null, "trustedMaintainer": false, - "version": "0.2.2" + "version": "0.3.0" }, "replacement": null, "status": "experimental", @@ -4799,11 +4799,11 @@ } ], "id": "model-routing-official", - "manifestSha256": "785722260f73376c9e2a6306b9bdf5ba1ef49b2099eb8d82050879f82e95f645", + "manifestSha256": "ee320b7581c3209e1ed50a8790f57c85352805ab494004653b303ac44ab8d478", "signatureVerified": false, "signer": null, "trustedMaintainer": false, - "version": "0.2.2" + "version": "0.3.0" }, "replacement": null, "status": "experimental", @@ -4962,11 +4962,11 @@ } ], "id": "model-routing-official", - "manifestSha256": "8fc1a3bc9c517cabdee2f0ce82674e8ccd0f1b4a18a1cfd0c9cce2fb2bec0ef4", + "manifestSha256": "b0fd2af2f24fb1342aea164391538d2eb6c24e1188dce2f6c4f40ad26071435b", "signatureVerified": false, "signer": null, "trustedMaintainer": false, - "version": "0.2.2" + "version": "0.3.0" }, "replacement": null, "status": "experimental", @@ -5171,11 +5171,11 @@ } ], "id": "model-routing-official", - "manifestSha256": "c4c6a7305cd9c577780bed51ce2968b0dc1276d7410cbabd33f4173e98b53127", + "manifestSha256": "411ee4caa515ed4368551f69268bc5f2065a306b9df2108325818df834da3d72", "signatureVerified": false, "signer": null, "trustedMaintainer": false, - "version": "0.2.2" + "version": "0.3.0" }, "replacement": null, "status": "experimental", diff --git a/src/components/Generator.tsx b/website/src/components/Generator.tsx similarity index 99% rename from src/components/Generator.tsx rename to website/src/components/Generator.tsx index c0af1cd..553c35b 100644 --- a/src/components/Generator.tsx +++ b/website/src/components/Generator.tsx @@ -326,7 +326,7 @@ export default function Generator({ hostCatalog, setupTransport }: { hostCatalog Download .switchloom/config.toml

- Preview before apply, run doctor before certification, and keep advisory receipts distinct from deterministic effective-routing proof. + Preview before apply, run doctor to check the host, and review every repository-local change before confirming setup.

diff --git a/src/components/ui/alert.tsx b/website/src/components/ui/alert.tsx similarity index 100% rename from src/components/ui/alert.tsx rename to website/src/components/ui/alert.tsx diff --git a/src/components/ui/badge.tsx b/website/src/components/ui/badge.tsx similarity index 100% rename from src/components/ui/badge.tsx rename to website/src/components/ui/badge.tsx diff --git a/src/components/ui/button.tsx b/website/src/components/ui/button.tsx similarity index 100% rename from src/components/ui/button.tsx rename to website/src/components/ui/button.tsx diff --git a/src/components/ui/card.tsx b/website/src/components/ui/card.tsx similarity index 100% rename from src/components/ui/card.tsx rename to website/src/components/ui/card.tsx diff --git a/src/components/ui/combobox.tsx b/website/src/components/ui/combobox.tsx similarity index 100% rename from src/components/ui/combobox.tsx rename to website/src/components/ui/combobox.tsx diff --git a/src/components/ui/field.tsx b/website/src/components/ui/field.tsx similarity index 100% rename from src/components/ui/field.tsx rename to website/src/components/ui/field.tsx diff --git a/src/components/ui/input-group.tsx b/website/src/components/ui/input-group.tsx similarity index 100% rename from src/components/ui/input-group.tsx rename to website/src/components/ui/input-group.tsx diff --git a/src/components/ui/input.tsx b/website/src/components/ui/input.tsx similarity index 100% rename from src/components/ui/input.tsx rename to website/src/components/ui/input.tsx diff --git a/src/components/ui/label.tsx b/website/src/components/ui/label.tsx similarity index 100% rename from src/components/ui/label.tsx rename to website/src/components/ui/label.tsx diff --git a/src/components/ui/separator.tsx b/website/src/components/ui/separator.tsx similarity index 100% rename from src/components/ui/separator.tsx rename to website/src/components/ui/separator.tsx diff --git a/src/components/ui/tabs.tsx b/website/src/components/ui/tabs.tsx similarity index 100% rename from src/components/ui/tabs.tsx rename to website/src/components/ui/tabs.tsx diff --git a/src/components/ui/textarea.tsx b/website/src/components/ui/textarea.tsx similarity index 100% rename from src/components/ui/textarea.tsx rename to website/src/components/ui/textarea.tsx diff --git a/src/components/ui/toggle-group.tsx b/website/src/components/ui/toggle-group.tsx similarity index 100% rename from src/components/ui/toggle-group.tsx rename to website/src/components/ui/toggle-group.tsx diff --git a/src/components/ui/toggle.tsx b/website/src/components/ui/toggle.tsx similarity index 100% rename from src/components/ui/toggle.tsx rename to website/src/components/ui/toggle.tsx diff --git a/src/env.d.ts b/website/src/env.d.ts similarity index 100% rename from src/env.d.ts rename to website/src/env.d.ts diff --git a/src/lib/build-site.test.ts b/website/src/lib/build-site.test.ts similarity index 97% rename from src/lib/build-site.test.ts rename to website/src/lib/build-site.test.ts index 2be0d86..7caa442 100644 --- a/src/lib/build-site.test.ts +++ b/website/src/lib/build-site.test.ts @@ -3,7 +3,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; -import { buildSite } from "../../scripts/build-site.mjs"; +import { buildSite } from "../../../scripts/build-site.mjs"; const roots: string[] = []; afterEach(async () => Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })))); diff --git a/src/lib/generator.test.ts b/website/src/lib/generator.test.ts similarity index 97% rename from src/lib/generator.test.ts rename to website/src/lib/generator.test.ts index 7853cd7..05acbf9 100644 --- a/src/lib/generator.test.ts +++ b/website/src/lib/generator.test.ts @@ -231,7 +231,7 @@ describe("Switchloom generator", () => { const recipe = setupRecipe(config, hostCatalog, transport.recipePrefix); expect(recipe).toMatch(/^sw1_[A-Za-z0-9_-]+$/); const command = recipeApplyCommand(config, hostCatalog, transport.recipePrefix); - expect(command).toMatch(/^npx switchloom@latest apply --recipe 'sw1_[A-Za-z0-9_-]+' --repository \.$/); + expect(command).toMatch(/^npx switchloom@0\.3\.0 apply --recipe 'sw1_[A-Za-z0-9_-]+' --repository \.$/); const toml = setupConfigToml(config, hostCatalog); expect(toml).toContain('host = "codex-openai"'); expect(toml).toContain('integration = "standalone"'); @@ -243,11 +243,10 @@ describe("Switchloom generator", () => { it("shows the full CLI lifecycle without claiming custom setup verification", () => { const commands = lifecycleCommands(createConfig("cursor"), hostCatalog); expect(commands).toEqual([ - "npm install -g switchloom", + "npm install -g switchloom@0.3.0", expect.stringMatching(/^switchloom preview --recipe 'sw1_/), expect.stringMatching(/^switchloom apply --recipe 'sw1_/), "switchloom doctor cursor", - "switchloom certify reports/native-host-certification/cursor-openai//workdir/dispatch-evidence.json --bundle reports/native-host-certification/cursor-openai//workdir/bundle.json", "switchloom update --repository .", "switchloom status --repository .", "switchloom rollback --repository .", diff --git a/src/lib/generator.ts b/website/src/lib/generator.ts similarity index 98% rename from src/lib/generator.ts rename to website/src/lib/generator.ts index e8bd1ec..abe09ea 100644 --- a/src/lib/generator.ts +++ b/website/src/lib/generator.ts @@ -467,19 +467,17 @@ export function shellQuote(value: string) { } export function recipeApplyCommand(config: GeneratorConfig, catalog: HostCatalog, recipePrefix = "sw1_") { - return `npx switchloom@latest apply --recipe ${shellQuote(setupRecipe(config, catalog, recipePrefix))} --repository .`; + return `npx switchloom@0.3.0 apply --recipe ${shellQuote(setupRecipe(config, catalog, recipePrefix))} --repository .`; } export function lifecycleCommands(config: GeneratorConfig, catalog: HostCatalog, recipePrefix = "sw1_") { const recipe = shellQuote(setupRecipe(config, catalog, recipePrefix)); const host = config.host; - const report = `reports/native-host-certification/${catalog[config.host].binding}//workdir`; return [ - "npm install -g switchloom", + "npm install -g switchloom@0.3.0", `switchloom preview --recipe ${recipe} --repository .`, `switchloom apply --recipe ${recipe} --repository .`, `switchloom doctor ${host}`, - `switchloom certify ${report}/dispatch-evidence.json --bundle ${report}/bundle.json`, "switchloom update --repository .", "switchloom status --repository .", "switchloom rollback --repository .", diff --git a/src/lib/security-headers.test.ts b/website/src/lib/security-headers.test.ts similarity index 100% rename from src/lib/security-headers.test.ts rename to website/src/lib/security-headers.test.ts diff --git a/src/lib/utils.ts b/website/src/lib/utils.ts similarity index 100% rename from src/lib/utils.ts rename to website/src/lib/utils.ts diff --git a/src/lib/website-cli-parity.test.ts b/website/src/lib/website-cli-parity.test.ts similarity index 99% rename from src/lib/website-cli-parity.test.ts rename to website/src/lib/website-cli-parity.test.ts index ccff248..8506584 100644 --- a/src/lib/website-cli-parity.test.ts +++ b/website/src/lib/website-cli-parity.test.ts @@ -193,7 +193,7 @@ describe("website SetupSpec to CLI parity", () => { const command = recipeApplyCommand(config, hostCatalog, setupTransport.recipePrefix); const recipe = command.match(/--recipe '([^']+)'/)?.[1]; expect(recipe).toBeTruthy(); - expect(command).toBe(`npx switchloom@latest apply --recipe ${shellQuote(recipe!)} --repository .`); + expect(command).toBe(`npx switchloom@0.3.0 apply --recipe ${shellQuote(recipe!)} --repository .`); const { repository } = await tempRepo("switchloom-website-standalone-"); const preview = report(run(["preview", "--recipe", recipe!, "--repository", repository])); diff --git a/src/pages/index.astro b/website/src/pages/index.astro similarity index 93% rename from src/pages/index.astro rename to website/src/pages/index.astro index 661bae6..43160a1 100644 --- a/src/pages/index.astro +++ b/website/src/pages/index.astro @@ -2,7 +2,7 @@ import "@/styles/global.css"; import Generator from "@/components/Generator"; import { hostCatalogFrom, setupTransportFrom } from "@/lib/generator"; -import catalog from "../../website/data/catalog.json"; +import catalog from "../../data/catalog.json"; const hostCatalog = hostCatalogFrom(catalog); const setupTransport = setupTransportFrom(catalog); diff --git a/src/styles/global.css b/website/src/styles/global.css similarity index 100% rename from src/styles/global.css rename to website/src/styles/global.css diff --git a/xtask/Cargo.toml b/xtask/Cargo.toml new file mode 100644 index 0000000..026191b --- /dev/null +++ b/xtask/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "xtask" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +publish = false + +[dependencies] +anyhow.workspace = true +clap.workspace = true +model-routing = { path = "..", version = "0.3.0" } +serde.workspace = true +serde_json.workspace = true +sha2.workspace = true + +[dev-dependencies] +toml.workspace = true + +[lints] +workspace = true diff --git a/xtask/command-ownership.toml b/xtask/command-ownership.toml new file mode 100644 index 0000000..c6fa66e --- /dev/null +++ b/xtask/command-ownership.toml @@ -0,0 +1,39 @@ +schema_version = 1 + +[public_cli] +owner = "model-routing" +commands = [ + "policy", + "compile", + "inspect", + "preview", + "apply", + "update", + "status", + "rollback", + "uninstall", + "doctor", +] + +[internal_cli] +owner = "xtask" +state = "reserved-fail-closed" +commands = [ + "certify codex", + "certify cursor", + "certify opencode", + "certify pi", + "certify planr", + "verify offline", + "release prepare", + "release verify", + "release package", +] + +[transfers] +"certify" = "xtask certify; public compatibility is removed only after validator parity" +"evidence validate" = "xtask certify; public compatibility is removed only after validator parity" +"probe" = "model-routing doctor; the public probe alias is removed" +"evaluate" = "model-routing library API; no standalone public CLI owner" +"catalog" = "xtask release prepare/verify" +"registry" = "model-routing library sign_registry/verify_registry_signature API" diff --git a/xtask/src/certify/codex.rs b/xtask/src/certify/codex.rs new file mode 100644 index 0000000..d9c86fe --- /dev/null +++ b/xtask/src/certify/codex.rs @@ -0,0 +1,701 @@ +use super::ensure; +use anyhow::{Context, Result}; +use serde::Deserialize; +use std::{ + collections::{BTreeMap, BTreeSet}, + fs, + path::Path, +}; + +const SCHEMA: &str = "switchloom.codex_runtime_evidence.v1"; + +#[derive(Deserialize)] +struct Receipt { + schema_version: String, + run: Run, + children: Vec, + dispatch_evidence: Vec, +} + +#[derive(Deserialize)] +struct Run { + status: String, + #[serde(default)] + complete_marker: Option, + evidence_source: String, + parent_thread_id: String, + parent_session: String, + workdir: String, +} + +#[derive(Deserialize)] +struct Child { + kind: String, + profile: String, + agent_type: String, + task_name: String, + canonical_task: String, + parent_thread_id: String, + child_thread_id: String, + spawn: Spawn, + #[serde(default = "missing_input")] + input: MessageInput, + spawn_output: SpawnOutput, + session: Session, + state: State, + final_answer: FinalAnswer, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Spawn { + surface: String, + agent_type: String, + task_name: String, + fork_turns: String, + call_id: String, + #[serde(default)] + model: Option, + #[serde(default)] + reasoning_effort: Option, +} + +#[derive(Deserialize)] +struct MessageInput { + message_sha256: String, + message_bytes: u64, + max_message_bytes: u64, + #[serde(default = "plaintext")] + message_encoding: String, + message_plaintext_verdict: String, + #[serde(default)] + message_plaintext_intent_sha256: Option, +} + +fn plaintext() -> String { + "plaintext".into() +} + +fn missing_input() -> MessageInput { + MessageInput { + message_sha256: String::new(), + message_bytes: 0, + max_message_bytes: 0, + message_encoding: plaintext(), + message_plaintext_verdict: String::new(), + message_plaintext_intent_sha256: None, + } +} + +#[derive(Deserialize)] +struct SpawnOutput { + #[serde(default)] + task_name: Option, + #[serde(default)] + agent_id: Option, +} + +#[derive(Deserialize)] +struct Session { + agent_role: String, + #[serde(default)] + agent_path: Option, + thread_source: String, + parent_thread_id: String, + session_file: String, +} + +#[derive(Deserialize)] +struct State { + agent_role: String, + #[serde(default)] + agent_path: Option, + model: String, + reasoning_effort: String, + thread_source: String, + cwd: String, +} + +#[derive(Deserialize)] +struct FinalAnswer { + message_type: String, +} + +#[derive(Deserialize)] +struct DispatchEvidence { + schema_version: u32, + package_digest: String, + host_version: String, + requested_dispatch: RequestedDispatch, + child_identity: ChildIdentity, + effective_model: String, + effective_effort: String, + nonce: String, + raw_evidence_refs: Vec, + verdict: String, +} + +#[derive(Deserialize)] +struct RequestedDispatch { + semantic_role: String, + profile: String, + model: String, + effort: String, + agent_type: String, + fork_turns: ForkTurns, + message_sha256: String, + message_encoding: String, + message_plaintext_verdict: String, + #[serde(default)] + message_plaintext_intent_sha256: Option, + message_bytes: u64, + max_message_bytes: u64, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct ForkTurns { + mode: String, +} + +#[derive(Deserialize)] +struct ChildIdentity { + host: String, + role: String, + agent_role: String, + agent_type: String, + task_name: String, +} + +#[derive(Deserialize)] +struct ExpectedReceipt { + #[serde(default)] + package_digest: Option, + #[serde(default)] + host_version: Option, + children: Vec, +} + +#[derive(Clone, Deserialize)] +struct ExpectedChild { + #[serde(default)] + semantic_role: Option, + #[serde(default)] + kind: Option, + agent_type: String, + profile: String, + task_name: String, + #[serde(default)] + message_sha256: Option, + #[serde(default)] + message_ciphertext_sha256: Option, + #[serde(default)] + max_message_bytes: Option, + #[serde(default)] + model: Option, + #[serde(default)] + effort: Option, + #[serde(default)] + state: Option, + #[serde(default)] + input: Option, +} + +#[derive(Clone, Deserialize)] +struct ExpectedState { + model: String, + reasoning_effort: String, +} + +#[derive(Clone, Deserialize)] +struct ExpectedInput { + message_sha256: String, + max_message_bytes: u64, +} + +pub(crate) fn validate(receipt_path: &Path, expected_path: Option<&Path>) -> Result<()> { + let receipt_json = + fs::read_to_string(receipt_path).context("failed to read Codex runtime evidence")?; + validate_json(&receipt_json, expected_path) +} + +pub(super) fn validate_json(receipt_json: &str, expected_path: Option<&Path>) -> Result<()> { + let receipt: Receipt = + serde_json::from_str(receipt_json).context("Codex runtime evidence is not valid JSON")?; + let expected: Option = expected_path + .map(|path| read_json(path, "expected Codex dispatch")) + .transpose()?; + + ensure( + receipt.schema_version == SCHEMA, + format_args!("schema_version must be {SCHEMA}"), + )?; + ensure( + receipt.run.status == "complete", + "run.status must be complete", + )?; + ensure( + receipt.run.complete_marker.as_deref() + == Some("SWITCHLOOM_CODEX_RUNTIME_EVIDENCE_COMPLETE"), + "run complete marker missing", + )?; + ensure( + receipt.run.evidence_source == "codex_persisted_spawn_state", + "run.evidence_source must be codex_persisted_spawn_state", + )?; + ensure( + is_uuid(&receipt.run.parent_thread_id), + "run.parent_thread_id must be a UUID", + )?; + ensure( + receipt.run.parent_session.ends_with(".jsonl"), + "run.parent_session must name a persisted session jsonl", + )?; + ensure( + receipt.run.workdir.starts_with('/'), + "run.workdir must be absolute", + )?; + + let expected_children = expected_children(&receipt, expected.as_ref())?; + ensure( + receipt.children.len() == expected_children.len(), + "children must contain exactly the expected roles", + )?; + ensure( + receipt.dispatch_evidence.len() == expected_children.len(), + "dispatch_evidence must contain exactly the expected receipts", + )?; + + let mut seen = BTreeSet::new(); + for child in &receipt.children { + let expected_child = expected_children + .get(&child.kind) + .with_context(|| format!("unexpected child kind {}", child.kind))?; + ensure( + seen.insert(child.kind.as_str()), + format_args!("duplicate child kind {}", child.kind), + )?; + validate_child(&receipt, child, expected_child, expected.as_ref())?; + } + Ok(()) +} + +fn validate_child( + receipt: &Receipt, + child: &Child, + expected: &Expected, + expected_receipt: Option<&ExpectedReceipt>, +) -> Result<()> { + let kind = &child.kind; + ensure( + child.agent_type == expected.agent_type, + format_args!("{kind} agent_type mismatch"), + )?; + ensure( + child.profile == expected.profile, + format_args!("{kind} profile mismatch"), + )?; + ensure( + child.task_name == expected.task_name, + format_args!("{kind} task_name mismatch"), + )?; + ensure( + valid_task_name(&child.task_name), + format_args!("{kind} task_name is invalid"), + )?; + ensure( + child.canonical_task == format!("/root/{}", child.task_name), + format_args!("{kind} canonical_task mismatch"), + )?; + ensure( + child.parent_thread_id == receipt.run.parent_thread_id, + format_args!("{kind} parent_thread_id mismatch"), + )?; + ensure( + is_uuid(&child.child_thread_id) && child.child_thread_id != receipt.run.parent_thread_id, + format_args!("{kind} child_thread_id must be a distinct UUID"), + )?; + ensure( + child.spawn.surface == "collaboration.spawn_agent", + format_args!("{kind} spawn surface must be Codex V2 collaboration.spawn_agent"), + )?; + ensure( + child.spawn.agent_type == child.agent_type, + format_args!("{kind} spawn agent_type mismatch"), + )?; + ensure( + child.spawn.task_name == child.task_name, + format_args!("{kind} spawn task_name mismatch"), + )?; + ensure( + child.spawn.fork_turns == "none", + format_args!("{kind} fork_turns must be none"), + )?; + ensure( + !child.spawn.call_id.trim().is_empty(), + format_args!("{kind} spawn call_id must not be blank"), + )?; + ensure( + child.spawn.model.is_none(), + format_args!("{kind} spawn must not manually override model"), + )?; + ensure( + child.spawn.reasoning_effort.is_none(), + format_args!("{kind} spawn must not manually override effort"), + )?; + ensure( + is_sha256_hex(&child.input.message_sha256), + format_args!("{kind} input message_sha256 must be lowercase sha256 hex"), + )?; + + let expected_hash = match child.input.message_encoding.as_str() { + "plaintext" => { + ensure( + child.input.message_plaintext_verdict == "deterministic", + format_args!("{kind} input message_plaintext_verdict mismatch"), + )?; + expected + .message_sha256 + .as_ref() + .context("plaintext input requires expected message_sha256")? + } + "codex-encrypted" => { + ensure( + child.input.message_plaintext_verdict == "unsupported", + format_args!("{kind} encrypted input cannot claim deterministic plaintext"), + )?; + if let Some(intent) = &child.input.message_plaintext_intent_sha256 { + ensure( + Some(intent) == expected.message_sha256.as_ref(), + format_args!("{kind} input message_plaintext_intent_sha256 mismatch"), + )?; + } + expected + .message_ciphertext_sha256 + .as_ref() + .unwrap_or(&child.input.message_sha256) + } + encoding => anyhow::bail!("{kind} unsupported input message_encoding {encoding}"), + }; + ensure( + is_sha256_hex(expected_hash) && child.input.message_sha256 == *expected_hash, + format_args!("{kind} input message_sha256 mismatch"), + )?; + ensure( + child.input.message_bytes > 0 && child.input.message_bytes <= expected.max_message_bytes, + format_args!("{kind} input message_bytes exceeds max_message_bytes"), + )?; + ensure( + child.input.max_message_bytes == expected.max_message_bytes, + format_args!("{kind} input max_message_bytes mismatch"), + )?; + ensure( + child.spawn_output.task_name.as_deref() == Some(&child.canonical_task) + || child.spawn_output.agent_id.as_deref() == Some(&child.child_thread_id), + format_args!("{kind} spawn output task mismatch"), + )?; + ensure( + child.session.agent_role == child.agent_type && child.state.agent_role == child.agent_type, + format_args!("{kind} agent_role mismatch"), + )?; + ensure( + path_matches(&child.session.agent_path, &child.canonical_task) + && path_matches(&child.state.agent_path, &child.canonical_task), + format_args!("{kind} agent_path mismatch"), + )?; + ensure( + child.session.thread_source == "subagent" && child.state.thread_source == "subagent", + format_args!("{kind} thread_source mismatch"), + )?; + ensure( + child.session.parent_thread_id == receipt.run.parent_thread_id, + format_args!("{kind} session parent mismatch"), + )?; + ensure( + child.session.session_file.ends_with(".jsonl"), + format_args!("{kind} session file missing"), + )?; + ensure( + child.state.model == expected.model, + format_args!("{kind} effective model mismatch"), + )?; + ensure( + child.state.reasoning_effort == expected.effort, + format_args!("{kind} effective effort mismatch"), + )?; + ensure( + child.state.cwd == receipt.run.workdir, + format_args!("{kind} state cwd mismatch"), + )?; + ensure( + !(child.state.model == "gpt-5.6-sol" && child.state.reasoning_effort == "medium"), + format_args!("{kind} inherited Sol Medium evidence is forbidden"), + )?; + ensure( + child.final_answer.message_type == "FINAL_ANSWER", + format_args!("{kind} final answer marker missing"), + )?; + + let dispatch = receipt + .dispatch_evidence + .iter() + .find(|e| { + e.requested_dispatch.semantic_role == *kind + && e.requested_dispatch.agent_type == child.agent_type + && e.child_identity.task_name == child.task_name + }) + .with_context(|| format!("{kind} dispatch_evidence receipt missing"))?; + validate_dispatch(receipt, child, expected, dispatch, expected_receipt) +} + +fn validate_dispatch( + receipt: &Receipt, + child: &Child, + expected: &Expected, + evidence: &DispatchEvidence, + expected_receipt: Option<&ExpectedReceipt>, +) -> Result<()> { + let kind = &child.kind; + ensure( + evidence.schema_version == 1, + format_args!("{kind} dispatch_evidence schema_version mismatch"), + )?; + ensure( + is_sha256_digest(&evidence.package_digest), + format_args!("{kind} dispatch_evidence package_digest must be sha256:<64 lowercase hex>"), + )?; + ensure( + is_codex_version(&evidence.host_version), + format_args!("{kind} dispatch_evidence host_version must come from codex --version"), + )?; + if let Some(expected_receipt) = expected_receipt { + if let Some(digest) = &expected_receipt.package_digest { + ensure( + evidence.package_digest == *digest, + format_args!("{kind} package_digest mismatch"), + )?; + } + if let Some(version) = &expected_receipt.host_version { + ensure( + evidence.host_version == *version, + format_args!("{kind} host_version mismatch"), + )?; + } + } + let requested = &evidence.requested_dispatch; + ensure( + requested.profile == expected.profile + && requested.model == expected.model + && requested.effort == expected.effort, + format_args!("{kind} requested dispatch mismatch"), + )?; + ensure( + requested.agent_type == child.agent_type && requested.fork_turns.mode == "none", + format_args!("{kind} requested child identity mismatch"), + )?; + ensure( + requested.message_sha256 == child.input.message_sha256, + format_args!("{kind} requested message_sha256 mismatch"), + )?; + ensure( + requested.message_encoding == child.input.message_encoding + && requested.message_plaintext_verdict == child.input.message_plaintext_verdict + && requested.message_plaintext_intent_sha256 + == child.input.message_plaintext_intent_sha256 + && requested.message_bytes == child.input.message_bytes + && requested.max_message_bytes == expected.max_message_bytes, + format_args!("{kind} requested message evidence mismatch"), + )?; + ensure( + evidence.child_identity.host == "codex", + format_args!("{kind} child host mismatch"), + )?; + ensure( + evidence.child_identity.role == *kind, + format_args!("{kind} child role mismatch"), + )?; + ensure( + evidence.child_identity.agent_role == child.agent_type, + format_args!("{kind} child agent_role mismatch"), + )?; + ensure( + evidence.child_identity.agent_type == child.agent_type, + format_args!("{kind} child agent_type mismatch"), + )?; + ensure( + evidence.effective_model == child.state.model + && evidence.effective_effort == child.state.reasoning_effort, + format_args!("{kind} effective receipt mismatch"), + )?; + let nonce = format!( + "{}:{}:{}", + receipt.run.parent_thread_id, child.child_thread_id, child.spawn.call_id + ); + ensure( + evidence.nonce == nonce + && !evidence.nonce.contains("nonce-") + && !evidence.nonce.contains("placeholder"), + format_args!( + "{kind} dispatch_evidence nonce must bind parent thread, child thread, and spawn call" + ), + )?; + for reference in [ + format!("codex-session:{}", receipt.run.parent_session), + format!("codex-session:{}", child.session.session_file), + format!( + "state_5.sqlite:thread_spawn_edges:{}:{}", + receipt.run.parent_thread_id, child.child_thread_id + ), + format!("spawn_call:{}", child.spawn.call_id), + ] { + ensure( + evidence.raw_evidence_refs.contains(&reference), + format_args!( + "{kind} raw evidence refs must bind parent session, child session, spawn edge, and spawn call" + ), + )?; + } + ensure( + evidence.verdict == "deterministic", + format_args!("{kind} dispatch_evidence verdict mismatch"), + ) +} + +struct Expected { + agent_type: String, + profile: String, + task_name: String, + message_sha256: Option, + message_ciphertext_sha256: Option, + max_message_bytes: u64, + model: String, + effort: String, +} + +fn expected_children( + receipt: &Receipt, + explicit: Option<&ExpectedReceipt>, +) -> Result> { + let mut result = BTreeMap::new(); + if let Some(explicit) = explicit { + for child in &explicit.children { + let role = child + .semantic_role + .as_ref() + .or(child.kind.as_ref()) + .context("expected child semantic_role must not be blank")?; + let model = child + .model + .clone() + .or_else(|| child.state.as_ref().map(|s| s.model.clone())) + .context("expected child model must not be blank")?; + let effort = child + .effort + .clone() + .or_else(|| child.state.as_ref().map(|s| s.reasoning_effort.clone())) + .context("expected child effort must not be blank")?; + let message_sha256 = child + .message_sha256 + .clone() + .or_else(|| child.input.as_ref().map(|i| i.message_sha256.clone())); + let max_message_bytes = child + .max_message_bytes + .or_else(|| child.input.as_ref().map(|i| i.max_message_bytes)) + .context("expected max_message_bytes must be positive")?; + result.insert( + role.clone(), + Expected { + agent_type: child.agent_type.clone(), + profile: child.profile.clone(), + task_name: child.task_name.clone(), + message_sha256, + message_ciphertext_sha256: child.message_ciphertext_sha256.clone(), + max_message_bytes, + model, + effort, + }, + ); + } + } else { + for child in &receipt.children { + ensure( + is_sha256_hex(&child.input.message_sha256), + format_args!( + "{} input message_sha256 must be lowercase sha256 hex", + child.kind + ), + )?; + result.insert( + child.kind.clone(), + Expected { + agent_type: child.agent_type.clone(), + profile: child.profile.clone(), + task_name: child.task_name.clone(), + message_sha256: Some(child.input.message_sha256.clone()), + message_ciphertext_sha256: None, + max_message_bytes: child.input.max_message_bytes, + model: child.state.model.clone(), + effort: child.state.reasoning_effort.clone(), + }, + ); + } + } + ensure( + result.values().all(|e| { + !e.agent_type.trim().is_empty() + && !e.profile.trim().is_empty() + && !e.task_name.trim().is_empty() + && e.max_message_bytes > 0 + && !e.model.trim().is_empty() + && !e.effort.trim().is_empty() + }), + "expected child fields must not be blank", + )?; + Ok(result) +} + +fn read_json(path: &Path, label: &str) -> Result { + let text = fs::read_to_string(path).with_context(|| format!("failed to read {label}"))?; + serde_json::from_str(&text).with_context(|| format!("{label} is not valid JSON")) +} + +pub(super) fn is_uuid(value: &str) -> bool { + value.len() == 36 + && [8, 13, 18, 23] + .iter() + .all(|&i| value.as_bytes().get(i) == Some(&b'-')) + && value.chars().enumerate().all(|(i, c)| { + [8, 13, 18, 23].contains(&i) || c.is_ascii_hexdigit() && !c.is_ascii_uppercase() + }) +} +pub(super) fn is_sha256_hex(value: &str) -> bool { + value.len() == 64 + && value + .bytes() + .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) +} +fn is_sha256_digest(value: &str) -> bool { + value.strip_prefix("sha256:").is_some_and(is_sha256_hex) +} +fn valid_task_name(value: &str) -> bool { + value.bytes().next().is_some_and(|b| b.is_ascii_lowercase()) + && value + .bytes() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_') +} +fn path_matches(value: &Option, canonical: &str) -> bool { + value.as_deref().is_none_or(|value| value == canonical) +} +pub(super) fn is_codex_version(value: &str) -> bool { + let Some(rest) = value + .strip_prefix("codex ") + .or_else(|| value.strip_prefix("codex-cli ")) + else { + return false; + }; + rest.split(['-', '+']).next().is_some_and(|v| { + v.split('.').count() == 3 + && v.split('.') + .all(|p| !p.is_empty() && p.bytes().all(|b| b.is_ascii_digit())) + }) +} diff --git a/xtask/src/certify/codex_spawn.rs b/xtask/src/certify/codex_spawn.rs new file mode 100644 index 0000000..d1dc9d4 --- /dev/null +++ b/xtask/src/certify/codex_spawn.rs @@ -0,0 +1,586 @@ +use super::{codex, ensure}; +use anyhow::{Context, Result}; +use serde::Deserialize; +use serde_json::{Value, json}; +use sha2::{Digest, Sha256}; +use std::{ + fs, + path::{Path, PathBuf}, + process::Command, +}; + +pub(crate) struct CodexRawInput { + pub events: PathBuf, + pub workdir: PathBuf, + pub expected: PathBuf, + pub state_db: Option, + pub sessions_dir: Option, + pub archived_sessions_dir: Option, +} + +#[derive(Deserialize)] +struct Expected { + package_digest: String, + host_version: String, + children: Vec, +} + +#[derive(Deserialize)] +struct ExpectedChild { + semantic_role: String, + profile: String, + agent_type: String, + task_name: String, + canonical_task: String, + model: String, + effort: String, + #[serde(default)] + message_sha256: Option, + #[serde(default)] + message_ciphertext_sha256: Option, + max_message_bytes: u64, + #[serde(default)] + completion_contains: Option, + #[serde(default)] + allow_encrypted_message: bool, +} + +#[derive(Deserialize)] +struct Edge { + parent_thread_id: String, + child_thread_id: String, + status: String, + agent_path: Option, + agent_role: Option, + model: String, + reasoning_effort: String, + thread_source: String, + cwd: String, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct SpawnArgs { + agent_type: String, + task_name: String, + fork_turns: String, + #[serde(default)] + message: Option, +} + +#[derive(Deserialize)] +struct SpawnOutput { + task_name: String, + #[serde(default)] + agent_id: Option, +} + +pub(crate) fn extract(input: CodexRawInput) -> Result { + let expected: Expected = read_json(&input.expected, "expected Codex dispatch")?; + ensure( + !expected.children.is_empty(), + "expected children list is empty", + )?; + ensure( + is_sha256_digest(&expected.package_digest), + "expected package_digest must be sha256:<64 lowercase hex>", + )?; + ensure( + super::codex::is_codex_version(&expected.host_version), + "expected host_version must be observed codex --version output", + )?; + + let outer = read_jsonl(&input.events)?; + let parent_thread_id = outer + .iter() + .find(|record| record["type"] == "thread.started") + .and_then(|record| record["thread_id"].as_str()) + .context("Codex exec JSONL did not contain thread.started.thread_id")? + .to_owned(); + ensure( + super::codex::is_uuid(&parent_thread_id), + "invalid parent thread id", + )?; + + let sessions_dir = input + .sessions_dir + .unwrap_or_else(|| default_codex_path("sessions")); + let archived_dir = input + .archived_sessions_dir + .unwrap_or_else(|| default_codex_path("archived_sessions")); + let parent_session = find_session(&parent_thread_id, [&sessions_dir, &archived_dir])?; + let parent_records = read_jsonl(&parent_session)?; + let parent_meta = + find_payload(&parent_records, "session_meta").context("parent session_meta missing")?; + ensure( + parent_meta["id"] == parent_thread_id, + "parent session_meta id does not match thread.started", + )?; + ensure( + parent_meta["thread_source"] == "user", + "parent session is not a user thread", + )?; + ensure( + parent_meta["cwd"].as_str() == input.workdir.to_str(), + "parent session cwd does not match oracle workdir", + )?; + + let state_db = input + .state_db + .unwrap_or_else(|| default_codex_path("state_5.sqlite")); + let edges = query_edges(&state_db, &parent_thread_id)?; + validate_expected(&expected.children)?; + let spawn_calls = parent_records + .iter() + .filter(|record| is_spawn_call(record)) + .collect::>(); + ensure( + spawn_calls.len() == expected.children.len(), + format_args!( + "parent must contain exactly {} V2 spawn_agent calls", + expected.children.len() + ), + )?; + let started = parent_records + .iter() + .filter(|record| { + record["type"] == "event_msg" + && record["payload"]["type"] == "sub_agent_activity" + && record["payload"]["kind"] == "started" + }) + .collect::>(); + ensure( + started.len() == expected.children.len(), + format_args!( + "parent must contain exactly {} sub_agent_activity started events", + expected.children.len() + ), + )?; + ensure( + edges.len() == expected.children.len(), + format_args!( + "parent must contain exactly {} persisted child edges", + expected.children.len() + ), + )?; + + for record in &spawn_calls { + let args: SpawnArgs = + parse_embedded(&record["payload"]["arguments"], "spawn_agent arguments")?; + ensure( + expected.children.iter().any(|child| { + child.agent_type == args.agent_type && child.task_name == args.task_name + }), + "unexpected spawn_agent call", + )?; + } + for record in &started { + let path = record["payload"]["agent_path"].as_str(); + ensure( + expected + .children + .iter() + .any(|child| Some(child.canonical_task.as_str()) == path), + "unexpected started child path", + )?; + } + + let mut observed = Vec::new(); + for child in &expected.children { + let matches = spawn_calls + .iter() + .filter_map(|record| { + let args: SpawnArgs = + parse_embedded(&record["payload"]["arguments"], "spawn_agent arguments") + .ok()?; + (args.agent_type == child.agent_type && args.task_name == child.task_name) + .then_some((*record, args)) + }) + .collect::>(); + ensure( + matches.len() == 1, + format_args!( + "{} must have exactly one raw spawn_agent call", + child.agent_type + ), + )?; + let (spawn_record, spawn_args) = &matches[0]; + ensure( + spawn_args.fork_turns == "none", + format_args!("{} spawn did not use fork_turns=none", child.agent_type), + )?; + let message = spawn_args.message.as_deref().unwrap_or_default(); + ensure( + !message.is_empty(), + format_args!("{} spawn message missing", child.agent_type), + )?; + let message_bytes = message.len() as u64; + ensure( + message_bytes <= child.max_message_bytes, + format_args!( + "{} spawn message exceeds max_message_bytes", + child.agent_type + ), + )?; + let message_hash = sha256_hex(message.as_bytes()); + let (encoding, plaintext_verdict, intent_hash) = + if child.message_sha256.as_deref() == Some(&message_hash) { + ("plaintext", "deterministic", None) + } else { + ensure( + is_codex_ciphertext(message), + format_args!("{} spawn message_sha256 mismatch", child.agent_type), + )?; + ensure( + child.allow_encrypted_message || child.message_ciphertext_sha256.is_some(), + format_args!( + "{} encrypted spawn message cannot certify expected plaintext", + child.agent_type + ), + )?; + if let Some(ciphertext_hash) = &child.message_ciphertext_sha256 { + ensure( + *ciphertext_hash == message_hash, + format_args!( + "{} encrypted spawn message_ciphertext_sha256 mismatch", + child.agent_type + ), + )?; + } + ( + "codex-encrypted", + "unsupported", + child.message_sha256.clone(), + ) + }; + let call_id = spawn_record["payload"]["call_id"] + .as_str() + .context("spawn call_id missing")?; + let output: SpawnOutput = parent_records + .iter() + .find(|record| { + record["type"] == "response_item" + && record["payload"]["type"] == "function_call_output" + && record["payload"]["call_id"] == call_id + }) + .map(|record| parse_embedded(&record["payload"]["output"], "spawn_agent output")) + .transpose()? + .context("spawn_agent output missing")?; + ensure( + output.task_name == child.canonical_task, + format_args!("{} spawn output task_name mismatch", child.agent_type), + )?; + let activity = started + .iter() + .find(|record| record["payload"]["event_id"] == call_id) + .context("missing sub_agent_activity started event")?; + let child_thread_id = activity["payload"]["agent_thread_id"] + .as_str() + .context("started event missing child thread id")?; + ensure( + super::codex::is_uuid(child_thread_id), + "started event missing child thread id", + )?; + ensure( + activity["payload"]["agent_path"] == child.canonical_task, + "started event agent_path mismatch", + )?; + ensure( + has_final_answer(&parent_records, child, child_thread_id), + format_args!( + "{} missing child FINAL_ANSWER payload in parent session", + child.agent_type + ), + )?; + let edge = edges + .iter() + .find(|edge| edge.child_thread_id == child_thread_id) + .context("missing thread_spawn_edges row")?; + validate_edge(edge, child, &parent_thread_id, &input.workdir)?; + let child_session = find_session(child_thread_id, [&sessions_dir, &archived_dir])?; + let child_records = read_jsonl(&child_session)?; + let meta = + find_payload(&child_records, "session_meta").context("child session_meta missing")?; + validate_child_meta(meta, child, &parent_thread_id, child_thread_id)?; + let context = + find_payload(&child_records, "turn_context").context("child turn_context missing")?; + ensure( + context["model"] == child.model && context["effort"] == child.effort, + "child turn_context model/effort mismatch", + )?; + ensure( + context["collaboration_mode"]["settings"]["model"] == child.model + && context["collaboration_mode"]["settings"]["reasoning_effort"] == child.effort, + "child collaboration model/effort mismatch", + )?; + + observed.push(json!({ + "kind": child.semantic_role, "profile": child.profile, "agent_type": child.agent_type, "task_name": child.task_name, + "canonical_task": child.canonical_task, "parent_thread_id": parent_thread_id, "child_thread_id": child_thread_id, + "spawn": {"surface":"collaboration.spawn_agent","agent_type":spawn_args.agent_type,"task_name":spawn_args.task_name,"fork_turns":spawn_args.fork_turns,"call_id":call_id}, + "input": {"message_sha256":message_hash,"message_bytes":message_bytes,"max_message_bytes":child.max_message_bytes,"message_encoding":encoding,"message_plaintext_verdict":plaintext_verdict,"message_plaintext_intent_sha256":intent_hash}, + "spawn_output": {"task_name":output.task_name,"agent_id":output.agent_id}, + "session": {"agent_role":meta["agent_role"],"agent_path":meta.get("agent_path").cloned().unwrap_or_else(|| json!(child.canonical_task)),"thread_source":meta["thread_source"],"parent_thread_id":meta["parent_thread_id"],"session_file":file_name(&child_session)}, + "state": {"agent_role":edge.agent_role,"agent_path":edge.agent_path,"model":edge.model,"reasoning_effort":edge.reasoning_effort,"thread_source":edge.thread_source,"cwd":edge.cwd}, + "final_answer":{"message_type":"FINAL_ANSWER"} + })); + } + + let dispatch = observed.iter().map(|child| json!({ + "schema_version":1,"package_digest":expected.package_digest,"host_version":expected.host_version, + "requested_dispatch":{"semantic_role":child["kind"],"profile":child["profile"],"model":child["state"]["model"],"effort":child["state"]["reasoning_effort"],"agent_type":child["agent_type"],"fork_turns":{"mode":child["spawn"]["fork_turns"]},"message_sha256":child["input"]["message_sha256"],"message_encoding":child["input"]["message_encoding"],"message_plaintext_verdict":child["input"]["message_plaintext_verdict"],"message_plaintext_intent_sha256":child["input"]["message_plaintext_intent_sha256"],"message_bytes":child["input"]["message_bytes"],"max_message_bytes":child["input"]["max_message_bytes"]}, + "child_identity":{"host":"codex","role":child["kind"],"agent_role":child["state"]["agent_role"],"agent_type":child["agent_type"],"task_name":child["task_name"]}, + "effective_model":child["state"]["model"],"effective_effort":child["state"]["reasoning_effort"], + "nonce":format!("{}:{}:{}", parent_thread_id, child["child_thread_id"].as_str().unwrap(), child["spawn"]["call_id"].as_str().unwrap()), + "raw_evidence_refs":[format!("codex-session:{}",file_name(&parent_session)),format!("codex-session:{}",child["session"]["session_file"].as_str().unwrap()),format!("state_5.sqlite:thread_spawn_edges:{}:{}",parent_thread_id,child["child_thread_id"].as_str().unwrap()),format!("spawn_call:{}",child["spawn"]["call_id"].as_str().unwrap())],"verdict":"deterministic" + })).collect::>(); + let receipt = json!({"schema_version":"switchloom.codex_runtime_evidence.v1","run":{"status":"complete","complete_marker":"SWITCHLOOM_CODEX_RUNTIME_EVIDENCE_COMPLETE","evidence_source":"codex_persisted_spawn_state","parent_thread_id":parent_thread_id,"parent_session":file_name(&parent_session),"workdir":input.workdir},"children":observed,"dispatch_evidence":dispatch}); + let text = serde_json::to_string_pretty(&receipt)?; + codex::validate_json(&text, Some(&input.expected))?; + Ok(text) +} + +fn validate_expected(children: &[ExpectedChild]) -> Result<()> { + for child in children { + ensure( + !child.semantic_role.is_empty() + && !child.profile.is_empty() + && !child.agent_type.is_empty() + && !child.task_name.is_empty(), + "expected child identity fields must not be blank", + )?; + ensure( + child.canonical_task == format!("/root/{}", child.task_name), + "expected child has invalid canonical_task", + )?; + ensure( + child.max_message_bytes > 0, + "expected max_message_bytes must be positive", + )?; + if let Some(hash) = &child.message_sha256 { + ensure( + super::codex::is_sha256_hex(hash), + "expected message_sha256 must be lowercase sha256 hex", + )?; + } + if let Some(hash) = &child.message_ciphertext_sha256 { + ensure( + super::codex::is_sha256_hex(hash), + "expected message_ciphertext_sha256 must be lowercase sha256 hex", + )?; + } + } + Ok(()) +} + +fn validate_edge(edge: &Edge, child: &ExpectedChild, parent: &str, workdir: &Path) -> Result<()> { + ensure( + edge.parent_thread_id == parent && !edge.status.is_empty() && edge.status != "unknown", + "persisted child edge mismatch", + )?; + ensure( + edge.agent_path + .as_deref() + .is_none_or(|path| path == child.canonical_task), + "state agent_path mismatch", + )?; + ensure( + edge.agent_role.as_deref() == Some(&child.agent_type) + && edge.model == child.model + && edge.reasoning_effort == child.effort, + "state identity/model/effort mismatch", + )?; + ensure( + edge.thread_source == "subagent" && Some(edge.cwd.as_str()) == workdir.to_str(), + "state source/cwd mismatch", + ) +} + +fn validate_child_meta( + meta: &Value, + child: &ExpectedChild, + parent: &str, + thread: &str, +) -> Result<()> { + ensure( + meta["id"] == thread + && meta["parent_thread_id"] == parent + && meta["thread_source"] == "subagent", + "child session correlation mismatch", + )?; + ensure( + meta["agent_role"] == child.agent_type, + "child session agent_role mismatch", + )?; + ensure( + meta.get("agent_path") + .is_none_or(|path| path.is_null() || path.as_str() == Some(&child.canonical_task)), + "child session agent_path mismatch", + )?; + let spawn = &meta["source"]["subagent"]["thread_spawn"]; + ensure( + spawn["parent_thread_id"] == parent && spawn["agent_role"] == child.agent_type, + "child source correlation mismatch", + )?; + ensure( + spawn + .get("agent_path") + .is_none_or(|path| path.is_null() || path.as_str() == Some(&child.canonical_task)), + "child source agent_path mismatch", + ) +} + +fn has_final_answer(records: &[Value], child: &ExpectedChild, thread: &str) -> bool { + records.iter().any(|record| { + let payload = &record["payload"]; + let text = content_text(payload); + (record["type"] == "response_item" + && payload["type"] == "agent_message" + && payload["author"] == child.canonical_task + && payload["recipient"] == "/root" + && text.contains("Message Type: FINAL_ANSWER") + && child + .completion_contains + .as_ref() + .is_none_or(|needle| text.contains(needle))) + || (record["type"] == "response_item" + && payload["type"] == "message" + && payload["role"] == "user" + && text.contains("") + && text.contains(thread) + && child + .completion_contains + .as_ref() + .is_none_or(|needle| text.contains(needle))) + }) +} + +fn content_text(payload: &Value) -> String { + payload["content"] + .as_array() + .into_iter() + .flatten() + .filter_map(|part| part["text"].as_str()) + .collect::>() + .join("\n") +} +fn is_spawn_call(record: &Value) -> bool { + record["type"] == "response_item" + && record["payload"]["type"] == "function_call" + && record["payload"]["namespace"] == "collaboration" + && record["payload"]["name"] == "spawn_agent" +} +fn find_payload<'a>(records: &'a [Value], kind: &str) -> Option<&'a Value> { + records + .iter() + .find(|record| record["type"] == kind) + .map(|record| &record["payload"]) +} +fn parse_embedded(value: &Value, label: &str) -> Result { + serde_json::from_str( + value + .as_str() + .with_context(|| format!("{label} is not a JSON string"))?, + ) + .with_context(|| format!("{label} is not valid JSON")) +} +fn read_json(path: &Path, label: &str) -> Result { + serde_json::from_str( + &fs::read_to_string(path).with_context(|| format!("failed to read {label}"))?, + ) + .with_context(|| format!("{label} is not valid JSON")) +} +fn read_jsonl(path: &Path) -> Result> { + fs::read_to_string(path) + .with_context(|| format!("failed to read {}", path.display()))? + .lines() + .filter(|line| !line.trim().is_empty()) + .enumerate() + .map(|(index, line)| { + serde_json::from_str(line) + .with_context(|| format!("{}:{} is not JSON", path.display(), index + 1)) + }) + .collect() +} +fn query_edges(db: &Path, parent: &str) -> Result> { + ensure( + db.is_file(), + format_args!("Codex state DB not found at {}", db.display()), + )?; + let query = format!( + "select e.parent_thread_id,e.child_thread_id,e.status,t.agent_path,t.agent_role,t.model,t.reasoning_effort,t.thread_source,t.cwd from thread_spawn_edges e join threads t on t.id=e.child_thread_id where e.parent_thread_id='{parent}'" + ); + let output = Command::new("sqlite3") + .args([ + "-json", + db.to_str().context("state DB path is not UTF-8")?, + &query, + ]) + .output() + .context("failed to run sqlite3")?; + ensure( + output.status.success(), + String::from_utf8_lossy(&output.stderr), + )?; + serde_json::from_slice(&output.stdout).context("sqlite3 returned invalid JSON") +} +fn find_session(thread: &str, roots: [&PathBuf; 2]) -> Result { + let suffix = format!("{thread}.jsonl"); + let mut hits = Vec::new(); + for root in roots { + walk(root, &suffix, &mut hits)?; + } + ensure( + hits.len() == 1, + format_args!( + "expected exactly one persisted Codex session for {thread}, found {}", + hits.len() + ), + )?; + Ok(hits.remove(0)) +} +fn walk(root: &Path, suffix: &str, hits: &mut Vec) -> Result<()> { + if !root.exists() { + return Ok(()); + } + for entry in fs::read_dir(root)? { + let path = entry?.path(); + if path.is_dir() { + walk(&path, suffix, hits)?; + } else if path + .file_name() + .and_then(|n| n.to_str()) + .is_some_and(|n| n.ends_with(suffix)) + { + hits.push(path); + } + } + Ok(()) +} +fn default_codex_path(name: &str) -> PathBuf { + std::env::var_os("CODEX_HOME") + .map(PathBuf::from) + .or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".codex"))) + .unwrap_or_else(|| PathBuf::from(".codex")) + .join(name) +} +fn file_name(path: &Path) -> String { + path.file_name() + .unwrap_or_default() + .to_string_lossy() + .into_owned() +} +fn sha256_hex(bytes: &[u8]) -> String { + format!("{:x}", Sha256::digest(bytes)) +} +fn is_sha256_digest(value: &str) -> bool { + value + .strip_prefix("sha256:") + .is_some_and(super::codex::is_sha256_hex) +} +fn is_codex_ciphertext(value: &str) -> bool { + value.starts_with("gAAAA") + && value[5..] + .bytes() + .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-' | b'=')) +} diff --git a/xtask/src/certify/live.rs b/xtask/src/certify/live.rs new file mode 100644 index 0000000..71a43a5 --- /dev/null +++ b/xtask/src/certify/live.rs @@ -0,0 +1,1034 @@ +use super::{ + CodexRawInput, OpencodeInput, PiInput, + runner::{ + FileSnapshot, OwnedReportRepo, ProcessReceipt, ProcessSpec, RestorationOutcome, + RestorationTracker, run_process, + }, + validate_opencode, validate_pi, +}; +use anyhow::{Context, Result, bail}; +use model_routing::{ + ChildIdentityEvidence, DispatchEvidenceV1, ForkPolicy, GuaranteeLevel, + RequestedDispatchEvidence, validate_dispatch_evidence_json_for_bundle, +}; +use serde::Serialize; +use serde_json::{Value, json}; +use sha2::{Digest, Sha256}; +use std::{ + collections::BTreeMap, + fs, + path::{Path, PathBuf}, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; + +pub(crate) struct LiveRunArgs { + pub routing_bin: PathBuf, + pub report_root: PathBuf, + pub timeout: Duration, +} +impl LiveRunArgs { + pub fn new(routing_bin: PathBuf, report_root: PathBuf, timeout_seconds: u64) -> Self { + Self { + routing_bin, + report_root, + timeout: Duration::from_secs(timeout_seconds), + } + } +} +pub(crate) struct PlanrRunArgs { + pub live: LiveRunArgs, + pub protected_planr_root: PathBuf, +} + +#[derive(Serialize)] +struct CertificationReport { + schema_version: u32, + host: String, + success: bool, + live_verified: bool, + limitation: Option, + restoration: RestorationOutcome, + workdir: PathBuf, + commands: Vec, +} + +struct CertificationSession { + report_dir: PathBuf, + report: CertificationReport, + restoration: Option, +} + +impl CertificationSession { + fn new(owned: &OwnedReportRepo, host: &str) -> Self { + Self { + report_dir: owned.report_dir.clone(), + report: CertificationReport { + schema_version: 1, + host: host.into(), + success: false, + live_verified: false, + limitation: Some("certification did not complete".into()), + restoration: RestorationOutcome::NotRequired, + workdir: owned.workdir.clone(), + commands: Vec::new(), + }, + restoration: None, + } + } + + fn track_restoration(&mut self, tracker: RestorationTracker) { + self.restoration = Some(tracker); + self.persist_best_effort(); + } + + fn run_checked( + &mut self, + spec: ProcessSpec, + owned: &OwnedReportRepo, + ) -> Result { + let receipt = run_process(&spec, &owned.report_dir)?; + self.report.commands.push(receipt.clone()); + if !receipt.success() { + self.report.limitation = Some(format!( + "{} failed with status {:?}{}", + receipt.label, + receipt.status, + if receipt.timed_out { + " after timeout" + } else { + "" + } + )); + self.persist()?; + bail!(self.report.limitation.clone().unwrap_or_default()); + } + self.persist()?; + Ok(receipt) + } + + fn record(&mut self, receipt: ProcessReceipt) -> Result<()> { + self.report.commands.push(receipt); + self.persist() + } + + fn fail(&mut self, message: impl Into) -> Result<()> { + self.report.limitation = Some(message.into()); + self.persist() + } + + fn complete(&mut self, live_verified: bool, limitation: Option) -> Result<()> { + self.report.success = true; + self.report.live_verified = live_verified; + self.report.limitation = limitation; + self.persist() + } + + fn persist(&mut self) -> Result<()> { + if let Some(tracker) = &self.restoration { + self.report.restoration = tracker.borrow().clone(); + } + write_json( + &self.report_dir.join("certification-report.json"), + &self.report, + ) + } + + fn persist_best_effort(&mut self) { + let _ = self.persist(); + } +} + +impl Drop for CertificationSession { + fn drop(&mut self) { + self.persist_best_effort(); + } +} + +pub(crate) fn run_native(host: &str, args: LiveRunArgs) -> Result<()> { + let owned = OwnedReportRepo::create(&args.report_root, host)?; + let mut session = CertificationSession::new(&owned, host); + if host == "claude-native" { + session.complete( + false, + Some("Claude effective model/effort telemetry is not treated as live verified".into()), + )?; + println!( + "Claude certification recorded as explicitly not live verified: {}", + owned.report_dir.display() + ); + return Ok(()); + } + let (model, profile) = match host { + "cursor-openai" => ("gpt-5.4-mini", "cursor-openai-worker"), + "cursor-fable-grok" => ("cursor-grok-4.5-medium", "cursor-grok-worker"), + other => bail!("unsupported native certification host {other}"), + }; + let routing_bin = absolute_binary(&args.routing_bin)?; + session.run_checked( + command( + "compile", + &routing_bin, + [ + "compile", + "balanced", + "--host", + host, + "--output", + "bundle.json", + ], + &owned, + args.timeout, + ), + &owned, + )?; + session.run_checked( + command( + "apply", + &routing_bin, + ["apply", "bundle.json", "--repository", "."], + &owned, + args.timeout, + ), + &owned, + )?; + ensure_file( + &owned + .workdir + .join(".cursor/agents/model-routing-preset-worker.md"), + )?; + let version = session.run_checked( + command( + "cursor-version", + Path::new("cursor-agent"), + ["--version"], + &owned, + args.timeout, + ), + &owned, + )?; + let host_version = last_line(&version.stdout, &version.stderr); + let nonce = nonce("cursor"); + let prompt = format!("Return only this nonce and do not edit files: {nonce}"); + let invocation = json!({"host":"cursor","mode":"live","nonce":nonce,"argv":["cursor-agent","--print","--output-format","json","--trust","--model",model],"prompt":prompt,"artifact_path":".cursor/agents/model-routing-preset-worker.md"}); + write_json( + &owned.workdir.join("requested-invocation.json"), + &invocation, + )?; + let host_run = session.run_checked( + command( + "cursor-host", + Path::new("cursor-agent"), + [ + "--print", + "--output-format", + "json", + "--trust", + "--model", + model, + &prompt, + ], + &owned, + args.timeout, + ), + &owned, + )?; + ensure_contains_nonce(&host_run, &nonce)?; + fs::write(owned.workdir.join("host-output.json"), &host_run.stdout)?; + fs::write(owned.workdir.join("host-output.stderr"), &host_run.stderr)?; + let output: Value = + serde_json::from_str(&host_run.stdout).context("Cursor output must be structured JSON")?; + let effective_model = output + .pointer("/effective_model") + .or_else(|| output.pointer("/model")) + .or_else(|| output.pointer("/result/model")) + .and_then(Value::as_str) + .map(str::to_owned); + let deterministic = effective_model.as_deref() == Some(model); + let receipt = DispatchEvidenceV1 { + schema_version: 1, + package_digest: sha256_file(&routing_bin)?, + host_version, + requested_dispatch: RequestedDispatchEvidence { + semantic_role: "worker".into(), + profile: profile.into(), + model: model.into(), + effort: None, + agent_type: Some("model-routing-preset-worker".into()), + fork_turns: Some(ForkPolicy { + mode: "none".into(), + turns: None, + }), + }, + child_identity: ChildIdentityEvidence { + host: "cursor".into(), + role: "worker".into(), + agent_role: "model-routing-preset-worker".into(), + agent_type: Some("model-routing-preset-worker".into()), + task_name: Some("model-routing-preset-worker".into()), + }, + effective_model, + effective_effort: None, + nonce, + raw_evidence_refs: vec![ + "requested-invocation:requested-invocation.json#argv".into(), + "host-output:host-output.json".into(), + "host-stderr:host-output.stderr".into(), + ], + verdict: if deterministic { + GuaranteeLevel::Deterministic + } else { + GuaranteeLevel::Advisory + }, + }; + write_json(&owned.workdir.join("dispatch-evidence.json"), &receipt)?; + validate_bundle_receipt(&owned)?; + session.complete( + true, + (!deterministic).then(|| { + "Cursor ran live but did not expose deterministic effective-model telemetry".into() + }), + )?; + println!( + "Cursor live certification passed: {}", + owned.report_dir.display() + ); + Ok(()) +} + +pub(crate) fn run_opencode(args: LiveRunArgs) -> Result<()> { + let host = "opencode-native"; + let owned = OwnedReportRepo::create(&args.report_root, host)?; + let mut session = CertificationSession::new(&owned, host); + let routing_bin = absolute_binary(&args.routing_bin)?; + session.run_checked( + command( + "compile", + &routing_bin, + [ + "compile", + "balanced", + "--host", + host, + "--output", + "bundle.json", + ], + &owned, + args.timeout, + ), + &owned, + )?; + session.run_checked( + command( + "apply", + &routing_bin, + ["apply", "bundle.json", "--repository", "."], + &owned, + args.timeout, + ), + &owned, + )?; + let version = session.run_checked( + command( + "opencode-version", + Path::new("opencode"), + ["--version"], + &owned, + args.timeout, + ), + &owned, + )?; + let host_version = last_line(&version.stdout, &version.stderr); + let nonce = nonce("opencode"); + let worker = "model-routing-preset-worker"; + let model = "opencode/gpt-5-nano"; + let variant = "low"; + let prompt = format!( + "Use the Task tool to invoke {worker}. The worker must return only this nonce and must not edit files: {nonce}. After the worker returns, return only the same nonce." + ); + let argv = vec![ + "env", + "XDG_DATA_HOME=.opencode-xdg/data", + "XDG_STATE_HOME=.opencode-xdg/state", + "XDG_CACHE_HOME=.opencode-xdg/cache", + "opencode", + "run", + "--dir", + ".", + "--agent", + "model-routing-preset-driver", + "--model", + model, + "--variant", + variant, + "--format", + "json", + ]; + write_json( + &owned.workdir.join("requested-invocation.json"), + &json!({"host":"opencode","mode":"live","nonce":nonce,"argv":argv,"prompt":prompt,"artifact_path":".opencode/agents/model-routing-preset-worker.md"}), + )?; + for dir in [ + ".opencode-xdg/data", + ".opencode-xdg/state", + ".opencode-xdg/cache", + ] { + fs::create_dir_all(owned.workdir.join(dir))?; + } + let mut spec = command( + "opencode-host", + Path::new("opencode"), + [ + "run", + "--dir", + ".", + "--agent", + "model-routing-preset-driver", + "--model", + model, + "--variant", + variant, + "--format", + "json", + &prompt, + ], + &owned, + args.timeout, + ); + spec.env = BTreeMap::from([ + ( + "XDG_DATA_HOME".into(), + owned + .workdir + .join(".opencode-xdg/data") + .display() + .to_string(), + ), + ( + "XDG_STATE_HOME".into(), + owned + .workdir + .join(".opencode-xdg/state") + .display() + .to_string(), + ), + ( + "XDG_CACHE_HOME".into(), + owned + .workdir + .join(".opencode-xdg/cache") + .display() + .to_string(), + ), + ]); + let host_run = session.run_checked(spec, &owned)?; + fs::write(owned.workdir.join("host-output.jsonl"), &host_run.stdout)?; + fs::write(owned.workdir.join("host-output.stderr"), &host_run.stderr)?; + validate_opencode(OpencodeInput { + jsonl: owned.workdir.join("host-output.jsonl"), + invocation: owned.workdir.join("requested-invocation.json"), + receipt: owned.workdir.join("dispatch-evidence.json"), + package_digest: sha256_file(&routing_bin)?, + host_version, + profile: "opencode-worker".into(), + model: model.into(), + variant: variant.into(), + worker: worker.into(), + })?; + validate_bundle_receipt(&owned)?; + session.complete( + true, + Some( + "OpenCode evidence remains advisory unless host telemetry proves effective routing" + .into(), + ), + )?; + println!( + "OpenCode live certification passed: {}", + owned.report_dir.display() + ); + Ok(()) +} + +pub(crate) fn run_pi(args: LiveRunArgs) -> Result<()> { + let host = "pi-external"; + let owned = OwnedReportRepo::create(&args.report_root, host)?; + let mut session = CertificationSession::new(&owned, host); + let routing_bin = absolute_binary(&args.routing_bin)?; + session.run_checked( + command( + "compile", + &routing_bin, + [ + "compile", + "balanced", + "--host", + host, + "--output", + "bundle.json", + ], + &owned, + args.timeout, + ), + &owned, + )?; + session.run_checked( + command( + "apply", + &routing_bin, + ["apply", "bundle.json", "--repository", "."], + &owned, + args.timeout, + ), + &owned, + )?; + fs::copy( + owned + .workdir + .join(".pi/workflows/model-routing-preset-runner.json"), + owned.workdir.join("workflow.json"), + )?; + let version = session.run_checked( + command( + "pi-version", + Path::new("pi"), + ["--version"], + &owned, + args.timeout, + ), + &owned, + )?; + let host_version = last_line(&version.stdout, &version.stderr); + let nonce = nonce("pi"); + let prompt = format!("Return only this nonce and no other text: {nonce}"); + let prompt_sha = format!("sha256:{:x}", Sha256::digest(prompt.as_bytes())); + let argv = vec![ + "env", + "PI_CODING_AGENT_DIR=.pi-agent", + "PI_OFFLINE=1", + "pi", + "--print", + "--no-session", + "--no-tools", + "--no-extensions", + "--no-skills", + "--provider", + "openai", + "--model", + "gpt-4o-mini", + "--thinking", + "low", + ]; + write_json( + &owned.workdir.join("requested-invocation.json"), + &json!({"host":"pi","nonce":nonce,"argv":argv,"env":{"PI_CODING_AGENT_DIR":".pi-agent","PI_OFFLINE":"1"},"requested":{"profile":"pi-worker","agent_type":"switchloom-pi-worker","provider_model":"openai/gpt-4o-mini","thinking":"low","isolation":{"session":"none","tools":"none","extensions":"none","skills":"none"}},"prompt_sha256":prompt_sha,"artifact_path":".pi/workflows/model-routing-preset-runner.json"}), + )?; + fs::create_dir_all(owned.workdir.join(".pi-agent"))?; + let mut spec = command( + "pi-host", + Path::new("pi"), + [ + "--print", + "--no-session", + "--no-tools", + "--no-extensions", + "--no-skills", + "--provider", + "openai", + "--model", + "gpt-4o-mini", + "--thinking", + "low", + &prompt, + ], + &owned, + args.timeout, + ); + spec.env = BTreeMap::from([ + ( + "PI_CODING_AGENT_DIR".into(), + owned.workdir.join(".pi-agent").display().to_string(), + ), + ("PI_OFFLINE".into(), "1".into()), + ]); + let host_run = session.run_checked(spec, &owned)?; + fs::write(owned.workdir.join("host-output.txt"), &host_run.stdout)?; + fs::write(owned.workdir.join("host-output.stderr"), &host_run.stderr)?; + validate_pi(PiInput { + workflow: owned.workdir.join("workflow.json"), + invocation: owned.workdir.join("requested-invocation.json"), + stdout: owned.workdir.join("host-output.txt"), + stderr: owned.workdir.join("host-output.stderr"), + workflow_receipt: owned.workdir.join("workflow-receipt.json"), + dispatch_receipt: owned.workdir.join("dispatch-evidence.json"), + package_digest: sha256_file(&routing_bin)?, + host_version, + profile: "pi-worker".into(), + model: "openai/gpt-4o-mini".into(), + thinking: "low".into(), + agent_type: "switchloom-pi-worker".into(), + })?; + validate_bundle_receipt(&owned)?; + session.complete( + true, + Some("Pi is an isolated external runner and reports advisory dispatch evidence".into()), + )?; + println!( + "Pi live certification passed: {}", + owned.report_dir.display() + ); + Ok(()) +} + +pub(crate) fn run_codex(args: LiveRunArgs) -> Result<()> { + let host = "codex-openai"; + let owned = OwnedReportRepo::create(&args.report_root, host)?; + let mut session = CertificationSession::new(&owned, host); + let routing_bin = absolute_binary(&args.routing_bin)?; + let codex_home = std::env::var_os("CODEX_HOME") + .map(PathBuf::from) + .or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".codex"))) + .context("CODEX_HOME/HOME is unavailable")?; + let mut external_config = FileSnapshot::capture(codex_home.join("config.toml"))?; + session.track_restoration(external_config.outcome_tracker()); + session.run_checked( + command( + "compile", + &routing_bin, + [ + "compile", + "balanced", + "--host", + host, + "--output", + "bundle.json", + ], + &owned, + args.timeout, + ), + &owned, + )?; + session.run_checked( + command( + "apply", + &routing_bin, + ["apply", "bundle.json", "--repository", "."], + &owned, + args.timeout, + ), + &owned, + )?; + let version = session.run_checked( + command( + "codex-version", + Path::new("codex"), + ["--version"], + &owned, + args.timeout, + ), + &owned, + )?; + let host_version = last_line(&version.stdout, &version.stderr); + let worker_message = "Inspect the generated repository without editing files. End your final answer with SWITCHLOOM_STANDALONE_IMPLEMENTER_DONE."; + let reviewer_message = "Independently inspect the generated repository without editing files. End your final answer with SWITCHLOOM_STANDALONE_REVIEWER_DONE."; + let expected = json!({"package_digest":sha256_file(&routing_bin)?,"host_version":host_version,"children":[{"semantic_role":"implementer","profile":"codex-terra-high","agent_type":"model_routing_terra_high","task_name":"standalone_implementer","canonical_task":"/root/standalone_implementer","model":"gpt-5.6-terra","effort":"high","message_sha256":format!("{:x}",Sha256::digest(worker_message)),"max_message_bytes":512,"completion_contains":"SWITCHLOOM_STANDALONE_IMPLEMENTER_DONE","allow_encrypted_message":true},{"semantic_role":"reviewer","profile":"codex-sol-high","agent_type":"model_routing_sol_high","task_name":"standalone_reviewer","canonical_task":"/root/standalone_reviewer","model":"gpt-5.6-sol","effort":"high","message_sha256":format!("{:x}",Sha256::digest(reviewer_message)),"max_message_bytes":512,"completion_contains":"SWITCHLOOM_STANDALONE_REVIEWER_DONE","allow_encrypted_message":true}]}); + write_json(&owned.workdir.join("expected.json"), &expected)?; + let prompt = format!( + "Use the native collaboration spawn_agent tool exactly twice, then wait for both child agents to finish.\n\n\ +Your first tool call must be spawn_agent with exactly these fields:\n\ +- agent_type: model_routing_terra_high\n\ +- task_name: standalone_implementer\n\ +- fork_turns: none\n\ +- message: {worker_message}\n\n\ +Your second tool call must be spawn_agent with exactly these fields:\n\ +- agent_type: model_routing_sol_high\n\ +- task_name: standalone_reviewer\n\ +- fork_turns: none\n\ +- message: {reviewer_message}\n\n\ +Do not omit agent_type. Do not change either message. Do not pass model or reasoning_effort in either spawn call. Do not call wait_agent or answer before both spawn_agent calls have succeeded.\n\n\ +After both children finish, return a short final answer containing:\n\ +SWITCHLOOM_CODEX_RUNTIME_EVIDENCE_COMPLETE" + ); + let trust_override = format!( + "projects.\"{}\".trust_level=\"trusted\"", + owned.workdir.display() + ); + let terra_override = format!( + "agents.model_routing_terra_high.config_file=\"{}/.codex/agents/model-routing-terra-high.toml\"", + owned.workdir.display() + ); + let sol_override = format!( + "agents.model_routing_sol_high.config_file=\"{}/.codex/agents/model-routing-sol-high.toml\"", + owned.workdir.display() + ); + let host_run = run_process( + &command( + "codex-host", + Path::new("codex"), + [ + "exec", + "--json", + "--ignore-user-config", + "-C", + owned + .workdir + .to_str() + .context("workdir path is not UTF-8")?, + "-s", + "workspace-write", + "-c", + "approval_policy=\"never\"", + "-c", + &trust_override, + "-c", + &terra_override, + "-c", + &sol_override, + "-c", + "multi_agent_v2.hide_spawn_agent_metadata=false", + "-c", + "cli_auth_credentials_store=\"auto\"", + "-c", + "mcp_oauth_credentials_store=\"auto\"", + &prompt, + ], + &owned, + args.timeout, + ), + &owned.report_dir, + )?; + session.record(host_run.clone())?; + if let Err(error) = external_config.restore() { + session.fail(format!("failed to restore external Codex config: {error}"))?; + return Err(error); + } + if !host_run.success() { + session.fail(format!( + "Codex host failed with status {:?}{}; external state restored", + host_run.status, + if host_run.timed_out { + " after timeout" + } else { + "" + } + ))?; + bail!( + "Codex host failed with status {:?}{}; external state restored", + host_run.status, + if host_run.timed_out { + " after timeout" + } else { + "" + } + ); + } + fs::write(owned.workdir.join("codex-events.jsonl"), &host_run.stdout)?; + let receipt = super::extract_codex(CodexRawInput { + events: owned.workdir.join("codex-events.jsonl"), + workdir: owned.workdir.clone(), + expected: owned.workdir.join("expected.json"), + state_db: Some(codex_home.join("state_5.sqlite")), + sessions_dir: Some(codex_home.join("sessions")), + archived_sessions_dir: Some(codex_home.join("archived_sessions")), + })?; + fs::write(owned.workdir.join("codex-runtime-evidence.json"), receipt)?; + session.complete(true, None)?; + println!( + "Codex live certification passed: {}", + owned.report_dir.display() + ); + Ok(()) +} + +pub(crate) fn run_planr(args: PlanrRunArgs) -> Result<()> { + let before = repo_identity(&args.protected_planr_root)?; + let owned = OwnedReportRepo::create(&args.live.report_root, "planr")?; + let mut session = CertificationSession::new(&owned, "planr"); + let routing_bin = absolute_binary(&args.live.routing_bin)?; + let db = owned.workdir.join(".planr/planr.sqlite"); + session.run_checked( + command( + "planr-init", + Path::new("planr"), + [ + "--db", + db.to_str().context("db path is not UTF-8")?, + "project", + "init", + "Switchloom Planr Certification", + "--json", + ], + &owned, + args.live.timeout, + ), + &owned, + )?; + session.run_checked( + command( + "compile", + &routing_bin, + [ + "compile", + "balanced", + "--host", + "codex-openai", + "--integration", + "planr", + "--output", + "bundle.json", + ], + &owned, + args.live.timeout, + ), + &owned, + )?; + session.run_checked( + command( + "apply", + &routing_bin, + ["apply", "bundle.json", "--repository", "."], + &owned, + args.live.timeout, + ), + &owned, + )?; + for path in [ + ".codex/config.toml", + ".codex/agents/model-routing-terra-high.toml", + ".codex/agents/model-routing-sol-high.toml", + ".planr/agents.toml", + ".planr/policy.toml", + ] { + ensure_file(&owned.workdir.join(path))?; + } + session.run_checked( + command( + "planr-agents", + Path::new("planr"), + ["--db", db.to_str().unwrap(), "agents", "check", "--json"], + &owned, + args.live.timeout, + ), + &owned, + )?; + session.run_checked( + command( + "planr-routing", + Path::new("planr"), + [ + "--db", + db.to_str().unwrap(), + "prompt", + "routing", + "--client", + "codex", + "--json", + ], + &owned, + args.live.timeout, + ), + &owned, + )?; + let after = repo_identity(&args.protected_planr_root)?; + if before != after { + bail!("protected Planr repository changed during certification"); + } + session.complete( + true, + Some("Planr certification validates generated declarations and routing consumption without mutating protected Planr source/state".into()), + )?; + println!( + "Planr certification passed with protected repository unchanged: {}", + owned.report_dir.display() + ); + Ok(()) +} + +fn command<'a>( + label: &str, + program: &Path, + args: impl IntoIterator, + owned: &OwnedReportRepo, + timeout: Duration, +) -> ProcessSpec { + ProcessSpec { + label: label.into(), + program: program.display().to_string(), + args: args.into_iter().map(str::to_owned).collect(), + env: BTreeMap::new(), + cwd: owned.workdir.clone(), + timeout, + } +} +fn write_json(path: &Path, value: &impl Serialize) -> Result<()> { + let mut bytes = serde_json::to_vec_pretty(value)?; + bytes.push(b'\n'); + fs::write(path, bytes).with_context(|| format!("failed to write {}", path.display())) +} +fn absolute_binary(path: &Path) -> Result { + if path.is_absolute() { + Ok(path.to_owned()) + } else { + Ok(std::env::current_dir()?.join(path)) + } + .and_then(|path| { + if path.is_file() { + Ok(path) + } else { + bail!("routing binary not found at {}", path.display()) + } + }) +} +fn sha256_file(path: &Path) -> Result { + Ok(format!("sha256:{:x}", Sha256::digest(fs::read(path)?))) +} +fn nonce(prefix: &str) -> String { + let epoch = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + format!( + "{prefix}-{:x}", + Sha256::digest(format!("{}-{epoch}", std::process::id())) + ) +} +fn last_line(stdout: &str, stderr: &str) -> String { + stdout + .lines() + .chain(stderr.lines()) + .filter(|line| !line.trim().is_empty()) + .last() + .unwrap_or("unknown") + .trim() + .to_owned() +} +fn ensure_contains_nonce(receipt: &ProcessReceipt, nonce: &str) -> Result<()> { + if receipt.stdout.contains(nonce) || receipt.stderr.contains(nonce) { + Ok(()) + } else { + bail!("live host output did not return correlated nonce") + } +} +fn ensure_file(path: &Path) -> Result<()> { + if path.is_file() { + Ok(()) + } else { + bail!( + "expected certification artifact missing: {}", + path.display() + ) + } +} +fn validate_bundle_receipt(owned: &OwnedReportRepo) -> Result<()> { + let receipt = fs::read_to_string(owned.workdir.join("dispatch-evidence.json"))?; + let bundle = fs::read_to_string(owned.workdir.join("bundle.json"))?; + validate_dispatch_evidence_json_for_bundle(&receipt, &bundle) + .map_err(|error| anyhow::anyhow!(error)) +} +fn repo_identity(path: &Path) -> Result { + let head = std::process::Command::new("git") + .args(["rev-parse", "HEAD"]) + .current_dir(path) + .output()?; + let status = std::process::Command::new("git") + .args(["status", "--porcelain=v1", "-z", "--untracked-files=all"]) + .current_dir(path) + .output()?; + if !head.status.success() || !status.status.success() { + bail!("protected Planr path is not a readable Git repository"); + } + Ok(format!( + "{:x}", + Sha256::digest([head.stdout, status.stdout].concat()) + )) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn report_root(name: &str) -> PathBuf { + std::env::temp_dir().join(format!( + "switchloom-certification-session-{name}-{}-{}", + std::process::id(), + nonce(name) + )) + } + + #[test] + fn exit_seven_report_preserves_status_and_explicit_restoration() { + let root = report_root("exit-seven"); + let owned = OwnedReportRepo::create(&root, "test").unwrap(); + let state = root.join("external-config.toml"); + fs::write(&state, "original").unwrap(); + let mut session = CertificationSession::new(&owned, "test"); + { + let mut snapshot = FileSnapshot::capture(&state).unwrap(); + session.track_restoration(snapshot.outcome_tracker()); + fs::write(&state, "temporary").unwrap(); + let result = session.run_checked( + ProcessSpec { + label: "exit-seven".into(), + program: "sh".into(), + args: vec!["-c".into(), "exit 7".into()], + env: BTreeMap::new(), + cwd: owned.workdir.clone(), + timeout: Duration::from_secs(1), + }, + &owned, + ); + assert!(result.is_err()); + snapshot.restore().unwrap(); + } + drop(session); + let report: Value = serde_json::from_slice( + &fs::read(owned.report_dir.join("certification-report.json")).unwrap(), + ) + .unwrap(); + assert_eq!(report.pointer("/commands/0/status"), Some(&json!(7))); + assert_eq!( + report.pointer("/restoration/status"), + Some(&json!("restored_explicitly")) + ); + assert_eq!(fs::read_to_string(state).unwrap(), "original"); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn timeout_report_records_timeout_and_drop_restoration() { + let root = report_root("timeout"); + let owned = OwnedReportRepo::create(&root, "test").unwrap(); + let state = root.join("external-config.toml"); + fs::write(&state, "original").unwrap(); + let mut session = CertificationSession::new(&owned, "test"); + { + let snapshot = FileSnapshot::capture(&state).unwrap(); + session.track_restoration(snapshot.outcome_tracker()); + fs::write(&state, "temporary").unwrap(); + let result = session.run_checked( + ProcessSpec { + label: "timeout".into(), + program: "sh".into(), + args: vec!["-c".into(), "sleep 2".into()], + env: BTreeMap::new(), + cwd: owned.workdir.clone(), + timeout: Duration::from_millis(50), + }, + &owned, + ); + assert!(result.is_err()); + } + drop(session); + let report: Value = serde_json::from_slice( + &fs::read(owned.report_dir.join("certification-report.json")).unwrap(), + ) + .unwrap(); + assert_eq!(report.pointer("/commands/0/timed_out"), Some(&json!(true))); + assert_eq!(report.pointer("/commands/0/status"), Some(&Value::Null)); + assert_eq!( + report.pointer("/restoration/status"), + Some(&json!("restored_by_drop_fallback")) + ); + assert_eq!(fs::read_to_string(state).unwrap(), "original"); + fs::remove_dir_all(root).unwrap(); + } +} diff --git a/xtask/src/certify/mod.rs b/xtask/src/certify/mod.rs new file mode 100644 index 0000000..399e109 --- /dev/null +++ b/xtask/src/certify/mod.rs @@ -0,0 +1,116 @@ +mod codex; +mod codex_spawn; +mod live; +mod opencode; +mod pi; +mod runner; + +#[cfg(test)] +mod tests; + +use crate::{OpencodeArgs, PiArgs}; +use anyhow::{Context, Result, bail}; +use model_routing::validate_dispatch_evidence_json_for_bundle; +use std::fs; +use std::path::{Path, PathBuf}; + +pub(crate) use codex::validate as validate_codex; +pub(crate) use codex_spawn::{CodexRawInput, extract as extract_codex}; +pub(crate) use live::{ + LiveRunArgs, PlanrRunArgs, run_codex as run_live_codex, run_native as run_live_native, + run_opencode as run_live_opencode, run_pi as run_live_pi, run_planr, +}; +pub(crate) use opencode::validate as validate_opencode; +pub(crate) use pi::validate as validate_pi; + +pub(crate) fn validate_cursor(receipt: &Path, bundle: &Path) -> Result<()> { + let receipt = fs::read_to_string(receipt).context("failed to read Cursor evidence receipt")?; + let bundle = fs::read_to_string(bundle).context("failed to read Cursor compiled bundle")?; + validate_dispatch_evidence_json_for_bundle(&receipt, &bundle) + .map_err(|error| anyhow::anyhow!(error)) +} + +fn required(value: Option, name: &str) -> Result { + value.ok_or_else(|| anyhow::anyhow!("--{name} is required when validating evidence")) +} + +pub(crate) struct OpencodeInput { + pub jsonl: PathBuf, + pub invocation: PathBuf, + pub receipt: PathBuf, + pub package_digest: String, + pub host_version: String, + pub profile: String, + pub model: String, + pub variant: String, + pub worker: String, +} + +impl TryFrom for OpencodeInput { + type Error = anyhow::Error; + + fn try_from(value: OpencodeArgs) -> Result { + Ok(Self { + jsonl: required(value.jsonl, "jsonl")?, + invocation: required(value.invocation, "invocation")?, + receipt: required(value.receipt, "receipt")?, + package_digest: required(value.package_digest, "package-digest")?, + host_version: required(value.host_version, "host-version")?, + profile: required(value.profile, "profile")?, + model: required(value.model, "model")?, + variant: required(value.variant, "variant")?, + worker: required(value.worker, "worker")?, + }) + } +} + +pub(crate) struct PiInput { + pub workflow: PathBuf, + pub invocation: PathBuf, + pub stdout: PathBuf, + pub stderr: PathBuf, + pub workflow_receipt: PathBuf, + pub dispatch_receipt: PathBuf, + pub package_digest: String, + pub host_version: String, + pub profile: String, + pub model: String, + pub thinking: String, + pub agent_type: String, +} + +impl TryFrom for PiInput { + type Error = anyhow::Error; + + fn try_from(value: PiArgs) -> Result { + Ok(Self { + workflow: required(value.workflow, "workflow")?, + invocation: required(value.invocation, "invocation")?, + stdout: required(value.stdout, "stdout")?, + stderr: required(value.stderr, "stderr")?, + workflow_receipt: required(value.workflow_receipt, "workflow-receipt")?, + dispatch_receipt: required(value.dispatch_receipt, "dispatch-receipt")?, + package_digest: required(value.package_digest, "package-digest")?, + host_version: required(value.host_version, "host-version")?, + profile: required(value.profile, "profile")?, + model: required(value.model, "model")?, + thinking: required(value.thinking, "thinking")?, + agent_type: required(value.agent_type, "agent-type")?, + }) + } +} + +fn write_json(path: &Path, value: &impl serde::Serialize) -> Result<()> { + let mut bytes = + serde_json::to_vec_pretty(value).context("failed to serialize evidence receipt")?; + bytes.push(b'\n'); + fs::write(path, bytes).with_context(|| format!("failed to write {}", path.display())) +} + +fn ensure(condition: bool, message: impl std::fmt::Display) -> Result<()> { + if condition { + Ok(()) + } else { + bail!("{message}") + } +} diff --git a/xtask/src/certify/opencode.rs b/xtask/src/certify/opencode.rs new file mode 100644 index 0000000..69d1c93 --- /dev/null +++ b/xtask/src/certify/opencode.rs @@ -0,0 +1,216 @@ +use super::{OpencodeInput, ensure, write_json}; +use anyhow::{Context, Result}; +use model_routing::{ + ChildIdentityEvidence, DispatchEvidenceV1, ForkPolicy, GuaranteeLevel, + RequestedDispatchEvidence, +}; +use serde::Deserialize; +use serde_json::Value; +use std::fs; + +#[derive(Deserialize)] +struct Invocation { + nonce: String, +} + +pub(crate) fn validate(input: OpencodeInput) -> Result<()> { + let invocation: Invocation = serde_json::from_str( + &fs::read_to_string(&input.invocation).context("failed to read requested invocation")?, + ) + .context("requested invocation is not valid JSON")?; + ensure( + !invocation.nonce.trim().is_empty(), + "requested invocation must include nonce", + )?; + let events = + parse_jsonl(&fs::read_to_string(&input.jsonl).context("failed to read host output")?)?; + ensure(!events.is_empty(), "host output has no JSON events")?; + + let task_invocations = events + .iter() + .filter_map(|event| { + let id = string_for_keys(event, ID_KEYS)?; + (contains(event, &input.worker) && mentions_task(event)).then_some((event, id)) + }) + .collect::>(); + ensure( + !task_invocations.is_empty(), + format_args!( + "no structured Task invocation with non-null call ID targeted {}", + input.worker + ), + )?; + let task_ids = task_invocations + .iter() + .map(|(_, id)| id.as_str()) + .collect::>(); + + for event in &events { + if contains(event, &invocation.nonce) && is_result(event) { + if let (Some(id), Some(agent)) = ( + string_for_keys(event, ID_KEYS), + string_for_keys(event, AGENT_KEYS), + ) { + if task_ids.contains(&id.as_str()) && agent != input.worker { + anyhow::bail!("worker result came from {agent}, expected {}", input.worker); + } + } + } + } + let worker_event = events + .iter() + .find(|event| { + contains(event, &invocation.nonce) + && is_result(event) + && string_for_keys(event, ID_KEYS).is_some_and(|id| task_ids.contains(&id.as_str())) + && string_for_keys(event, AGENT_KEYS).as_deref() == Some(input.worker.as_str()) + }) + .with_context(|| { + format!( + "nonce {} was not returned by an explicit {} Task result with matching call ID", + invocation.nonce, input.worker + ) + })?; + let observed_agent = string_for_keys(worker_event, AGENT_KEYS) + .context("worker result is missing explicit child identity")?; + let effective_model = string_for_keys(worker_event, MODEL_KEYS).or_else(|| { + task_invocations + .iter() + .find_map(|(event, _)| string_for_keys(event, MODEL_KEYS)) + }); + let effective_effort = string_for_keys(worker_event, VARIANT_KEYS).or_else(|| { + task_invocations + .iter() + .find_map(|(event, _)| string_for_keys(event, VARIANT_KEYS)) + }); + + let receipt = DispatchEvidenceV1 { + schema_version: 1, + package_digest: input.package_digest, + host_version: input.host_version, + requested_dispatch: RequestedDispatchEvidence { + semantic_role: "worker".into(), + profile: input.profile, + model: input.model, + effort: Some(input.variant), + agent_type: Some(input.worker.clone()), + fork_turns: Some(ForkPolicy { + mode: "none".into(), + turns: None, + }), + }, + child_identity: ChildIdentityEvidence { + host: "opencode".into(), + role: "worker".into(), + agent_role: observed_agent.clone(), + agent_type: Some(observed_agent.clone()), + task_name: Some(observed_agent), + }, + effective_model, + effective_effort, + nonce: invocation.nonce, + raw_evidence_refs: vec![ + "requested-invocation:requested-invocation.json#argv".into(), + "host-output:host-output.jsonl#task".into(), + "host-stderr:host-output.stderr".into(), + ], + verdict: GuaranteeLevel::Advisory, + }; + write_json(&input.receipt, &receipt) +} + +const ID_KEYS: &[&str] = &[ + "id", + "toolCallID", + "toolCallId", + "call_id", + "callId", + "taskID", + "taskId", +]; +const AGENT_KEYS: &[&str] = &[ + "agent", + "agentName", + "agent_name", + "subagent", + "subagentName", + "taskAgent", + "task_agent", +]; +const MODEL_KEYS: &[&str] = &[ + "model", + "modelID", + "modelId", + "providerModel", + "provider_model", +]; +const VARIANT_KEYS: &[&str] = &["variant", "effort", "reasoningEffort", "reasoning_effort"]; + +fn parse_jsonl(text: &str) -> Result> { + text.lines() + .filter(|line| !line.trim().is_empty()) + .enumerate() + .map(|(index, line)| { + serde_json::from_str(line) + .with_context(|| format!("host output line {} is not JSON", index + 1)) + }) + .collect() +} + +fn visit(value: &Value, callback: &mut impl FnMut(&str, &Value)) { + match value { + Value::Array(values) => values.iter().for_each(|value| visit(value, callback)), + Value::Object(values) => { + for (key, value) in values { + callback(key, value); + visit(value, callback); + } + } + _ => {} + } +} + +fn string_for_keys(value: &Value, keys: &[&str]) -> Option { + let mut found = None; + visit(value, &mut |key, value| { + if found.is_none() && keys.contains(&key) { + found = value.as_str().map(str::to_owned); + } + }); + found +} + +fn contains(value: &Value, needle: &str) -> bool { + value.to_string().contains(needle) +} + +fn mentions_task(value: &Value) -> bool { + let mut found = false; + visit(value, &mut |key, value| { + if value.as_str().is_some_and(|text| { + (key.to_ascii_lowercase().contains("tool") + || key.to_ascii_lowercase().contains("type") + || key.to_ascii_lowercase().contains("name")) + && text.to_ascii_lowercase().contains("task") + }) { + found = true; + } + }); + found +} + +fn is_result(value: &Value) -> bool { + let mut found = false; + visit(value, &mut |key, value| { + if value.as_str().is_some_and(|text| { + let key = key.to_ascii_lowercase(); + let text = text.to_ascii_lowercase(); + ((key.contains("type") || key.contains("event") || key.contains("kind")) + && text.contains("result")) + || ((key.contains("tool") || key.contains("name")) && text.contains("result")) + }) { + found = true; + } + }); + found +} diff --git a/xtask/src/certify/pi.rs b/xtask/src/certify/pi.rs new file mode 100644 index 0000000..ce6c702 --- /dev/null +++ b/xtask/src/certify/pi.rs @@ -0,0 +1,282 @@ +use super::{PiInput, ensure, write_json}; +use anyhow::{Context, Result}; +use model_routing::{ + ChildIdentityEvidence, DispatchEvidenceV1, ForkPolicy, GuaranteeLevel, + RequestedDispatchEvidence, +}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::{collections::BTreeMap, fs}; + +#[derive(Deserialize)] +struct Workflow { + schema_version: u32, + workflow: String, + runner: String, + runtime_class: String, + arguments: Arguments, + process: Process, +} +#[derive(Deserialize)] +struct Arguments { + agent_type: String, + provider_model: String, + thinking: String, + isolation: Isolation, + task: Task, +} +#[derive(Clone, Deserialize, Serialize)] +struct Isolation { + session: String, + tools: String, + extensions: String, + skills: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + agent_dir: Option, +} +#[derive(Deserialize)] +struct Task { + semantic_role: String, + returns: String, +} +#[derive(Deserialize)] +struct Process { + argv: Vec, +} +#[derive(Deserialize)] +struct Invocation { + nonce: String, + argv: Vec, + env: BTreeMap, + prompt_sha256: String, +} + +#[derive(Serialize)] +struct WorkflowReceipt<'a> { + schema_version: u32, + runner: &'static str, + workflow: &'a str, + runtime_class: &'static str, + package_digest: &'a str, + host_version: &'a str, + invocation: ReceiptInvocation<'a>, + requested: ReceiptRequested<'a>, + observed: ReceiptObserved<'a>, + verdict: &'static str, +} +#[derive(Serialize)] +struct ReceiptInvocation<'a> { + argv: &'a [String], + env: &'a BTreeMap, + prompt_sha256: &'a str, +} +#[derive(Serialize)] +struct ReceiptRequested<'a> { + semantic_role: &'static str, + profile: &'a str, + agent_type: &'a str, + provider_model: &'a str, + thinking: &'a str, + isolation: &'a Isolation, +} +#[derive(Serialize)] +struct ReceiptObserved<'a> { + stdout_ref: &'static str, + stderr_ref: &'static str, + nonce: &'a str, +} + +pub(crate) fn validate(input: PiInput) -> Result<()> { + let workflow: Workflow = read_json(&input.workflow, "workflow")?; + let invocation: Invocation = read_json(&input.invocation, "requested invocation")?; + let stdout = fs::read_to_string(&input.stdout).context("failed to read host stdout")?; + fs::read_to_string(&input.stderr).context("failed to read host stderr")?; + ensure( + !invocation.nonce.trim().is_empty(), + "requested invocation must include nonce", + )?; + ensure( + workflow.schema_version == 1 + && workflow.runner == "pi" + && workflow.runtime_class == "external-runner", + "workflow must be schema v1 Pi external-runner", + )?; + let args = &workflow.arguments; + ensure( + args.agent_type == input.agent_type, + "workflow agent_type mismatch", + )?; + ensure( + args.provider_model == input.model, + "workflow provider_model mismatch", + )?; + ensure( + args.thinking == input.thinking, + "workflow thinking mismatch", + )?; + ensure( + args.task.semantic_role == "worker" && args.task.returns == "nonce-only", + "workflow task must require worker nonce-only return", + )?; + ensure( + args.isolation.session == "none", + "workflow isolation must disable session persistence", + )?; + ensure( + args.isolation.tools == "none", + "workflow isolation must disable tools", + )?; + ensure( + args.isolation.extensions == "none", + "workflow isolation must disable extensions", + )?; + ensure( + args.isolation.skills == "none", + "workflow isolation must disable skills", + )?; + for required in [ + "--print", + "--no-session", + "--no-tools", + "--no-extensions", + "--no-skills", + "--provider", + "--model", + "--thinking", + ] { + ensure( + workflow.process.argv.iter().any(|arg| arg == required), + format_args!("workflow process argv must include {required}"), + )?; + } + let expected_argv = [ + vec![ + "env".into(), + "PI_CODING_AGENT_DIR=.pi-agent".into(), + "PI_OFFLINE=1".into(), + ], + workflow.process.argv.clone(), + ] + .concat(); + ensure( + invocation.argv == expected_argv, + "requested invocation argv does not match workflow process argv with report-local env boundary", + )?; + ensure( + invocation + .env + .get("PI_CODING_AGENT_DIR") + .map(String::as_str) + == Some(".pi-agent") + && invocation.env.get("PI_OFFLINE").map(String::as_str) == Some("1"), + "requested invocation must set report-local PI_CODING_AGENT_DIR and PI_OFFLINE", + )?; + let executed = &invocation.argv[3..]; + ensure( + executed.first().map(String::as_str) == Some("pi"), + "requested invocation must execute pi", + )?; + let provider = option_value(executed, "--provider") + .context("requested invocation must include provider")?; + let model = + option_value(executed, "--model").context("requested invocation must include model")?; + let thinking = option_value(executed, "--thinking") + .context("requested invocation must include thinking")?; + ensure( + format!("{provider}/{model}") == input.model && thinking == input.thinking, + "requested invocation model or thinking mismatch", + )?; + let expected_prompt = format!( + "sha256:{:x}", + Sha256::digest(format!( + "Return only this nonce and no other text: {}", + invocation.nonce + )) + ); + ensure( + invocation.prompt_sha256 == expected_prompt, + "requested invocation prompt hash does not match nonce task", + )?; + ensure( + stdout.split_whitespace().collect::>().join(" ") == invocation.nonce, + format_args!( + "Pi child output did not exactly return nonce {}", + invocation.nonce + ), + )?; + + let workflow_receipt = WorkflowReceipt { + schema_version: 1, + runner: "pi", + workflow: &workflow.workflow, + runtime_class: "external-runner", + package_digest: &input.package_digest, + host_version: &input.host_version, + invocation: ReceiptInvocation { + argv: &invocation.argv, + env: &invocation.env, + prompt_sha256: &invocation.prompt_sha256, + }, + requested: ReceiptRequested { + semantic_role: "worker", + profile: &input.profile, + agent_type: &input.agent_type, + provider_model: &input.model, + thinking: &input.thinking, + isolation: &args.isolation, + }, + observed: ReceiptObserved { + stdout_ref: "host-output:host-output.txt", + stderr_ref: "host-stderr:host-output.stderr", + nonce: &invocation.nonce, + }, + verdict: "advisory", + }; + write_json(&input.workflow_receipt, &workflow_receipt)?; + let dispatch = DispatchEvidenceV1 { + schema_version: 1, + package_digest: input.package_digest.clone(), + host_version: input.host_version.clone(), + requested_dispatch: RequestedDispatchEvidence { + semantic_role: "worker".into(), + profile: input.profile.clone(), + model: input.model.clone(), + effort: Some(input.thinking.clone()), + agent_type: Some(input.agent_type.clone()), + fork_turns: Some(ForkPolicy { + mode: "none".into(), + turns: None, + }), + }, + child_identity: ChildIdentityEvidence { + host: "pi".into(), + role: "worker".into(), + agent_role: input.agent_type.clone(), + agent_type: Some(input.agent_type), + task_name: Some("model-routing-preset-runner".into()), + }, + effective_model: Some(input.model), + effective_effort: Some(input.thinking), + nonce: invocation.nonce, + raw_evidence_refs: vec![ + "workflow:workflow.json#arguments".into(), + "requested-invocation:requested-invocation.json#argv".into(), + "workflow-receipt:workflow-receipt.json".into(), + "host-output:host-output.txt".into(), + "host-stderr:host-output.stderr".into(), + ], + verdict: GuaranteeLevel::Advisory, + }; + write_json(&input.dispatch_receipt, &dispatch) +} + +fn read_json(path: &std::path::Path, label: &str) -> Result { + serde_json::from_str( + &fs::read_to_string(path).with_context(|| format!("failed to read {label}"))?, + ) + .with_context(|| format!("{label} is not valid JSON")) +} +fn option_value<'a>(argv: &'a [String], option: &str) -> Option<&'a str> { + let index = argv.iter().position(|arg| arg == option)?; + argv.get(index + 1).map(String::as_str) +} diff --git a/xtask/src/certify/runner.rs b/xtask/src/certify/runner.rs new file mode 100644 index 0000000..dc68b1d --- /dev/null +++ b/xtask/src/certify/runner.rs @@ -0,0 +1,411 @@ +use anyhow::{Context, Result, bail}; +use serde::{Deserialize, Serialize}; +use std::{ + cell::RefCell, + collections::BTreeMap, + fs::{self, File}, + path::{Path, PathBuf}, + process::{Command, ExitStatus, Stdio}, + rc::Rc, + sync::atomic::{AtomicU64, Ordering}, + thread, + time::{Duration, Instant, SystemTime, UNIX_EPOCH}, +}; + +static NEXT_RUN: AtomicU64 = AtomicU64::new(0); + +#[derive(Debug)] +pub(crate) struct OwnedReportRepo { + pub report_dir: PathBuf, + pub workdir: PathBuf, +} + +impl OwnedReportRepo { + pub fn create(report_root: &Path, host: &str) -> Result { + let report_root = if report_root.is_absolute() { + report_root.to_owned() + } else { + std::env::current_dir()?.join(report_root) + }; + let epoch = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs(); + let suffix = NEXT_RUN.fetch_add(1, Ordering::Relaxed); + let report_dir = report_root + .join(host) + .join(format!("{epoch}-{}-{suffix}", std::process::id())); + let workdir = report_dir.join("workdir"); + fs::create_dir_all(&workdir).context("failed to create owned certification repository")?; + let status = Command::new("git") + .args(["init", "--quiet"]) + .current_dir(&workdir) + .status() + .context("failed to initialize owned certification repository")?; + if !status.success() { + bail!("git init failed for owned certification repository"); + } + Ok(Self { + report_dir, + workdir, + }) + } +} + +#[derive(Debug)] +pub(crate) struct FileSnapshot { + path: PathBuf, + original: Option>, + restored: bool, + outcome: RestorationTracker, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(tag = "status", content = "error", rename_all = "snake_case")] +pub(crate) enum RestorationOutcome { + NotRequired, + Pending, + RestoredExplicitly, + RestoredByDropFallback, + Failed(String), +} + +pub(crate) type RestorationTracker = Rc>; + +impl FileSnapshot { + pub fn capture(path: impl Into) -> Result { + let path = path.into(); + let original = if path.exists() { + Some( + fs::read(&path) + .with_context(|| format!("failed to snapshot {}", path.display()))?, + ) + } else { + None + }; + Ok(Self { + path, + original, + restored: false, + outcome: Rc::new(RefCell::new(RestorationOutcome::Pending)), + }) + } + + pub fn restore(&mut self) -> Result<()> { + match restore_file(&self.path, self.original.as_deref()) { + Ok(()) => { + self.restored = true; + *self.outcome.borrow_mut() = RestorationOutcome::RestoredExplicitly; + Ok(()) + } + Err(error) => { + *self.outcome.borrow_mut() = RestorationOutcome::Failed(error.to_string()); + Err(error) + } + } + } + + pub fn outcome_tracker(&self) -> RestorationTracker { + Rc::clone(&self.outcome) + } +} + +impl Drop for FileSnapshot { + fn drop(&mut self) { + if !self.restored { + *self.outcome.borrow_mut() = match restore_file(&self.path, self.original.as_deref()) { + Ok(()) => RestorationOutcome::RestoredByDropFallback, + Err(error) => RestorationOutcome::Failed(error.to_string()), + }; + } + } +} + +fn restore_file(path: &Path, original: Option<&[u8]>) -> Result<()> { + match original { + Some(bytes) => { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + fs::write(path, bytes) + .with_context(|| format!("failed to restore {}", path.display()))?; + } + None if path.exists() => fs::remove_file(path).with_context(|| { + format!( + "failed to remove temporary external state {}", + path.display() + ) + })?, + None => {} + } + Ok(()) +} + +#[derive(Debug)] +pub(crate) struct ProcessSpec { + pub label: String, + pub program: String, + pub args: Vec, + pub env: BTreeMap, + pub cwd: PathBuf, + pub timeout: Duration, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub(crate) struct ProcessReceipt { + pub label: String, + pub argv: Vec, + pub env_keys: Vec, + pub stdout: String, + pub stderr: String, + pub status: Option, + pub timed_out: bool, + pub elapsed_ms: u128, +} + +impl ProcessReceipt { + pub fn success(&self) -> bool { + self.status == Some(0) && !self.timed_out + } +} + +pub(crate) fn run_process(spec: &ProcessSpec, capture_dir: &Path) -> Result { + run_process_with_environment(spec, capture_dir, std::env::vars()) +} + +fn run_process_with_environment( + spec: &ProcessSpec, + capture_dir: &Path, + ambient_env: impl IntoIterator, +) -> Result { + fs::create_dir_all(capture_dir)?; + let stdout_path = capture_dir.join(format!("{}.stdout", spec.label)); + let stderr_path = capture_dir.join(format!("{}.stderr", spec.label)); + let raw = RawCapture::create(&spec.label)?; + let stdout_file = File::create(&raw.stdout)?; + let stderr_file = File::create(&raw.stderr)?; + let mut effective_env = ambient_env.into_iter().collect::>(); + effective_env.extend(spec.env.clone()); + let secrets = effective_env + .iter() + .filter(|(key, _)| secret_key(key)) + .map(|(_, value)| value.clone()) + .filter(|value| !value.is_empty()) + .collect::>(); + let started = Instant::now(); + let mut child = Command::new(&spec.program) + .args(&spec.args) + .env_clear() + .envs(&effective_env) + .current_dir(&spec.cwd) + .stdin(Stdio::null()) + .stdout(Stdio::from(stdout_file)) + .stderr(Stdio::from(stderr_file)) + .spawn() + .with_context(|| format!("failed to spawn {}", spec.label))?; + let (status, timed_out) = wait_bounded(&mut child, spec.timeout)?; + let secret_refs = secrets.iter().map(String::as_str).collect::>(); + let stdout = redact( + &String::from_utf8_lossy(&fs::read(&raw.stdout).unwrap_or_default()), + &secret_refs, + ); + let stderr = redact( + &String::from_utf8_lossy(&fs::read(&raw.stderr).unwrap_or_default()), + &secret_refs, + ); + fs::write(&stdout_path, &stdout)?; + fs::write(&stderr_path, &stderr)?; + let argv = std::iter::once(spec.program.as_str()) + .chain(spec.args.iter().map(String::as_str)) + .map(|value| redact(value, &secret_refs)) + .collect(); + let receipt = ProcessReceipt { + label: spec.label.clone(), + argv, + env_keys: spec + .env + .keys() + .filter(|key| !secret_key(key)) + .cloned() + .collect(), + stdout, + stderr, + status: status.code(), + timed_out, + elapsed_ms: started.elapsed().as_millis(), + }; + write_receipt(capture_dir, &receipt)?; + Ok(receipt) +} + +struct RawCapture { + stdout: PathBuf, + stderr: PathBuf, +} + +impl RawCapture { + fn create(label: &str) -> Result { + let id = NEXT_RUN.fetch_add(1, Ordering::Relaxed); + let root = std::env::temp_dir().join(format!( + "switchloom-capture-{}-{}-{id}", + std::process::id(), + label.replace(|character: char| !character.is_ascii_alphanumeric(), "-") + )); + fs::create_dir_all(&root)?; + Ok(Self { + stdout: root.join("stdout"), + stderr: root.join("stderr"), + }) + } +} + +impl Drop for RawCapture { + fn drop(&mut self) { + if let Some(root) = self.stdout.parent() { + let _ = fs::remove_dir_all(root); + } + } +} + +fn write_receipt(capture_dir: &Path, receipt: &ProcessReceipt) -> Result<()> { + let mut bytes = serde_json::to_vec_pretty(receipt)?; + bytes.push(b'\n'); + fs::write( + capture_dir.join(format!("{}.receipt.json", receipt.label)), + bytes, + )?; + Ok(()) +} + +fn wait_bounded(child: &mut std::process::Child, timeout: Duration) -> Result<(ExitStatus, bool)> { + let deadline = Instant::now() + timeout; + loop { + if let Some(status) = child.try_wait()? { + return Ok((status, false)); + } + if Instant::now() >= deadline { + child + .kill() + .context("failed to terminate timed-out host process")?; + return Ok((child.wait()?, true)); + } + thread::sleep(Duration::from_millis(10)); + } +} + +fn secret_key(key: &str) -> bool { + let key = key.to_ascii_uppercase(); + [ + "TOKEN", + "SECRET", + "PASSWORD", + "CREDENTIAL", + "AUTH", + "API_KEY", + ] + .iter() + .any(|needle| key.contains(needle)) +} + +fn redact(value: &str, secrets: &[&str]) -> String { + let mut redacted = value.to_owned(); + for secret in secrets { + redacted = redacted.replace(secret, "[REDACTED]"); + } + redacted +} + +#[cfg(test)] +mod tests { + use super::*; + + fn temp(name: &str) -> PathBuf { + let path = std::env::temp_dir().join(format!( + "switchloom-runner-{name}-{}-{}", + std::process::id(), + NEXT_RUN.fetch_add(1, Ordering::Relaxed) + )); + fs::create_dir_all(&path).unwrap(); + path + } + + #[test] + fn exit_seven_is_preserved_after_explicit_restoration() { + let root = temp("exit-7"); + let state = root.join("config.toml"); + fs::write(&state, "original").unwrap(); + let mut snapshot = FileSnapshot::capture(&state).unwrap(); + fs::write(&state, "temporary mutation").unwrap(); + let receipt = run_process( + &ProcessSpec { + label: "exit-seven".into(), + program: "sh".into(), + args: vec!["-c".into(), "exit 7".into()], + env: BTreeMap::new(), + cwd: root.clone(), + timeout: Duration::from_secs(2), + }, + &root, + ) + .unwrap(); + snapshot.restore().unwrap(); + assert_eq!(receipt.status, Some(7)); + let retained: ProcessReceipt = + serde_json::from_slice(&fs::read(root.join("exit-seven.receipt.json")).unwrap()) + .unwrap(); + assert_eq!(retained.status, Some(7)); + assert_eq!(fs::read_to_string(state).unwrap(), "original"); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn drop_fallback_restores_temporary_external_state() { + let root = temp("drop"); + let state = root.join("config.toml"); + fs::write(&state, "original").unwrap(); + { + let _snapshot = FileSnapshot::capture(&state).unwrap(); + fs::write(&state, "temporary mutation").unwrap(); + } + assert_eq!(fs::read_to_string(state).unwrap(), "original"); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn runner_times_out_and_redacts_secret_values() { + let root = temp("timeout"); + let secret = "never-print-this-token"; + let receipt = run_process_with_environment( + &ProcessSpec { + label: "timeout".into(), + program: "sh".into(), + args: vec![ + "-c".into(), + "printf '%s' \"$AMBIENT_AUTH_TOKEN\"; sleep 2".into(), + ], + env: BTreeMap::new(), + cwd: root.clone(), + timeout: Duration::from_millis(50), + }, + &root, + [("AMBIENT_AUTH_TOKEN".into(), secret.into())], + ) + .unwrap(); + assert!(receipt.timed_out); + assert_eq!(receipt.status, None); + assert!(!receipt.stdout.contains(secret)); + assert!( + !fs::read_to_string(root.join("timeout.stdout")) + .unwrap() + .contains(secret) + ); + assert!( + !fs::read_to_string(root.join("timeout.stderr")) + .unwrap() + .contains(secret) + ); + let retained: ProcessReceipt = + serde_json::from_slice(&fs::read(root.join("timeout.receipt.json")).unwrap()).unwrap(); + assert!(retained.timed_out); + assert_eq!(retained.status, None); + assert!(!retained.stdout.contains(secret)); + fs::remove_dir_all(root).unwrap(); + } +} diff --git a/xtask/src/certify/tests.rs b/xtask/src/certify/tests.rs new file mode 100644 index 0000000..c28a6c0 --- /dev/null +++ b/xtask/src/certify/tests.rs @@ -0,0 +1,187 @@ +use super::{OpencodeInput, PiInput, validate_codex, validate_opencode, validate_pi}; +use serde_json::{Value, json}; +use sha2::{Digest, Sha256}; +use std::{ + fs, + path::PathBuf, + sync::atomic::{AtomicU64, Ordering}, +}; + +static NEXT_TEMP: AtomicU64 = AtomicU64::new(0); + +struct TempDir(PathBuf); +impl TempDir { + fn new(name: &str) -> Self { + let path = std::env::temp_dir().join(format!( + "switchloom-{name}-{}-{}", + std::process::id(), + NEXT_TEMP.fetch_add(1, Ordering::Relaxed) + )); + fs::create_dir_all(&path).unwrap(); + Self(path) + } + fn write(&self, name: &str, contents: impl AsRef<[u8]>) -> PathBuf { + let path = self.0.join(name); + fs::write(&path, contents).unwrap(); + path + } +} +impl Drop for TempDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} + +fn codex_receipt() -> Value { + let parent = "11111111-1111-4111-8111-111111111111"; + let child = "22222222-2222-4222-8222-222222222222"; + let hash = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + json!({ + "schema_version": "switchloom.codex_runtime_evidence.v1", + "run": { "status": "complete", "complete_marker": "SWITCHLOOM_CODEX_RUNTIME_EVIDENCE_COMPLETE", "evidence_source": "codex_persisted_spawn_state", "parent_thread_id": parent, "parent_session": format!("{parent}.jsonl"), "workdir": "/tmp/work" }, + "children": [{ + "kind": "worker", "profile": "codex-terra-high", "agent_type": "model_routing_terra_high", "task_name": "worker", "canonical_task": "/root/worker", "parent_thread_id": parent, "child_thread_id": child, + "spawn": { "surface": "collaboration.spawn_agent", "agent_type": "model_routing_terra_high", "task_name": "worker", "fork_turns": "none", "call_id": "call-worker" }, + "input": { "message_sha256": hash, "message_bytes": 12, "max_message_bytes": 512, "message_encoding": "plaintext", "message_plaintext_verdict": "deterministic" }, + "spawn_output": { "task_name": "/root/worker" }, + "session": { "agent_role": "model_routing_terra_high", "agent_path": "/root/worker", "thread_source": "subagent", "parent_thread_id": parent, "session_file": format!("{child}.jsonl") }, + "state": { "agent_role": "model_routing_terra_high", "agent_path": "/root/worker", "model": "gpt-5.6-terra", "reasoning_effort": "high", "thread_source": "subagent", "cwd": "/tmp/work" }, + "final_answer": { "message_type": "FINAL_ANSWER" } + }], + "dispatch_evidence": [{ + "schema_version": 1, "package_digest": format!("sha256:{hash}"), "host_version": "codex 0.144.0", + "requested_dispatch": { "semantic_role": "worker", "profile": "codex-terra-high", "model": "gpt-5.6-terra", "effort": "high", "agent_type": "model_routing_terra_high", "fork_turns": { "mode": "none" }, "message_sha256": hash, "message_encoding": "plaintext", "message_plaintext_verdict": "deterministic", "message_bytes": 12, "max_message_bytes": 512 }, + "child_identity": { "host": "codex", "role": "worker", "agent_role": "model_routing_terra_high", "agent_type": "model_routing_terra_high", "task_name": "worker" }, + "effective_model": "gpt-5.6-terra", "effective_effort": "high", "nonce": format!("{parent}:{child}:call-worker"), + "raw_evidence_refs": [format!("codex-session:{parent}.jsonl"), format!("codex-session:{child}.jsonl"), format!("state_5.sqlite:thread_spawn_edges:{parent}:{child}"), "spawn_call:call-worker"], "verdict": "deterministic" + }] + }) +} + +fn validate_codex_value(value: &Value) -> anyhow::Result<()> { + let dir = TempDir::new("codex"); + let path = dir.write("receipt.json", serde_json::to_vec(value).unwrap()); + validate_codex(&path, None) +} + +#[test] +fn codex_accepts_correlated_typed_receipt() { + validate_codex_value(&codex_receipt()).unwrap(); +} + +#[test] +fn codex_fails_closed_on_prose_missing_inherited_uncorrelated_and_tampered_evidence() { + let dir = TempDir::new("codex-prose"); + let prose = dir.write("receipt.json", "worker used Terra High"); + assert!(validate_codex(&prose, None).is_err()); + for mutate in [ + |v: &mut Value| { + v["run"].as_object_mut().unwrap().remove("complete_marker"); + }, + |v: &mut Value| { + v["children"][0]["session"]["parent_thread_id"] = + json!("33333333-3333-4333-8333-333333333333"); + }, + |v: &mut Value| { + v["children"][0]["state"]["model"] = json!("gpt-5.6-sol"); + v["children"][0]["state"]["reasoning_effort"] = json!("medium"); + }, + |v: &mut Value| { + v["dispatch_evidence"][0]["nonce"] = json!("nonce-placeholder"); + }, + ] { + let mut receipt = codex_receipt(); + mutate(&mut receipt); + assert!(validate_codex_value(&receipt).is_err()); + } +} + +fn opencode_input(dir: &TempDir, events: &[Value]) -> OpencodeInput { + OpencodeInput { + jsonl: dir.write( + "host.jsonl", + events + .iter() + .map(Value::to_string) + .collect::>() + .join("\n"), + ), + invocation: dir.write("invocation.json", r#"{"nonce":"nonce-123"}"#), + receipt: dir.0.join("receipt.json"), + package_digest: "sha256:abc".into(), + host_version: "1.14.17".into(), + profile: "opencode-worker".into(), + model: "opencode/gpt-5-nano".into(), + variant: "low".into(), + worker: "model-routing-preset-worker".into(), + } +} + +#[test] +fn opencode_accepts_only_correlated_structured_task_results() { + let dir = TempDir::new("opencode-valid"); + validate_opencode(opencode_input(&dir, &[json!({"type":"tool_call","tool":"Task","id":"call-1","agent":"model-routing-preset-worker","model":"opencode/gpt-5-nano","variant":"low"}), json!({"type":"tool_result","toolCallID":"call-1","agent":"model-routing-preset-worker","result":"nonce-123"})])).unwrap(); + let dir = TempDir::new("opencode-prose"); + assert!(validate_opencode(opencode_input(&dir, &[json!({"type":"message","agent":"driver","text":"model-routing-preset-worker returned nonce-123"})])).is_err()); + let dir = TempDir::new("opencode-tamper"); + assert!(validate_opencode(opencode_input(&dir, &[json!({"type":"tool_call","tool":"Task","id":"call-1","agent":"model-routing-preset-worker"}), json!({"type":"tool_result","toolCallID":"call-1","agent":"other-worker","result":"nonce-123"})])).is_err()); +} + +fn pi_fixture(dir: &TempDir) -> PiInput { + let argv = [ + "pi", + "--print", + "--no-session", + "--no-tools", + "--no-extensions", + "--no-skills", + "--provider", + "openai", + "--model", + "gpt-4o-mini", + "--thinking", + "low", + ]; + let nonce = "nonce-123"; + let prompt = format!( + "sha256:{:x}", + Sha256::digest(format!("Return only this nonce and no other text: {nonce}")) + ); + let workflow = json!({"schema_version":1,"workflow":"model-routing-preset-runner","runner":"pi","runtime_class":"external-runner","arguments":{"agent_type":"switchloom-pi-worker","provider_model":"openai/gpt-4o-mini","thinking":"low","isolation":{"session":"none","tools":"none","extensions":"none","skills":"none","agent_dir":"report-workdir/.pi-agent"},"task":{"semantic_role":"worker","returns":"nonce-only"}},"process":{"argv":argv}}); + let invocation_argv = ["env", "PI_CODING_AGENT_DIR=.pi-agent", "PI_OFFLINE=1"] + .into_iter() + .chain(argv) + .collect::>(); + let invocation = json!({"nonce":nonce,"argv":invocation_argv,"env":{"PI_CODING_AGENT_DIR":".pi-agent","PI_OFFLINE":"1"},"prompt_sha256":prompt}); + PiInput { + workflow: dir.write("workflow.json", serde_json::to_vec(&workflow).unwrap()), + invocation: dir.write("invocation.json", serde_json::to_vec(&invocation).unwrap()), + stdout: dir.write("stdout", nonce), + stderr: dir.write("stderr", ""), + workflow_receipt: dir.0.join("workflow-receipt.json"), + dispatch_receipt: dir.0.join("dispatch.json"), + package_digest: format!("sha256:{}", "b".repeat(64)), + host_version: "0.66.1".into(), + profile: "pi-worker".into(), + model: "openai/gpt-4o-mini".into(), + thinking: "low".into(), + agent_type: "switchloom-pi-worker".into(), + } +} + +#[test] +fn pi_accepts_nonce_only_and_rejects_prose_or_tampered_workflow() { + let dir = TempDir::new("pi-valid"); + validate_pi(pi_fixture(&dir)).unwrap(); + let dir = TempDir::new("pi-prose"); + let mut input = pi_fixture(&dir); + input.stdout = dir.write("stdout-bad", "nonce-123 plus prose"); + assert!(validate_pi(input).is_err()); + let dir = TempDir::new("pi-tamper"); + let input = pi_fixture(&dir); + let mut workflow: Value = + serde_json::from_str(&fs::read_to_string(&input.workflow).unwrap()).unwrap(); + workflow["arguments"]["isolation"]["tools"] = json!("default"); + fs::write(&input.workflow, serde_json::to_vec(&workflow).unwrap()).unwrap(); + assert!(validate_pi(input).is_err()); +} diff --git a/xtask/src/main.rs b/xtask/src/main.rs new file mode 100644 index 0000000..786a150 --- /dev/null +++ b/xtask/src/main.rs @@ -0,0 +1,448 @@ +use anyhow::{Result, bail}; +use clap::{Args, Parser, Subcommand}; +use std::path::PathBuf; + +mod certify; +mod release; + +#[derive(Debug, Parser)] +#[command(name = "xtask", about = "Unpublished Switchloom maintainer tooling")] +struct Cli { + #[command(subcommand)] + command: Command, +} + +#[derive(Debug, Subcommand)] +enum Command { + /// Run bounded host or integration certification. + Certify(Box), + /// Run deterministic offline verification. + Verify(VerifyArgs), + /// Prepare and verify release artifacts without publishing them. + Release(Box), +} + +#[derive(Debug, Args)] +struct CertifyArgs { + #[command(subcommand)] + command: CertifyCommand, +} + +#[derive(Debug, Subcommand)] +enum CertifyCommand { + Codex(CodexArgs), + Cursor(CursorArgs), + Opencode(OpencodeArgs), + Pi(PiArgs), + Planr(PlanrArgs), +} + +#[derive(Debug, Args)] +struct CodexArgs { + /// Validate an extracted Codex persisted-runtime receipt. + #[arg(long)] + receipt: Option, + /// Bind the receipt to an independently recorded expected dispatch. + #[arg(long)] + expect: Option, + /// Extract and validate evidence directly from raw Codex runtime state. + #[arg(long, conflicts_with = "receipt")] + events: Option, + #[arg(long, requires = "events")] + workdir: Option, + #[arg(long, requires = "events")] + state_db: Option, + #[arg(long, requires = "events")] + sessions_dir: Option, + #[arg(long, requires = "events")] + archived_sessions_dir: Option, + #[arg(long, default_value = "target/debug/model-routing")] + routing_bin: PathBuf, + #[arg(long, default_value = "reports/native-host-certification")] + report_root: PathBuf, + #[arg(long, default_value_t = 180)] + timeout_seconds: u64, +} + +#[derive(Debug, Args)] +struct CursorArgs { + /// Validate a Cursor dispatch receipt against its compiled bundle. + #[arg(long)] + receipt: Option, + #[arg(long, requires = "receipt")] + bundle: Option, + #[arg(long, default_value = "cursor-openai")] + host: String, + #[arg(long, default_value = "target/debug/model-routing")] + routing_bin: PathBuf, + #[arg(long, default_value = "reports/native-host-certification")] + report_root: PathBuf, + #[arg(long, default_value_t = 180)] + timeout_seconds: u64, +} + +#[derive(Debug, Args)] +struct OpencodeArgs { + #[arg(long)] + jsonl: Option, + #[arg(long)] + invocation: Option, + #[arg(long)] + receipt: Option, + #[arg(long)] + package_digest: Option, + #[arg(long)] + host_version: Option, + #[arg(long)] + profile: Option, + #[arg(long)] + model: Option, + #[arg(long)] + variant: Option, + #[arg(long)] + worker: Option, + #[arg(long, default_value = "target/debug/model-routing")] + routing_bin: PathBuf, + #[arg(long, default_value = "reports/native-host-certification")] + report_root: PathBuf, + #[arg(long, default_value_t = 180)] + timeout_seconds: u64, +} + +#[derive(Debug, Args)] +struct PiArgs { + #[arg(long)] + workflow: Option, + #[arg(long)] + invocation: Option, + #[arg(long)] + stdout: Option, + #[arg(long)] + stderr: Option, + #[arg(long)] + workflow_receipt: Option, + #[arg(long)] + dispatch_receipt: Option, + #[arg(long)] + package_digest: Option, + #[arg(long)] + host_version: Option, + #[arg(long)] + profile: Option, + #[arg(long)] + model: Option, + #[arg(long)] + thinking: Option, + #[arg(long)] + agent_type: Option, + #[arg(long, default_value = "target/debug/model-routing")] + routing_bin: PathBuf, + #[arg(long, default_value = "reports/native-host-certification")] + report_root: PathBuf, + #[arg(long, default_value_t = 180)] + timeout_seconds: u64, +} + +#[derive(Debug, Args)] +struct PlanrArgs { + #[arg(long, default_value = "target/debug/model-routing")] + routing_bin: PathBuf, + #[arg(long, default_value = "reports/native-host-certification")] + report_root: PathBuf, + #[arg(long, default_value_t = 180)] + timeout_seconds: u64, + #[arg(long, default_value = "/Users/kregenrek/projects/planr")] + protected_planr_root: PathBuf, +} + +#[derive(Debug, Args)] +struct VerifyArgs { + #[command(subcommand)] + command: VerifyCommand, +} + +#[derive(Clone, Copy, Debug, Subcommand)] +enum VerifyCommand { + Offline, +} + +#[derive(Debug, Args)] +struct ReleaseArgs { + #[command(subcommand)] + command: ReleaseCommand, +} + +#[derive(Debug, Subcommand)] +enum ReleaseCommand { + Prepare(ReleasePrepareArgs), + Verify(ReleaseVerifyArgs), + Package(ReleasePackageArgs), +} + +#[derive(Debug, Args)] +struct ReleasePrepareArgs { + #[arg(long)] + version: Option, + #[arg(long, default_value = ".")] + root: PathBuf, + #[arg(long)] + allow_dirty: bool, +} + +#[derive(Debug, Args)] +struct ReleaseVerifyArgs { + #[arg(long, default_value = ".")] + root: PathBuf, + #[arg(long)] + inventory_only: bool, + #[arg(long)] + contract_only: bool, + #[arg(long)] + require_provenance: bool, + #[arg(long)] + expected_tag: Option, +} + +#[derive(Debug, Args)] +struct ReleasePackageArgs { + #[arg(long, default_value = ".")] + root: PathBuf, + #[arg(long)] + target: Option, + #[arg(long)] + cargo_target: Option, + #[arg(long)] + stage_npm: bool, + #[arg(long)] + assemble_provenance: bool, + #[arg(long)] + aggregate_checksums_dir: Option, + #[arg(long)] + provenance_dir: Option, + #[arg(long, default_value = "local")] + runner: String, + #[arg(long)] + git_sha: Option, + #[arg(long, default_value = "local-reproducible")] + built_at: String, + #[arg(long, default_value = "xtask-release")] + generated_by: String, +} + +fn main() { + if let Err(error) = run(Cli::parse()) { + eprintln!("error: {error:#}"); + std::process::exit(1); + } +} + +fn run(cli: Cli) -> Result<()> { + let command = match cli.command { + Command::Certify(args) => match args.command { + CertifyCommand::Codex(args) => { + if let Some(receipt) = args.receipt { + certify::validate_codex(&receipt, args.expect.as_deref())?; + println!("codex runtime evidence validation passed"); + return Ok(()); + } + if let Some(events) = args.events { + let receipt = certify::extract_codex(certify::CodexRawInput { + events, + workdir: args.workdir.ok_or_else(|| { + anyhow::anyhow!("--workdir is required with --events") + })?, + expected: args + .expect + .ok_or_else(|| anyhow::anyhow!("--expect is required with --events"))?, + state_db: args.state_db, + sessions_dir: args.sessions_dir, + archived_sessions_dir: args.archived_sessions_dir, + })?; + println!("{receipt}"); + return Ok(()); + } + certify::run_live_codex(certify::LiveRunArgs::new( + args.routing_bin, + args.report_root, + args.timeout_seconds, + ))?; + return Ok(()); + } + CertifyCommand::Cursor(args) => { + if let (Some(receipt), Some(bundle)) = (args.receipt, args.bundle) { + certify::validate_cursor(&receipt, &bundle)?; + println!("cursor runtime evidence validation passed"); + return Ok(()); + } + certify::run_live_native( + &args.host, + certify::LiveRunArgs::new( + args.routing_bin, + args.report_root, + args.timeout_seconds, + ), + )?; + return Ok(()); + } + CertifyCommand::Opencode(args) => { + if args.jsonl.is_some() { + certify::validate_opencode(args.try_into()?)?; + println!("opencode runtime evidence validated"); + return Ok(()); + } + certify::run_live_opencode(certify::LiveRunArgs::new( + args.routing_bin, + args.report_root, + args.timeout_seconds, + ))?; + return Ok(()); + } + CertifyCommand::Pi(args) => { + if args.workflow.is_some() { + certify::validate_pi(args.try_into()?)?; + println!("pi runtime evidence validated"); + return Ok(()); + } + certify::run_live_pi(certify::LiveRunArgs::new( + args.routing_bin, + args.report_root, + args.timeout_seconds, + ))?; + return Ok(()); + } + CertifyCommand::Planr(args) => { + certify::run_planr(certify::PlanrRunArgs { + live: certify::LiveRunArgs::new( + args.routing_bin, + args.report_root, + args.timeout_seconds, + ), + protected_planr_root: args.protected_planr_root, + })?; + return Ok(()); + } + }, + Command::Verify(args) => match args.command { + VerifyCommand::Offline => "verify offline", + }, + Command::Release(args) => match args.command { + ReleaseCommand::Prepare(args) => { + release::prepare(release::PrepareOptions { + root: args.root, + version: args.version, + allow_dirty: args.allow_dirty, + })?; + return Ok(()); + } + ReleaseCommand::Verify(args) => { + release::verify(release::VerifyOptions { + root: args.root, + inventory_only: args.inventory_only, + contract_only: args.contract_only, + require_provenance: args.require_provenance, + expected_tag: args.expected_tag, + })?; + return Ok(()); + } + ReleaseCommand::Package(args) => { + release::package(release::PackageOptions { + root: args.root, + target: args.target, + cargo_target: args.cargo_target, + stage_npm: args.stage_npm, + assemble_provenance: args.assemble_provenance, + aggregate_checksums_dir: args.aggregate_checksums_dir, + provenance_dir: args.provenance_dir, + runner: args.runner, + git_sha: args.git_sha, + built_at: args.built_at, + generated_by: args.generated_by, + })?; + return Ok(()); + } + }, + }; + + bail!( + "internal command root `{command}` is reserved but not implemented; later migration slices must transfer one canonical owner before enabling it" + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use clap::CommandFactory; + + #[test] + fn internal_command_tree_matches_the_ownership_contract() { + let command = Cli::command(); + let roots = command + .get_subcommands() + .map(|subcommand| subcommand.get_name()) + .collect::>(); + assert_eq!(roots, ["certify", "verify", "release"]); + } + + #[test] + fn ownership_contract_classifies_every_public_internal_and_transfer_command() { + let ownership: toml::Value = toml::from_str(include_str!("../command-ownership.toml")) + .expect("ownership contract must be valid TOML"); + + let commands = |section: &str| { + ownership[section]["commands"] + .as_array() + .expect("command section must be an array") + .iter() + .map(|value| value.as_str().expect("commands must be strings")) + .collect::>() + }; + assert_eq!( + commands("public_cli"), + [ + "policy", + "compile", + "inspect", + "preview", + "apply", + "update", + "status", + "rollback", + "uninstall", + "doctor", + ] + ); + assert_eq!( + commands("internal_cli"), + [ + "certify codex", + "certify cursor", + "certify opencode", + "certify pi", + "certify planr", + "verify offline", + "release prepare", + "release verify", + "release package", + ] + ); + + let mut transfers = ownership["transfers"] + .as_table() + .expect("transfers must be a table") + .keys() + .map(String::as_str) + .collect::>(); + transfers.sort_unstable(); + assert_eq!( + transfers, + [ + "catalog", + "certify", + "evaluate", + "evidence validate", + "probe", + "registry", + ] + ); + } +} diff --git a/xtask/src/release.rs b/xtask/src/release.rs new file mode 100644 index 0000000..febecfa --- /dev/null +++ b/xtask/src/release.rs @@ -0,0 +1,928 @@ +use anyhow::{Context, Result, bail, ensure}; +use model_routing::{Integration, catalog_json, compile_json}; +use serde::Deserialize; +use serde_json::Value; +use sha2::{Digest, Sha256}; +use std::{ + collections::BTreeSet, + fs, + path::{Path, PathBuf}, + process::{Command, Output}, +}; + +const FORBIDDEN_PUBLIC_PATHS: &[&str] = &[ + "xtask/", + "scripts/", + "reports/", + "retained-evidence/", + "evidence/", + ".planr/", + ".codex/", + ".claude/", + ".cursor/", + "tmp/", +]; +const FORBIDDEN_PUBLIC_WORDS: &[&str] = &["credential", "secret", "receipt"]; + +pub(crate) struct PrepareOptions { + pub root: PathBuf, + pub version: Option, + pub allow_dirty: bool, +} + +pub(crate) struct VerifyOptions { + pub root: PathBuf, + pub inventory_only: bool, + pub contract_only: bool, + pub require_provenance: bool, + pub expected_tag: Option, +} + +pub(crate) struct PackageOptions { + pub root: PathBuf, + pub target: Option, + pub cargo_target: Option, + pub stage_npm: bool, + pub assemble_provenance: bool, + pub aggregate_checksums_dir: Option, + pub provenance_dir: Option, + pub runner: String, + pub git_sha: Option, + pub built_at: String, + pub generated_by: String, +} + +pub(crate) fn prepare(options: PrepareOptions) -> Result<()> { + if !options.allow_dirty { + ensure_clean_worktree(&options.root)?; + } + if let Some(version) = options.version.as_deref() { + ensure!( + valid_release_version(version), + "invalid release version {version}" + ); + replace_manifest_versions(&options.root, version)?; + run(&options.root, "cargo", &["check", "--quiet"])?; + run( + &options.root, + "cargo", + &[ + "run", + "--quiet", + "-p", + "xtask", + "--", + "release", + "prepare", + "--allow-dirty", + ], + )?; + return Ok(()); + } + regenerate_catalog(&options.root)?; + for (host, output) in [ + ( + "codex-openai", + "fixtures/routing-bundle-v1/valid-balanced-codex.json", + ), + ( + "mixed-host", + "fixtures/routing-bundle-v1/valid-balanced-mixed.json", + ), + ] { + fs::write( + options.root.join(output), + compile_json("balanced", host, Integration::Planr)?, + )?; + } + verify_version_contract(&options.root)?; + println!("release preparation passed"); + Ok(()) +} + +pub(crate) fn verify(options: VerifyOptions) -> Result<()> { + let version = verify_version_contract(&options.root)?; + if let Some(expected_tag) = options.expected_tag.as_deref() { + ensure!( + expected_tag == format!("v{version}"), + "release tag {expected_tag} does not match manifest version v{version}" + ); + } + if options.contract_only { + println!("release contract passed for v{version}"); + return Ok(()); + } + verify_catalog(&options.root)?; + verify_public_inventories(&options.root)?; + if options.require_provenance { + ensure!( + options.root.join("npm/native/provenance.json").is_file(), + "required native provenance is missing" + ); + verify_native_provenance(&options.root)?; + } + if !options.inventory_only { + run(&options.root, "cargo", &["fmt", "--all", "--", "--check"])?; + run( + &options.root, + "cargo", + &[ + "clippy", + "--workspace", + "--all-targets", + "--all-features", + "--", + "-D", + "warnings", + ], + )?; + run( + &options.root, + "cargo", + &["test", "--workspace", "--all-targets", "--all-features"], + )?; + run( + &options.root, + "sh", + &["scripts/check-migration-manifest.sh"], + )?; + run( + &options.root, + "node", + &["scripts/check-evidence-validator-parity.mjs"], + )?; + run(&options.root, "node", &["scripts/build-site.mjs"])?; + run(&options.root, "betterleaks", &["dir", "."])?; + run( + &options.root, + "trivy", + &[ + "fs", + "--skip-db-update", + "--skip-java-db-update", + "--scanners", + "vuln,secret,misconfig", + "--skip-dirs", + "node_modules", + "--skip-dirs", + "target", + "--skip-dirs", + "dist", + "--skip-dirs", + ".pnpm-store", + ".", + ], + )?; + run(&options.root, "zizmor", &[".github/workflows"])?; + } + println!("release verification passed"); + Ok(()) +} + +pub(crate) fn package(options: PackageOptions) -> Result<()> { + let version = verify_version_contract(&options.root)?; + if let Some(directory) = options.aggregate_checksums_dir.as_deref() { + aggregate_release_checksums(&options.root, directory)?; + println!("aggregate release checksums generated"); + return Ok(()); + } + if options.assemble_provenance { + assemble_native_provenance(&options, &version)?; + println!("native provenance assembled for {version}"); + return Ok(()); + } + let target = options.target.unwrap_or_else(detect_target); + validate_package_target(&target, options.cargo_target.as_deref(), &detect_target())?; + let mut cargo_args = vec!["build", "--release", "--locked", "--bin", "model-routing"]; + if let Some(cargo_target) = options.cargo_target.as_deref() { + cargo_args.extend(["--target", cargo_target]); + } + run(&options.root, "cargo", &cargo_args)?; + let binary = match options.cargo_target.as_deref() { + Some(cargo_target) => options + .root + .join("target") + .join(cargo_target) + .join("release/model-routing"), + None => options.root.join("target/release/model-routing"), + }; + ensure!( + binary.is_file(), + "release binary missing at {}", + binary.display() + ); + let dist = options.root.join("dist"); + let stage = dist.join(format!("switchloom-{version}")); + let archive = absolute( + &std::env::current_dir()?, + &dist.join(format!("switchloom-{target}.tar.gz")), + ); + remove_owned_path(&options.root, &stage)?; + if archive.exists() { + fs::remove_file(&archive)?; + } + fs::create_dir_all(&stage)?; + for (source, destination) in [ + (binary.as_path(), stage.join("model-routing")), + ( + options.root.join("README.md").as_path(), + stage.join("README.md"), + ), + ( + options.root.join("LICENSE").as_path(), + stage.join("LICENSE"), + ), + ] { + fs::copy(source, destination)?; + } + let sums = ["model-routing", "README.md", "LICENSE"] + .into_iter() + .map(|name| Ok(format!("{} {name}\n", sha256_file(&stage.join(name))?))) + .collect::>()?; + fs::write(stage.join("SHA256SUMS"), sums)?; + run( + &stage, + "tar", + &[ + "-czf", + archive.to_str().context("archive path is not UTF-8")?, + "model-routing", + "README.md", + "LICENSE", + "SHA256SUMS", + ], + )?; + if options.stage_npm { + let npm_binary = options + .root + .join("npm/native") + .join(&target) + .join("model-routing"); + if let Some(parent) = npm_binary.parent() { + fs::create_dir_all(parent)?; + } + fs::copy(&binary, &npm_binary)?; + if let Some(provenance_dir) = options.provenance_dir { + let git_sha = options + .git_sha + .map(Ok) + .unwrap_or_else(|| git_stdout(&options.root, &["rev-parse", "HEAD"]))?; + ensure!( + is_git_sha(&git_sha), + "git SHA must be 40 lowercase hex characters" + ); + let rust_target = options.cargo_target.unwrap_or_else(|| target.clone()); + let receipt = serde_json::json!({ + "target": target, + "rust_target": rust_target, + "runner": options.runner, + "path": format!("npm/native/{target}/model-routing"), + "version": format!("model-routing {version}"), + "sha256": sha256_file(&npm_binary)?, + "git_sha": git_sha, + "built_at": options.built_at, + }); + let target_dir = absolute(&options.root, &provenance_dir).join(&target); + fs::create_dir_all(&target_dir)?; + fs::write( + target_dir.join("provenance.json"), + format!("{}\n", serde_json::to_string_pretty(&receipt)?), + )?; + fs::write( + target_dir.join("SHA256SUMS"), + format!( + "{} npm/native/{target}/model-routing\n", + sha256_file(&npm_binary)? + ), + )?; + } + } + println!("release artifact: {}", archive.display()); + Ok(()) +} + +fn assemble_native_provenance(options: &PackageOptions, version: &str) -> Result<()> { + let git_sha = options + .git_sha + .clone() + .map(Ok) + .unwrap_or_else(|| git_stdout(&options.root, &["rev-parse", "HEAD"]))?; + ensure!( + is_git_sha(&git_sha), + "git SHA must be 40 lowercase hex characters" + ); + let targets = [ + "darwin-arm64", + "darwin-x86_64", + "linux-arm64", + "linux-x86_64", + ] + .into_iter() + .map(|target| { + let (rust_target, runner) = target_metadata(target)?; + let relative = format!("npm/native/{target}/model-routing"); + let binary = options.root.join(&relative); + ensure!( + binary.is_file(), + "native binary missing at {}", + binary.display() + ); + Ok(serde_json::json!({ + "target": target, + "rust_target": rust_target, + "runner": runner, + "path": relative, + "version": format!("model-routing {version}"), + "sha256": sha256_file(&binary)?, + "git_sha": git_sha, + "built_at": options.built_at, + })) + }) + .collect::>>()?; + let provenance = serde_json::json!({ + "schema_version": "switchloom.native_provenance.v1", + "package_version": version, + "git_sha": git_sha, + "generated_by": options.generated_by, + "targets": targets, + }); + let output = options.root.join("npm/native/provenance.json"); + if let Some(parent) = output.parent() { + fs::create_dir_all(parent)?; + } + fs::write( + output, + format!("{}\n", serde_json::to_string_pretty(&provenance)?), + )?; + verify_native_provenance(&options.root) +} + +fn aggregate_release_checksums(root: &Path, directory: &Path) -> Result<()> { + let directory = absolute(root, directory); + ensure!(directory.is_dir(), "release asset directory missing"); + let expected = BTreeSet::from([ + "switchloom-darwin-arm64.tar.gz".to_owned(), + "switchloom-darwin-x86_64.tar.gz".to_owned(), + "switchloom-linux-arm64.tar.gz".to_owned(), + "switchloom-linux-x86_64.tar.gz".to_owned(), + ]); + let actual = fs::read_dir(&directory)? + .filter_map(|entry| entry.ok()) + .filter(|entry| entry.file_type().is_ok_and(|kind| kind.is_file())) + .map(|entry| entry.file_name().to_string_lossy().into_owned()) + .filter(|name| name.starts_with("switchloom-") && name.ends_with(".tar.gz")) + .collect::>(); + ensure!( + actual == expected, + "release archive set mismatch: expected {expected:?}, found {actual:?}" + ); + let checksums = actual + .iter() + .map(|name| Ok(format!("{} {name}\n", sha256_file(&directory.join(name))?))) + .collect::>()?; + fs::write(directory.join("SHA256SUMS"), checksums)?; + Ok(()) +} + +fn regenerate_catalog(root: &Path) -> Result<()> { + let catalog = root.join("website/data/catalog.json"); + let generated = catalog_json()?; + fs::write(&catalog, &generated)?; + let data: Value = serde_json::from_str(&generated)?; + let compositions = data["compositions"] + .as_array() + .context("catalog compositions must be an array")?; + let bundles = root.join("website/data/bundles"); + remove_owned_path(root, &bundles)?; + fs::create_dir_all(&bundles)?; + for entry in compositions { + let id = entry["entryId"] + .as_str() + .context("catalog entryId missing")?; + let policy = entry["policy"]["id"] + .as_str() + .context("catalog policy missing")?; + let host = entry["binding"]["id"] + .as_str() + .context("catalog binding missing")?; + fs::write( + bundles.join(format!("{id}.json")), + compile_json(policy, host, Integration::Standalone)?, + )?; + } + println!("regenerated {} catalog compositions", compositions.len()); + Ok(()) +} + +fn verify_catalog(root: &Path) -> Result<()> { + let current = fs::read_to_string(root.join("website/data/catalog.json"))?; + ensure!( + current == catalog_json()?, + "catalog does not match package-owned generated sources" + ); + println!("catalog verified"); + Ok(()) +} + +fn verify_public_inventories(root: &Path) -> Result<()> { + verify_publication_boundary(root)?; + let cargo = output( + root, + "cargo", + &[ + "package", + "--package", + "model-routing", + "--list", + "--allow-dirty", + "--no-verify", + "--offline", + ], + )?; + let cargo_files = lines(&cargo.stdout); + verify_inventory("Cargo", &cargo_files)?; + let npm = output(root, "npm", &["pack", "--dry-run", "--json"])?; + let npm_value: Value = serde_json::from_slice(&npm.stdout)?; + let npm_files = npm_value + .pointer("/0/files") + .and_then(Value::as_array) + .context("npm pack JSON has no file inventory")? + .iter() + .map(|entry| entry["path"].as_str().unwrap_or_default().to_owned()) + .collect::>(); + verify_inventory("npm", &npm_files)?; + ensure!( + cargo_files.iter().any(|path| path == "src/lib.rs"), + "Cargo package omitted src/lib.rs" + ); + ensure!( + npm_files + .iter() + .any(|path| path == "npm/bin/model-routing.js"), + "npm package omitted launcher" + ); + println!( + "public inventories passed: {} Cargo files, {} npm files", + cargo_files.len(), + npm_files.len() + ); + Ok(()) +} + +fn verify_publication_boundary(root: &Path) -> Result<()> { + let metadata = output( + root, + "cargo", + &["metadata", "--format-version", "1", "--no-deps"], + )?; + let metadata: Value = serde_json::from_slice(&metadata.stdout)?; + let packages = metadata["packages"] + .as_array() + .context("Cargo metadata packages missing")?; + let publishable = packages + .iter() + .filter(|package| { + package["publish"] + .as_array() + .is_none_or(|registries| !registries.is_empty()) + }) + .filter_map(|package| package["name"].as_str()) + .collect::>(); + ensure!( + publishable == ["model-routing"], + "only model-routing may be published; found {publishable:?}" + ); + Ok(()) +} + +fn verify_inventory(kind: &str, files: &[String]) -> Result<()> { + for file in files { + let normalized = file.trim_start_matches("./").to_ascii_lowercase(); + if FORBIDDEN_PUBLIC_PATHS.iter().any(|prefix| { + normalized == prefix.trim_end_matches('/') || normalized.starts_with(prefix) + }) || FORBIDDEN_PUBLIC_WORDS + .iter() + .any(|word| normalized.contains(word)) + { + bail!("{kind} public artifact contains forbidden path {file}"); + } + } + Ok(()) +} + +fn verify_version_contract(root: &Path) -> Result { + let version = workspace_version(root)?; + ensure!( + valid_release_version(&version), + "invalid workspace version {version}" + ); + let package: Value = serde_json::from_slice(&fs::read(root.join("package.json"))?)?; + ensure!( + package["version"].as_str() == Some(&version), + "Cargo/npm version mismatch" + ); + let xtask = fs::read_to_string(root.join("xtask/Cargo.toml"))?; + ensure!( + xtask.contains(&format!( + "model-routing = {{ path = \"..\", version = \"{version}\" }}" + )), + "xtask path dependency version does not match workspace version" + ); + let changelog = fs::read_to_string(root.join("CHANGELOG.md"))?; + ensure!( + changelog.contains(&format!("## [{version}]")), + "CHANGELOG has no {version} section" + ); + Ok(version) +} + +fn workspace_version(root: &Path) -> Result { + let manifest = fs::read_to_string(root.join("Cargo.toml"))?; + let mut in_workspace_package = false; + for line in manifest.lines() { + if line.starts_with('[') { + in_workspace_package = line.trim() == "[workspace.package]"; + } else if in_workspace_package { + if let Some(version) = line + .strip_prefix("version = \"") + .and_then(|line| line.strip_suffix('"')) + { + return Ok(version.to_owned()); + } + } + } + bail!("workspace package version missing") +} + +fn replace_manifest_versions(root: &Path, version: &str) -> Result<()> { + replace_once( + &root.join("Cargo.toml"), + &format!("version = \"{}\"", workspace_version(root)?), + &format!("version = \"{version}\""), + )?; + let package_path = root.join("package.json"); + let old_npm = serde_json::from_slice::(&fs::read(&package_path)?)?["version"] + .as_str() + .context("package version missing")? + .to_owned(); + replace_once( + &package_path, + &format!("\"version\": \"{old_npm}\""), + &format!("\"version\": \"{version}\""), + )?; + let xtask_path = root.join("xtask/Cargo.toml"); + let xtask = fs::read_to_string(&xtask_path)?; + let start = "model-routing = { path = \"..\", version = \""; + let old = xtask + .lines() + .find(|line| line.starts_with(start)) + .context("xtask model-routing dependency missing")?; + replace_once( + &xtask_path, + old, + &format!("model-routing = {{ path = \"..\", version = \"{version}\" }}"), + )?; + Ok(()) +} + +fn replace_once(path: &Path, old: &str, new: &str) -> Result<()> { + let text = fs::read_to_string(path)?; + ensure!( + text.matches(old).count() == 1, + "expected one version field in {}", + path.display() + ); + fs::write(path, text.replacen(old, new, 1))?; + Ok(()) +} + +fn ensure_clean_worktree(root: &Path) -> Result<()> { + let status = git_stdout(root, &["status", "--porcelain=v1", "--untracked-files=all"])?; + ensure!( + status.is_empty(), + "worktree is dirty; commit or stash before preparing a release" + ); + Ok(()) +} + +#[derive(Deserialize)] +struct NativeProvenance { + schema_version: String, + package_version: String, + git_sha: String, + generated_by: String, + targets: Vec, +} + +#[derive(Deserialize)] +struct NativeTarget { + target: String, + rust_target: String, + runner: String, + path: String, + version: String, + sha256: String, + git_sha: String, + built_at: String, +} + +fn verify_native_provenance(root: &Path) -> Result<()> { + let provenance: NativeProvenance = + serde_json::from_slice(&fs::read(root.join("npm/native/provenance.json"))?)?; + ensure!( + provenance.schema_version == "switchloom.native_provenance.v1", + "unsupported native provenance schema" + ); + ensure!( + !provenance.generated_by.trim().is_empty(), + "provenance generator missing" + ); + ensure!( + provenance.package_version == workspace_version(root)?, + "provenance version mismatch" + ); + ensure!( + is_git_sha(&provenance.git_sha), + "invalid provenance git SHA" + ); + let expected = BTreeSet::from([ + "darwin-arm64".to_owned(), + "darwin-x86_64".to_owned(), + "linux-arm64".to_owned(), + "linux-x86_64".to_owned(), + ]); + let actual = provenance + .targets + .iter() + .map(|target| target.target.clone()) + .collect::>(); + ensure!(actual == expected, "native provenance target set mismatch"); + for target in provenance.targets { + let (expected_rust_target, _) = target_metadata(&target.target)?; + ensure!( + target.git_sha == provenance.git_sha, + "target git SHA mismatch" + ); + ensure!( + target.version == format!("model-routing {}", provenance.package_version), + "target version mismatch" + ); + ensure!( + target.rust_target == expected_rust_target, + "target Rust triple mismatch" + ); + ensure!(!target.runner.trim().is_empty(), "target runner missing"); + ensure!( + !target.built_at.trim().is_empty(), + "target build identity missing" + ); + ensure!( + target.sha256.len() == 64 + && target.sha256.chars().all(|character| { + character.is_ascii_hexdigit() && !character.is_ascii_uppercase() + }), + "invalid target digest" + ); + ensure!( + target.path == format!("npm/native/{}/model-routing", target.target), + "native path does not match target" + ); + let path = root.join(&target.path); + ensure!( + path.starts_with(root.join("npm/native")), + "native path escapes npm/native" + ); + ensure!( + path.is_file(), + "native binary missing at {}", + path.display() + ); + ensure!( + sha256_file(&path)? == target.sha256, + "native digest mismatch for {}", + target.target + ); + } + let current_target = detect_target(); + if valid_target(¤t_target) { + let binary = root + .join("npm/native") + .join(current_target) + .join("model-routing"); + if binary.is_file() { + let output = Command::new(&binary).arg("--version").output()?; + ensure!( + output.status.success(), + "current native binary version check failed" + ); + ensure!( + String::from_utf8(output.stdout)?.trim() + == format!("model-routing {}", provenance.package_version), + "current native binary version mismatch" + ); + } + } + println!( + "native provenance validated for {}", + provenance.package_version + ); + Ok(()) +} + +fn target_metadata(target: &str) -> Result<(&'static str, &'static str)> { + match target { + "darwin-arm64" => Ok(("aarch64-apple-darwin", "macos-14")), + "darwin-x86_64" => Ok(("x86_64-apple-darwin", "macos-14")), + "linux-arm64" => Ok(("aarch64-unknown-linux-gnu", "ubuntu-24.04-arm")), + "linux-x86_64" => Ok(("x86_64-unknown-linux-gnu", "ubuntu-24.04")), + _ => bail!("unsupported native target {target}"), + } +} + +fn valid_release_version(value: &str) -> bool { + let (core, suffix) = value + .split_once('-') + .map_or((value, None), |(core, suffix)| (core, Some(suffix))); + let core_valid = core.split('.').count() == 3 + && core + .split('.') + .all(|part| !part.is_empty() && part.chars().all(|c| c.is_ascii_digit())); + let suffix_valid = suffix.is_none_or(|suffix| { + ["alpha", "beta", "rc"].into_iter().any(|kind| { + suffix + .strip_prefix(kind) + .and_then(|rest| rest.strip_prefix('.')) + .is_some_and(|n| !n.is_empty() && n.chars().all(|c| c.is_ascii_digit())) + }) + }); + core_valid && suffix_valid +} + +fn valid_target(target: &str) -> bool { + matches!( + target, + "darwin-arm64" | "darwin-x86_64" | "linux-arm64" | "linux-x86_64" + ) +} + +fn validate_package_target( + target: &str, + cargo_target: Option<&str>, + detected_host: &str, +) -> Result<()> { + ensure!(valid_target(target), "unsupported release target {target}"); + match cargo_target { + Some(cargo_target) => { + let (expected, _) = target_metadata(target)?; + ensure!( + cargo_target == expected, + "release target {target} requires Cargo target {expected}, got {cargo_target}" + ); + } + None => ensure!( + target == detected_host, + "release target {target} does not match detected host {detected_host}; pass its matching --cargo-target" + ), + } + Ok(()) +} + +fn detect_target() -> String { + let os = match std::env::consts::OS { + "macos" => "darwin", + other => other, + }; + let arch = match std::env::consts::ARCH { + "aarch64" => "arm64", + "x86_64" => "x86_64", + other => other, + }; + format!("{os}-{arch}") +} + +fn is_git_sha(value: &str) -> bool { + value.len() == 40 + && value + .chars() + .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()) +} + +fn remove_owned_path(root: &Path, path: &Path) -> Result<()> { + ensure!( + path.starts_with(root), + "refusing to remove path outside repository" + ); + if path.exists() { + fs::remove_dir_all(path)?; + } + Ok(()) +} + +fn sha256_file(path: &Path) -> Result { + Ok(format!("{:x}", Sha256::digest(fs::read(path)?))) +} + +fn absolute(root: &Path, path: &Path) -> PathBuf { + if path.is_absolute() { + path.to_owned() + } else { + root.join(path) + } +} + +fn lines(bytes: &[u8]) -> Vec { + String::from_utf8_lossy(bytes) + .lines() + .map(str::to_owned) + .collect() +} + +fn git_stdout(root: &Path, args: &[&str]) -> Result { + let output = output(root, "git", args)?; + Ok(String::from_utf8(output.stdout)?.trim().to_owned()) +} + +fn run(root: &Path, program: &str, args: &[&str]) -> Result<()> { + run_path(root, Path::new(program), args) +} + +fn run_path(root: &Path, program: &Path, args: &[&str]) -> Result<()> { + let status = Command::new(program) + .args(args) + .current_dir(root) + .status() + .with_context(|| format!("failed to run {}", program.display()))?; + ensure!( + status.success(), + "{} exited with status {status}", + program.display() + ); + Ok(()) +} + +fn output(root: &Path, program: &str, args: &[&str]) -> Result { + let output = Command::new(program) + .args(args) + .current_dir(root) + .output() + .with_context(|| format!("failed to run {program}"))?; + if !output.status.success() { + bail!( + "{program} exited with {}: {}", + output.status, + String::from_utf8_lossy(&output.stderr) + ); + } + Ok(output) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn release_versions_are_bounded() { + for valid in ["1.2.3", "1.2.3-alpha.1", "1.2.3-beta.9", "1.2.3-rc.0"] { + assert!(valid_release_version(valid)); + } + for invalid in ["v1.2.3", "1.2", "1.2.3-dev.1", "1.2.3-rc", "1.2.3.4"] { + assert!(!valid_release_version(invalid)); + } + } + + #[test] + fn public_inventory_rejects_internal_and_sensitive_paths() { + for path in [ + "xtask/src/main.rs", + "scripts/release.sh", + "reports/live.json", + "retained-evidence/report.json", + "tmp/state", + "docs/credential-receipt.json", + ] { + assert!( + verify_inventory("test", &[path.into()]).is_err(), + "accepted {path}" + ); + } + verify_inventory("test", &["src/lib.rs".into(), "README.md".into()]).unwrap(); + } + + #[test] + fn archive_label_must_match_host_or_explicit_cargo_triple() { + assert!(validate_package_target("darwin-arm64", None, "darwin-arm64").is_ok()); + assert!(validate_package_target("linux-x86_64", None, "darwin-arm64").is_err()); + assert!( + validate_package_target( + "linux-x86_64", + Some("x86_64-unknown-linux-gnu"), + "darwin-arm64" + ) + .is_ok() + ); + assert!( + validate_package_target("linux-x86_64", Some("aarch64-apple-darwin"), "darwin-arm64") + .is_err() + ); + } +}