diff --git a/.github/workflows/e2e.yaml b/.github/workflows/e2e.yaml index 6484d435c25..b31146b3042 100644 --- a/.github/workflows/e2e.yaml +++ b/.github/workflows/e2e.yaml @@ -1782,16 +1782,13 @@ jobs: source_commit: ${{ steps.resolve_openshell_dev_artifact.outputs.source_commit }} manifest_sha256: ${{ steps.resolve_openshell_dev_artifact.outputs.manifest_sha256 }} steps: - - name: Checkout trusted OpenShell dev tooling + - name: Checkout trusted OpenShell dev artifact resolver uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: ${{ github.repository }} ref: ${{ inputs.workflow_sha || github.workflow_sha }} path: .trusted-openshell-dev-artifact persist-credentials: false - sparse-checkout: | - scripts/install-openshell.sh - tools/e2e/openshell-dev-artifact.mts - name: Set up Node for OpenShell dev artifact resolution uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 @@ -1839,7 +1836,7 @@ jobs: ref: ${{ inputs.checkout_sha || github.sha }} persist-credentials: false - - name: Checkout trusted OpenShell dev tooling + - name: Checkout trusted OpenShell dev artifact tooling uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: ${{ github.repository }} @@ -1847,7 +1844,6 @@ jobs: path: .trusted-openshell-dev-artifact persist-credentials: false sparse-checkout: | - scripts/install-openshell.sh tools/e2e/openshell-dev-artifact.mts - *dockerhub-auth @@ -1857,11 +1853,6 @@ jobs: with: build-cli: "false" - - name: Restore exact-commit CLI artifact - uses: NVIDIA/NemoClaw/.github/actions/restore-e2e-cli-artifact@c246409193a31133cab10c8a3589001cc0d59eb3 - with: - provenance-json: ${{ needs.generate-matrix.outputs.cli_artifact_provenance }} - - name: Restore immutable OpenShell dev artifact uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: @@ -1881,6 +1872,32 @@ jobs: "$OPENSHELL_DEV_EXPECTED_SOURCE_COMMIT" "$OPENSHELL_DEV_EXPECTED_MANIFEST_SHA256" + - name: Revoke Docker auth before OpenShell development tooling + uses: NVIDIA/NemoClaw/.github/actions/docker-auth-cleanup@d5f37099766ca82a4516e7d8f0de117cda197fe3 + + - name: Install immutable OpenShell dev artifact + env: + OPENSHELL_DEV_ARTIFACT_DIR: ${{ runner.temp }}/openshell-dev-artifact + OPENSHELL_DEV_BINARY_DIR: ${{ runner.temp }}/openshell-dev-binaries + OPENSHELL_DEV_EXPECTED_MANIFEST_SHA256: ${{ needs.openshell-dev-artifact.outputs.manifest_sha256 }} + OPENSHELL_DEV_EXPECTED_SOURCE_COMMIT: ${{ needs.openshell-dev-artifact.outputs.source_commit }} + run: | + set -euo pipefail + node --experimental-strip-types --no-warnings \ + "${{ github.workspace }}/.trusted-openshell-dev-artifact/tools/e2e/openshell-dev-artifact.mts" prepare \ + "$OPENSHELL_DEV_ARTIFACT_DIR" \ + "$OPENSHELL_DEV_BINARY_DIR" \ + "$OPENSHELL_DEV_EXPECTED_SOURCE_COMMIT" \ + "$OPENSHELL_DEV_EXPECTED_MANIFEST_SHA256" + sudo install -m 755 "$OPENSHELL_DEV_BINARY_DIR/openshell" /usr/local/bin/openshell + sudo install -m 755 "$OPENSHELL_DEV_BINARY_DIR/openshell-gateway" /usr/local/bin/openshell-gateway + sudo install -m 755 "$OPENSHELL_DEV_BINARY_DIR/openshell-sandbox" /usr/local/bin/openshell-sandbox + + - name: Restore exact-commit CLI artifact + uses: NVIDIA/NemoClaw/.github/actions/restore-e2e-cli-artifact@c246409193a31133cab10c8a3589001cc0d59eb3 + with: + provenance-json: ${{ needs.generate-matrix.outputs.cli_artifact_provenance }} + - name: Install and verify cloudflared prerequisite # Update posture: keep this dev compatibility lane on the same reviewed # version/SHA256 pair as the stable lane; workflow-contract tests fail @@ -1906,48 +1923,6 @@ jobs: - name: Generate MCP test TLS run: bash test/e2e/setup-mcp-test-tls.sh - - name: Revoke Docker auth before OpenShell development tooling - shell: bash - run: bash .github/scripts/docker-auth-cleanup.sh - - - name: Install immutable OpenShell dev artifact - env: - NEMOCLAW_ACCEPT_DEV_UNVERIFIED_INSTALL: "1" - NEMOCLAW_OPENSHELL_FORCE_INSTALL: "1" - OPENSHELL_DEV_ASSET_DIR: ${{ runner.temp }}/openshell-dev-artifact/assets - run: | - set -euo pipefail - shim_dir="$(mktemp -d)" - trap 'rm -rf "$shim_dir"' EXIT - cat >"$shim_dir/gh" <<'EOF' - #!/usr/bin/env bash - set -euo pipefail - if [[ "$#" -ne 10 || "$1" != "release" || "$2" != "download" || "$3" != "dev" || "$4" != "--repo" || "$5" != "NVIDIA/OpenShell" || "$6" != "--pattern" || "$8" != "--dir" || "${10}" != "--clobber" ]]; then - printf 'Unsupported gh invocation for retained OpenShell assets.\n' >&2 - exit 64 - fi - asset="$7" - destination="$9" - case "$asset" in - openshell-x86_64-unknown-linux-musl.tar.gz | openshell-checksums-sha256.txt | openshell-gateway-x86_64-unknown-linux-gnu.tar.gz | openshell-gateway-checksums-sha256.txt | openshell-sandbox-x86_64-unknown-linux-gnu.tar.gz | openshell-sandbox-checksums-sha256.txt) ;; - *) - printf 'Unsupported retained OpenShell asset: %s\n' "$asset" >&2 - exit 64 - ;; - esac - source_asset="${OPENSHELL_DEV_ASSET_DIR}/${asset}" - [[ -f "$source_asset" && ! -L "$source_asset" && "$destination" = /* && -d "$destination" && ! -L "$destination" ]] - cp -- "$source_asset" "$destination/$asset" - EOF - cat >"$shim_dir/curl" <<'EOF' - #!/usr/bin/env bash - printf 'Network fallback is disabled for retained OpenShell assets.\n' >&2 - exit 1 - EOF - chmod 700 "$shim_dir/gh" "$shim_dir/curl" - PATH="$shim_dir:$PATH" \ - bash "${{ github.workspace }}/.trusted-openshell-dev-artifact/scripts/install-openshell.sh" - - id: mcp_runtime_compatibility name: Classify OpenShell credential-boundary compatibility run: | @@ -1988,8 +1963,7 @@ jobs: - name: Clean up Docker auth if: always() - shell: bash - run: bash .github/scripts/docker-auth-cleanup.sh + uses: NVIDIA/NemoClaw/.github/actions/docker-auth-cleanup@d5f37099766ca82a4516e7d8f0de117cda197fe3 # Manual PR qualification also requires the exact candidate activation contract. diff --git a/scripts/checks/vitest-project-overlap.mts b/scripts/checks/vitest-project-overlap.mts index e4e4fb7bdaa..40857afc416 100644 --- a/scripts/checks/vitest-project-overlap.mts +++ b/scripts/checks/vitest-project-overlap.mts @@ -46,7 +46,6 @@ const INSTALLER_INTEGRATION_TESTS = new Set([ "test/install-forward-restore-diagnostics.test.ts", "test/install-hermes-forward-restore.test.ts", "test/install-managed-cli-reuse.test.ts", - "test/install-openshell-e2e-artifact.test.ts", "test/install-openshell-version-pin.test.ts", "test/install-openshell-version-check.test.ts", "test/install-preflight-docker-bootstrap.test.ts", diff --git a/test/e2e/README.md b/test/e2e/README.md index d6bfc56f0f9..75cfe7d770a 100644 --- a/test/e2e/README.md +++ b/test/e2e/README.md @@ -468,12 +468,8 @@ Linux x64 archive and checksum file. It rejects release drift during download, then uploads the verified bytes under a content-addressed name with the shared 14-day E2E retention policy. -The OpenClaw, Hermes, and LangChain Deep Agents Code shards restore and verify -that same artifact with the trusted workflow revision. An exact-argument and -asset-allowlisted `gh` shim presents only those retained files to the unchanged -trusted `scripts/install-openshell.sh` path. A separate `curl` shim blocks -network fallback. The installer still checks the release checksums and archive -structure before installation. A missing, replaced, or corrupt upstream asset +The OpenClaw, Hermes, and Deep Agents Code shards restore and verify that same +artifact before installation. A missing, replaced, or corrupt upstream asset fails the resolver as an infrastructure failure. The job error reports the failed identifier and source URL, and `resolution.json` records them when the artifact directory remains writable. The three product shards do not start in diff --git a/test/e2e/support/dockerhub-auth-workflow-boundary.test.ts b/test/e2e/support/dockerhub-auth-workflow-boundary.test.ts index 6c686f0dbd4..331fb90db6e 100644 --- a/test/e2e/support/dockerhub-auth-workflow-boundary.test.ts +++ b/test/e2e/support/dockerhub-auth-workflow-boundary.test.ts @@ -268,6 +268,20 @@ describe("shared Docker Hub authentication workflow boundary (#6961)", () => { testTimeout(15_000), ); + it("rejects an unpinned final cleanup action in the OpenShell dev job (#9051)", () => { + expect(validateE2eWorkflowBoundary()).toEqual([]); + + const errors = validateMutation((workflow) => { + const cleanup = namedStep(workflow.jobs["mcp-bridge-dev"], CLEANUP_STEP_NAME); + expect(cleanup).toBeDefined(); + cleanup!.uses = "NVIDIA/NemoClaw/.github/actions/docker-auth-cleanup@main"; + }); + + expect(errors).toContain( + "mcp-bridge-dev Docker Hub cleanup step must use the pinned cleanup action", + ); + }); + it("rejects missing auth and cleanup coverage for every classified image job", () => { const workflow = loadWorkflow(); const requiredJobs = imageJobNames(workflow); @@ -408,7 +422,7 @@ describe("shared Docker Hub authentication workflow boundary (#6961)", () => { } }); - for (const jobName of requiredJobs) { + for (const jobName of requiredJobs.filter((name) => name !== "mcp-bridge-dev")) { expect(errors).toContain( `${jobName} Docker Hub cleanup step must contain exactly name, if, shell, and run`, ); @@ -416,6 +430,9 @@ describe("shared Docker Hub authentication workflow boundary (#6961)", () => { `${jobName} Docker Hub cleanup step must run only ${CLEANUP_HELPER_RUN}`, ); } + expect(errors).toContain( + "mcp-bridge-dev Docker Hub cleanup step must contain exactly name, if, and uses", + ); }); it("treats every new E2E job as image-consuming unless it is explicitly exempt", () => { diff --git a/test/e2e/support/mcp-workflow-boundary.test.ts b/test/e2e/support/mcp-workflow-boundary.test.ts index 05161d2c335..f2fd1e3bb70 100644 --- a/test/e2e/support/mcp-workflow-boundary.test.ts +++ b/test/e2e/support/mcp-workflow-boundary.test.ts @@ -308,20 +308,16 @@ describe("MCP workflow artifact boundary", () => { const restore = dev.steps.find( (step) => step.name === "Restore immutable OpenShell dev artifact", ); - const verify = dev.steps.find( - (step) => step.name === "Verify immutable OpenShell dev artifact", - ); const install = dev.steps.find( (step) => step.name === "Install immutable OpenShell dev artifact", ); requireFixture(restore?.with, "OpenShell dev artifact restore fixture is missing"); - requireFixture(verify?.run, "OpenShell dev artifact verification fixture is missing"); requireFixture(install?.run, "OpenShell dev artifact installation fixture is missing"); restore.uses = "actions/download-artifact@main"; restore.with.name = "openshell-dev-latest"; - verify.run = verify.run.replace(".trusted-openshell-dev-artifact/", ""); install.env = { NEMOCLAW_ACCEPT_DEV_UNVERIFIED_INSTALL: "1" }; - install.run = "bash scripts/install-openshell.sh"; + install.run = install.run.replace(" prepare ", " resolve "); + install.run += "\nbash scripts/install-openshell.sh\n"; fs.writeFileSync(workflowPath, YAML.stringify(workflow)); expect(validateMcpOpenShellWorkflowBoundary(workflowPath)).toEqual( @@ -329,9 +325,9 @@ describe("MCP workflow artifact boundary", () => { "mcp-bridge-dev must depend on its reviewed artifact producers", "mcp-bridge-dev must use the reviewed immutable artifact downloader", "mcp-bridge-dev must restore exactly the resolver's content-addressed artifact", - "mcp-bridge-dev must verify the immutable OpenShell artifact before installation", - "mcp-bridge-dev installer must receive only the retained OpenShell asset directory", - "mcp-bridge-dev must install retained assets through the trusted no-network release path", + "mcp-bridge-dev artifact installation must receive only its reviewed artifact identity", + "mcp-bridge-dev must install only the verified same-run binaries", + "mcp-bridge-dev must not modify or invoke the base-trusted release installer", ]), ); } finally { @@ -344,7 +340,7 @@ describe("MCP workflow artifact boundary", () => { name: "candidate checkout ref", mutate: (job: { steps: Array> }) => { const checkout = job.steps.find( - (step) => step.name === "Checkout trusted OpenShell dev tooling", + (step) => step.name === "Checkout trusted OpenShell dev artifact resolver", ); requireFixture( checkout?.with, @@ -391,6 +387,128 @@ describe("MCP workflow artifact boundary", () => { } }); + it.each([ + { + name: "candidate revision", + mutate: (job: { steps: Array> }) => { + const checkout = job.steps.find( + (step) => step.name === "Checkout trusted OpenShell dev artifact tooling", + ); + requireFixture(checkout?.with, "trusted OpenShell dev tooling checkout is missing"); + const withValues = checkout.with as Record; + withValues.ref = "${{ inputs.checkout_sha || github.sha }}"; + }, + expected: "mcp-bridge-dev must check out only the trusted OpenShell dev artifact tooling", + }, + { + name: "candidate verifier invocation", + mutate: (job: { steps: Array> }) => { + const verify = job.steps.find( + (step) => step.name === "Verify immutable OpenShell dev artifact", + ); + requireFixture( + typeof verify?.run === "string", + "trusted OpenShell dev artifact verification is missing", + ); + verify.run = verify.run.replace( + "${{ github.workspace }}/.trusted-openshell-dev-artifact/", + "${{ github.workspace }}/.candidate-runtime/", + ); + }, + expected: "mcp-bridge-dev must verify the immutable OpenShell artifact before installation", + }, + { + name: "candidate preparer invocation", + mutate: (job: { steps: Array> }) => { + const install = job.steps.find( + (step) => step.name === "Install immutable OpenShell dev artifact", + ); + requireFixture( + typeof install?.run === "string", + "trusted OpenShell dev artifact preparation is missing", + ); + install.run = install.run.replace( + "${{ github.workspace }}/.trusted-openshell-dev-artifact/", + "${{ github.workspace }}/.candidate-runtime/", + ); + }, + expected: "mcp-bridge-dev must install only the verified same-run binaries", + }, + ])("rejects a $name for OpenShell dev artifact consumption (#9051)", ({ expected, mutate }) => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-mcp-workflow-")); + const workflowPath = path.join(directory, "e2e.yaml"); + try { + const workflow = YAML.parse(fs.readFileSync(".github/workflows/e2e.yaml", "utf8")) as { + jobs: Record> }>; + }; + mutate(workflow.jobs["mcp-bridge-dev"]); + fs.writeFileSync(workflowPath, YAML.stringify(workflow)); + + expect(validateMcpOpenShellWorkflowBoundary(workflowPath)).toContain(expected); + } finally { + fs.rmSync(directory, { force: true, recursive: true }); + } + }); + + it.each(["Restore exact-commit CLI artifact", "Generate MCP test TLS"])( + "rejects %s before trusted OpenShell installation (#9051)", + (candidateStepName) => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-mcp-workflow-")); + const workflowPath = path.join(directory, "e2e.yaml"); + try { + const workflow = YAML.parse(fs.readFileSync(".github/workflows/e2e.yaml", "utf8")) as { + jobs: Record> }>; + }; + const steps = workflow.jobs["mcp-bridge-dev"].steps; + const candidateStepIndex = steps.findIndex((step) => step.name === candidateStepName); + requireFixture(candidateStepIndex >= 0, `${candidateStepName} fixture is missing`); + const [candidateStep] = steps.splice(candidateStepIndex, 1); + const installIndex = steps.findIndex( + (step) => step.name === "Install immutable OpenShell dev artifact", + ); + requireFixture(candidateStep, `${candidateStepName} fixture is missing`); + requireFixture(installIndex >= 0, "trusted OpenShell installation fixture is missing"); + steps.splice(installIndex, 0, candidateStep); + fs.writeFileSync(workflowPath, YAML.stringify(workflow)); + + expect(validateMcpOpenShellWorkflowBoundary(workflowPath)).toContain( + "mcp-bridge-dev must verify and install OpenShell before candidate code runs", + ); + } finally { + fs.rmSync(directory, { force: true, recursive: true }); + } + }, + ); + + it.each([ + { + stepName: "Revoke Docker auth before OpenShell development tooling", + expected: "mcp-bridge-dev must revoke Docker auth before OpenShell development tooling", + }, + { + stepName: "Clean up Docker auth", + expected: "mcp-bridge-dev must use the canonical unconditional Docker auth cleanup", + }, + ])("rejects an unpinned cleanup action in $stepName (#9051)", ({ expected, stepName }) => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-mcp-workflow-")); + const workflowPath = path.join(directory, "e2e.yaml"); + try { + const workflow = YAML.parse(fs.readFileSync(".github/workflows/e2e.yaml", "utf8")) as { + jobs: Record> }>; + }; + const step = workflow.jobs["mcp-bridge-dev"].steps.find( + (candidate) => candidate.name === stepName, + ); + requireFixture(step, `${stepName} fixture is missing`); + step.uses = "NVIDIA/NemoClaw/.github/actions/docker-auth-cleanup@main"; + fs.writeFileSync(workflowPath, YAML.stringify(workflow)); + + expect(validateMcpOpenShellWorkflowBoundary(workflowPath)).toContain(expected); + } finally { + fs.rmSync(directory, { force: true, recursive: true }); + } + }); + it("rejects any additional artifact upload outside the scanned directory", () => { const directory = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-mcp-workflow-")); const workflowPath = path.join(directory, "e2e.yaml"); diff --git a/test/e2e/support/openshell-dev-artifact-fixture.ts b/test/e2e/support/openshell-dev-artifact-fixture.ts index 7747c0214d8..b926b137053 100644 --- a/test/e2e/support/openshell-dev-artifact-fixture.ts +++ b/test/e2e/support/openshell-dev-artifact-fixture.ts @@ -10,13 +10,16 @@ import { OPENSHELL_DEV_ASSET_NAMES } from "../../../tools/e2e/openshell-dev-arti export const API_ROOT = "https://api.github.com/repos/NVIDIA/OpenShell"; export const RELEASE_URL = `${API_ROOT}/releases/tags/dev`; +const TAG_URL = `${API_ROOT}/git/ref/tags/dev`; +const ANNOTATED_TAG_SHA = "a".repeat(40); export const SOURCE_COMMIT = "b".repeat(40); type FixtureOptions = { missingAsset?: string; driftAfterDownload?: boolean; corruptAsset?: string; - sourceCommit?: string; + oversizedAsset?: string; + releaseTargetCommit?: string; }; export function fixtureFetch(options: FixtureOptions = {}): typeof fetch { @@ -45,7 +48,7 @@ export function fixtureFetch(options: FixtureOptions = {}): typeof fetch { return Response.json({ id: 9051, tag_name: "dev", - target_commitish: options.sourceCommit ?? SOURCE_COMMIT, + target_commitish: options.releaseTargetCommit ?? SOURCE_COMMIT, url: `${API_ROOT}/releases/9051`, html_url: "https://github.com/NVIDIA/OpenShell/releases/tag/dev", updated_at: @@ -55,6 +58,24 @@ export function fixtureFetch(options: FixtureOptions = {}): typeof fetch { assets, }); } + if (url === TAG_URL) { + return Response.json({ + object: { + type: "tag", + sha: ANNOTATED_TAG_SHA, + url: `${API_ROOT}/git/tags/${ANNOTATED_TAG_SHA}`, + }, + }); + } + if (url === `${API_ROOT}/git/tags/${ANNOTATED_TAG_SHA}`) { + return Response.json({ + object: { + type: "commit", + sha: SOURCE_COMMIT, + url: `${API_ROOT}/git/commits/${SOURCE_COMMIT}`, + }, + }); + } const assetMatch = url.match( /^https:\/\/api\.github\.com\/repos\/NVIDIA\/OpenShell\/releases\/assets\/(\d+)$/, ); @@ -71,7 +92,8 @@ export function fixtureFetch(options: FixtureOptions = {}): typeof fetch { if (!name) throw new Error(`unexpected fixture asset id ${downloadMatch[1]}`); const expected = contents.get(name); if (!expected) throw new Error(`missing fixture bytes for ${name}`); - const bytes = name === options.corruptAsset ? Buffer.from(expected).fill(120) : expected; + let bytes = name === options.corruptAsset ? Buffer.from(expected).fill(120) : expected; + if (name === options.oversizedAsset) bytes = Buffer.concat([bytes, Buffer.from("x")]); return new Response(bytes, { status: 200 }); } return new Response("not found", { status: 404 }); @@ -81,3 +103,39 @@ export function fixtureFetch(options: FixtureOptions = {}): typeof fetch { export function temporaryDirectory(): string { return fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-openshell-dev-artifact-")); } + +export function fixtureTarRunner(args: string[]) { + return fixtureTarRunnerWithSize(1)(args); +} + +export function fixtureTarRunnerWithSize( + declaredSize: number, + onExtract: (args: string[]) => void = () => {}, + extractedSize = declaredSize, +) { + return (args: string[]) => { + const members = new Map([ + ["openshell-x86_64-unknown-linux-musl.tar.gz", "openshell"], + ["openshell-gateway-x86_64-unknown-linux-gnu.tar.gz", "openshell-gateway"], + ["openshell-sandbox-x86_64-unknown-linux-gnu.tar.gz", "openshell-sandbox"], + ]); + const archiveName = path.basename(args[1]); + const member = members.get(archiveName); + if (!member) return { status: 1, stdout: "", stderr: "unexpected archive" }; + if (args[0] === "-tzf") return { status: 0, stdout: `${member}\n`, stderr: "" }; + if (args[0] === "-tvzf") { + return { + status: 0, + stdout: `-rwxr-xr-x 0/0 ${declaredSize} 2026-01-01 00:00 ${member}\n`, + stderr: "", + }; + } + if (args[0] === "-xzf") { + onExtract(args); + const outputDirectory = args[args.indexOf("-C") + 1]; + fs.writeFileSync(path.join(outputDirectory, member), Buffer.alloc(extractedSize, 120)); + return { status: 0, stdout: "", stderr: "" }; + } + return { status: 1, stdout: "", stderr: "unexpected tar operation" }; + }; +} diff --git a/test/e2e/support/openshell-dev-artifact.test.ts b/test/e2e/support/openshell-dev-artifact.test.ts index 495cf37e5ce..71d9b023912 100644 --- a/test/e2e/support/openshell-dev-artifact.test.ts +++ b/test/e2e/support/openshell-dev-artifact.test.ts @@ -7,13 +7,17 @@ import path from "node:path"; import { describe, expect, it } from "vitest"; import { + MAX_OPENSHELL_DEV_BINARY_BYTES, OPENSHELL_DEV_ASSET_NAMES, + prepareOpenShellDevBinaries, resolveOpenShellDevArtifact, verifyOpenShellDevArtifact, } from "../../../tools/e2e/openshell-dev-artifact.mts"; import { API_ROOT, fixtureFetch, + fixtureTarRunner, + fixtureTarRunnerWithSize, RELEASE_URL, SOURCE_COMMIT, temporaryDirectory, @@ -78,30 +82,32 @@ describe("OpenShell dev artifact resolver", () => { } }); - it("rejects a moving release target before download (#9051)", async () => { + it("rejects asset bytes that disagree with the published digest (#9051)", async () => { const directory = temporaryDirectory(); + const corruptAsset = OPENSHELL_DEV_ASSET_NAMES[0]; try { await expect( - resolveOpenShellDevArtifact(directory, fixtureFetch({ sourceCommit: "main" })), + resolveOpenShellDevArtifact(directory, fixtureFetch({ corruptAsset })), ).rejects.toMatchObject({ - identifier: "release:9051:tag:dev", - sourceUrl: RELEASE_URL, + identifier: `asset:${corruptAsset}:id:1000`, + sourceUrl: `${API_ROOT}/releases/assets/1000`, }); } finally { fs.rmSync(directory, { force: true, recursive: true }); } }); - it("rejects asset bytes that disagree with the published digest (#9051)", async () => { + it("stops reading an asset when its body exceeds the declared size (#9051)", async () => { const directory = temporaryDirectory(); - const corruptAsset = OPENSHELL_DEV_ASSET_NAMES[0]; + const oversizedAsset = OPENSHELL_DEV_ASSET_NAMES[0]; try { await expect( - resolveOpenShellDevArtifact(directory, fixtureFetch({ corruptAsset })), + resolveOpenShellDevArtifact(directory, fixtureFetch({ oversizedAsset })), ).rejects.toMatchObject({ - identifier: `asset:${corruptAsset}:id:1000`, + identifier: `asset:${oversizedAsset}:id:1000`, sourceUrl: `${API_ROOT}/releases/assets/1000`, }); + expect(fs.existsSync(path.join(directory, "assets"))).toBe(false); } finally { fs.rmSync(directory, { force: true, recursive: true }); } @@ -121,6 +127,23 @@ describe("OpenShell dev artifact resolver", () => { } }); + it("rejects a release target that differs from the dev tag commit (#9051)", async () => { + const directory = temporaryDirectory(); + try { + await expect( + resolveOpenShellDevArtifact( + directory, + fixtureFetch({ releaseTargetCommit: "c".repeat(40) }), + ), + ).rejects.toMatchObject({ + identifier: "release:9051:tag:dev", + sourceUrl: RELEASE_URL, + }); + } finally { + fs.rmSync(directory, { force: true, recursive: true }); + } + }); + it("rejects cached bytes changed after resolution (#9051)", async () => { const directory = temporaryDirectory(); try { @@ -156,4 +179,107 @@ describe("OpenShell dev artifact resolver", () => { fs.rmSync(directory, { force: true, recursive: true }); } }); + + it("prepares only the three reviewed regular binaries (#9051)", async () => { + const directory = temporaryDirectory(); + const binaryDirectory = `${directory}-binaries`; + try { + const resolution = await resolveOpenShellDevArtifact(directory, fixtureFetch()); + const manifestSha256 = resolution.manifestSha256; + requireFixture(manifestSha256, "fixture resolution omitted manifest digest"); + + prepareOpenShellDevBinaries( + directory, + binaryDirectory, + SOURCE_COMMIT, + manifestSha256, + fixtureTarRunner, + ); + + expect(fs.readdirSync(binaryDirectory).sort()).toEqual([ + "openshell", + "openshell-gateway", + "openshell-sandbox", + ]); + } finally { + fs.rmSync(directory, { force: true, recursive: true }); + fs.rmSync(binaryDirectory, { force: true, recursive: true }); + } + }); + + it("rejects an archive with an unexpected member before extraction (#9051)", async () => { + const directory = temporaryDirectory(); + const binaryDirectory = `${directory}-binaries`; + try { + const resolution = await resolveOpenShellDevArtifact(directory, fixtureFetch()); + const manifestSha256 = resolution.manifestSha256; + requireFixture(manifestSha256, "fixture resolution omitted manifest digest"); + + expect(() => + prepareOpenShellDevBinaries( + directory, + binaryDirectory, + SOURCE_COMMIT, + manifestSha256, + () => ({ status: 0, stdout: "../openshell\n", stderr: "" }), + ), + ).toThrow(/expected exactly one member/); + expect(fs.existsSync(binaryDirectory)).toBe(false); + } finally { + fs.rmSync(directory, { force: true, recursive: true }); + fs.rmSync(binaryDirectory, { force: true, recursive: true }); + } + }); + + it("rejects an oversized archive member before extraction (#9051)", async () => { + const directory = temporaryDirectory(); + const binaryDirectory = `${directory}-binaries`; + try { + const resolution = await resolveOpenShellDevArtifact(directory, fixtureFetch()); + const manifestSha256 = resolution.manifestSha256; + requireFixture(manifestSha256, "fixture resolution omitted manifest digest"); + const extractionAttempts: string[][] = []; + + expect(() => + prepareOpenShellDevBinaries( + directory, + binaryDirectory, + SOURCE_COMMIT, + manifestSha256, + fixtureTarRunnerWithSize(MAX_OPENSHELL_DEV_BINARY_BYTES + 1, (args) => + extractionAttempts.push(args), + ), + ), + ).toThrow(/exceeds the .* binary limit/); + expect(extractionAttempts).toEqual([]); + expect(fs.existsSync(binaryDirectory)).toBe(false); + } finally { + fs.rmSync(directory, { force: true, recursive: true }); + fs.rmSync(binaryDirectory, { force: true, recursive: true }); + } + }); + + it("rejects an extracted binary whose size differs from its archive header (#9051)", async () => { + const directory = temporaryDirectory(); + const binaryDirectory = `${directory}-binaries`; + try { + const resolution = await resolveOpenShellDevArtifact(directory, fixtureFetch()); + const manifestSha256 = resolution.manifestSha256; + requireFixture(manifestSha256, "fixture resolution omitted manifest digest"); + + expect(() => + prepareOpenShellDevBinaries( + directory, + binaryDirectory, + SOURCE_COMMIT, + manifestSha256, + fixtureTarRunnerWithSize(8, undefined, 7), + ), + ).toThrow(/size does not match its archive header/); + expect(fs.existsSync(binaryDirectory)).toBe(false); + } finally { + fs.rmSync(directory, { force: true, recursive: true }); + fs.rmSync(binaryDirectory, { force: true, recursive: true }); + } + }); }); diff --git a/test/install-openshell-e2e-artifact.test.ts b/test/install-openshell-e2e-artifact.test.ts deleted file mode 100644 index 709c25a4c7d..00000000000 --- a/test/install-openshell-e2e-artifact.test.ts +++ /dev/null @@ -1,139 +0,0 @@ -// 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 fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; - -import { describe, expect, it } from "vitest"; -import YAML from "yaml"; - -const INSTALLER = path.join(import.meta.dirname, "..", "scripts", "install-openshell.sh"); -const WORKFLOW = path.join(import.meta.dirname, "..", ".github", "workflows", "e2e.yaml"); -const FEATURE_MARKERS = - "request-body-credential-rewrite websocket-credential-rewrite allow_all_known_mcp_methods"; - -function writeExecutable(target: string, contents: string): void { - fs.writeFileSync(target, contents, { mode: 0o755 }); -} - -function createFixture() { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-openshell-dev-assets-")); - const assetDirectory = path.join(root, "assets"); - const fakeBin = path.join(root, "bin"); - const source = path.join(root, "source"); - fs.mkdirSync(assetDirectory); - fs.mkdirSync(fakeBin); - fs.mkdirSync(source); - - const archives = [ - ["openshell-x86_64-unknown-linux-musl.tar.gz", "openshell", "openshell-checksums-sha256.txt"], - [ - "openshell-gateway-x86_64-unknown-linux-gnu.tar.gz", - "openshell-gateway", - "openshell-gateway-checksums-sha256.txt", - ], - [ - "openshell-sandbox-x86_64-unknown-linux-gnu.tar.gz", - "openshell-sandbox", - "openshell-sandbox-checksums-sha256.txt", - ], - ] as const; - for (const [archive, binary, checksum] of archives) { - writeExecutable( - path.join(source, binary), - `#!/usr/bin/env bash\nif [ "\${1:-}" = "--version" ]; then echo "${binary} 0.0.106-dev.1+gabc12345"; exit 0; fi\n# ${FEATURE_MARKERS}\nexit 0\n`, - ); - const archivePath = path.join(assetDirectory, archive); - const tar = spawnSync("tar", ["czf", archivePath, "-C", source, binary]); - expect(tar.status, `unable to create ${archive}`).toBe(0); - const digest = createHash("sha256").update(fs.readFileSync(archivePath)).digest("hex"); - fs.writeFileSync(path.join(assetDirectory, checksum), `${digest} ${archive}\n`); - } - writeExecutable( - path.join(fakeBin, "uname"), - `#!/usr/bin/env bash\nif [ "\${1:-}" = "-m" ]; then echo x86_64; else echo Linux; fi`, - ); - writeExecutable( - path.join(fakeBin, "openshell"), - `#!/usr/bin/env bash\nif [ "\${1:-}" = "--version" ]; then echo "openshell 0.0.36"; exit 0; fi\nexit 99`, - ); - return { assetDirectory, fakeBin, root }; -} - -function installStepRun(): string { - const workflow = YAML.parse(fs.readFileSync(WORKFLOW, "utf8")) as { - jobs: Record }>; - }; - const run = workflow.jobs["mcp-bridge-dev"].steps.find( - (step) => step.name === "Install immutable OpenShell dev artifact", - )?.run; - expect(run).toBeTypeOf("string"); - return String(run).replace( - "${{ github.workspace }}/.trusted-openshell-dev-artifact/scripts/install-openshell.sh", - INSTALLER, - ); -} - -function runInstallStep(fixture: ReturnType) { - return spawnSync("bash", ["-c", installStepRun()], { - env: { - ...process.env, - NEMOCLAW_ACCEPT_DEV_UNVERIFIED_INSTALL: "1", - NEMOCLAW_OPENSHELL_CHANNEL: "dev", - NEMOCLAW_OPENSHELL_FORCE_INSTALL: "1", - OPENSHELL_DEV_ASSET_DIR: fixture.assetDirectory, - PATH: `${fixture.fakeBin}:/usr/bin:/bin`, - XDG_BIN_HOME: path.join(fixture.root, "local-bin"), - }, - encoding: "utf8", - }); -} - -describe("OpenShell retained E2E artifact installation", () => { - it("runs retained assets through the trusted installer without network fallback (#9051)", () => { - const fixture = createFixture(); - try { - const result = runInstallStep(fixture); - expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(0); - expect(result.stdout).toContain("Verifying SHA-256 checksum"); - expect(result.stderr).not.toContain("Network fallback is disabled"); - } finally { - fs.rmSync(fixture.root, { force: true, recursive: true }); - } - }); - - it("rejects retained bytes that do not match their release checksum (#9051)", () => { - const fixture = createFixture(); - try { - fs.appendFileSync( - path.join(fixture.assetDirectory, "openshell-x86_64-unknown-linux-musl.tar.gz"), - "tampered", - ); - const result = runInstallStep(fixture); - expect(result.status).not.toBe(0); - expect(result.stderr).toContain("SHA-256 checksum verification failed"); - } finally { - fs.rmSync(fixture.root, { force: true, recursive: true }); - } - }); - - it("blocks network fallback when a retained asset is a symbolic link (#9051)", () => { - const fixture = createFixture(); - try { - const archive = path.join( - fixture.assetDirectory, - "openshell-x86_64-unknown-linux-musl.tar.gz", - ); - fs.rmSync(archive); - fs.symlinkSync(path.join(fixture.root, "source", "openshell"), archive); - const result = runInstallStep(fixture); - expect(result.status).not.toBe(0); - expect(result.stderr).toContain("Network fallback is disabled for retained OpenShell assets"); - } finally { - fs.rmSync(fixture.root, { force: true, recursive: true }); - } - }); -}); diff --git a/tools/e2e/cli-artifact-workflow-boundary.mts b/tools/e2e/cli-artifact-workflow-boundary.mts index 39ba4944b57..5b5859a09fc 100644 --- a/tools/e2e/cli-artifact-workflow-boundary.mts +++ b/tools/e2e/cli-artifact-workflow-boundary.mts @@ -398,7 +398,16 @@ function validateConsumer( errors.push(`${jobName} must prepare before restoring the CLI artifact`); } const reviewedStepsBeforeRestore = - jobName === "live" ? ["Record immutable Deep Agents Code base evidence"] : []; + jobName === "live" + ? ["Record immutable Deep Agents Code base evidence"] + : jobName === "mcp-bridge-dev" + ? [ + "Restore immutable OpenShell dev artifact", + "Verify immutable OpenShell dev artifact", + "Revoke Docker auth before OpenShell development tooling", + "Install immutable OpenShell dev artifact", + ] + : []; const stepsBeforeRestore = jobSteps .slice(prepareIndex + 1, restoreIndex) .map((step) => step.name); diff --git a/tools/e2e/mcp-workflow-boundary.mts b/tools/e2e/mcp-workflow-boundary.mts index 2e19c485495..809f9d1b649 100644 --- a/tools/e2e/mcp-workflow-boundary.mts +++ b/tools/e2e/mcp-workflow-boundary.mts @@ -29,27 +29,23 @@ const DEV_ARTIFACT_JOB_CONDITION = "${{ contains(fromJSON(needs.generate-matrix.outputs.selected_jobs), 'mcp-bridge-dev') }}"; const DEV_ARTIFACT_DOWNLOAD_ACTION = "actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c"; -const DEV_ARTIFACT_TRUSTED_CHECKOUT_NAME = "Checkout trusted OpenShell dev tooling"; +const DEV_ARTIFACT_TRUSTED_CHECKOUT_NAME = "Checkout trusted OpenShell dev artifact tooling"; const DEV_ARTIFACT_TRUSTED_CHECKOUT = ".trusted-openshell-dev-artifact"; -const DEV_ARTIFACT_TRUSTED_PATHS = - "scripts/install-openshell.sh\ntools/e2e/openshell-dev-artifact.mts\n"; -const DEV_ARTIFACT_TRUSTED_TOOL = `\${{ github.workspace }}/${DEV_ARTIFACT_TRUSTED_CHECKOUT}/${DEV_ARTIFACT_TOOL}`; -const DEV_ARTIFACT_TRUSTED_INSTALLER = `\${{ github.workspace }}/${DEV_ARTIFACT_TRUSTED_CHECKOUT}/scripts/install-openshell.sh`; -const DEV_ARTIFACT_SOURCE_OUTPUT = "${{ needs.openshell-dev-artifact.outputs.source_commit }}"; -const DEV_ARTIFACT_MANIFEST_OUTPUT = "${{ needs.openshell-dev-artifact.outputs.manifest_sha256 }}"; +const DEV_ARTIFACT_TRUSTED_PATHS = `${DEV_ARTIFACT_TOOL}\n`; +const DEV_ARTIFACT_TRUSTED_TOOL = + `\${{ github.workspace }}/${DEV_ARTIFACT_TRUSTED_CHECKOUT}/${DEV_ARTIFACT_TOOL}`; +const DEV_DOCKER_CLEANUP_ACTION = + "NVIDIA/NemoClaw/.github/actions/docker-auth-cleanup@d5f37099766ca82a4516e7d8f0de117cda197fe3"; +const DEV_ARTIFACT_SOURCE_OUTPUT = + "${{ needs.openshell-dev-artifact.outputs.source_commit }}"; +const DEV_ARTIFACT_MANIFEST_OUTPUT = + "${{ needs.openshell-dev-artifact.outputs.manifest_sha256 }}"; const DEV_ARTIFACT_ENV = { OPENSHELL_DEV_ARTIFACT_DIR: OPENSHELL_DEV_ARTIFACT_DIRECTORY, OPENSHELL_DEV_EXPECTED_MANIFEST_SHA256: DEV_ARTIFACT_MANIFEST_OUTPUT, OPENSHELL_DEV_EXPECTED_SOURCE_COMMIT: DEV_ARTIFACT_SOURCE_OUTPUT, } as const; -const DEV_ARTIFACT_INSTALL_ASSETS = [ - "openshell-x86_64-unknown-linux-musl.tar.gz", - "openshell-checksums-sha256.txt", - "openshell-gateway-x86_64-unknown-linux-gnu.tar.gz", - "openshell-gateway-checksums-sha256.txt", - "openshell-sandbox-x86_64-unknown-linux-gnu.tar.gz", - "openshell-sandbox-checksums-sha256.txt", -] as const; +const DEV_BINARY_DIRECTORY = "${{ runner.temp }}/openshell-dev-binaries"; const DEV_COMPATIBILITY_STEP_NAME = "Classify OpenShell credential-boundary compatibility"; const DEV_COMPATIBILITY_STEP_ID = "mcp_runtime_compatibility"; const DEV_COMPATIBILITY_TOOL = "tools/e2e/mcp-bridge-runtime-compatibility.mts"; @@ -299,12 +295,15 @@ function validateJobSecurity( if (JSON.stringify(login) !== JSON.stringify(canonicalDockerAuth)) { errors.push(`${jobName} must reuse the canonical isolated Docker Hub auth step`); } - const expectedCleanup = { - name: "Clean up Docker auth", - if: "always()", - shell: "bash", - run: DOCKER_CLEANUP_RUN, - }; + const expectedCleanup = + jobName === "mcp-bridge-dev" + ? { name: "Clean up Docker auth", if: "always()", uses: DEV_DOCKER_CLEANUP_ACTION } + : { + name: "Clean up Docker auth", + if: "always()", + shell: "bash", + run: DOCKER_CLEANUP_RUN, + }; if (JSON.stringify(cleanup) !== JSON.stringify(expectedCleanup)) { errors.push(`${jobName} must use the canonical unconditional Docker auth cleanup`); } @@ -327,14 +326,13 @@ function validateJobSecurity( "sparse-checkout": DEV_ARTIFACT_TRUSTED_PATHS, }) ) { - errors.push("mcp-bridge-dev must check out only the trusted OpenShell dev tooling"); + errors.push("mcp-bridge-dev must check out only the trusted OpenShell dev artifact tooling"); } const devCleanup = namedStep(job, DEV_DOCKER_CLEANUP_NAME); const install = namedStep(job, "Install immutable OpenShell dev artifact"); const expectedDevCleanup = { name: DEV_DOCKER_CLEANUP_NAME, - shell: "bash", - run: DOCKER_CLEANUP_RUN, + uses: DEV_DOCKER_CLEANUP_ACTION, }; if (JSON.stringify(devCleanup) !== JSON.stringify(expectedDevCleanup)) { errors.push("mcp-bridge-dev must revoke Docker auth before OpenShell development tooling"); @@ -421,22 +419,30 @@ function validateJobExecution( errors, tls.run, "bash test/e2e/setup-mcp-test-tls.sh", - `${jobName} must generate its HTTPS fixture before installation`, + `${jobName} must use the reviewed HTTPS fixture generator`, ); - if (steps.indexOf(tls) < 0 || steps.indexOf(install) <= steps.indexOf(tls)) { - errors.push(`${jobName} must generate HTTPS fixtures before installing OpenShell`); + if ( + steps.indexOf(tls) < 0 || + (jobName === "mcp-bridge-dev" + ? steps.indexOf(tls) <= steps.indexOf(install) + : steps.indexOf(install) <= steps.indexOf(tls)) + ) { + errors.push( + jobName === "mcp-bridge-dev" + ? "mcp-bridge-dev must install OpenShell before candidate fixture code runs" + : "mcp-bridge must generate HTTPS fixtures before installing OpenShell", + ); } const installEnv = asRecord(install.env); if (jobName === "mcp-bridge-dev") { if ( !hasExactEntries(installEnv, { - NEMOCLAW_ACCEPT_DEV_UNVERIFIED_INSTALL: "1", - NEMOCLAW_OPENSHELL_FORCE_INSTALL: "1", - OPENSHELL_DEV_ASSET_DIR: `${OPENSHELL_DEV_ARTIFACT_DIRECTORY}/assets`, + ...DEV_ARTIFACT_ENV, + OPENSHELL_DEV_BINARY_DIR: DEV_BINARY_DIRECTORY, }) ) { errors.push( - "mcp-bridge-dev installer must receive only the retained OpenShell asset directory", + "mcp-bridge-dev artifact installation must receive only its reviewed artifact identity", ); } } else { @@ -501,38 +507,37 @@ function validateJobExecution( ); } for (const token of [ - ...DEV_ARTIFACT_INSTALL_ASSETS, - 'cat >"$shim_dir/gh"', - 'source_asset="${OPENSHELL_DEV_ASSET_DIR}/${asset}"', - '! -L "$source_asset"', - '"$destination" = /*', - '! -L "$destination"', - 'cp -- "$source_asset" "$destination/$asset"', - 'cat >"$shim_dir/curl"', - "Network fallback is disabled for retained OpenShell assets.", - 'PATH="$shim_dir:$PATH"', - `bash "${DEV_ARTIFACT_TRUSTED_INSTALLER}"`, + `"${DEV_ARTIFACT_TRUSTED_TOOL}"`, + " prepare ", + '"$OPENSHELL_DEV_ARTIFACT_DIR"', + '"$OPENSHELL_DEV_BINARY_DIR"', + '"$OPENSHELL_DEV_EXPECTED_SOURCE_COMMIT"', + '"$OPENSHELL_DEV_EXPECTED_MANIFEST_SHA256"', + 'sudo install -m 755 "$OPENSHELL_DEV_BINARY_DIR/openshell" /usr/local/bin/openshell', + 'sudo install -m 755 "$OPENSHELL_DEV_BINARY_DIR/openshell-gateway" /usr/local/bin/openshell-gateway', + 'sudo install -m 755 "$OPENSHELL_DEV_BINARY_DIR/openshell-sandbox" /usr/local/bin/openshell-sandbox', ]) { requireContains( errors, install.run, token, - "mcp-bridge-dev must install retained assets through the trusted no-network release path", + "mcp-bridge-dev must install only the verified same-run binaries", ); } - if (asString(install.run).includes("tools/e2e/openshell-dev-artifact.mts prepare")) { - errors.push("mcp-bridge-dev must not maintain a second OpenShell installer"); + if (asString(install.run).includes("scripts/install-openshell.sh")) { + errors.push("mcp-bridge-dev must not modify or invoke the base-trusted release installer"); } const devCleanup = namedStep(job, DEV_DOCKER_CLEANUP_NAME); if ( - steps.indexOf(restoreCli) < 0 || - steps.indexOf(restoreArtifact) <= steps.indexOf(restoreCli) || + steps.indexOf(restoreArtifact) < 0 || steps.indexOf(verifyArtifact) <= steps.indexOf(restoreArtifact) || steps.indexOf(devCleanup) <= steps.indexOf(verifyArtifact) || - steps.indexOf(install) <= steps.indexOf(devCleanup) + steps.indexOf(install) <= steps.indexOf(devCleanup) || + steps.indexOf(restoreCli) <= steps.indexOf(install) || + steps.indexOf(tls) <= steps.indexOf(install) ) { errors.push( - "mcp-bridge-dev must restore, verify, revoke Docker auth, and install in reviewed order", + "mcp-bridge-dev must verify and install OpenShell before candidate code runs", ); } if (compatibilitySteps.length !== 1 || compatibilitySteps[0] !== compatibility) { @@ -714,7 +719,6 @@ function validateDevArtifactJob(errors: string[], job: UnknownRecord): void { ref: "${{ inputs.workflow_sha || github.workflow_sha }}", path: DEV_ARTIFACT_TRUSTED_CHECKOUT, "persist-credentials": false, - "sparse-checkout": DEV_ARTIFACT_TRUSTED_PATHS, }) ) { errors.push(`${DEV_ARTIFACT_JOB} must check out only the trusted workflow revision`); diff --git a/tools/e2e/openshell-dev-artifact.mts b/tools/e2e/openshell-dev-artifact.mts index 2ea4119e6fe..33e7a5ef2a1 100644 --- a/tools/e2e/openshell-dev-artifact.mts +++ b/tools/e2e/openshell-dev-artifact.mts @@ -2,14 +2,18 @@ // SPDX-License-Identifier: Apache-2.0 import { createHash } from "node:crypto"; +import { spawnSync } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; import { pathToFileURL } from "node:url"; const OPENSHELL_API_ROOT = "https://api.github.com/repos/NVIDIA/OpenShell"; const DEV_RELEASE_URL = `${OPENSHELL_API_ROOT}/releases/tags/dev`; +const DEV_TAG_URL = `${OPENSHELL_API_ROOT}/git/ref/tags/dev`; const ASSET_API_PREFIX = `${OPENSHELL_API_ROOT}/releases/assets/`; +const TAG_API_PREFIX = `${OPENSHELL_API_ROOT}/git/tags/`; const MAX_ASSET_BYTES = 256 * 1024 * 1024; +export const MAX_OPENSHELL_DEV_BINARY_BYTES = 128 * 1024 * 1024; const SHA256_PATTERN = /^[a-f0-9]{64}$/; const COMMIT_PATTERN = /^[a-f0-9]{40}$/; @@ -24,6 +28,13 @@ export const OPENSHELL_DEV_ASSET_NAMES = [ type JsonRecord = Record; type Fetch = typeof fetch; +type TarResult = { + status: number | null; + stdout: string; + stderr: string; + error?: Error; +}; +type TarRunner = (args: string[]) => TarResult; type ReleaseAsset = { id: number; @@ -132,6 +143,45 @@ async function fetchJson(fetchFn: Fetch, url: string, identifier: string): Promi } } +async function resolveDevTagCommit(fetchFn: Fetch): Promise { + const reference = await fetchJson(fetchFn, DEV_TAG_URL, "tag:dev"); + let object = record(reference.object); + for (let depth = 0; depth < 3; depth += 1) { + const type = stringField(object, "type"); + const sha = stringField(object, "sha"); + if (!COMMIT_PATTERN.test(sha)) { + throw new OpenShellDevArtifactInfrastructureError( + "OpenShell dev tag returned an invalid Git object identifier", + `tag:dev:${sha}`, + DEV_TAG_URL, + ); + } + if (type === "commit") return sha; + if (type !== "tag") { + throw new OpenShellDevArtifactInfrastructureError( + `OpenShell dev tag resolved to unsupported Git object type ${type}`, + `tag:dev:${sha}`, + DEV_TAG_URL, + ); + } + const tagUrl = stringField(object, "url"); + if (tagUrl !== `${TAG_API_PREFIX}${sha}`) { + throw new OpenShellDevArtifactInfrastructureError( + "OpenShell dev tag returned an unexpected source URL", + `tag:dev:${sha}`, + tagUrl, + ); + } + const tag = await fetchJson(fetchFn, tagUrl, `tag:dev:${sha}`); + object = record(tag.object); + } + throw new OpenShellDevArtifactInfrastructureError( + "OpenShell dev tag exceeded the supported annotated-tag depth", + "tag:dev", + DEV_TAG_URL, + ); +} + function parseAsset(value: unknown): ReleaseAsset { const asset = record(value); const id = integerField(asset, "id"); @@ -166,7 +216,10 @@ function parseAsset(value: unknown): ReleaseAsset { } async function readReleaseSnapshot(fetchFn: Fetch): Promise { - const release = await fetchJson(fetchFn, DEV_RELEASE_URL, "release:dev"); + const [release, sourceCommit] = await Promise.all([ + fetchJson(fetchFn, DEV_RELEASE_URL, "release:dev"), + resolveDevTagCommit(fetchFn), + ]); const id = integerField(release, "id"); const tag = stringField(release, "tag_name"); if (tag !== "dev") { @@ -179,14 +232,7 @@ async function readReleaseSnapshot(fetchFn: Fetch): Promise { const apiUrl = stringField(release, "url"); const htmlUrl = stringField(release, "html_url"); const updatedAt = stringField(release, "updated_at"); - const sourceCommit = stringField(release, "target_commitish"); - if (!COMMIT_PATTERN.test(sourceCommit)) { - throw new OpenShellDevArtifactInfrastructureError( - "OpenShell dev release target is not an immutable commit", - `release:${id}:tag:dev`, - DEV_RELEASE_URL, - ); - } + const target = stringField(release, "target_commitish"); if ( apiUrl !== `${OPENSHELL_API_ROOT}/releases/${id}` || htmlUrl !== "https://github.com/NVIDIA/OpenShell/releases/tag/dev" @@ -197,6 +243,13 @@ async function readReleaseSnapshot(fetchFn: Fetch): Promise { apiUrl, ); } + if (COMMIT_PATTERN.test(target) && target !== sourceCommit) { + throw new OpenShellDevArtifactInfrastructureError( + "OpenShell dev release and tag point to different source commits", + `release:${id}:tag:dev`, + DEV_RELEASE_URL, + ); + } if (!Array.isArray(release.assets)) { throw new OpenShellDevArtifactInfrastructureError( "OpenShell dev release has no asset list", @@ -242,6 +295,40 @@ function snapshotIdentity(snapshot: ReleaseSnapshot): string { }); } +async function readBoundedAssetBody( + response: Response, + asset: ReleaseAsset, +): Promise { + const reader = response.body?.getReader(); + if (!reader) throw new Error(`OpenShell release asset ${asset.name} returned no response body`); + const chunks: Uint8Array[] = []; + let receivedSize = 0; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + if (!value) continue; + receivedSize += value.byteLength; + if (receivedSize > asset.size) { + await reader.cancel(); + throw new Error( + `OpenShell release asset ${asset.name} exceeds its declared ${asset.size}-byte size`, + ); + } + chunks.push(value); + } + } finally { + reader.releaseLock(); + } + const bytes = new Uint8Array(receivedSize); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + return bytes; +} + async function downloadAsset(fetchFn: Fetch, asset: ReleaseAsset): Promise { let response: Response; try { @@ -278,7 +365,7 @@ async function downloadAsset(fetchFn: Fetch, asset: ReleaseAsset): Promise MAX_OPENSHELL_DEV_BINARY_BYTES) { + throw new Error( + `Unsafe OpenShell dev archive ${archiveName}: ${expectedMember} exceeds the ${MAX_OPENSHELL_DEV_BINARY_BYTES}-byte binary limit`, + ); + } + checkedTar(tarRunner, ["-xzf", archivePath, "-C", binaryDirectory], archiveName); + const binaryPath = path.join(binaryDirectory, expectedMember); + const binaryStat = fs.lstatSync(binaryPath); + if (!binaryStat.isFile() || binaryStat.isSymbolicLink()) { + throw new Error(`Extracted OpenShell dev binary ${expectedMember} must be a regular file`); + } + if (binaryStat.size !== declaredSize) { + throw new Error( + `Extracted OpenShell dev binary ${expectedMember} size does not match its archive header`, + ); + } + fs.chmodSync(binaryPath, 0o755); + } + const actualBinaries = fs.readdirSync(binaryDirectory).sort(); + const expectedBinaries = OPENSHELL_DEV_ARCHIVES.map(([, member]) => member).sort(); + if (actualBinaries.join("\n") !== expectedBinaries.join("\n")) { + throw new Error("OpenShell dev binary directory contains an unexpected file set"); + } + } catch (error) { + fs.rmSync(binaryDirectory, { recursive: true, force: true }); + throw error; + } +} + function appendGithubOutput(values: Record): void { const outputPath = process.env.GITHUB_OUTPUT; if (!outputPath) throw new Error("GITHUB_OUTPUT is required"); @@ -495,7 +677,7 @@ function appendGithubOutput(values: Record): void { } async function main(): Promise { - const [command, outputDirectory, argument3, argument4] = process.argv.slice(2); + const [command, outputDirectory, argument3, argument4, argument5] = process.argv.slice(2); if (!outputDirectory || !path.isAbsolute(outputDirectory)) { throw new Error("an absolute OpenShell dev artifact directory is required"); } @@ -527,8 +709,16 @@ async function main(): Promise { console.log(`Verified OpenShell dev artifact source ${argument3}`); return; } + if (command === "prepare" && argument3 && argument4 && argument5) { + if (!path.isAbsolute(argument3)) { + throw new Error("an absolute OpenShell dev binary output directory is required"); + } + prepareOpenShellDevBinaries(outputDirectory, argument3, argument4, argument5); + console.log(`Prepared OpenShell dev binaries from source ${argument4}`); + return; + } throw new Error( - "usage: openshell-dev-artifact.mts resolve | verify ", + "usage: openshell-dev-artifact.mts resolve | verify | prepare ", ); } diff --git a/tools/e2e/operations-workflow-boundary.mts b/tools/e2e/operations-workflow-boundary.mts index c57c83042fc..d7f9a876485 100644 --- a/tools/e2e/operations-workflow-boundary.mts +++ b/tools/e2e/operations-workflow-boundary.mts @@ -400,9 +400,15 @@ function validateManualPrDispatch(errors: string[], workflow: OperationsWorkflow step.name === "Check out trusted Jetson controller" && step.with?.repository === "NVIDIA/NemoClaw" && step.with?.ref === "${{ github.workflow_sha }}"; - const trustedOpenShellDevToolingCheckout = - ["mcp-bridge-dev", "openshell-dev-artifact"].includes(jobName) && - step.name === "Checkout trusted OpenShell dev tooling" && + const trustedOpenShellDevArtifactCheckout = + jobName === "openshell-dev-artifact" && + step.name === "Checkout trusted OpenShell dev artifact resolver" && + step.with?.repository === "${{ github.repository }}" && + step.with?.ref === "${{ inputs.workflow_sha || github.workflow_sha }}" && + step.with?.path === ".trusted-openshell-dev-artifact"; + const trustedOpenShellDevConsumerCheckout = + jobName === "mcp-bridge-dev" && + step.name === "Checkout trusted OpenShell dev artifact tooling" && step.with?.repository === "${{ github.repository }}" && step.with?.ref === "${{ inputs.workflow_sha || github.workflow_sha }}" && step.with?.path === ".trusted-openshell-dev-artifact"; @@ -417,7 +423,8 @@ function validateManualPrDispatch(errors: string[], workflow: OperationsWorkflow trustedLlamaCppPlanCheckout || trustedLlamaCppQualificationCheckout || trustedJetsonControllerCheckout || - trustedOpenShellDevToolingCheckout; + trustedOpenShellDevArtifactCheckout || + trustedOpenShellDevConsumerCheckout; if ( step.uses?.startsWith("actions/checkout@") && step.with?.ref !== "${{ inputs.checkout_sha || github.sha }}" && diff --git a/tools/e2e/workflow-boundary.mts b/tools/e2e/workflow-boundary.mts index a725181c1d9..308e6a6004a 100644 --- a/tools/e2e/workflow-boundary.mts +++ b/tools/e2e/workflow-boundary.mts @@ -180,6 +180,8 @@ const NO_IMAGE_E2E_JOBS = new Set(["staging-brev-launchable", SHARED_E2E_JOB_ID] const DOCKER_HUB_AUTH_STEP = "Authenticate to Docker Hub"; const DOCKER_HUB_CLEANUP_STEP = "Clean up Docker auth"; const DOCKER_HUB_CLEANUP_RUN = "bash .github/scripts/docker-auth-cleanup.sh"; +const DOCKER_HUB_CLEANUP_ACTION = + "NVIDIA/NemoClaw/.github/actions/docker-auth-cleanup@d5f37099766ca82a4516e7d8f0de117cda197fe3"; const DOCKER_HUB_AUTH_PROVENANCE = E2E_ACTION_PROVENANCE.dockerAuth; const DOCKER_HUB_CLEANUP_PROVENANCE = E2E_ACTION_PROVENANCE.dockerCleanup; const DOCKER_HUB_AUTH_USES = DOCKER_HUB_AUTH_PROVENANCE.reference; @@ -1085,6 +1087,7 @@ function validateFreeStandingJobSelector( } } + function validateCatalogueOwnedJobs(errors: string[], jobs: WorkflowRecord): void { for (const jobName of ["gpu-double-onboard", "gpu-e2e", "llama-cpp-generic-gpu"]) { if (Object.hasOwn(jobs, jobName)) { @@ -1296,6 +1299,8 @@ function validateSharedE2eJob(errors: string[], jobs: WorkflowRecord): void { requireRunContains(errors, runVitest, "--reporter=test/e2e/risk-signal-reporter.ts"); } + + function requireNoDockerHubAuthInRun(errors: string[], owner: string, runScript: string): void { if (!runScript) return; const usesDockerLogin = /\bdocker\s+login\b/i.test(runScript); @@ -1361,6 +1366,27 @@ function requireCanonicalDockerHubCleanupRun( ): void { if (!cleanupStep) return; + if (jobName === "mcp-bridge-dev") { + const cleanupKeys = Object.keys(cleanupStep).sort(); + const expectedKeys = ["if", "name", "uses"]; + if ( + cleanupKeys.length !== expectedKeys.length || + cleanupKeys.some((key, index) => key !== expectedKeys[index]) + ) { + errors.push(`${jobName} Docker Hub cleanup step must contain exactly name, if, and uses`); + } + if (cleanupStep.name !== DOCKER_HUB_CLEANUP_STEP) { + errors.push(`${jobName} Docker Hub cleanup step must use the canonical name`); + } + if (cleanupStep.if !== "always()") { + errors.push(`${jobName} Docker Hub cleanup step must always run`); + } + if (cleanupStep.uses !== DOCKER_HUB_CLEANUP_ACTION) { + errors.push(`${jobName} Docker Hub cleanup step must use the pinned cleanup action`); + } + return; + } + const cleanupKeys = Object.keys(cleanupStep).sort(); if ( cleanupKeys.length !== DOCKER_HUB_CLEANUP_KEYS.length || @@ -1441,7 +1467,9 @@ function validateDockerHubAuthBoundary(errors: string[], jobs: WorkflowRecord): return stringValue(step.uses).startsWith("actions/checkout@") ? [index] : []; }); const checkoutIndex = - jobName === "mcp-bridge-dev" ? (checkoutIndexes.at(-1) ?? -1) : (checkoutIndexes[0] ?? -1); + jobName === "mcp-bridge-dev" + ? (checkoutIndexes.at(-1) ?? -1) + : (checkoutIndexes[0] ?? -1); const protectedCacheDownloadIndex = jobName === "managed-image-protected-runtime" ? workflowSteps.findIndex( diff --git a/vitest.config.ts b/vitest.config.ts index d734c4e851d..5b7f50b2bd6 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -176,7 +176,6 @@ export default defineConfig({ "test/install-station-host-preparation.test.ts", "test/install-station-package-state.test.ts", "test/install-station-package-transaction.test.ts", - "test/install-openshell-e2e-artifact.test.ts", "test/install-openshell-version-pin.test.ts", "test/install-openshell-version-check.test.ts", ], @@ -209,7 +208,6 @@ export default defineConfig({ "test/install-station-host-preparation.test.ts", "test/install-station-package-state.test.ts", "test/install-station-package-transaction.test.ts", - "test/install-openshell-e2e-artifact.test.ts", "test/install-openshell-version-pin.test.ts", "test/install-openshell-version-check.test.ts", ],