diff --git a/.github/workflows/candidate-compatibility.yaml b/.github/workflows/candidate-compatibility.yaml new file mode 100644 index 0000000000..6c7d14814b --- /dev/null +++ b/.github/workflows/candidate-compatibility.yaml @@ -0,0 +1,447 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +name: Dependencies / OpenShell Candidate Compatibility +run-name: "OpenShell candidate ${{ inputs.candidate }} at ${{ inputs.nemoclaw_ref }}" + +on: + workflow_dispatch: + inputs: + nemoclaw_ref: + description: Exact NemoClaw branch, tag, or commit to resolve once. + required: true + type: string + component: + description: Official dependency candidate to exercise. + required: true + type: choice + options: + - openshell + candidate: + description: Exact official OpenShell version or vX.Y.Z release tag. + required: true + type: string + +permissions: + contents: read + +concurrency: + group: candidate-compatibility-${{ inputs.nemoclaw_ref }}-${{ inputs.component }}-${{ inputs.candidate }} + cancel-in-progress: false + +jobs: + resolve: + runs-on: ubuntu-latest + timeout-minutes: 10 + outputs: + matrix: ${{ steps.plan.outputs.matrix }} + nemoclaw_sha: ${{ steps.identity.outputs.nemoclaw_sha }} + resolution_id: ${{ steps.identity.outputs.resolution_id }} + steps: + - name: Check out trusted compatibility controller + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + ref: ${{ github.sha }} + path: controller + fetch-depth: 1 + persist-credentials: false + + - name: Resolve and check out requested NemoClaw ref + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + ref: ${{ inputs.nemoclaw_ref }} + path: candidate-source + fetch-depth: 1 + persist-credentials: false + + - name: Set up Node.js + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.0.0 + with: + node-version: "22" + + - id: identity + name: Resolve official candidate provenance + env: + CANDIDATE: ${{ inputs.candidate }} + COMPONENT: ${{ inputs.component }} + GITHUB_TOKEN: ${{ github.token }} + WORKFLOW_REF: ${{ github.ref }} + shell: bash + run: | + set -euo pipefail + [[ "$WORKFLOW_REF" == refs/heads/main ]] || { echo "::error::candidate compatibility must be dispatched from main"; exit 1; } + nemoclaw_sha="$(git -C candidate-source rev-parse --verify HEAD^{commit})" + [[ "$nemoclaw_sha" =~ ^[a-f0-9]{40}$ ]] || { echo "::error::NemoClaw ref did not resolve to a full commit SHA"; exit 1; } + [[ -z "$(git -C candidate-source status --short --untracked-files=no)" ]] || { echo "::error::checkout is not clean before candidate resolution"; exit 1; } + node --experimental-strip-types controller/tools/candidate-compat.mts resolve \ + --nemoclaw-sha "$nemoclaw_sha" \ + --component "$COMPONENT" \ + --candidate "$CANDIDATE" \ + --output candidate-receipt.json + resolution_id="$(node -e 'const r=require("./candidate-receipt.json"); process.stdout.write(r.resolutionId)')" + [[ "$resolution_id" =~ ^[a-f0-9]{64}$ ]] || { echo "::error::resolver emitted an invalid identity"; exit 1; } + printf 'nemoclaw_sha=%s\nresolution_id=%s\n' "$nemoclaw_sha" "$resolution_id" >> "$GITHUB_OUTPUT" + + - id: plan + name: Plan deterministic and live compatibility lanes + env: + COMPONENT: ${{ inputs.component }} + shell: bash + run: | + set -euo pipefail + node --experimental-strip-types controller/tools/candidate-compat.mts plan \ + --component "$COMPONENT" \ + --e2e-workflow candidate-source/.github/workflows/e2e.yaml \ + --e2e-registry candidate-source/test/e2e/registry/definitions/baseline.ts \ + --output candidate-plan.json + matrix="$(node -e 'const p=require("./candidate-plan.json"); process.stdout.write(JSON.stringify({lane:p.deterministic.filter(x=>x.status==="selected").map(x=>x.id)}))')" + printf 'matrix=%s\n' "$matrix" >> "$GITHUB_OUTPUT" + + - name: Upload immutable resolution and plan + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: candidate-resolution-${{ steps.identity.outputs.resolution_id }} + path: | + candidate-receipt.json + candidate-plan.json + if-no-files-found: error + retention-days: 30 + + deterministic: + needs: resolve + runs-on: ubuntu-latest + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.resolve.outputs.matrix) }} + steps: + - name: Check out trusted compatibility controller + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + ref: ${{ github.sha }} + path: controller + fetch-depth: 1 + persist-credentials: false + + - name: Check out resolved NemoClaw commit + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + ref: ${{ needs.resolve.outputs.nemoclaw_sha }} + path: candidate-source + fetch-depth: 1 + persist-credentials: false + + - name: Verify checkout identity + env: + EXPECTED_SHA: ${{ needs.resolve.outputs.nemoclaw_sha }} + shell: bash + run: | + set -euo pipefail + [[ "$(git -C candidate-source rev-parse --verify HEAD)" == "$EXPECTED_SHA" ]] || { echo "::error::lane checkout differs from resolved NemoClaw SHA"; exit 1; } + + - name: Set up Node.js + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.0.0 + with: + node-version: "22" + cache: npm + cache-dependency-path: candidate-source/package-lock.json + + - name: Install repository dependencies + working-directory: candidate-source + run: npm ci --ignore-scripts + + - name: Download immutable resolution and plan + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: candidate-resolution-${{ needs.resolve.outputs.resolution_id }} + path: candidate-input + + - id: candidate + name: Materialize and verify candidate runtime + continue-on-error: true + env: + LANE: ${{ matrix.lane }} + RESOLUTION_ID: ${{ needs.resolve.outputs.resolution_id }} + shell: bash + run: | + set -euo pipefail + node --experimental-strip-types controller/tools/candidate-compat.mts materialize \ + --receipt candidate-input/candidate-receipt.json \ + --resolution-id "$RESOLUTION_ID" \ + --directory "${RUNNER_TEMP}/candidate-runtime" \ + --output "candidate-observed-${LANE}.json" \ + --github-env "$GITHUB_ENV" 2>&1 | tee "candidate-materialize-${LANE}.log" + + - id: lane + name: Run ${{ matrix.lane }} lane + if: ${{ steps.candidate.outcome == 'success' }} + continue-on-error: true + env: + LANE: ${{ matrix.lane }} + shell: bash + working-directory: candidate-source + run: | + set -euo pipefail + exec > >(tee "candidate-lane-${LANE}.log") 2>&1 + [[ "$LANE" == installer ]] || { echo "::error::untrusted lane id: $LANE"; exit 1; } + npx vitest run --project installer-integration \ + test/install-openshell-version-check.test.ts \ + --testNamePattern "validates the receipt-bound candidate through the installer path" + base_path="${PATH#*:}" + env \ + -u NEMOCLAW_CANDIDATE_COMPONENT \ + -u NEMOCLAW_CANDIDATE_INVOCATION_LOG \ + -u NEMOCLAW_CANDIDATE_RECEIPT \ + -u NEMOCLAW_CANDIDATE_RESOLUTION_ID \ + -u NEMOCLAW_CANDIDATE_VERSION \ + -u NEMOCLAW_OPENSHELL_SANDBOX_BIN \ + -u OPENSHELL_BIN \ + -u OPENSHELL_GATEWAY_BIN \ + PATH="$base_path" \ + npx vitest run --project installer-integration + + - name: Record receipt-bound lane result + if: ${{ always() }} + env: + CANDIDATE_OUTCOME: ${{ steps.candidate.outcome }} + LANE: ${{ matrix.lane }} + LANE_OUTCOME: ${{ steps.lane.outcome }} + RESOLUTION_ID: ${{ needs.resolve.outputs.resolution_id }} + shell: bash + run: | + set -euo pipefail + mkdir -p candidate-results + if [[ "$CANDIDATE_OUTCOME" == success && "$LANE_OUTCOME" == success ]]; then + node --experimental-strip-types controller/tools/candidate-compat.mts verify-invocations \ + --receipt candidate-input/candidate-receipt.json \ + --resolution-id "$RESOLUTION_ID" \ + --log "$NEMOCLAW_CANDIDATE_INVOCATION_LOG" \ + --lane "$LANE" \ + --output "candidate-results/${LANE}.json" + else + LANE_NAME="$LANE" node -e ' + const fs = require("node:fs"); + fs.writeFileSync(`candidate-results/${process.env.LANE_NAME}.json`, JSON.stringify({ + conclusion: "failure", + lane: process.env.LANE_NAME, + resolutionId: process.env.RESOLUTION_ID, + }) + "\n", {mode: 0o600}); + ' + fi + + - name: Upload lane evidence + if: ${{ always() }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: candidate-result-${{ needs.resolve.outputs.resolution_id }}-${{ matrix.lane }} + path: | + candidate-results/${{ matrix.lane }}.json + candidate-observed-${{ matrix.lane }}.json + candidate-materialize-${{ matrix.lane }}.log + candidate-source/candidate-lane-${{ matrix.lane }}.log + if-no-files-found: error + retention-days: 30 + + - name: Enforce lane result + if: ${{ always() && (steps.candidate.outcome != 'success' || steps.lane.outcome != 'success') }} + run: exit 1 + + live: + needs: resolve + runs-on: ubuntu-latest + timeout-minutes: 25 + steps: + - name: Check out trusted compatibility controller + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + ref: ${{ github.sha }} + path: controller + fetch-depth: 1 + persist-credentials: false + + - name: Check out resolved NemoClaw commit + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + ref: ${{ needs.resolve.outputs.nemoclaw_sha }} + path: candidate-source + fetch-depth: 1 + persist-credentials: false + + - name: Set up Node.js + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.0.0 + with: + node-version: "22" + cache: npm + cache-dependency-path: candidate-source/package-lock.json + + - name: Install repository dependencies + working-directory: candidate-source + run: npm ci --ignore-scripts + + - name: Download immutable resolution and plan + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: candidate-resolution-${{ needs.resolve.outputs.resolution_id }} + path: candidate-input + + - id: candidate + name: Materialize verified OpenShell runtime + continue-on-error: true + env: + RESOLUTION_ID: ${{ needs.resolve.outputs.resolution_id }} + run: | + set -euo pipefail + node --experimental-strip-types controller/tools/candidate-compat.mts materialize \ + --receipt candidate-input/candidate-receipt.json \ + --resolution-id "$RESOLUTION_ID" \ + --directory "${RUNNER_TEMP}/candidate-runtime" \ + --output candidate-live-observed.json \ + --github-env "$GITHUB_ENV" + + - id: live_test + name: Run OpenShell gateway auth contract against candidate + if: ${{ steps.candidate.outcome == 'success' }} + continue-on-error: true + working-directory: candidate-source + env: + DOCKER_GRPC_PROBE_IMAGE: node:22-trixie-slim@sha256:2d9f5c76c8f4dd36e8f253bee5d828a83a6c09f36188f0b0414325232e0b175d + E2E_ARTIFACT_DIR: ${{ github.workspace }}/candidate-source/e2e-artifacts/live/openshell-gateway-auth-contract + E2E_JOB: "1" + E2E_TARGET_ID: openshell-gateway-auth-contract + NEMOCLAW_NON_INTERACTIVE: "1" + NEMOCLAW_CANDIDATE_INVOCATION_CONTEXT: live:openshell-gateway-auth-contract:${{ needs.resolve.outputs.resolution_id }} + NEMOCLAW_RUN_LIVE_E2E: "1" + run: | + set -euo pipefail + npm run build:cli + docker pull "$DOCKER_GRPC_PROBE_IMAGE" + "$OPENSHELL_GATEWAY_BIN" --version + npx vitest run --project e2e-live \ + test/e2e/live/openshell-gateway-auth-source-contract.test.ts \ + --silent=false --reporter=default --reporter=test/e2e/risk-signal-reporter.ts + + - name: Record receipt-bound live result + if: ${{ always() }} + env: + CANDIDATE_OUTCOME: ${{ steps.candidate.outcome }} + LANE_OUTCOME: ${{ steps.live_test.outcome }} + RESOLUTION_ID: ${{ needs.resolve.outputs.resolution_id }} + run: | + set -euo pipefail + mkdir -p candidate-results + lane=live:openshell-gateway-auth-contract + if [[ "$CANDIDATE_OUTCOME" == success && "$LANE_OUTCOME" == success ]]; then + node --experimental-strip-types controller/tools/candidate-compat.mts verify-invocations \ + --receipt candidate-input/candidate-receipt.json \ + --resolution-id "$RESOLUTION_ID" \ + --log "$NEMOCLAW_CANDIDATE_INVOCATION_LOG" \ + --lane "$lane" \ + --output candidate-results/live-openshell-gateway-auth-contract.json + else + LANE_NAME="$lane" node -e ' + const fs = require("node:fs"); + fs.writeFileSync("candidate-results/live-openshell-gateway-auth-contract.json", JSON.stringify({ + conclusion: "failure", + lane: process.env.LANE_NAME, + resolutionId: process.env.RESOLUTION_ID, + }) + "\n", {mode: 0o600}); + ' + fi + + - name: Upload live evidence + if: ${{ always() }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: candidate-result-${{ needs.resolve.outputs.resolution_id }}-live-openshell-gateway-auth-contract + path: | + candidate-results/live-openshell-gateway-auth-contract.json + candidate-live-observed.json + candidate-source/e2e-artifacts/live/openshell-gateway-auth-contract/ + if-no-files-found: error + retention-days: 30 + + - name: Enforce live result + if: ${{ always() && (steps.candidate.outcome != 'success' || steps.live_test.outcome != 'success') }} + run: exit 1 + + evidence: + if: ${{ always() && needs.resolve.result == 'success' }} + needs: + - resolve + - deterministic + - live + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Check out trusted compatibility controller + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + ref: ${{ github.sha }} + path: controller + fetch-depth: 1 + persist-credentials: false + + - name: Download immutable resolution and plan + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: candidate-resolution-${{ needs.resolve.outputs.resolution_id }} + path: candidate-input + + - name: Download deterministic lane evidence + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: candidate-result-${{ needs.resolve.outputs.resolution_id }}-* + path: candidate-results + merge-multiple: true + + - name: Finalize auditable evidence + env: + RESOLUTION_ID: ${{ needs.resolve.outputs.resolution_id }} + RUN_ATTEMPT: ${{ github.run_attempt }} + RUN_ID: ${{ github.run_id }} + shell: bash + run: | + set -euo pipefail + node --experimental-strip-types controller/tools/candidate-compat.mts finalize \ + --receipt candidate-input/candidate-receipt.json \ + --resolution-id "$RESOLUTION_ID" \ + --plan candidate-input/candidate-plan.json \ + --results candidate-results \ + --run-id "$RUN_ID" \ + --attempt "$RUN_ATTEMPT" \ + --output candidate-compatibility-evidence.json + node <<'NODE' >> "$GITHUB_STEP_SUMMARY" + const evidence = require("./candidate-compatibility-evidence.json"); + console.log("## Candidate compatibility evidence\n"); + console.log(`- NemoClaw SHA: \`${evidence.receipt.nemoclawSha}\``); + console.log(`- Candidate: \`${evidence.receipt.component} ${evidence.receipt.requestedCandidate}\``); + console.log(`- Resolution: \`${evidence.receipt.resolutionId}\``); + console.log(`- Overall deterministic result: **${evidence.overall}**\n`); + console.log("| Lane | Selection | Result / reason |"); + console.log("| --- | --- | --- |"); + const results = new Map(evidence.results.map((result) => [result.lane, result.conclusion])); + for (const lane of evidence.plan.deterministic) { + console.log(`| \`${lane.id}\` | ${lane.status} | ${results.get(lane.id) ?? lane.reason} |`); + } + for (const lane of evidence.plan.live) { + console.log(`| \`e2e:${lane.id}\` | ${lane.status} | ${results.get(`live:${lane.id}`) ?? lane.reason} |`); + } + NODE + + - name: Upload compatibility evidence + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: candidate-compatibility-${{ needs.resolve.outputs.resolution_id }}-run-${{ github.run_id }}-${{ github.run_attempt }} + path: | + candidate-compatibility-evidence.json + candidate-input/candidate-receipt.json + candidate-input/candidate-plan.json + candidate-results/ + if-no-files-found: error + retention-days: 30 + + - name: Enforce aggregate result + env: + DETERMINISTIC_RESULT: ${{ needs.deterministic.result }} + LIVE_RESULT: ${{ needs.live.result }} + run: test "$DETERMINISTIC_RESULT" = success && test "$LIVE_RESULT" = success diff --git a/ci/source-shape-test-budget.json b/ci/source-shape-test-budget.json index 2883159471..0d32cce497 100644 --- a/ci/source-shape-test-budget.json +++ b/ci/source-shape-test-budget.json @@ -61,6 +61,11 @@ "test": "verifies the pinned Brev CLI digest before extracting it", "category": "security" }, + { + "file": "test/candidate-compat.test.ts", + "test": "keeps the manual controller read-only and runs digest-bound deterministic and live lanes (#6691)", + "category": "security" + }, { "file": "test/cloudflared-update-check-workflow.test.ts", "test": "keeps automatic and on-demand update checks reachable and credential-free", diff --git a/test/candidate-compat.test.ts b/test/candidate-compat.test.ts new file mode 100644 index 0000000000..a971ab6622 --- /dev/null +++ b/test/candidate-compat.test.ts @@ -0,0 +1,443 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { basename, join, resolve } from "node:path"; + +import { describe, expect, it } from "vitest"; +import { parse as parseYaml } from "yaml"; + +import { + buildCandidatePlan, + type CandidateReceipt, + downloadCandidateArtifact, + finalizeEvidence, + materializeCandidate, + parseCandidatePlan, + parseCandidateReceipt, + resolveCandidate, + verifyCandidateInvocations, + verifyDigest, + verifyObservedVersion, +} from "../tools/candidate-compat.mts"; + +const SHA = "a".repeat(40); +const VERSION = "0.0.82"; +const E2E_SOURCES = readFileSync(resolve(".github/workflows/e2e.yaml"), "utf8"); +const ASSETS = [ + { name: "openshell-x86_64-unknown-linux-musl.tar.gz", role: "cli" as const }, + { + name: "openshell-gateway-x86_64-unknown-linux-gnu.tar.gz", + role: "gateway" as const, + }, + { + name: "openshell-sandbox-x86_64-unknown-linux-gnu.tar.gz", + role: "sandbox" as const, + }, +]; + +function response(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + headers: { "content-type": "application/json" }, + status, + }); +} + +function releaseMetadata() { + return { + assets: ASSETS.map(({ name }, index) => ({ + browser_download_url: `https://github.com/NVIDIA/OpenShell/releases/download/v${VERSION}/${name}`, + digest: `sha256:${String(index + 1).repeat(64)}`, + name, + })), + draft: false, + id: 82, + tag_name: `v${VERSION}`, + }; +} + +function receipt(overrides: Partial = {}): CandidateReceipt { + const base = { + artifacts: ASSETS.map(({ name, role }, index) => ({ + digest: String(index + 1).repeat(64), + digestAlgorithm: "sha256" as const, + kind: "archive" as const, + name, + role, + url: `https://github.com/NVIDIA/OpenShell/releases/download/v${VERSION}/${name}`, + })), + component: "openshell" as const, + nemoclawSha: SHA, + officialSource: "github:NVIDIA/OpenShell:release:82", + requestedCandidate: `v${VERSION}`, + resolutionId: "d".repeat(64), + resolvedCandidate: VERSION, + schemaVersion: 1 as const, + }; + return { ...base, ...overrides }; +} + +describe("OpenShell candidate compatibility contract", () => { + // source-shape-contract: security -- The controller is read-only and candidate code is isolated from provenance resolution. + it("keeps the manual controller read-only and runs digest-bound deterministic and live lanes (#6691)", () => { + const source = readFileSync(resolve(".github/workflows/candidate-compatibility.yaml"), "utf8"); + const workflow = parseYaml(source) as { + jobs: Record; + on: { workflow_dispatch: { inputs: Record } }; + permissions: Record; + }; + expect(Object.keys(workflow.on.workflow_dispatch.inputs).sort()).toEqual([ + "candidate", + "component", + "nemoclaw_ref", + ]); + expect(workflow.permissions).toEqual({ contents: "read" }); + expect(Object.keys(workflow.jobs).sort()).toEqual([ + "deterministic", + "evidence", + "live", + "resolve", + ]); + expect(source).toContain("candidate compatibility must be dispatched from main"); + expect(source).toContain("path: controller"); + expect(source).toContain("path: candidate-source"); + expect(source).toContain("RESOLUTION_ID: ${{ needs.resolve.outputs.resolution_id }}"); + expect(source).toContain("verify-invocations"); + expect(source).toContain("openshell-gateway-auth-source-contract.test.ts"); + expect(source).not.toMatch(/\b(?:git push|gh pr|npm publish|docker push)\b/u); + }); + + it("rejects unsupported, ambiguous, and unsafe candidate input before metadata access (#6691)", async () => { + const fetcher = async () => { + throw new Error("metadata must not be fetched"); + }; + await expect( + resolveCandidate({ + candidate: "latest", + component: "openshell", + fetcher, + nemoclawSha: SHA, + }), + ).rejects.toThrow("exact version"); + await expect( + resolveCandidate({ + candidate: "v1.2.3\nINJECTED=1", + component: "openshell", + fetcher, + nemoclawSha: SHA, + }), + ).rejects.toThrow("unsafe characters"); + await expect( + resolveCandidate({ + candidate: "1.2.3", + component: "hermes", + fetcher, + nemoclawSha: SHA, + }), + ).rejects.toThrow("component must be one of: openshell"); + }); + + it("binds all required Linux runtime assets to official release digests (#6691)", async () => { + const fetcher = async () => response(releaseMetadata()); + const first = await resolveCandidate({ + candidate: `v${VERSION}`, + component: "openshell", + fetcher, + nemoclawSha: SHA, + }); + const rerun = await resolveCandidate({ + candidate: `v${VERSION}`, + component: "openshell", + fetcher, + nemoclawSha: SHA, + }); + expect(first).toEqual(rerun); + expect(first.artifacts.map(({ name, role }) => ({ name, role }))).toEqual(ASSETS); + expect(first.resolutionId).toMatch(/^[a-f0-9]{64}$/u); + expect(parseCandidateReceipt(first, first.resolutionId)).toEqual(first); + expect(() => + parseCandidateReceipt( + { + ...first, + artifacts: first.artifacts.map((item, index) => + index === 1 ? { ...item, url: "https://127.0.0.1/internal" } : item, + ), + }, + first.resolutionId, + ), + ).toThrow("approved official HTTPS host"); + }); + + it("fails closed when any required asset or SHA-256 provenance is absent (#6691)", async () => { + const missingGateway = releaseMetadata(); + missingGateway.assets = missingGateway.assets.filter(({ name }) => !name.includes("gateway")); + await expect( + resolveCandidate({ + candidate: VERSION, + component: "openshell", + fetcher: async () => response(missingGateway), + nemoclawSha: SHA, + }), + ).rejects.toThrow("missing openshell-gateway"); + const missingDigest = releaseMetadata(); + missingDigest.assets[2]!.digest = ""; + await expect( + resolveCandidate({ + candidate: VERSION, + component: "openshell", + fetcher: async () => response(missingDigest), + nemoclawSha: SHA, + }), + ).rejects.toThrow("non-empty string"); + }); + + it("validates the initial URL and every redirect before writing an artifact (#6691)", async () => { + const directory = mkdtempSync(join(tmpdir(), "nemoclaw-candidate-download-")); + const body = "candidate archive"; + const candidateArtifact = { + ...receipt().artifacts[0]!, + digest: createHash("sha256").update(body).digest("hex"), + }; + try { + await expect( + downloadCandidateArtifact( + { ...candidateArtifact, url: "http://127.0.0.1/internal" }, + directory, + 0, + async () => { + throw new Error("unapproved initial URL must not be fetched"); + }, + ), + ).rejects.toThrow("not approved"); + const requests: string[] = []; + await expect( + downloadCandidateArtifact(candidateArtifact, directory, 0, async (url, init) => { + requests.push(url); + expect(init?.redirect).toBe("manual"); + return new Response(null, { + headers: { location: "http://127.0.0.1/internal" }, + status: 302, + }); + }), + ).rejects.toThrow("not approved"); + expect(requests).toEqual([candidateArtifact.url]); + const target = await downloadCandidateArtifact( + candidateArtifact, + directory, + 0, + async () => new Response(body), + ); + expect(basename(target)).toBe("candidate-0.tar.gz"); + expect(readFileSync(target, "utf8")).toBe(body); + } finally { + rmSync(directory, { force: true, recursive: true }); + } + }); + + it("materializes exact runtime binaries and records real wrapper invocations (#6691)", async () => { + const directory = mkdtempSync(join(tmpdir(), "nemoclaw-candidate-materialize-")); + const archives = new Map(); + try { + const assets = receipt().artifacts.map((artifact) => { + const binaryName = artifact.role === "cli" ? "openshell" : `openshell-${artifact.role}`; + const source = join(directory, binaryName); + const features = + artifact.role === "cli" + ? "# request-body-credential-rewrite websocket-credential-rewrite" + : artifact.role === "sandbox" + ? "# allow_all_known_mcp_methods" + : ""; + writeFileSync( + source, + `#!/bin/sh\n${features}\nprintf '%s\\n' '${binaryName} ${VERSION}'\n`, + ); + chmodSync(source, 0o700); + const archive = join(directory, `${artifact.role}-exact.tar.gz`); + expect(spawnSync("tar", ["-czf", archive, "-C", directory, binaryName]).status).toBe(0); + const bytes = readFileSync(archive); + archives.set(artifact.url, bytes); + return { + browser_download_url: artifact.url, + digest: `sha256:${createHash("sha256").update(bytes).digest("hex")}`, + name: artifact.name, + }; + }); + const candidateReceipt = await resolveCandidate({ + candidate: `v${VERSION}`, + component: "openshell", + fetcher: async () => response({ assets, draft: false, id: 82, tag_name: `v${VERSION}` }), + nemoclawSha: SHA, + }); + const observed = await materializeCandidate( + candidateReceipt, + join(directory, "runtime"), + async (url) => new Response(archives.get(url)), + ); + const log = join(directory, "invocations.log"); + const env = { + ...process.env, + NEMOCLAW_CANDIDATE_INVOCATION_CONTEXT: `installer:${candidateReceipt.resolutionId}`, + NEMOCLAW_CANDIDATE_INVOCATION_LOG: log, + NEMOCLAW_OPENSHELL_CHANNEL: "stable", + NEMOCLAW_OPENSHELL_GATEWAY_BIN: join(observed.binDirectory, "openshell-gateway"), + NEMOCLAW_OPENSHELL_MAX_VERSION: VERSION, + NEMOCLAW_OPENSHELL_MIN_VERSION: VERSION, + NEMOCLAW_OPENSHELL_PIN_VERSION: VERSION, + NEMOCLAW_OPENSHELL_SANDBOX_BIN: join(observed.binDirectory, "openshell-sandbox"), + PATH: `${observed.binDirectory}:/usr/bin:/bin`, + }; + const installer = spawnSync("bash", [resolve("scripts/install-openshell.sh")], { + encoding: "utf8", + env, + }); + expect(installer.status, `${installer.stdout}\n${installer.stderr}`).toBe(0); + expect(installer.stdout).toContain(`openshell already installed: ${VERSION}`); + expect( + verifyCandidateInvocations({ + invocationLog: readFileSync(log, "utf8"), + lane: "installer", + receipt: candidateReceipt, + }), + ).toMatchObject({ conclusion: "success", observedVersion: VERSION }); + expect(() => + verifyCandidateInvocations({ + invocationLog: `gateway\tlive:openshell-gateway-auth-contract:${candidateReceipt.resolutionId}\t--version\n`, + lane: "live:openshell-gateway-auth-contract", + receipt: candidateReceipt, + }), + ).toThrow("did not start the candidate runtime"); + expect(() => + verifyCandidateInvocations({ + invocationLog: + "cli\tpreflight\t--version\ngateway\tpreflight\t--version\nsandbox\tpreflight\t--version\n", + lane: "installer", + receipt: candidateReceipt, + }), + ).toThrow("receipt-bound cli"); + } finally { + rmSync(directory, { force: true, recursive: true }); + } + }); + + it("selects only candidate-aware deterministic and live lanes (#6691)", () => { + const plan = buildCandidatePlan("openshell", E2E_SOURCES); + expect(parseCandidatePlan(plan, "openshell")).toEqual(plan); + expect( + plan.deterministic.filter(({ status }) => status === "selected").map(({ id }) => id), + ).toEqual(["installer"]); + expect(plan.live).toEqual([ + expect.objectContaining({ + id: "openshell-gateway-auth-contract", + selector: "job", + status: "selected", + }), + ]); + expect(() => parseCandidatePlan({ ...plan, deterministic: [], live: [] }, "openshell")).toThrow( + "every deterministic lane", + ); + }); + + it("rejects an empty persisted plan through the finalize CLI (#6691)", async () => { + const directory = mkdtempSync(join(tmpdir(), "nemoclaw-candidate-finalize-")); + try { + const candidateReceipt = await resolveCandidate({ + candidate: `v${VERSION}`, + component: "openshell", + fetcher: async () => response(releaseMetadata()), + nemoclawSha: SHA, + }); + const receiptPath = join(directory, "receipt.json"); + const planPath = join(directory, "plan.json"); + const resultsPath = join(directory, "results"); + writeFileSync(receiptPath, JSON.stringify(candidateReceipt)); + writeFileSync( + planPath, + JSON.stringify({ + component: "openshell", + deterministic: [], + live: [], + schemaVersion: 1, + }), + ); + mkdirSync(resultsPath); + const result = spawnSync( + process.execPath, + [ + "--experimental-strip-types", + resolve("tools/candidate-compat.mts"), + "finalize", + "--receipt", + receiptPath, + "--resolution-id", + candidateReceipt.resolutionId, + "--plan", + planPath, + "--results", + resultsPath, + "--run-id", + "123", + "--attempt", + "1", + "--output", + join(directory, "evidence.json"), + ], + { encoding: "utf8" }, + ); + expect(result.status).toBe(1); + expect(result.stderr).toContain("every deterministic lane exactly once"); + } finally { + rmSync(directory, { force: true, recursive: true }); + } + }); + + it("finalizes only complete receipt-bound deterministic and live evidence (#6691)", () => { + const plan = buildCandidatePlan("openshell", E2E_SOURCES); + const candidateReceipt = receipt(); + const results = ["installer", "live:openshell-gateway-auth-contract"].map((lane) => ({ + conclusion: "success" as const, + lane, + observedOutput: `openshell ${VERSION}`, + observedVersion: VERSION, + resolutionId: candidateReceipt.resolutionId, + })); + expect( + finalizeEvidence({ + attempt: "2", + plan, + receipt: candidateReceipt, + results, + runId: "123", + }), + ).toMatchObject({ overall: "success", schemaVersion: 1 }); + expect(() => + finalizeEvidence({ + attempt: "2", + plan, + receipt: candidateReceipt, + results: results.slice(1), + runId: "123", + }), + ).toThrow("every selected compatibility lane"); + expect(() => + finalizeEvidence({ + attempt: "2", + plan, + receipt: candidateReceipt, + results: [{ ...results[0]!, resolutionId: "f".repeat(64) }, results[1]!], + runId: "123", + }), + ).toThrow("different candidate resolution"); + }); + + it("detects digest and runtime version mismatches (#6691)", () => { + expect(() => + verifyDigest(new TextEncoder().encode("candidate"), receipt().artifacts[0]!), + ).toThrow("sha256 mismatch"); + expect(() => verifyObservedVersion(receipt(), "openshell 0.0.81")).toThrow( + `does not match ${VERSION}`, + ); + }); +}); diff --git a/test/e2e/live/openshell-gateway-auth-source-contract-helpers.ts b/test/e2e/live/openshell-gateway-auth-source-contract-helpers.ts index f9cb0df8db..221c3500fb 100644 --- a/test/e2e/live/openshell-gateway-auth-source-contract-helpers.ts +++ b/test/e2e/live/openshell-gateway-auth-source-contract-helpers.ts @@ -147,12 +147,13 @@ export async function withOpenShellGatewayAuthArtifactSafety( function resolveGatewayBin(): string | null { for (const candidate of [ + process.env.OPENSHELL_GATEWAY_BIN, path.join(os.homedir(), ".local", "bin", "openshell-gateway"), "/opt/homebrew/bin/openshell-gateway", "/usr/local/bin/openshell-gateway", "/usr/bin/openshell-gateway", ]) { - if (fs.existsSync(candidate)) return candidate; + if (candidate && fs.existsSync(candidate)) return candidate; } const which = run("sh", ["-c", "command -v openshell-gateway"]); return which.status === 0 && which.stdout.trim() ? which.stdout.trim() : null; @@ -646,7 +647,7 @@ async function runOpenShellGatewayAuthSourceContractScenarioUnchecked( const version = run(gatewayBin, ["--version"]); expect(version.status, commandOutput(version)).toBe(0); - expect(commandOutput(version)).toContain("0.0.72"); + expect(commandOutput(version)).toContain(process.env.NEMOCLAW_CANDIDATE_VERSION ?? "0.0.72"); await requireDockerDaemon({ dockerBin, host, skip }); diff --git a/test/e2e/live/openshell-gateway-auth-source-contract.test.ts b/test/e2e/live/openshell-gateway-auth-source-contract.test.ts index 8e72703339..7e10be3d02 100644 --- a/test/e2e/live/openshell-gateway-auth-source-contract.test.ts +++ b/test/e2e/live/openshell-gateway-auth-source-contract.test.ts @@ -7,7 +7,7 @@ import { test } from "../fixtures/e2e-test.ts"; import { runOpenShellGatewayAuthSourceContractScenario } from "./openshell-gateway-auth-source-contract-helpers.ts"; const LIVE_TIMEOUT_MS = 8 * 60_000; -const OPENSHELL_GATEWAY_AUTH_CONTRACT_VERSION = "0.0.72"; +const OPENSHELL_GATEWAY_AUTH_CONTRACT_VERSION = process.env.NEMOCLAW_CANDIDATE_VERSION ?? "0.0.72"; test( `OpenShell ${OPENSHELL_GATEWAY_AUTH_CONTRACT_VERSION} Docker-driver gateway auth uses NemoClaw mTLS plus sandbox JWT`, diff --git a/test/install-openshell-version-check.test.ts b/test/install-openshell-version-check.test.ts index a4c4fb2651..69eab5cdbc 100644 --- a/test/install-openshell-version-check.test.ts +++ b/test/install-openshell-version-check.test.ts @@ -11,6 +11,14 @@ import credentialBoundaryManifest from "../src/lib/actions/sandbox/openshell-chi import { buildRebuildHermesChildEnv } from "./e2e/live/rebuild-hermes-env.ts"; const SCRIPT = path.join(import.meta.dirname, "..", "scripts", "install-openshell.sh"); +const CANDIDATE_RUNTIME = { + cli: process.env.OPENSHELL_BIN, + gateway: process.env.OPENSHELL_GATEWAY_BIN, + resolutionId: process.env.NEMOCLAW_CANDIDATE_RESOLUTION_ID, + sandbox: process.env.NEMOCLAW_OPENSHELL_SANDBOX_BIN, + version: process.env.NEMOCLAW_CANDIDATE_VERSION, +}; +const CANDIDATE_RUNTIME_ENABLED = Object.values(CANDIDATE_RUNTIME).every(Boolean); const PINNED_OPEN_SHELL_SHA256 = { cliDarwinArm64: "117b5354cc42d80bc4d5e070ea5ac4e341208ff6d3c29b516d8a9c80e2310f8d", cliLinuxArm64: "a5ff01a3240d73c72ec1700eda6cc6c752a86cf50c5dd1b5bdc459f544d03045", @@ -209,6 +217,28 @@ exit 0`, } describe("install-openshell.sh version check", { timeout: 15_000 }, () => { + it.runIf(CANDIDATE_RUNTIME_ENABLED)( + "validates the receipt-bound candidate through the installer path (#6691)", + () => { + const context = `installer:${CANDIDATE_RUNTIME.resolutionId}`; + const result = spawnSync("bash", [SCRIPT], { + encoding: "utf8", + env: { + ...process.env, + NEMOCLAW_CANDIDATE_INVOCATION_CONTEXT: context, + NEMOCLAW_OPENSHELL_CHANNEL: "stable", + NEMOCLAW_OPENSHELL_GATEWAY_BIN: CANDIDATE_RUNTIME.gateway, + NEMOCLAW_OPENSHELL_MAX_VERSION: CANDIDATE_RUNTIME.version, + NEMOCLAW_OPENSHELL_MIN_VERSION: CANDIDATE_RUNTIME.version, + NEMOCLAW_OPENSHELL_PIN_VERSION: CANDIDATE_RUNTIME.version, + NEMOCLAW_OPENSHELL_SANDBOX_BIN: CANDIDATE_RUNTIME.sandbox, + }, + }); + expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(0); + expect(result.stdout).toContain(`openshell already installed: ${CANDIDATE_RUNTIME.version}`); + }, + ); + it("exits cleanly when the required OpenShell and driver binaries are already installed", () => { const result = runWithInstalledVersion(REQUIRED_OPENSHELL_VERSION); expect(result.status).toBe(0); diff --git a/tools/candidate-compat.mts b/tools/candidate-compat.mts new file mode 100644 index 0000000000..ef8e93770a --- /dev/null +++ b/tools/candidate-compat.mts @@ -0,0 +1,882 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { chmod, mkdir, readdir, readFile, writeFile } from "node:fs/promises"; +import { basename, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +export const COMPONENTS = ["openshell"] as const; +export type CandidateComponent = (typeof COMPONENTS)[number]; + +export type Artifact = { + digest: string; + digestAlgorithm: "sha256"; + kind: "archive"; + name: string; + role: "cli" | "gateway" | "sandbox"; + url: string; +}; + +export type CandidateReceipt = { + artifacts: Artifact[]; + component: CandidateComponent; + nemoclawSha: string; + officialSource: string; + requestedCandidate: string; + resolutionId: string; + resolvedCandidate: string; + schemaVersion: 1; +}; + +export type CandidatePlan = { + component: CandidateComponent; + deterministic: Array<{ + id: DeterministicLane; + reason: string; + status: "selected" | "skipped"; + }>; + live: Array<{ + id: string; + reason: string; + selector: "job" | "target"; + status: "selected" | "skipped"; + }>; + schemaVersion: 1; +}; + +type LaneResult = { + conclusion: "failure" | "success"; + lane: string; + observedOutput?: string; + observedVersion?: string; + resolutionId: string; +}; + +type FetchLike = (input: string, init?: RequestInit) => Promise; +type DeterministicLane = + | "source-unit" + | "integration" + | "installer" + | "package-contract" + | "plugin" + | "e2e-support"; + +const FULL_SHA = /^[a-f0-9]{40}$/; +const VERSION = /^[0-9]+(?:\.[0-9]+){2}(?:[-+][0-9A-Za-z.-]+)?$/; +const DIGEST = /^[a-f0-9]+$/; +const OFFICIAL_HOSTS = new Set(["api.github.com", "codeload.github.com", "github.com"]); +const DOWNLOAD_HOSTS = new Set([...OFFICIAL_HOSTS, "release-assets.githubusercontent.com"]); +const MAX_DOWNLOAD_REDIRECTS = 5; +const ALL_LANES: DeterministicLane[] = [ + "source-unit", + "integration", + "installer", + "package-contract", + "plugin", + "e2e-support", +]; + +const SELECTED_LANES: Record> = { + openshell: new Set(["installer"]), +}; + +const LIVE_SELECTORS: Record< + CandidateComponent, + ReadonlyArray<{ id: string; selector: "job" | "target" }> +> = { + openshell: [{ id: "openshell-gateway-auth-contract", selector: "job" }], +}; + +function stableJson(value: unknown): string { + if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`; + if (value && typeof value === "object") { + return `{${Object.entries(value as Record) + .filter(([, item]) => item !== undefined) + .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0)) + .map(([key, item]) => `${JSON.stringify(key)}:${stableJson(item)}`) + .join(",")}}`; + } + return JSON.stringify(value); +} + +function sha256(value: string | Uint8Array): string { + return createHash("sha256").update(value).digest("hex"); +} + +function assertComponent(value: string): asserts value is CandidateComponent { + if (!COMPONENTS.includes(value as CandidateComponent)) { + throw new Error(`component must be one of: ${COMPONENTS.join(", ")}`); + } +} + +function assertCandidate(component: CandidateComponent, candidate: string): void { + if (candidate.length > 128 || candidate.includes("/") || /[\x00-\x20\x7f]/u.test(candidate)) { + throw new Error("candidate contains unsafe characters"); + } + const normalized = candidate.replace(/^v/u, ""); + if (!VERSION.test(normalized)) { + throw new Error(`${component} candidate must be an exact version`); + } +} + +function assertOfficialUrl(raw: string, expectedPathPrefix: string): URL { + const url = new URL(raw); + if (url.protocol !== "https:" || !OFFICIAL_HOSTS.has(url.hostname)) { + throw new Error(`candidate artifact is not on an approved official HTTPS host: ${raw}`); + } + if (!url.pathname.startsWith(expectedPathPrefix)) { + throw new Error(`candidate artifact has unexpected official-source path: ${raw}`); + } + if (url.username || url.password || url.search || url.hash) { + throw new Error( + `candidate artifact URL must not contain credentials, query, or fragment: ${raw}`, + ); + } + return url; +} + +async function fetchJson(fetcher: FetchLike, url: string, token?: string): Promise { + const response = await fetcher(url, { + headers: { + Accept: "application/vnd.github+json, application/json", + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + redirect: "error", + }); + if (!response.ok) + throw new Error(`official metadata request failed (${response.status}): ${url}`); + return response.json(); +} + +function record(value: unknown, label: string): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error(`${label} must be an object`); + } + return value as Record; +} + +function stringField(value: Record, key: string, label: string): string { + const field = value[key]; + if (typeof field !== "string" || field.length === 0) { + throw new Error(`${label}.${key} must be a non-empty string`); + } + return field; +} + +function artifact(input: Artifact, expectedPathPrefix: string): Artifact { + const url = assertOfficialUrl(input.url, expectedPathPrefix); + if (!DIGEST.test(input.digest)) throw new Error(`${input.name} has an invalid digest`); + const expectedLength = 64; + if (input.digest.length !== expectedLength) { + throw new Error(`${input.name} has an invalid ${input.digestAlgorithm} digest`); + } + return { ...input, url: url.href }; +} + +async function resolveOpenShell( + candidate: string, + fetcher: FetchLike, + token?: string, +): Promise> { + const version = candidate.replace(/^v/u, ""); + const tag = `v${version}`; + const metadata = record( + await fetchJson( + fetcher, + `https://api.github.com/repos/NVIDIA/OpenShell/releases/tags/${tag}`, + token, + ), + "OpenShell release", + ); + if (metadata.draft === true) throw new Error("OpenShell candidate release is a draft"); + if (stringField(metadata, "tag_name", "OpenShell release") !== tag) { + throw new Error("OpenShell release tag does not match the requested candidate"); + } + if (!Array.isArray(metadata.assets)) throw new Error("OpenShell release assets must be an array"); + const requiredAssets = [ + { + name: "openshell-x86_64-unknown-linux-musl.tar.gz", + role: "cli" as const, + }, + { + name: "openshell-gateway-x86_64-unknown-linux-gnu.tar.gz", + role: "gateway" as const, + }, + { + name: "openshell-sandbox-x86_64-unknown-linux-gnu.tar.gz", + role: "sandbox" as const, + }, + ]; + const releaseAssets = metadata.assets.map((item) => record(item, "OpenShell asset")); + const artifacts = requiredAssets.map(({ name, role }) => { + const assetMetadata = releaseAssets.find((item) => item.name === name); + if (!assetMetadata) throw new Error(`OpenShell release is missing ${name}`); + const digest = stringField(assetMetadata, "digest", `OpenShell asset ${name}`); + if (!digest.startsWith("sha256:")) { + throw new Error(`OpenShell asset ${name} is missing SHA-256 provenance`); + } + return artifact( + { + digest: digest.slice("sha256:".length), + digestAlgorithm: "sha256", + kind: "archive", + name, + role, + url: stringField(assetMetadata, "browser_download_url", `OpenShell asset ${name}`), + }, + `/NVIDIA/OpenShell/releases/download/${tag}/`, + ); + }); + return { + artifacts, + component: "openshell", + officialSource: `github:NVIDIA/OpenShell:release:${String(metadata.id)}`, + requestedCandidate: candidate, + resolvedCandidate: version, + }; +} + +export async function resolveCandidate(input: { + candidate: string; + component: string; + fetcher?: FetchLike; + githubToken?: string; + nemoclawSha: string; +}): Promise { + assertComponent(input.component); + if (!FULL_SHA.test(input.nemoclawSha)) throw new Error("NemoClaw ref must resolve to a full SHA"); + assertCandidate(input.component, input.candidate); + const fetcher = input.fetcher ?? fetch; + const resolved = await resolveOpenShell(input.candidate, fetcher, input.githubToken); + const receiptWithoutId = { + ...resolved, + nemoclawSha: input.nemoclawSha, + schemaVersion: 1 as const, + }; + return { + ...receiptWithoutId, + resolutionId: sha256(stableJson(receiptWithoutId)), + }; +} + +function parseArtifact(value: unknown, resolvedCandidate: string, index: number): Artifact { + const input = record(value, `candidate artifact ${index}`); + const digestAlgorithm = stringField(input, "digestAlgorithm", `candidate artifact ${index}`); + if (digestAlgorithm !== "sha256") { + throw new Error(`candidate artifact ${index} has an invalid digest algorithm`); + } + const kind = stringField(input, "kind", `candidate artifact ${index}`); + if (kind !== "archive") { + throw new Error(`candidate artifact ${index} has an invalid kind`); + } + const name = stringField(input, "name", `candidate artifact ${index}`); + if (name !== basename(name) || name.length > 255) { + throw new Error(`candidate artifact ${index} has an unsafe name`); + } + + const expected = [ + { + name: "openshell-x86_64-unknown-linux-musl.tar.gz", + role: "cli" as const, + }, + { + name: "openshell-gateway-x86_64-unknown-linux-gnu.tar.gz", + role: "gateway" as const, + }, + { + name: "openshell-sandbox-x86_64-unknown-linux-gnu.tar.gz", + role: "sandbox" as const, + }, + ][index]; + if (!expected || name !== expected.name || input.role !== expected.role) { + throw new Error(`candidate artifact ${index} does not match the resolved component`); + } + return artifact( + { + digest: stringField(input, "digest", `candidate artifact ${index}`), + digestAlgorithm, + kind, + name, + role: expected.role, + url: stringField(input, "url", `candidate artifact ${index}`), + }, + `/NVIDIA/OpenShell/releases/download/v${resolvedCandidate}/`, + ); +} + +/** Parse an uploaded resolver receipt and bind it to the trusted resolve-job output. */ +export function parseCandidateReceipt( + value: unknown, + expectedResolutionId: string, +): CandidateReceipt { + if (!/^[a-f0-9]{64}$/u.test(expectedResolutionId)) { + throw new Error("expected candidate resolution id is invalid"); + } + const input = record(value, "candidate receipt"); + if (input.schemaVersion !== 1) throw new Error("candidate receipt schema version must be 1"); + const component = stringField(input, "component", "candidate receipt"); + assertComponent(component); + const requestedCandidate = stringField(input, "requestedCandidate", "candidate receipt"); + assertCandidate(component, requestedCandidate); + const resolvedCandidate = stringField(input, "resolvedCandidate", "candidate receipt"); + if (!VERSION.test(resolvedCandidate)) { + throw new Error("candidate receipt has an invalid resolved version"); + } + if (requestedCandidate.replace(/^v/u, "") !== resolvedCandidate) { + throw new Error("candidate receipt resolved version does not match the request"); + } + const nemoclawSha = stringField(input, "nemoclawSha", "candidate receipt"); + if (!FULL_SHA.test(nemoclawSha)) throw new Error("candidate receipt has an invalid NemoClaw SHA"); + if (!Array.isArray(input.artifacts)) + throw new Error("candidate receipt artifacts must be an array"); + const expectedArtifactCount = 3; + if (input.artifacts.length !== expectedArtifactCount) { + throw new Error(`candidate receipt for ${component} has an invalid artifact count`); + } + const artifacts = input.artifacts.map((item, index) => + parseArtifact(item, resolvedCandidate, index), + ); + const officialSource = stringField(input, "officialSource", "candidate receipt"); + const sourcePattern = /^github:NVIDIA\/OpenShell:release:[1-9][0-9]*$/u; + if (!sourcePattern.test(officialSource)) { + throw new Error("candidate receipt has an invalid official source"); + } + if (input.resolvedCommit !== undefined) { + throw new Error("candidate receipt has an invalid resolved commit"); + } + const receiptWithoutId = { + artifacts, + component, + nemoclawSha, + officialSource, + requestedCandidate, + resolvedCandidate, + schemaVersion: 1 as const, + }; + const resolutionId = sha256(stableJson(receiptWithoutId)); + if ( + stringField(input, "resolutionId", "candidate receipt") !== resolutionId || + resolutionId !== expectedResolutionId + ) { + throw new Error("candidate receipt does not match the trusted resolution id"); + } + return { ...receiptWithoutId, resolutionId }; +} + +export function buildCandidatePlan( + component: CandidateComponent, + e2eSources: string, +): CandidatePlan { + const selected = SELECTED_LANES[component]; + const live = LIVE_SELECTORS[component].map((entry) => { + const marker = entry.selector === "job" ? ` ${entry.id}:\n` : `id: "${entry.id}"`; + if (!e2eSources.includes(marker)) { + throw new Error(`E2E source of truth does not declare ${entry.selector} ${entry.id}`); + } + return { + ...entry, + reason: "the selected live job executes the digest-bound OpenShell gateway candidate", + status: "selected" as const, + }; + }); + return { + component, + deterministic: ALL_LANES.map((id) => ({ + id, + reason: selected.has(id) + ? `the ${id} lane exercises ${component}-owned source or integration boundaries` + : `the ${id} lane does not exercise an ${component}-owned boundary`, + status: selected.has(id) ? "selected" : "skipped", + })), + live, + schemaVersion: 1, + }; +} + +/** Validate a persisted plan before it can select compatibility evidence. */ +export function parseCandidatePlan( + value: unknown, + expectedComponent: CandidateComponent, +): CandidatePlan { + const input = record(value, "candidate plan"); + if (input.schemaVersion !== 1) throw new Error("candidate plan schema version must be 1"); + const component = stringField(input, "component", "candidate plan"); + assertComponent(component); + if (component !== expectedComponent) { + throw new Error("candidate plan component does not match the receipt"); + } + if (!Array.isArray(input.deterministic)) { + throw new Error("candidate plan deterministic lanes must be an array"); + } + const deterministic = input.deterministic.map((value, index) => { + const lane = record(value, `candidate plan deterministic lane ${index}`); + const id = stringField(lane, "id", `candidate plan deterministic lane ${index}`); + if (!ALL_LANES.includes(id as DeterministicLane)) { + throw new Error(`candidate plan has an unsupported deterministic lane: ${id}`); + } + const statusValue = stringField(lane, "status", `candidate plan deterministic lane ${index}`); + if (statusValue !== "selected" && statusValue !== "skipped") { + throw new Error(`candidate plan deterministic lane ${id} has an invalid status`); + } + const status: "selected" | "skipped" = statusValue; + return { + id: id as DeterministicLane, + reason: stringField(lane, "reason", `candidate plan deterministic lane ${index}`), + status, + }; + }); + if ( + deterministic.length !== ALL_LANES.length || + new Set(deterministic.map(({ id }) => id)).size !== ALL_LANES.length + ) { + throw new Error("candidate plan must account for every deterministic lane exactly once"); + } + for (const lane of deterministic) { + const expectedStatus = SELECTED_LANES[component].has(lane.id) ? "selected" : "skipped"; + if (lane.status !== expectedStatus) { + throw new Error(`candidate plan has an invalid selection for ${lane.id}`); + } + } + if (!Array.isArray(input.live)) { + throw new Error("candidate plan live lanes must be an array"); + } + const expectedLive = LIVE_SELECTORS[component]; + const live = input.live.map((value, index) => { + const lane = record(value, `candidate plan live lane ${index}`); + const id = stringField(lane, "id", `candidate plan live lane ${index}`); + const selector = stringField(lane, "selector", `candidate plan live lane ${index}`); + const expected = expectedLive.find((candidate) => candidate.id === id); + if (!expected || selector !== expected.selector) { + throw new Error(`candidate plan has an unsupported live lane: ${id}`); + } + if (lane.status !== "selected") { + throw new Error(`candidate plan live lane ${id} must be selected`); + } + return { + id, + reason: stringField(lane, "reason", `candidate plan live lane ${index}`), + selector: expected.selector, + status: "selected" as const, + }; + }); + if ( + live.length !== expectedLive.length || + new Set(live.map(({ id }) => id)).size !== expectedLive.length + ) { + throw new Error("candidate plan must account for every live lane exactly once"); + } + return { component, deterministic, live, schemaVersion: 1 }; +} + +export function verifyDigest(bytes: Uint8Array, artifactValue: Artifact): void { + const actual = createHash(artifactValue.digestAlgorithm).update(bytes).digest("hex"); + if (actual !== artifactValue.digest) { + throw new Error(`${artifactValue.name} ${artifactValue.digestAlgorithm} mismatch`); + } +} + +export function verifyObservedVersion(receipt: CandidateReceipt, output: string): string { + const escaped = receipt.resolvedCandidate.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + if (!new RegExp(`(^|[^0-9A-Za-z.])v?${escaped}([^0-9A-Za-z.]|$)`, "u").test(output.trim())) { + throw new Error( + `observed ${receipt.component} version does not match ${receipt.resolvedCandidate}: ${output.trim()}`, + ); + } + return receipt.resolvedCandidate; +} + +export function finalizeEvidence(input: { + attempt: string; + plan: CandidatePlan; + receipt: CandidateReceipt; + results: LaneResult[]; + runId: string; +}): Record { + if (!/^[1-9][0-9]*$/u.test(input.runId) || !/^[1-9][0-9]*$/u.test(input.attempt)) { + throw new Error("run id and attempt must be positive integers"); + } + const selected = [ + ...input.plan.deterministic.filter((lane) => lane.status === "selected").map((lane) => lane.id), + ...input.plan.live + .filter((lane) => lane.status === "selected") + .map((lane) => `live:${lane.id}`), + ].sort(); + const actual = input.results.map((result) => result.lane).sort(); + if (new Set(actual).size !== actual.length || stableJson(actual) !== stableJson(selected)) { + throw new Error( + "lane results do not account for every selected compatibility lane exactly once", + ); + } + for (const result of input.results) { + if (result.resolutionId !== input.receipt.resolutionId) { + throw new Error(`lane ${result.lane} used a different candidate resolution`); + } + if (result.conclusion !== "success" && result.conclusion !== "failure") { + throw new Error(`lane ${result.lane} has an invalid conclusion`); + } + if ( + result.conclusion === "success" && + (result.observedVersion !== input.receipt.resolvedCandidate || !result.observedOutput) + ) { + throw new Error(`lane ${result.lane} has no matching observed candidate version`); + } + } + return { + execution: { attempt: input.attempt, runId: input.runId }, + overall: input.results.every((result) => result.conclusion === "success") + ? "success" + : "failure", + plan: input.plan, + receipt: input.receipt, + results: [...input.results].sort((left, right) => + left.lane < right.lane ? -1 : left.lane > right.lane ? 1 : 0, + ), + schemaVersion: 1, + }; +} + +function run( + command: string, + args: string[], + options: { cwd?: string; env?: NodeJS.ProcessEnv } = {}, +) { + const result = spawnSync(command, args, { + cwd: options.cwd, + encoding: "utf8", + env: options.env ?? process.env, + stdio: ["ignore", "pipe", "pipe"], + }); + if (result.status !== 0) { + const detail = result.error?.message ?? result.stderr?.trim() ?? result.stdout?.trim() ?? ""; + throw new Error(`${command} ${args.join(" ")} failed${detail ? `: ${detail}` : ""}`); + } + return result.stdout.trim(); +} + +function assertDownloadUrl(raw: string): URL { + const url = new URL(raw); + if ( + url.protocol !== "https:" || + !DOWNLOAD_HOSTS.has(url.hostname) || + url.username || + url.password || + url.hash || + url.href.length > 4096 + ) { + throw new Error(`candidate download URL is not approved: ${raw}`); + } + return url; +} + +async function fetchDownload(fetcher: FetchLike, rawUrl: string): Promise { + let current = assertDownloadUrl(rawUrl); + for (let redirectCount = 0; redirectCount <= MAX_DOWNLOAD_REDIRECTS; redirectCount += 1) { + const response = await fetcher(current.href, { redirect: "manual" }); + if (![301, 302, 303, 307, 308].includes(response.status)) return response; + if (redirectCount === MAX_DOWNLOAD_REDIRECTS) { + throw new Error("candidate download exceeded the redirect limit"); + } + const location = response.headers.get("location"); + if (!location) throw new Error("candidate download redirect has no location"); + current = assertDownloadUrl(new URL(location, current).href); + } + throw new Error("candidate download redirect handling failed"); +} + +export async function downloadCandidateArtifact( + artifactValue: Artifact, + directory: string, + index: number, + fetcher: FetchLike = fetch, +): Promise { + const response = await fetchDownload(fetcher, artifactValue.url); + if (!response.ok) throw new Error(`candidate download failed (${response.status})`); + const bytes = new Uint8Array(await response.arrayBuffer()); + verifyDigest(bytes, artifactValue); + const target = join(directory, `candidate-${index}.tar.gz`); + await writeFile(target, bytes, { mode: 0o600 }); + return target; +} + +const ROLE_BINARY: Record = { + cli: "openshell", + gateway: "openshell-gateway", + sandbox: "openshell-sandbox", +}; + +const INSTALLER_FEATURE_MARKERS = [ + "request-body-credential-rewrite", + "websocket-credential-rewrite", + "allow_all_known_mcp_methods", +] as const; + +function shellQuote(value: string): string { + return `'${value.replaceAll("'", `'"'"'`)}'`; +} + +function binaryContains(binary: string, marker: string): boolean { + const result = spawnSync("grep", ["-aFq", "--", marker, binary], { + stdio: "ignore", + }); + if (result.status === 0) return true; + if (result.status === 1) return false; + throw new Error(`failed to inspect ${basename(binary)} for installer feature markers`); +} + +function extractCandidateBinary( + archive: string, + artifactValue: Artifact, + runtimeDirectory: string, +): string { + const binaryName = ROLE_BINARY[artifactValue.role]; + const members = run("tar", ["-tzf", archive]); + if (members !== binaryName) { + throw new Error(`${artifactValue.name} must contain exactly one ${binaryName} binary`); + } + const listing = run("tar", ["-tvzf", archive]); + if (listing.includes("\n") || !listing.startsWith("-") || !listing.endsWith(` ${binaryName}`)) { + throw new Error(`${artifactValue.name} must contain one regular ${binaryName} binary`); + } + run("tar", ["-xzf", archive, "-C", runtimeDirectory, binaryName]); + return join(runtimeDirectory, binaryName); +} + +export function verifyCandidateInvocations(input: { + invocationLog: string; + lane: string; + receipt: CandidateReceipt; +}): LaneResult { + const entries = input.invocationLog + .split("\n") + .filter(Boolean) + .map((line) => { + const fields = line.split("\t"); + if (fields.length !== 3 || !fields[0] || !fields[1]) + throw new Error("candidate invocation log contains a malformed entry"); + return { + args: fields[2] ?? "", + context: fields[1], + role: fields[0], + }; + }); + const requiredRoles = input.lane === "installer" ? ["cli", "gateway", "sandbox"] : ["gateway"]; + if (input.lane !== "installer" && input.lane !== "live:openshell-gateway-auth-contract") { + throw new Error(`unsupported candidate-aware lane: ${input.lane}`); + } + const expectedContext = `${input.lane}:${input.receipt.resolutionId}`; + for (const role of requiredRoles) { + if ( + !entries.some( + (entry) => + entry.role === role && entry.context === expectedContext && entry.args === "--version", + ) + ) { + throw new Error(`lane ${input.lane} did not invoke receipt-bound ${role} --version`); + } + } + if ( + input.lane === "live:openshell-gateway-auth-contract" && + !entries.some( + (entry) => + entry.role === "gateway" && entry.context === expectedContext && entry.args !== "--version", + ) + ) { + throw new Error("live gateway lane did not start the candidate runtime"); + } + return { + conclusion: "success", + lane: input.lane, + observedOutput: entries + .map((entry) => `${entry.role} ${entry.context} ${entry.args}`) + .join("\n"), + observedVersion: input.receipt.resolvedCandidate, + resolutionId: input.receipt.resolutionId, + }; +} + +export async function materializeCandidate( + receipt: CandidateReceipt, + directory: string, + fetcher: FetchLike = fetch, +) { + await mkdir(directory, { recursive: true, mode: 0o700 }); + const runtimeDirectory = join(directory, "runtime"); + const binDirectory = join(directory, "bin"); + await mkdir(runtimeDirectory, { mode: 0o700 }); + await mkdir(binDirectory, { mode: 0o700 }); + const archives = await Promise.all( + receipt.artifacts.map((candidateArtifact, index) => + downloadCandidateArtifact(candidateArtifact, directory, index, fetcher), + ), + ); + const observed: Record = {}; + for (const [index, artifactValue] of receipt.artifacts.entries()) { + const archive = archives[index]; + if (!archive) throw new Error(`candidate artifact ${index} was not downloaded`); + const binary = extractCandidateBinary(archive, artifactValue, runtimeDirectory); + await chmod(binary, 0o700); + const output = run(binary, ["--version"]); + const version = verifyObservedVersion(receipt, output); + const wrapper = join(binDirectory, ROLE_BINARY[artifactValue.role]); + const installerFeatureMarkers = INSTALLER_FEATURE_MARKERS.filter((marker) => + binaryContains(binary, marker), + ); + await writeFile( + wrapper, + [ + "#!/bin/sh", + "set -eu", + ...installerFeatureMarkers.map((marker) => `# receipt-bound feature: ${marker}`), + ': "${NEMOCLAW_CANDIDATE_INVOCATION_LOG:?candidate invocation log is required}"', + ': "${NEMOCLAW_CANDIDATE_INVOCATION_CONTEXT:?candidate invocation context is required}"', + `printf '%s\\t%s\\t%s\\n' ${shellQuote(artifactValue.role)} "$NEMOCLAW_CANDIDATE_INVOCATION_CONTEXT" "$*" >> "$NEMOCLAW_CANDIDATE_INVOCATION_LOG"`, + `exec ${shellQuote(binary)} "$@"`, + "", + ].join("\n"), + { mode: 0o500 }, + ); + observed[artifactValue.role] = { output, path: binary, version }; + } + return { + binDirectory, + component: receipt.component, + observed, + resolutionId: receipt.resolutionId, + schemaVersion: 1 as const, + }; +} + +function parseArgs(argv: string[]): Map { + const values = new Map(); + for (let index = 0; index < argv.length; index += 2) { + const key = argv[index]; + const value = argv[index + 1]; + if (!key?.startsWith("--") || value === undefined) + throw new Error(`invalid argument: ${key ?? ""}`); + if (values.has(key)) throw new Error(`duplicate argument: ${key}`); + values.set(key, value); + } + return values; +} + +function required(args: Map, name: string): string { + const value = args.get(name); + if (!value) throw new Error(`${name} is required`); + return value; +} + +async function main(argv: string[]): Promise { + const [command, ...rest] = argv; + const args = parseArgs(rest); + if (command === "resolve") { + const receipt = await resolveCandidate({ + candidate: required(args, "--candidate"), + component: required(args, "--component"), + githubToken: process.env.GITHUB_TOKEN, + nemoclawSha: required(args, "--nemoclaw-sha"), + }); + await writeFile(required(args, "--output"), `${stableJson(receipt)}\n`, { + mode: 0o600, + }); + return; + } + if (command === "plan") { + const component = required(args, "--component"); + assertComponent(component); + const sources = await Promise.all([ + readFile(required(args, "--e2e-workflow"), "utf8"), + readFile(required(args, "--e2e-registry"), "utf8"), + ]); + const plan = buildCandidatePlan(component, sources.join("\n")); + await writeFile(required(args, "--output"), `${stableJson(plan)}\n`, { + mode: 0o600, + }); + return; + } + if (command === "materialize") { + const receipt = parseCandidateReceipt( + JSON.parse(await readFile(required(args, "--receipt"), "utf8")), + required(args, "--resolution-id"), + ); + const observed = await materializeCandidate(receipt, required(args, "--directory")); + await writeFile(required(args, "--output"), `${stableJson(observed)}\n`, { + mode: 0o600, + }); + const githubEnv = args.get("--github-env"); + if (githubEnv) { + const invocationLog = join(required(args, "--directory"), "candidate-invocations.log"); + await writeFile( + githubEnv, + [ + `PATH=${observed.binDirectory}:${process.env.PATH ?? ""}`, + `NEMOCLAW_CANDIDATE_COMPONENT=${receipt.component}`, + `NEMOCLAW_CANDIDATE_INVOCATION_LOG=${invocationLog}`, + `NEMOCLAW_CANDIDATE_RECEIPT=${resolve(required(args, "--receipt"))}`, + `NEMOCLAW_CANDIDATE_RESOLUTION_ID=${receipt.resolutionId}`, + `NEMOCLAW_CANDIDATE_VERSION=${receipt.resolvedCandidate}`, + `NEMOCLAW_OPENSHELL_SANDBOX_BIN=${join(observed.binDirectory, "openshell-sandbox")}`, + `OPENSHELL_BIN=${join(observed.binDirectory, "openshell")}`, + `OPENSHELL_GATEWAY_BIN=${join(observed.binDirectory, "openshell-gateway")}`, + "", + ].join("\n"), + { flag: "a" }, + ); + } + return; + } + if (command === "verify-invocations") { + const receipt = parseCandidateReceipt( + JSON.parse(await readFile(required(args, "--receipt"), "utf8")), + required(args, "--resolution-id"), + ); + const result = verifyCandidateInvocations({ + invocationLog: await readFile(required(args, "--log"), "utf8"), + lane: required(args, "--lane"), + receipt, + }); + await writeFile(required(args, "--output"), `${stableJson(result)}\n`, { + mode: 0o600, + }); + return; + } + if (command === "finalize") { + const receipt = parseCandidateReceipt( + JSON.parse(await readFile(required(args, "--receipt"), "utf8")), + required(args, "--resolution-id"), + ); + const plan = parseCandidatePlan( + JSON.parse(await readFile(required(args, "--plan"), "utf8")), + receipt.component, + ); + const resultDirectory = required(args, "--results"); + const resultFiles = (await readdir(resultDirectory, { recursive: true })).filter((path) => + path.endsWith(".json"), + ); + const results = await Promise.all( + resultFiles.map(async (path) => + JSON.parse(await readFile(join(resultDirectory, path), "utf8")), + ), + ); + const evidence = finalizeEvidence({ + attempt: required(args, "--attempt"), + plan, + receipt, + results, + runId: required(args, "--run-id"), + }); + await writeFile(required(args, "--output"), `${stableJson(evidence)}\n`, { + mode: 0o600, + }); + return; + } + throw new Error("command must be resolve, plan, materialize, verify-invocations, or finalize"); +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + main(process.argv.slice(2)).catch((error) => { + console.error(`candidate-compat: ${error instanceof Error ? error.message : String(error)}`); + process.exitCode = 1; + }); +}