diff --git a/.github/workflows/e2e.yaml b/.github/workflows/e2e.yaml index 6b734147ca8..6484d435c25 100644 --- a/.github/workflows/e2e.yaml +++ b/.github/workflows/e2e.yaml @@ -1770,10 +1770,52 @@ jobs: shell: bash run: bash .github/scripts/docker-auth-cleanup.sh - mcp-bridge-dev: + openshell-dev-artifact: needs: generate-matrix if: ${{ contains(fromJSON(needs.generate-matrix.outputs.selected_jobs), 'mcp-bridge-dev') }} runs-on: ubuntu-latest + permissions: + contents: read + timeout-minutes: 15 + outputs: + artifact_name: ${{ steps.resolve_openshell_dev_artifact.outputs.artifact_name }} + 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 + 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 + with: + node-version: 22 + + - id: resolve_openshell_dev_artifact + name: Resolve immutable OpenShell dev artifact + run: >- + node --experimental-strip-types --no-warnings + "${{ github.workspace }}/.trusted-openshell-dev-artifact/tools/e2e/openshell-dev-artifact.mts" resolve + "${{ runner.temp }}/openshell-dev-artifact" + + - name: Upload OpenShell dev artifact resolution + if: ${{ always() }} + uses: NVIDIA/NemoClaw/.github/actions/upload-e2e-artifacts@7768e15eb90d3ee2d33432f481dfe8747e4f6d57 + with: + name: ${{ steps.resolve_openshell_dev_artifact.outputs.artifact_name || format('openshell-dev-infrastructure-failure-{0}-{1}', github.run_id, github.run_attempt) }} + path: ${{ runner.temp }}/openshell-dev-artifact/ + + mcp-bridge-dev: + needs: [generate-matrix, openshell-dev-artifact] + if: ${{ contains(fromJSON(needs.generate-matrix.outputs.selected_jobs), 'mcp-bridge-dev') }} + runs-on: ubuntu-latest permissions: contents: read timeout-minutes: 90 @@ -1797,6 +1839,17 @@ jobs: ref: ${{ inputs.checkout_sha || github.sha }} persist-credentials: false + - name: Checkout trusted OpenShell dev tooling + 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 + - *dockerhub-auth - name: Prepare E2E workspace @@ -1809,6 +1862,25 @@ jobs: 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: + name: ${{ needs.openshell-dev-artifact.outputs.artifact_name }} + path: ${{ runner.temp }}/openshell-dev-artifact + digest-mismatch: error + + - name: Verify immutable OpenShell dev artifact + env: + OPENSHELL_DEV_ARTIFACT_DIR: ${{ runner.temp }}/openshell-dev-artifact + 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: >- + node --experimental-strip-types --no-warnings + "${{ github.workspace }}/.trusted-openshell-dev-artifact/tools/e2e/openshell-dev-artifact.mts" verify + "$OPENSHELL_DEV_ARTIFACT_DIR" + "$OPENSHELL_DEV_EXPECTED_SOURCE_COMMIT" + "$OPENSHELL_DEV_EXPECTED_MANIFEST_SHA256" + - 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 @@ -1834,17 +1906,47 @@ jobs: - name: Generate MCP test TLS run: bash test/e2e/setup-mcp-test-tls.sh - - name: Revoke Docker auth before unverified dev tooling + - name: Revoke Docker auth before OpenShell development tooling shell: bash run: bash .github/scripts/docker-auth-cleanup.sh - - name: Install OpenShell CLI + - 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 - bash scripts/install-openshell.sh + 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 @@ -3543,6 +3645,7 @@ jobs: openshell-gateway-auth-contract, mcp-bridge, openshell-credential-generation-window, + openshell-dev-artifact, mcp-bridge-dev, managed-image-multiarch-startup, llama-cpp-dgx-spark-plan, diff --git a/scripts/checks/vitest-project-overlap.mts b/scripts/checks/vitest-project-overlap.mts index 40857afc416..e4e4fb7bdaa 100644 --- a/scripts/checks/vitest-project-overlap.mts +++ b/scripts/checks/vitest-project-overlap.mts @@ -46,6 +46,7 @@ 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 190666b0548..d6bfc56f0f9 100644 --- a/test/e2e/README.md +++ b/test/e2e/README.md @@ -459,6 +459,27 @@ trust boundary and are never host-prebuilt by this fixture. The runtime target for `openclaw-plugin-runtime-exdev` is 16–17 minutes. Push-run timing for the reduced lifecycle has not yet been measured. +## OpenShell development artifact retention + +The `openshell-dev-artifact` job resolves the public OpenShell `dev` release +once for each selected `mcp-bridge-dev` run. It records the source commit and +the GitHub asset ID, source URL, size, and SHA-256 digest for every required +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 +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 +that case, so the run cannot report a product failure before reaching product +assertions. + ## Larger-runner routing The larger-runner experiment is inactive while the configuration variable diff --git a/test/e2e/support/mcp-workflow-boundary.test.ts b/test/e2e/support/mcp-workflow-boundary.test.ts index 53718d5964e..05161d2c335 100644 --- a/test/e2e/support/mcp-workflow-boundary.test.ts +++ b/test/e2e/support/mcp-workflow-boundary.test.ts @@ -264,7 +264,7 @@ describe("MCP workflow artifact boundary", () => { } }); - it("revokes Docker credentials before executing unverified dev artifacts", () => { + it("revokes Docker credentials before executing OpenShell development tooling (#9051)", () => { const directory = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-mcp-workflow-")); const workflowPath = path.join(directory, "e2e.yaml"); try { @@ -272,18 +272,125 @@ describe("MCP workflow artifact boundary", () => { jobs: Record> }>; }; workflow.jobs["mcp-bridge-dev"].steps = workflow.jobs["mcp-bridge-dev"].steps.filter( - (step) => step.name !== "Revoke Docker auth before unverified dev tooling", + (step) => step.name !== "Revoke Docker auth before OpenShell development tooling", ); fs.writeFileSync(workflowPath, YAML.stringify(workflow)); expect(validateMcpOpenShellWorkflowBoundary(workflowPath)).toContain( - "mcp-bridge-dev must revoke Docker auth before unverified dev tooling", + "mcp-bridge-dev must revoke Docker auth before OpenShell development tooling", ); } finally { fs.rmSync(directory, { force: true, recursive: true }); } }); + it("rejects moving or unverified inputs for the OpenShell dev shards (#9051)", () => { + 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< + string, + { + needs?: string | string[]; + steps: Array<{ + env?: Record; + name?: string; + run?: string; + uses?: string; + with?: Record; + }>; + } + >; + }; + const dev = workflow.jobs["mcp-bridge-dev"]; + dev.needs = "generate-matrix"; + 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"; + fs.writeFileSync(workflowPath, YAML.stringify(workflow)); + + expect(validateMcpOpenShellWorkflowBoundary(workflowPath)).toEqual( + expect.arrayContaining([ + "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", + ]), + ); + } finally { + fs.rmSync(directory, { force: true, recursive: true }); + } + }); + + it.each([ + { + name: "candidate checkout ref", + mutate: (job: { steps: Array> }) => { + const checkout = job.steps.find( + (step) => step.name === "Checkout trusted OpenShell dev tooling", + ); + requireFixture( + checkout?.with, + "trusted OpenShell resolver checkout fixture is missing", + ); + const withValues = checkout.with as Record; + withValues.ref = "${{ inputs.checkout_sha || github.sha }}"; + }, + expected: "openshell-dev-artifact must check out only the trusted workflow revision", + }, + { + name: "candidate workspace invocation", + mutate: (job: { steps: Array> }) => { + const resolve = job.steps.find( + (step) => step.name === "Resolve immutable OpenShell dev artifact", + ); + requireFixture( + typeof resolve?.run === "string", + "trusted OpenShell resolver invocation fixture is missing", + ); + resolve.run = resolve.run.replace( + ".trusted-openshell-dev-artifact/tools/e2e/openshell-dev-artifact.mts", + ".candidate-runtime/tools/e2e/openshell-dev-artifact.mts", + ); + }, + expected: "openshell-dev-artifact must run the trusted immutable resolver", + }, + ])("rejects a $name for OpenShell dev artifact resolution (#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["openshell-dev-artifact"]); + 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 new file mode 100644 index 00000000000..7747c0214d8 --- /dev/null +++ b/test/e2e/support/openshell-dev-artifact-fixture.ts @@ -0,0 +1,83 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { createHash } from "node:crypto"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { OPENSHELL_DEV_ASSET_NAMES } from "../../../tools/e2e/openshell-dev-artifact.mts"; + +export const API_ROOT = "https://api.github.com/repos/NVIDIA/OpenShell"; +export const RELEASE_URL = `${API_ROOT}/releases/tags/dev`; +export const SOURCE_COMMIT = "b".repeat(40); + +type FixtureOptions = { + missingAsset?: string; + driftAfterDownload?: boolean; + corruptAsset?: string; + sourceCommit?: string; +}; + +export function fixtureFetch(options: FixtureOptions = {}): typeof fetch { + const contents = new Map( + OPENSHELL_DEV_ASSET_NAMES.map((name) => [name, Buffer.from(`fixture:${name}\n`)] as const), + ); + let releaseReads = 0; + return (async (input: string | URL | Request) => { + const url = String(input); + if (url === RELEASE_URL) { + releaseReads += 1; + const assets = OPENSHELL_DEV_ASSET_NAMES.filter((name) => name !== options.missingAsset).map( + (name, index) => { + const bytes = contents.get(name); + if (!bytes) throw new Error(`missing fixture bytes for ${name}`); + return { + id: 1000 + index, + name, + size: bytes.byteLength, + digest: `sha256:${createHash("sha256").update(bytes).digest("hex")}`, + url: `${API_ROOT}/releases/assets/${1000 + index}`, + browser_download_url: `https://github.com/NVIDIA/OpenShell/releases/download/dev/${name}`, + }; + }, + ); + return Response.json({ + id: 9051, + tag_name: "dev", + target_commitish: options.sourceCommit ?? SOURCE_COMMIT, + url: `${API_ROOT}/releases/9051`, + html_url: "https://github.com/NVIDIA/OpenShell/releases/tag/dev", + updated_at: + options.driftAfterDownload && releaseReads > 1 + ? "2026-08-13T22:08:00Z" + : "2026-08-13T22:07:00Z", + assets, + }); + } + const assetMatch = url.match( + /^https:\/\/api\.github\.com\/repos\/NVIDIA\/OpenShell\/releases\/assets\/(\d+)$/, + ); + if (assetMatch) { + return new Response(null, { + status: 302, + headers: { location: `https://release-assets.githubusercontent.com/${assetMatch[1]}` }, + }); + } + const downloadMatch = url.match(/^https:\/\/release-assets\.githubusercontent\.com\/(\d+)$/); + if (downloadMatch) { + const index = Number(downloadMatch[1]) - 1000; + const name = OPENSHELL_DEV_ASSET_NAMES[index]; + 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; + return new Response(bytes, { status: 200 }); + } + return new Response("not found", { status: 404 }); + }) as typeof fetch; +} + +export function temporaryDirectory(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-openshell-dev-artifact-")); +} diff --git a/test/e2e/support/openshell-dev-artifact.test.ts b/test/e2e/support/openshell-dev-artifact.test.ts new file mode 100644 index 00000000000..495cf37e5ce --- /dev/null +++ b/test/e2e/support/openshell-dev-artifact.test.ts @@ -0,0 +1,159 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; +import path from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { + OPENSHELL_DEV_ASSET_NAMES, + resolveOpenShellDevArtifact, + verifyOpenShellDevArtifact, +} from "../../../tools/e2e/openshell-dev-artifact.mts"; +import { + API_ROOT, + fixtureFetch, + RELEASE_URL, + SOURCE_COMMIT, + temporaryDirectory, +} from "./openshell-dev-artifact-fixture.ts"; +import { requireFixture } from "./require-fixture.ts"; + +describe("OpenShell dev artifact resolver", () => { + it("binds one source commit to immutable asset identifiers and digests (#9051)", async () => { + const directory = temporaryDirectory(); + try { + const resolution = await resolveOpenShellDevArtifact(directory, fixtureFetch()); + + expect(resolution.classification).toBe("resolved"); + expect(resolution.sourceCommit).toBe(SOURCE_COMMIT); + expect(resolution.artifactName).toBe( + `openshell-dev-${SOURCE_COMMIT}-${resolution.manifestSha256}`, + ); + const manifestSha256 = resolution.manifestSha256; + requireFixture(manifestSha256, "fixture resolution omitted manifest digest"); + expect(() => + verifyOpenShellDevArtifact(directory, SOURCE_COMMIT, manifestSha256), + ).not.toThrow(); + const manifest = JSON.parse(fs.readFileSync(path.join(directory, "manifest.json"), "utf8")); + expect(manifest.assets.map((asset: { name: string }) => asset.name)).toEqual( + OPENSHELL_DEV_ASSET_NAMES, + ); + expect(manifest.assets).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: expect.any(Number), + digest: expect.stringMatching(/^[a-f0-9]{64}$/), + apiUrl: expect.stringMatching(/\/releases\/assets\/\d+$/), + }), + ]), + ); + } finally { + fs.rmSync(directory, { force: true, recursive: true }); + } + }); + + it("classifies a missing upstream asset as infrastructure with its source URL (#9051)", async () => { + const directory = temporaryDirectory(); + const missingAsset = OPENSHELL_DEV_ASSET_NAMES[1]; + try { + await expect( + resolveOpenShellDevArtifact(directory, fixtureFetch({ missingAsset })), + ).rejects.toMatchObject({ + identifier: `release:9051:asset:${missingAsset}`, + sourceUrl: RELEASE_URL, + }); + const resolution = JSON.parse( + fs.readFileSync(path.join(directory, "resolution.json"), "utf8"), + ); + expect(resolution).toMatchObject({ + classification: "infrastructure-failure", + identifier: `release:9051:asset:${missingAsset}`, + sourceUrl: RELEASE_URL, + }); + expect(fs.existsSync(path.join(directory, "assets"))).toBe(false); + } finally { + fs.rmSync(directory, { force: true, recursive: true }); + } + }); + + it("rejects a moving release target before download (#9051)", async () => { + const directory = temporaryDirectory(); + try { + await expect( + resolveOpenShellDevArtifact(directory, fixtureFetch({ sourceCommit: "main" })), + ).rejects.toMatchObject({ + identifier: "release:9051:tag:dev", + sourceUrl: RELEASE_URL, + }); + } finally { + fs.rmSync(directory, { force: true, recursive: true }); + } + }); + + 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({ corruptAsset })), + ).rejects.toMatchObject({ + identifier: `asset:${corruptAsset}:id:1000`, + sourceUrl: `${API_ROOT}/releases/assets/1000`, + }); + } finally { + fs.rmSync(directory, { force: true, recursive: true }); + } + }); + + it("rejects a dev release that changes during resolution (#9051)", async () => { + const directory = temporaryDirectory(); + try { + await expect( + resolveOpenShellDevArtifact(directory, fixtureFetch({ driftAfterDownload: true })), + ).rejects.toMatchObject({ + identifier: `release:9051:tag:dev:source:${SOURCE_COMMIT}`, + sourceUrl: RELEASE_URL, + }); + } finally { + fs.rmSync(directory, { force: true, recursive: true }); + } + }); + + it("rejects cached bytes changed after resolution (#9051)", async () => { + const directory = temporaryDirectory(); + try { + const resolution = await resolveOpenShellDevArtifact(directory, fixtureFetch()); + const assetPath = path.join(directory, "assets", OPENSHELL_DEV_ASSET_NAMES[0]); + const original = fs.readFileSync(assetPath); + fs.writeFileSync(assetPath, Buffer.alloc(original.byteLength)); + const manifestSha256 = resolution.manifestSha256; + requireFixture(manifestSha256, "fixture resolution omitted manifest digest"); + + expect(() => verifyOpenShellDevArtifact(directory, SOURCE_COMMIT, manifestSha256)).toThrow( + /SHA-256 mismatch/, + ); + } finally { + fs.rmSync(directory, { force: true, recursive: true }); + } + }); + + it("rejects a cached asset replaced by a symbolic link (#9051)", async () => { + const directory = temporaryDirectory(); + try { + const resolution = await resolveOpenShellDevArtifact(directory, fixtureFetch()); + const manifestSha256 = resolution.manifestSha256; + requireFixture(manifestSha256, "fixture resolution omitted manifest digest"); + const assetPath = path.join(directory, "assets", OPENSHELL_DEV_ASSET_NAMES[0]); + fs.unlinkSync(assetPath); + fs.symlinkSync(path.join(directory, "manifest.json"), assetPath); + + expect(() => verifyOpenShellDevArtifact(directory, SOURCE_COMMIT, manifestSha256)).toThrow( + /must be a regular file/, + ); + } finally { + fs.rmSync(directory, { force: true, recursive: true }); + } + }); +}); diff --git a/test/install-openshell-e2e-artifact.test.ts b/test/install-openshell-e2e-artifact.test.ts new file mode 100644 index 00000000000..709c25a4c7d --- /dev/null +++ b/test/install-openshell-e2e-artifact.test.ts @@ -0,0 +1,139 @@ +// 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 2927d158f1d..39ba4944b57 100644 --- a/tools/e2e/cli-artifact-workflow-boundary.mts +++ b/tools/e2e/cli-artifact-workflow-boundary.mts @@ -340,7 +340,11 @@ function validateConsumer( job: WorkflowRecord, jobSteps: WorkflowStep[], ): void { - if (job.needs !== CLI_ARTIFACT_PRODUCER_JOB) { + const expectedNeeds = + jobName === "mcp-bridge-dev" + ? [CLI_ARTIFACT_PRODUCER_JOB, "openshell-dev-artifact"] + : CLI_ARTIFACT_PRODUCER_JOB; + if (!isDeepStrictEqual(job.needs, expectedNeeds)) { errors.push(`${jobName} must depend directly on the CLI artifact producer`); } const candidateCheckoutIndexes = jobSteps.flatMap((step, index) => @@ -393,8 +397,18 @@ function validateConsumer( if (!(prepareIndex >= 0 && prepareIndex < restoreIndex)) { errors.push(`${jobName} must prepare before restoring the CLI artifact`); } - if (prepareIndex >= 0 && restoreIndex !== prepareIndex + 1) { - errors.push(`${jobName} must restore the CLI artifact in the step after workspace preparation`); + const reviewedStepsBeforeRestore = + jobName === "live" ? ["Record immutable Deep Agents Code base evidence"] : []; + const stepsBeforeRestore = jobSteps + .slice(prepareIndex + 1, restoreIndex) + .map((step) => step.name); + if ( + prepareIndex >= 0 && + !isDeepStrictEqual(stepsBeforeRestore, reviewedStepsBeforeRestore) + ) { + errors.push( + `${jobName} must contain only reviewed steps between workspace preparation and CLI artifact restore`, + ); } } diff --git a/tools/e2e/mcp-workflow-boundary.mts b/tools/e2e/mcp-workflow-boundary.mts index e733830b8f5..2e19c485495 100644 --- a/tools/e2e/mcp-workflow-boundary.mts +++ b/tools/e2e/mcp-workflow-boundary.mts @@ -4,10 +4,15 @@ import fs from "node:fs"; import YAML from "yaml"; -import { UPLOAD_E2E_ARTIFACTS_ACTION } from "./upload-e2e-artifacts-workflow-boundary.mts"; +import { + OPENSHELL_DEV_ARTIFACT_DIRECTORY, + OPENSHELL_DEV_ARTIFACT_UPLOAD_NAME, + UPLOAD_E2E_ARTIFACTS_ACTION, +} from "./upload-e2e-artifacts-workflow-boundary.mts"; const DEFAULT_WORKFLOW_PATH = ".github/workflows/e2e.yaml"; const MCP_JOBS = ["mcp-bridge", "mcp-bridge-dev"] as const; +const DEV_ARTIFACT_JOB = "openshell-dev-artifact"; const CREDENTIAL_WINDOW_JOB = "openshell-credential-generation-window"; const MCP_AGENT_SHARDS = ["openclaw", "hermes", "deepagents"] as const; const MATRIX_AGENT_EXPRESSION = "${{ matrix.agent }}"; @@ -18,7 +23,33 @@ const TERMINAL_JOBS = [ "scorecard", ] as const; const DOCKER_CLEANUP_RUN = "bash .github/scripts/docker-auth-cleanup.sh"; -const DEV_DOCKER_CLEANUP_NAME = "Revoke Docker auth before unverified dev tooling"; +const DEV_DOCKER_CLEANUP_NAME = "Revoke Docker auth before OpenShell development tooling"; +const DEV_ARTIFACT_TOOL = "tools/e2e/openshell-dev-artifact.mts"; +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 = ".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_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_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"; @@ -143,6 +174,14 @@ function validateJobIdentity( `${jobName} must bound each shard to 90 minutes`, ); requireEqual(errors, strategy["fail-fast"], false, `${jobName} shards must not fail fast`); + requireEqual( + errors, + JSON.stringify(jobNeeds(job)), + JSON.stringify( + jobName === "mcp-bridge-dev" ? ["generate-matrix", DEV_ARTIFACT_JOB] : ["generate-matrix"], + ), + `${jobName} must depend on its reviewed artifact producers`, + ); if (JSON.stringify(matrix.agent) !== JSON.stringify(MCP_AGENT_SHARDS)) { errors.push(`${jobName} must exercise the reviewed OpenClaw, Hermes, and Deep Agents shards`); } @@ -212,7 +251,7 @@ function validateJobIdentity( "mcp-bridge-dev must select the OpenShell dev channel", ); if (Object.hasOwn(env, "NEMOCLAW_ACCEPT_DEV_UNVERIFIED_INSTALL")) { - errors.push("mcp-bridge-dev must scope unverified artifact opt-in to its installer step"); + errors.push("mcp-bridge-dev must not authorize moving unverified dev artifacts"); } requireEqual( errors, @@ -237,7 +276,12 @@ function validateJobSecurity( const checkouts = asSteps(job).filter((step) => asString(step.uses).startsWith("actions/checkout@"), ); - if (checkouts.length !== 1) errors.push(`${jobName} must use exactly one checkout step`); + const expectedCheckoutCount = jobName === "mcp-bridge-dev" ? 2 : 1; + if (checkouts.length !== expectedCheckoutCount) { + errors.push( + `${jobName} must use exactly ${expectedCheckoutCount === 1 ? "one checkout step" : "two checkout steps"}`, + ); + } for (const checkout of checkouts) { if (!/^actions\/checkout@[0-9a-f]{40}$/.test(asString(checkout.uses))) { errors.push(`${jobName} must use a SHA-pinned checkout`); @@ -265,9 +309,7 @@ function validateJobSecurity( errors.push(`${jobName} must use the canonical unconditional Docker auth cleanup`); } const steps = asSteps(job); - const checkoutIndex = steps.findIndex((step) => - asString(step.uses).startsWith("actions/checkout@"), - ); + const checkoutIndex = Math.max(...checkouts.map((checkout) => steps.indexOf(checkout))); if (steps.indexOf(login) !== checkoutIndex + 1) { errors.push(`${jobName} must authenticate immediately after credential-free checkout`); } @@ -275,21 +317,33 @@ function validateJobSecurity( errors.push(`${jobName} Docker auth cleanup must remain the final step`); } if (jobName === "mcp-bridge-dev") { + const trustedCheckout = namedStep(job, DEV_ARTIFACT_TRUSTED_CHECKOUT_NAME); + if ( + !hasExactEntries(asRecord(trustedCheckout.with), { + repository: "${{ github.repository }}", + ref: "${{ inputs.workflow_sha || github.workflow_sha }}", + path: DEV_ARTIFACT_TRUSTED_CHECKOUT, + "persist-credentials": false, + "sparse-checkout": DEV_ARTIFACT_TRUSTED_PATHS, + }) + ) { + errors.push("mcp-bridge-dev must check out only the trusted OpenShell dev tooling"); + } const devCleanup = namedStep(job, DEV_DOCKER_CLEANUP_NAME); - const install = namedStep(job, "Install OpenShell CLI"); + const install = namedStep(job, "Install immutable OpenShell dev artifact"); const expectedDevCleanup = { name: DEV_DOCKER_CLEANUP_NAME, shell: "bash", run: DOCKER_CLEANUP_RUN, }; if (JSON.stringify(devCleanup) !== JSON.stringify(expectedDevCleanup)) { - errors.push("mcp-bridge-dev must revoke Docker auth before unverified dev tooling"); + errors.push("mcp-bridge-dev must revoke Docker auth before OpenShell development tooling"); } const devCleanupIndex = steps.indexOf(devCleanup); const installIndex = steps.indexOf(install); if (devCleanupIndex <= steps.indexOf(login) || installIndex <= devCleanupIndex) { errors.push( - "mcp-bridge-dev Docker auth revocation must follow setup and precede the dev installer", + "mcp-bridge-dev Docker auth revocation must follow setup and precede development artifact installation", ); } if ( @@ -309,7 +363,12 @@ function validateJobExecution( const steps = asSteps(job); const cloudflared = namedStep(job, "Install and verify cloudflared prerequisite"); const tls = namedStep(job, "Generate MCP test TLS"); - const install = namedStep(job, "Install OpenShell CLI"); + const install = namedStep( + job, + jobName === "mcp-bridge-dev" + ? "Install immutable OpenShell dev artifact" + : "Install OpenShell CLI", + ); const run = namedStep(job, "Run MCP OpenShell provider live test"); const compatibility = namedStep(job, DEV_COMPATIBILITY_STEP_NAME); const compatibilitySteps = steps.filter((step) => @@ -367,37 +426,115 @@ function validateJobExecution( if (steps.indexOf(tls) < 0 || steps.indexOf(install) <= steps.indexOf(tls)) { errors.push(`${jobName} must generate HTTPS fixtures before installing OpenShell`); } - requireEqual( - errors, - asRecord(install.env).NEMOCLAW_OPENSHELL_FORCE_INSTALL, - "1", - `${jobName} must force the selected OpenShell install`, - ); 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`, + }) + ) { + errors.push( + "mcp-bridge-dev installer must receive only the retained OpenShell asset directory", + ); + } + } else { requireEqual( errors, - installEnv.NEMOCLAW_ACCEPT_DEV_UNVERIFIED_INSTALL, + installEnv.NEMOCLAW_OPENSHELL_FORCE_INSTALL, "1", - "mcp-bridge-dev installer must explicitly authorize unverified dev artifacts", + `${jobName} must force the selected OpenShell install`, ); - } else if (Object.hasOwn(installEnv, "NEMOCLAW_ACCEPT_DEV_UNVERIFIED_INSTALL")) { - errors.push("mcp-bridge stable installer must not authorize unverified dev artifacts"); - } else { + if (Object.hasOwn(installEnv, "NEMOCLAW_ACCEPT_DEV_UNVERIFIED_INSTALL")) { + errors.push("mcp-bridge stable installer must not authorize unverified dev artifacts"); + } const installRun = asString(install.run); for (const token of STABLE_RELEASE_PROVENANCE_TOKENS) { if (!installRun.includes(token)) { errors.push(`mcp-bridge stable release provenance is missing reviewed identity: ${token}`); } } + requireContains( + errors, + install.run, + "bash scripts/install-openshell.sh", + `${jobName} must use the repository OpenShell installer`, + ); } - requireContains( - errors, - install.run, - "bash scripts/install-openshell.sh", - `${jobName} must use the repository OpenShell installer`, - ); if (jobName === "mcp-bridge-dev") { + const restoreCli = namedStep(job, "Restore exact-commit CLI artifact"); + const restoreArtifact = namedStep(job, "Restore immutable OpenShell dev artifact"); + const verifyArtifact = namedStep(job, "Verify immutable OpenShell dev artifact"); + requireEqual( + errors, + restoreArtifact.uses, + DEV_ARTIFACT_DOWNLOAD_ACTION, + "mcp-bridge-dev must use the reviewed immutable artifact downloader", + ); + if ( + !hasExactEntries(asRecord(restoreArtifact.with), { + name: "${{ needs.openshell-dev-artifact.outputs.artifact_name }}", + path: OPENSHELL_DEV_ARTIFACT_DIRECTORY, + "digest-mismatch": "error", + }) + ) { + errors.push("mcp-bridge-dev must restore exactly the resolver's content-addressed artifact"); + } + if (!hasExactEntries(asRecord(verifyArtifact.env), DEV_ARTIFACT_ENV)) { + errors.push( + "mcp-bridge-dev artifact verification must receive only its reviewed artifact identity", + ); + } + for (const token of [ + `"${DEV_ARTIFACT_TRUSTED_TOOL}"`, + " verify ", + '"$OPENSHELL_DEV_ARTIFACT_DIR"', + '"$OPENSHELL_DEV_EXPECTED_SOURCE_COMMIT"', + '"$OPENSHELL_DEV_EXPECTED_MANIFEST_SHA256"', + ]) { + requireContains( + errors, + verifyArtifact.run, + token, + "mcp-bridge-dev must verify the immutable OpenShell artifact before installation", + ); + } + 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}"`, + ]) { + requireContains( + errors, + install.run, + token, + "mcp-bridge-dev must install retained assets through the trusted no-network release path", + ); + } + if (asString(install.run).includes("tools/e2e/openshell-dev-artifact.mts prepare")) { + errors.push("mcp-bridge-dev must not maintain a second OpenShell installer"); + } + const devCleanup = namedStep(job, DEV_DOCKER_CLEANUP_NAME); + if ( + steps.indexOf(restoreCli) < 0 || + steps.indexOf(restoreArtifact) <= steps.indexOf(restoreCli) || + steps.indexOf(verifyArtifact) <= steps.indexOf(restoreArtifact) || + steps.indexOf(devCleanup) <= steps.indexOf(verifyArtifact) || + steps.indexOf(install) <= steps.indexOf(devCleanup) + ) { + errors.push( + "mcp-bridge-dev must restore, verify, revoke Docker auth, and install in reviewed order", + ); + } if (compatibilitySteps.length !== 1 || compatibilitySteps[0] !== compatibility) { errors.push("mcp-bridge-dev must use exactly one canonical runtime compatibility classifier"); } @@ -519,6 +656,126 @@ function validateJobExecution( } } +function validateDevArtifactJob(errors: string[], job: UnknownRecord): void { + if (Object.keys(job).length === 0) { + errors.push(`missing OpenShell development artifact job: ${DEV_ARTIFACT_JOB}`); + return; + } + requireEqual( + errors, + JSON.stringify(jobNeeds(job)), + JSON.stringify(["generate-matrix"]), + `${DEV_ARTIFACT_JOB} must depend only on matrix generation`, + ); + requireEqual( + errors, + job.if, + DEV_ARTIFACT_JOB_CONDITION, + `${DEV_ARTIFACT_JOB} must use the trusted execution plan`, + ); + requireEqual( + errors, + job["runs-on"], + "ubuntu-latest", + `${DEV_ARTIFACT_JOB} must use an ephemeral standard runner`, + ); + requireEqual( + errors, + job["timeout-minutes"], + 15, + `${DEV_ARTIFACT_JOB} must retain its bounded 15-minute budget`, + ); + if (!hasExactEntries(asRecord(job.permissions), { contents: "read" })) { + errors.push(`${DEV_ARTIFACT_JOB} must use only contents:read permissions`); + } + if ( + !hasExactEntries(asRecord(job.outputs), { + artifact_name: "${{ steps.resolve_openshell_dev_artifact.outputs.artifact_name }}", + source_commit: "${{ steps.resolve_openshell_dev_artifact.outputs.source_commit }}", + manifest_sha256: "${{ steps.resolve_openshell_dev_artifact.outputs.manifest_sha256 }}", + }) + ) { + errors.push(`${DEV_ARTIFACT_JOB} must expose only the immutable artifact identity`); + } + if (FORBIDDEN_INFERENCE_SECRETS.test(JSON.stringify(job))) { + errors.push(`${DEV_ARTIFACT_JOB} must not receive inference or GitHub credentials`); + } + + const steps = asSteps(job); + const checkouts = steps.filter((step) => asString(step.uses).startsWith("actions/checkout@")); + if (checkouts.length !== 1) errors.push(`${DEV_ARTIFACT_JOB} must use exactly one checkout`); + const checkout = checkouts[0] ?? {}; + if (!/^actions\/checkout@[a-f0-9]{40}$/u.test(asString(checkout.uses))) { + errors.push(`${DEV_ARTIFACT_JOB} must use a SHA-pinned checkout`); + } + if ( + !hasExactEntries(asRecord(checkout.with), { + repository: "${{ github.repository }}", + 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`); + } + const setup = namedStep(job, "Set up Node for OpenShell dev artifact resolution"); + if (!/^actions\/setup-node@[a-f0-9]{40}$/u.test(asString(setup.uses))) { + errors.push(`${DEV_ARTIFACT_JOB} must use a SHA-pinned Node setup`); + } + if (!hasExactEntries(asRecord(setup.with), { "node-version": 22 })) { + errors.push(`${DEV_ARTIFACT_JOB} must use only the reviewed Node version`); + } + const resolve = namedStep(job, "Resolve immutable OpenShell dev artifact"); + requireEqual( + errors, + resolve.id, + "resolve_openshell_dev_artifact", + `${DEV_ARTIFACT_JOB} resolver must expose its canonical step id`, + ); + for (const token of [ + `"${DEV_ARTIFACT_TRUSTED_TOOL}"`, + " resolve ", + OPENSHELL_DEV_ARTIFACT_DIRECTORY, + ]) { + requireContains( + errors, + resolve.run, + token, + `${DEV_ARTIFACT_JOB} must run the trusted immutable resolver`, + ); + } + const upload = namedStep(job, "Upload OpenShell dev artifact resolution"); + requireEqual( + errors, + upload.uses, + UPLOAD_E2E_ARTIFACTS_ACTION, + `${DEV_ARTIFACT_JOB} must use the reviewed shared uploader`, + ); + requireEqual( + errors, + upload.if, + "${{ always() }}", + `${DEV_ARTIFACT_JOB} must retain infrastructure diagnostics on failure`, + ); + if ( + !hasExactEntries(asRecord(upload.with), { + name: OPENSHELL_DEV_ARTIFACT_UPLOAD_NAME, + path: `${OPENSHELL_DEV_ARTIFACT_DIRECTORY}/`, + }) + ) { + errors.push(`${DEV_ARTIFACT_JOB} must retain its content-addressed 14-day artifact contract`); + } + if ( + steps.indexOf(checkout) !== 0 || + steps.indexOf(setup) <= steps.indexOf(checkout) || + steps.indexOf(resolve) <= steps.indexOf(setup) || + steps.indexOf(upload) !== steps.length - 1 + ) { + errors.push(`${DEV_ARTIFACT_JOB} must resolve before its final diagnostic-preserving upload`); + } +} + function validateCredentialWindowJob( errors: string[], job: UnknownRecord, @@ -762,12 +1019,13 @@ export function validateMcpOpenShellWorkflowBoundary( validateJobSecurity(errors, jobName, job, canonicalDockerAuth); validateJobExecution(errors, jobName, job); } + validateDevArtifactJob(errors, asRecord(jobs[DEV_ARTIFACT_JOB])); validateCredentialWindowJob(errors, asRecord(jobs[CREDENTIAL_WINDOW_JOB]), canonicalDockerAuth); for (const terminalJobName of TERMINAL_JOBS) { const terminal = asRecord(jobs[terminalJobName]); const terminalNeeds = new Set(jobNeeds(terminal)); - for (const mcpJob of [...MCP_JOBS, CREDENTIAL_WINDOW_JOB]) { + for (const mcpJob of [...MCP_JOBS, DEV_ARTIFACT_JOB, CREDENTIAL_WINDOW_JOB]) { if (!terminalNeeds.has(mcpJob)) { errors.push(`${terminalJobName} must wait for ${mcpJob}`); } diff --git a/tools/e2e/openshell-dev-artifact.mts b/tools/e2e/openshell-dev-artifact.mts new file mode 100644 index 00000000000..2ea4119e6fe --- /dev/null +++ b/tools/e2e/openshell-dev-artifact.mts @@ -0,0 +1,540 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { createHash } from "node:crypto"; +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 ASSET_API_PREFIX = `${OPENSHELL_API_ROOT}/releases/assets/`; +const MAX_ASSET_BYTES = 256 * 1024 * 1024; +const SHA256_PATTERN = /^[a-f0-9]{64}$/; +const COMMIT_PATTERN = /^[a-f0-9]{40}$/; + +export const OPENSHELL_DEV_ASSET_NAMES = [ + "openshell-x86_64-unknown-linux-musl.tar.gz", + "openshell-gateway-x86_64-unknown-linux-gnu.tar.gz", + "openshell-sandbox-x86_64-unknown-linux-gnu.tar.gz", + "openshell-checksums-sha256.txt", + "openshell-gateway-checksums-sha256.txt", + "openshell-sandbox-checksums-sha256.txt", +] as const; + +type JsonRecord = Record; +type Fetch = typeof fetch; + +type ReleaseAsset = { + id: number; + name: string; + size: number; + digest: string; + apiUrl: string; + browserDownloadUrl: string; +}; + +type ReleaseSnapshot = { + id: number; + tag: "dev"; + apiUrl: string; + htmlUrl: string; + sourceCommit: string; + updatedAt: string; + assets: ReleaseAsset[]; +}; + +export type OpenShellDevArtifactManifest = { + schemaVersion: 1; + release: Omit; + assets: ReleaseAsset[]; +}; + +export type OpenShellDevArtifactResolution = { + schemaVersion: 1; + classification: "resolved" | "infrastructure-failure"; + identifier: string; + sourceUrl: string; + message: string; + artifactName?: string; + manifestSha256?: string; + sourceCommit?: string; +}; + +export class OpenShellDevArtifactInfrastructureError extends Error { + readonly identifier: string; + readonly sourceUrl: string; + + constructor(message: string, identifier: string, sourceUrl: string) { + super(message); + this.name = "OpenShellDevArtifactInfrastructureError"; + this.identifier = identifier; + this.sourceUrl = sourceUrl; + } +} + +function record(value: unknown): JsonRecord { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error("expected a JSON object"); + } + return value as JsonRecord; +} + +function stringField(value: JsonRecord, key: string): string { + const field = value[key]; + if (typeof field !== "string" || field.length === 0) { + throw new Error(`expected non-empty string field ${key}`); + } + return field; +} + +function integerField(value: JsonRecord, key: string): number { + const field = value[key]; + if (!Number.isSafeInteger(field) || (field as number) <= 0) { + throw new Error(`expected positive integer field ${key}`); + } + return field as number; +} + +function infrastructureError(error: unknown, identifier: string, sourceUrl: string) { + if (error instanceof OpenShellDevArtifactInfrastructureError) return error; + return new OpenShellDevArtifactInfrastructureError( + error instanceof Error ? error.message : String(error), + identifier, + sourceUrl, + ); +} + +async function fetchJson(fetchFn: Fetch, url: string, identifier: string): Promise { + let response: Response; + try { + response = await fetchFn(url, { + headers: { + Accept: "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + }, + redirect: "error", + }); + } catch (error) { + throw infrastructureError(error, identifier, url); + } + if (!response.ok) { + throw new OpenShellDevArtifactInfrastructureError( + `GitHub returned HTTP ${response.status}`, + identifier, + url, + ); + } + try { + return record(await response.json()); + } catch (error) { + throw infrastructureError(error, identifier, url); + } +} + +function parseAsset(value: unknown): ReleaseAsset { + const asset = record(value); + const id = integerField(asset, "id"); + const name = stringField(asset, "name"); + const size = integerField(asset, "size"); + const digestValue = stringField(asset, "digest"); + const [algorithm, digest] = digestValue.split(":", 2); + const apiUrl = stringField(asset, "url"); + const browserDownloadUrl = stringField(asset, "browser_download_url"); + if (algorithm !== "sha256" || !SHA256_PATTERN.test(digest ?? "")) { + throw new OpenShellDevArtifactInfrastructureError( + `OpenShell release asset ${name} has no valid SHA-256 digest`, + `asset:${name}:id:${id}`, + apiUrl, + ); + } + if (apiUrl !== `${ASSET_API_PREFIX}${id}`) { + throw new OpenShellDevArtifactInfrastructureError( + `OpenShell release asset ${name} has an unexpected API URL`, + `asset:${name}:id:${id}`, + apiUrl, + ); + } + if (size > MAX_ASSET_BYTES) { + throw new OpenShellDevArtifactInfrastructureError( + `OpenShell release asset ${name} exceeds the ${MAX_ASSET_BYTES}-byte limit`, + `asset:${name}:id:${id}`, + apiUrl, + ); + } + return { id, name, size, digest, apiUrl, browserDownloadUrl }; +} + +async function readReleaseSnapshot(fetchFn: Fetch): Promise { + const release = await fetchJson(fetchFn, DEV_RELEASE_URL, "release:dev"); + const id = integerField(release, "id"); + const tag = stringField(release, "tag_name"); + if (tag !== "dev") { + throw new OpenShellDevArtifactInfrastructureError( + `OpenShell release lookup returned unexpected tag ${tag}`, + `release:${id}`, + DEV_RELEASE_URL, + ); + } + 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, + ); + } + if ( + apiUrl !== `${OPENSHELL_API_ROOT}/releases/${id}` || + htmlUrl !== "https://github.com/NVIDIA/OpenShell/releases/tag/dev" + ) { + throw new OpenShellDevArtifactInfrastructureError( + "OpenShell dev release returned an unexpected source URL", + `release:${id}:tag:dev`, + apiUrl, + ); + } + if (!Array.isArray(release.assets)) { + throw new OpenShellDevArtifactInfrastructureError( + "OpenShell dev release has no asset list", + `release:${id}:tag:dev`, + DEV_RELEASE_URL, + ); + } + const parsedAssets = release.assets.map(parseAsset); + const assetsByName = new Map(parsedAssets.map((asset) => [asset.name, asset] as const)); + if (assetsByName.size !== parsedAssets.length) { + throw new OpenShellDevArtifactInfrastructureError( + "OpenShell dev release contains duplicate asset names", + `release:${id}:tag:dev`, + DEV_RELEASE_URL, + ); + } + const assets = OPENSHELL_DEV_ASSET_NAMES.map((name) => { + const asset = assetsByName.get(name); + if (!asset) { + throw new OpenShellDevArtifactInfrastructureError( + `OpenShell dev release is missing required asset ${name}`, + `release:${id}:asset:${name}`, + DEV_RELEASE_URL, + ); + } + return asset; + }); + return { id, tag: "dev", apiUrl, htmlUrl, sourceCommit, updatedAt, assets }; +} + +function snapshotIdentity(snapshot: ReleaseSnapshot): string { + return JSON.stringify({ + id: snapshot.id, + sourceCommit: snapshot.sourceCommit, + updatedAt: snapshot.updatedAt, + assets: snapshot.assets.map(({ id, name, size, digest, apiUrl }) => ({ + id, + name, + size, + digest, + apiUrl, + })), + }); +} + +async function downloadAsset(fetchFn: Fetch, asset: ReleaseAsset): Promise { + let response: Response; + try { + response = await fetchFn(asset.apiUrl, { + headers: { + Accept: "application/octet-stream", + "X-GitHub-Api-Version": "2022-11-28", + }, + redirect: "manual", + }); + if (response.status >= 300 && response.status < 400) { + const location = response.headers.get("location"); + if (!location) throw new Error("GitHub asset response omitted its redirect URL"); + const redirectUrl = new URL(location); + if ( + redirectUrl.protocol !== "https:" || + redirectUrl.hostname !== "release-assets.githubusercontent.com" + ) { + throw new Error( + `GitHub asset response redirected to unexpected host ${redirectUrl.hostname}`, + ); + } + response = await fetchFn(redirectUrl, { redirect: "error" }); + } + } catch (error) { + throw infrastructureError(error, `asset:${asset.name}:id:${asset.id}`, asset.apiUrl); + } + if (!response.ok) { + throw new OpenShellDevArtifactInfrastructureError( + `GitHub returned HTTP ${response.status} for OpenShell release asset ${asset.name}`, + `asset:${asset.name}:id:${asset.id}`, + asset.apiUrl, + ); + } + let bytes: Uint8Array; + try { + bytes = new Uint8Array(await response.arrayBuffer()); + } catch (error) { + throw infrastructureError(error, `asset:${asset.name}:id:${asset.id}`, asset.apiUrl); + } + if (bytes.byteLength !== asset.size) { + throw new OpenShellDevArtifactInfrastructureError( + `OpenShell release asset ${asset.name} size mismatch: expected ${asset.size}, received ${bytes.byteLength}`, + `asset:${asset.name}:id:${asset.id}`, + asset.apiUrl, + ); + } + const actualDigest = createHash("sha256").update(bytes).digest("hex"); + if (actualDigest !== asset.digest) { + throw new OpenShellDevArtifactInfrastructureError( + `OpenShell release asset ${asset.name} SHA-256 mismatch`, + `asset:${asset.name}:id:${asset.id}`, + asset.apiUrl, + ); + } + return bytes; +} + +function writeJson(filePath: string, value: unknown, flag: "w" | "wx" = "wx"): void { + fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, { flag, mode: 0o600 }); +} + +function prepareOutputDirectory(outputDirectory: string): void { + fs.mkdirSync(outputDirectory, { recursive: true, mode: 0o700 }); + const stat = fs.lstatSync(outputDirectory); + if (!stat.isDirectory() || stat.isSymbolicLink()) { + throw new Error("OpenShell dev artifact output path must be a regular directory"); + } + if (fs.readdirSync(outputDirectory).length !== 0) { + throw new Error("OpenShell dev artifact output directory must be empty"); + } +} + +export async function resolveOpenShellDevArtifact( + outputDirectory: string, + fetchFn: Fetch = fetch, +): Promise { + prepareOutputDirectory(outputDirectory); + const assetsDirectory = path.join(outputDirectory, "assets"); + fs.mkdirSync(assetsDirectory, { mode: 0o700 }); + try { + const initial = await readReleaseSnapshot(fetchFn); + for (const asset of initial.assets) { + const bytes = await downloadAsset(fetchFn, asset); + fs.writeFileSync(path.join(assetsDirectory, asset.name), bytes, { flag: "wx", mode: 0o600 }); + } + const final = await readReleaseSnapshot(fetchFn); + if (snapshotIdentity(initial) !== snapshotIdentity(final)) { + throw new OpenShellDevArtifactInfrastructureError( + "OpenShell dev release changed while its assets were being resolved", + `release:${initial.id}:tag:dev:source:${initial.sourceCommit}`, + DEV_RELEASE_URL, + ); + } + const manifest: OpenShellDevArtifactManifest = { + schemaVersion: 1, + release: { + id: initial.id, + tag: initial.tag, + apiUrl: initial.apiUrl, + htmlUrl: initial.htmlUrl, + sourceCommit: initial.sourceCommit, + updatedAt: initial.updatedAt, + }, + assets: initial.assets, + }; + const manifestText = `${JSON.stringify(manifest, null, 2)}\n`; + const manifestSha256 = createHash("sha256").update(manifestText).digest("hex"); + const artifactName = `openshell-dev-${initial.sourceCommit}-${manifestSha256}`; + fs.writeFileSync(path.join(outputDirectory, "manifest.json"), manifestText, { + flag: "wx", + mode: 0o600, + }); + const resolution: OpenShellDevArtifactResolution = { + schemaVersion: 1, + classification: "resolved", + identifier: `release:${initial.id}:tag:dev:source:${initial.sourceCommit}`, + sourceUrl: DEV_RELEASE_URL, + message: "OpenShell dev assets resolved and verified", + artifactName, + manifestSha256, + sourceCommit: initial.sourceCommit, + }; + writeJson(path.join(outputDirectory, "resolution.json"), resolution); + return resolution; + } catch (error) { + fs.rmSync(assetsDirectory, { recursive: true, force: true }); + fs.rmSync(path.join(outputDirectory, "manifest.json"), { force: true }); + const classified = infrastructureError(error, "release:dev", DEV_RELEASE_URL); + const resolution: OpenShellDevArtifactResolution = { + schemaVersion: 1, + classification: "infrastructure-failure", + identifier: classified.identifier, + sourceUrl: classified.sourceUrl, + message: classified.message, + }; + try { + writeJson(path.join(outputDirectory, "resolution.json"), resolution, "w"); + } catch (writeError) { + console.error( + `Unable to retain OpenShell dev infrastructure-failure evidence: ${writeError instanceof Error ? writeError.message : String(writeError)}`, + ); + } + throw classified; + } +} + +function parseManifest(manifestBytes: Buffer): OpenShellDevArtifactManifest { + const manifest = record(JSON.parse(manifestBytes.toString("utf8"))); + if (manifest.schemaVersion !== 1) throw new Error("unsupported OpenShell dev manifest schema"); + const release = record(manifest.release); + const assets = manifest.assets; + if (!Array.isArray(assets)) throw new Error("OpenShell dev manifest has no asset list"); + return { + schemaVersion: 1, + release: { + id: integerField(release, "id"), + tag: stringField(release, "tag") as "dev", + apiUrl: stringField(release, "apiUrl"), + htmlUrl: stringField(release, "htmlUrl"), + sourceCommit: stringField(release, "sourceCommit"), + updatedAt: stringField(release, "updatedAt"), + }, + assets: assets.map(parseManifestAsset), + }; +} + +function parseManifestAsset(value: unknown): ReleaseAsset { + const asset = record(value); + const parsed = { + id: integerField(asset, "id"), + name: stringField(asset, "name"), + size: integerField(asset, "size"), + digest: stringField(asset, "digest"), + apiUrl: stringField(asset, "apiUrl"), + browserDownloadUrl: stringField(asset, "browserDownloadUrl"), + }; + if (!SHA256_PATTERN.test(parsed.digest)) throw new Error(`invalid digest for ${parsed.name}`); + if (parsed.size > MAX_ASSET_BYTES) throw new Error(`asset ${parsed.name} exceeds the size limit`); + if (parsed.apiUrl !== `${ASSET_API_PREFIX}${parsed.id}`) { + throw new Error(`unexpected asset API URL for ${parsed.name}`); + } + return parsed; +} + +function readRegularFileNoFollow(filePath: string, label: string): { bytes: Buffer; size: number } { + let descriptor: number | undefined; + try { + descriptor = fs.openSync(filePath, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW); + const stat = fs.fstatSync(descriptor); + if (!stat.isFile()) throw new Error(`${label} must be a regular file`); + return { bytes: fs.readFileSync(descriptor), size: stat.size }; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ELOOP") { + throw new Error(`${label} must be a regular file`); + } + throw error; + } finally { + if (descriptor !== undefined) fs.closeSync(descriptor); + } +} + +export function verifyOpenShellDevArtifact( + outputDirectory: string, + expectedSourceCommit: string, + expectedManifestSha256: string, +): OpenShellDevArtifactManifest { + if (!COMMIT_PATTERN.test(expectedSourceCommit) || !SHA256_PATTERN.test(expectedManifestSha256)) { + throw new Error("expected source commit and manifest SHA-256 must be lowercase hexadecimal"); + } + const manifestPath = path.join(outputDirectory, "manifest.json"); + const { bytes: manifestBytes } = readRegularFileNoFollow(manifestPath, "OpenShell dev manifest"); + const actualManifestSha256 = createHash("sha256").update(manifestBytes).digest("hex"); + if (actualManifestSha256 !== expectedManifestSha256) { + throw new Error("OpenShell dev manifest SHA-256 mismatch"); + } + const manifest = parseManifest(manifestBytes); + if (manifest.release.tag !== "dev" || manifest.release.sourceCommit !== expectedSourceCommit) { + throw new Error("OpenShell dev manifest source identity mismatch"); + } + if (manifest.assets.map(({ name }) => name).join("\n") !== OPENSHELL_DEV_ASSET_NAMES.join("\n")) { + throw new Error("OpenShell dev manifest asset set or order mismatch"); + } + const assetsDirectory = path.join(outputDirectory, "assets"); + const actualNames = fs.readdirSync(assetsDirectory).sort(); + const expectedNames = [...OPENSHELL_DEV_ASSET_NAMES].sort(); + if (actualNames.join("\n") !== expectedNames.join("\n")) { + throw new Error("OpenShell dev artifact directory contains an unexpected asset set"); + } + for (const asset of manifest.assets) { + const assetPath = path.join(assetsDirectory, asset.name); + const { bytes, size } = readRegularFileNoFollow(assetPath, `OpenShell dev asset ${asset.name}`); + if (size !== asset.size) throw new Error(`OpenShell dev asset ${asset.name} size mismatch`); + const digest = createHash("sha256").update(bytes).digest("hex"); + if (digest !== asset.digest) + throw new Error(`OpenShell dev asset ${asset.name} SHA-256 mismatch`); + } + return manifest; +} + +function appendGithubOutput(values: Record): void { + const outputPath = process.env.GITHUB_OUTPUT; + if (!outputPath) throw new Error("GITHUB_OUTPUT is required"); + fs.appendFileSync( + outputPath, + `${Object.entries(values) + .map(([key, value]) => `${key}=${value}`) + .join("\n")}\n`, + "utf8", + ); +} + +async function main(): Promise { + const [command, outputDirectory, argument3, argument4] = process.argv.slice(2); + if (!outputDirectory || !path.isAbsolute(outputDirectory)) { + throw new Error("an absolute OpenShell dev artifact directory is required"); + } + if (command === "resolve") { + try { + const resolution = await resolveOpenShellDevArtifact(outputDirectory); + if (!resolution.artifactName || !resolution.sourceCommit || !resolution.manifestSha256) { + throw new Error("successful OpenShell dev resolution omitted its immutable identity"); + } + appendGithubOutput({ + artifact_name: resolution.artifactName, + source_commit: resolution.sourceCommit, + manifest_sha256: resolution.manifestSha256, + }); + console.log(`Resolved ${resolution.identifier} from ${resolution.sourceUrl}`); + } catch (error) { + const classified = infrastructureError(error, "release:dev", DEV_RELEASE_URL); + const suffix = `${process.env.GITHUB_RUN_ID ?? "local"}-${process.env.GITHUB_RUN_ATTEMPT ?? "1"}`; + appendGithubOutput({ artifact_name: `openshell-dev-infrastructure-failure-${suffix}` }); + console.error( + `::error title=OpenShell dev artifact infrastructure failure::identifier=${classified.identifier}; source=${classified.sourceUrl}; ${classified.message}`, + ); + throw classified; + } + return; + } + if (command === "verify" && argument3 && argument4) { + verifyOpenShellDevArtifact(outputDirectory, argument3, argument4); + console.log(`Verified OpenShell dev artifact source ${argument3}`); + return; + } + throw new Error( + "usage: openshell-dev-artifact.mts resolve | verify ", + ); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; + }); +} diff --git a/tools/e2e/operations-workflow-boundary.mts b/tools/e2e/operations-workflow-boundary.mts index cd07899e74b..c57c83042fc 100644 --- a/tools/e2e/operations-workflow-boundary.mts +++ b/tools/e2e/operations-workflow-boundary.mts @@ -400,6 +400,12 @@ 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" && + step.with?.repository === "${{ github.repository }}" && + step.with?.ref === "${{ inputs.workflow_sha || github.workflow_sha }}" && + step.with?.path === ".trusted-openshell-dev-artifact"; const trustedCheckout = trustedHermesFixtureCheckout || trustedReportHelperCheckout || @@ -410,7 +416,8 @@ function validateManualPrDispatch(errors: string[], workflow: OperationsWorkflow trustedManagedImageRuntimeCheckout || trustedLlamaCppPlanCheckout || trustedLlamaCppQualificationCheckout || - trustedJetsonControllerCheckout; + trustedJetsonControllerCheckout || + trustedOpenShellDevToolingCheckout; if ( step.uses?.startsWith("actions/checkout@") && step.with?.ref !== "${{ inputs.checkout_sha || github.sha }}" && diff --git a/tools/e2e/upload-e2e-artifacts-workflow-boundary.mts b/tools/e2e/upload-e2e-artifacts-workflow-boundary.mts index e291d30f538..d53dbc2cb36 100644 --- a/tools/e2e/upload-e2e-artifacts-workflow-boundary.mts +++ b/tools/e2e/upload-e2e-artifacts-workflow-boundary.mts @@ -28,6 +28,9 @@ const DEFAULT_ACTION_PATH = join( export const UPLOAD_E2E_ARTIFACTS_ACTION_PROVENANCE = E2E_ACTION_PROVENANCE.uploadArtifacts; export const UPLOAD_E2E_ARTIFACTS_ACTION = UPLOAD_E2E_ARTIFACTS_ACTION_PROVENANCE.reference; +export const OPENSHELL_DEV_ARTIFACT_DIRECTORY = "${{ runner.temp }}/openshell-dev-artifact"; +export const OPENSHELL_DEV_ARTIFACT_UPLOAD_NAME = + "${{ steps.resolve_openshell_dev_artifact.outputs.artifact_name || format('openshell-dev-infrastructure-failure-{0}-{1}', github.run_id, github.run_attempt) }}"; const CHECKOUT_LOCAL_UPLOAD_E2E_ARTIFACTS_ACTION = "./.github/actions/upload-e2e-artifacts"; const UPLOAD_E2E_ARTIFACTS_ACTION_PREFIX = "NVIDIA/NemoClaw/.github/actions/upload-e2e-artifacts@"; @@ -134,6 +137,7 @@ const EXPLICIT_UPLOAD_CONTRACTS = new Map([ "e2e-artifacts/live/${{ matrix.id }}/environment.result.json", "e2e-artifacts/live/${{ matrix.id }}/onboarding.result.json", "e2e-artifacts/live/${{ matrix.id }}/state-validation.result.json", + "e2e-artifacts/live/${{ matrix.id }}/dcode-base-image.json", "e2e-artifacts/live/${{ matrix.id }}/cloud-onboard-trace-timing-summary.json", "e2e-artifacts/live/risk-signal.json", "e2e-artifacts/live/${{ matrix.id }}/actions/", @@ -192,6 +196,13 @@ const EXPLICIT_UPLOAD_CONTRACTS = new Map([ path: "e2e-artifacts/live/mcp-bridge-dev/${{ matrix.agent }}/", }, ], + [ + "openshell-dev-artifact", + { + name: OPENSHELL_DEV_ARTIFACT_UPLOAD_NAME, + path: `${OPENSHELL_DEV_ARTIFACT_DIRECTORY}/`, + }, + ], [ "openshell-credential-generation-window", { @@ -206,6 +217,7 @@ const EXPLICIT_CALLER_CONDITIONS = new Map([ ["staging-brev-launchable", "${{ always() && steps.workspace.outputs.work_dir != '' }}"], ["mcp-bridge", MCP_SCANNED_UPLOAD_CONDITION], ["mcp-bridge-dev", MCP_SCANNED_UPLOAD_CONDITION], + ["openshell-dev-artifact", "${{ always() }}"], ["openshell-credential-generation-window", CREDENTIAL_WINDOW_SCANNED_UPLOAD_CONDITION], ["openshell-gateway-auth-contract", GATEWAY_AUTH_SCANNED_UPLOAD_CONDITION], ]); @@ -336,6 +348,7 @@ export function validateUploadE2eArtifactsInvocations(workflow: WorkflowRecord): jobName === "generate-matrix" || jobName === "jetson-nvmap-gpu" || jobName === "live" || + jobName === "openshell-dev-artifact" || jobName === RETIRED_SELECTOR_COMPATIBILITY_JOB || env.E2E_JOB === "1" || env.NEMOCLAW_RUN_LIVE_E2E === "1" || diff --git a/tools/e2e/workflow-boundary.mts b/tools/e2e/workflow-boundary.mts index 63fd793a9d2..a725181c1d9 100644 --- a/tools/e2e/workflow-boundary.mts +++ b/tools/e2e/workflow-boundary.mts @@ -1073,7 +1073,11 @@ function validateFreeStandingJobSelector( _explicitOnly = false, ): void { const job = asRecord(jobs[jobName]); - if (job.needs !== "generate-matrix") { + const expectedNeeds = + jobName === "mcp-bridge-dev" + ? ["generate-matrix", "openshell-dev-artifact"] + : "generate-matrix"; + if (!isDeepStrictEqual(job.needs, expectedNeeds)) { errors.push(`${jobName} job must depend on generate-matrix`); } if (job.if !== selectedJobsCondition(jobName)) { @@ -1081,7 +1085,6 @@ 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)) { @@ -1293,8 +1296,6 @@ 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); @@ -1430,15 +1431,17 @@ function validateDockerHubAuthBoundary(errors: string[], jobs: WorkflowRecord): } requireCanonicalDockerHubCleanupRun(errors, jobName, cleanup); - const checkoutIndex = workflowSteps.findIndex((step) => { + const checkoutIndexes = workflowSteps.flatMap((step, index) => { if (jobName === "managed-image-protected-runtime") { - return step.name === "Checkout exact protected runtime candidate source"; + return step.name === "Checkout exact protected runtime candidate source" ? [index] : []; } if (jobName === "llama-cpp-dgx-spark-qualification") { - return step.name === "Checkout exact llama.cpp qualification candidate"; + return step.name === "Checkout exact llama.cpp qualification candidate" ? [index] : []; } - return stringValue(step.uses).startsWith("actions/checkout@"); + return stringValue(step.uses).startsWith("actions/checkout@") ? [index] : []; }); + const checkoutIndex = + 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 5b7f50b2bd6..d734c4e851d 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -176,6 +176,7 @@ 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", ], @@ -208,6 +209,7 @@ 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", ],