diff --git a/.github/actions/publish-base-image-manifest/action.yaml b/.github/actions/publish-base-image-manifest/action.yaml index 984157b9ec3..211186a797b 100644 --- a/.github/actions/publish-base-image-manifest/action.yaml +++ b/.github/actions/publish-base-image-manifest/action.yaml @@ -71,4 +71,4 @@ runs: name: managed-base-${{ github.run_id }}-${{ github.run_attempt }}-${{ inputs.agent }} path: ${{ runner.temp }}/managed-base-contract/contract.json if-no-files-found: error - retention-days: 1 + retention-days: 90 diff --git a/.github/workflows/e2e.yaml b/.github/workflows/e2e.yaml index 85eab178b5f..6b734147ca8 100644 --- a/.github/workflows/e2e.yaml +++ b/.github/workflows/e2e.yaml @@ -103,6 +103,9 @@ jobs: base-image-publication: runs-on: ubuntu-latest timeout-minutes: 55 + outputs: + dcode_base_contract: ${{ steps.validate_dcode_base.outputs.contract }} + dcode_base_ref: ${{ steps.validate_dcode_base.outputs.base_ref }} permissions: actions: read contents: read @@ -122,7 +125,7 @@ jobs: required=1 ;; NVIDIA/NemoClaw:refs/heads/main:workflow_dispatch:controller) - required=0 + required=1 ;; *) echo "::error::base-image publication mode is not trusted" >&2 @@ -145,19 +148,41 @@ jobs: with: node-version: 22 - - name: Verify applicable base-image publication + - id: publication + name: Verify applicable base-image publication if: ${{ steps.publication_mode.outputs.required == '1' }} env: EXPECTED_SHA: ${{ github.sha }} GITHUB_TOKEN: ${{ github.token }} run: node --experimental-strip-types --no-warnings tools/e2e/base-image-publication.mts --wait-seconds 3000 --poll-seconds 30 + - name: Download immutable Deep Agents Code base contract + if: ${{ steps.publication_mode.outputs.required == '1' }} + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + github-token: ${{ github.token }} + name: managed-base-${{ steps.publication.outputs.run_id }}-${{ steps.publication.outputs.run_attempt }}-langchain-deepagents-code + path: ${{ runner.temp }}/dcode-base-contract + repository: NVIDIA/NemoClaw + run-id: ${{ steps.publication.outputs.run_id }} + + - id: validate_dcode_base + name: Validate immutable Deep Agents Code base + if: ${{ steps.publication_mode.outputs.required == '1' }} + env: + PUBLICATION_HEAD_SHA: ${{ steps.publication.outputs.head_sha }} + PUBLICATION_RUN_ATTEMPT: ${{ steps.publication.outputs.run_attempt }} + PUBLICATION_RUN_ID: ${{ steps.publication.outputs.run_id }} + run: node --experimental-strip-types --no-warnings tools/e2e/dcode-base-image-contract.mts "${RUNNER_TEMP}/dcode-base-contract/contract.json" + generate-matrix: needs: base-image-publication runs-on: ubuntu-latest timeout-minutes: 10 outputs: cli_artifact_provenance: ${{ steps.record_cli_artifact.outputs.provenance }} + dcode_base_contract: ${{ needs.base-image-publication.outputs.dcode_base_contract }} + dcode_base_ref: ${{ needs.base-image-publication.outputs.dcode_base_ref }} matrix: ${{ steps.matrix.outputs.matrix }} test_matrix: ${{ steps.matrix.outputs.test_matrix }} hermes_selected: ${{ steps.matrix.outputs.hermes_selected }} @@ -770,6 +795,7 @@ jobs: include: ${{ fromJSON(needs.generate-matrix.outputs.matrix) }} env: E2E_ARTIFACT_DIR: ${{ github.workspace }}/e2e-artifacts/live + NEMOCLAW_LANGCHAIN_DEEPAGENTS_CODE_SANDBOX_BASE_IMAGE_REF: ${{ needs.generate-matrix.outputs.dcode_base_ref }} NEMOCLAW_CLI_BIN: ${{ github.workspace }}/bin/nemoclaw.js NEMOCLAW_RUN_LIVE_E2E: "1" NEMOCLAW_E2E_USE_HOSTED_INFERENCE: "1" @@ -823,6 +849,26 @@ jobs: with: build-cli: "false" + - name: Record immutable Deep Agents Code base evidence + if: ${{ matrix.id == 'ubuntu-repo-cloud-langchain-deepagents-code' }} + env: + BASE_CONTRACT: ${{ needs.generate-matrix.outputs.dcode_base_contract }} + CANDIDATE_SHA: ${{ inputs.checkout_sha || github.sha }} + TARGET_ID: ${{ matrix.id }} + shell: bash + run: | + set -euo pipefail + evidence_dir="${E2E_ARTIFACT_DIR}/${TARGET_ID}" + install -d -m 0700 "${evidence_dir}" + jq -e -n \ + --arg candidateSha "${CANDIDATE_SHA}" \ + --argjson baseContract "${BASE_CONTRACT}" \ + '{contractVersion: 1, candidateSha: $candidateSha, base: $baseContract} + | select( + (.candidateSha | test("^[0-9a-f]{40}$")) + and (.base.reference | test("@sha256:[0-9a-f]{64}$")) + )' >"${evidence_dir}/dcode-base-image.json" + - name: Restore exact-commit CLI artifact uses: NVIDIA/NemoClaw/.github/actions/restore-e2e-cli-artifact@c246409193a31133cab10c8a3589001cc0d59eb3 with: @@ -942,6 +988,7 @@ jobs: 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/ diff --git a/ci/source-shape-test-budget.json b/ci/source-shape-test-budget.json index 3ea56bb7448..1b52e1bba08 100644 --- a/ci/source-shape-test-budget.json +++ b/ci/source-shape-test-budget.json @@ -101,6 +101,11 @@ "test": "hermes-e2e: install.sh onboards Hermes and proves health plus live inference", "category": "security" }, + { + "file": "test/e2e/support/base-image-publication-workflow-boundary.test.ts", + "test": "retains immutable base contracts for later qualification (#9049)", + "category": "security" + }, { "file": "test/e2e/support/dockerhub-auth-workflow-boundary.test.ts", "test": "binds the cleanup action and helper content to the pinned commit", diff --git a/test/e2e/support/base-image-publication-workflow-boundary.test.ts b/test/e2e/support/base-image-publication-workflow-boundary.test.ts index c899d52170c..f4ac0a5f98e 100644 --- a/test/e2e/support/base-image-publication-workflow-boundary.test.ts +++ b/test/e2e/support/base-image-publication-workflow-boundary.test.ts @@ -7,6 +7,7 @@ import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; +import YAML from "yaml"; import { type OperationsWorkflow, @@ -24,7 +25,9 @@ type MutableStep = { }; type MutableJob = Record & { + env?: Record; needs?: unknown; + outputs?: Record; permissions?: Record; steps?: MutableStep[]; }; @@ -90,20 +93,44 @@ function runClassifier(environment: { } describe("base-image publication workflow boundary (#7372)", () => { + // source-shape-contract: security -- Immutable base contracts must outlive the qualification interval so later E2E cannot fall back to a mutable alias. + it("retains immutable base contracts for later qualification (#9049)", () => { + const action = YAML.parse( + fs.readFileSync( + path.join(process.cwd(), ".github/actions/publish-base-image-manifest/action.yaml"), + "utf8", + ), + ) as { runs: { steps: MutableStep[] } }; + const upload = action.runs.steps.find( + (step) => step.name === "Upload managed base image contract", + ); + + expect(upload).toMatchObject({ + uses: "actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a", + with: { + "if-no-files-found": "error", + "retention-days": 90, + }, + }); + }); + it.each([ ["push to main", "push", "", "1"], ["manual main", "workflow_dispatch", "", "1"], - ["controller-selected PR", "workflow_dispatch", "a".repeat(40), "0"], - ])("classifies %s without executing untrusted code (#7372)", (_case, eventName, checkoutSha, required) => { - expect( - runClassifier({ - checkoutSha, - eventName, - ref: "refs/heads/main", - repository: "NVIDIA/NemoClaw", - }), - ).toEqual({ output: `required=${required}\n`, status: 0 }); - }); + ["controller-selected PR", "workflow_dispatch", "a".repeat(40), "1"], + ])( + "classifies %s without executing untrusted code (#7372)", + (_case, eventName, checkoutSha, required) => { + expect( + runClassifier({ + checkoutSha, + eventName, + ref: "refs/heads/main", + repository: "NVIDIA/NemoClaw", + }), + ).toEqual({ output: `required=${required}\n`, status: 0 }); + }, + ); it.each([ ["a fork", "push", "", "refs/heads/main", "attacker/NemoClaw"], @@ -116,9 +143,12 @@ describe("base-image publication workflow boundary (#7372)", () => { "refs/heads/main", "NVIDIA/NemoClaw", ], - ])("rejects %s instead of skipping the gate (#7372)", (_case, eventName, checkoutSha, ref, repository) => { - expect(runClassifier({ checkoutSha, eventName, ref, repository }).status).not.toBe(0); - }); + ])( + "rejects %s instead of skipping the gate (#7372)", + (_case, eventName, checkoutSha, ref, repository) => { + expect(runClassifier({ checkoutSha, eventName, ref, repository }).status).not.toBe(0); + }, + ); const mutations: Array<[string, (value: MutableWorkflow) => void]> = [ ["runner size", (value) => (value.jobs["base-image-publication"]["runs-on"] = "self-hosted")], @@ -142,7 +172,7 @@ describe("base-image publication workflow boundary (#7372)", () => { [ "classifier outcome", (value) => { - gateSteps(value)[0].run = gateSteps(value)[0].run!.replace("required=0", "required=1"); + gateSteps(value)[0].run = gateSteps(value)[0].run!.replace("required=1", "required=0"); }, ], ["checkout condition", (value) => (gateSteps(value)[1].if = "${{ always() }}")], @@ -165,8 +195,56 @@ describe("base-image publication workflow boundary (#7372)", () => { gateSteps(value)[3].run = "node tools/e2e/base-image-publication.mts"; }, ], + [ + "contract download pin", + (value) => (gateSteps(value)[4].uses = "actions/download-artifact@v8"), + ], + [ + "contract run binding", + (value) => (gateSteps(value)[4].with!["run-id"] = "${{ github.run_id }}"), + ], + [ + "contract validation", + (value) => + (gateSteps(value)[5].run = "node tools/e2e/dcode-base-image-contract.mts contract.json"), + ], ["step count", (value) => gateSteps(value).push({ name: "Unreviewed step", run: "true" })], ["fanout dependency", (value) => (value.jobs["generate-matrix"].needs = [])], + [ + "matrix base output", + (value) => { + (value.jobs["generate-matrix"].outputs as Record).dcode_base_ref = + "${{ inputs.base_ref }}"; + }, + ], + [ + "live mutable base", + (value) => { + value.jobs.live.env!.NEMOCLAW_LANGCHAIN_DEEPAGENTS_CODE_SANDBOX_BASE_IMAGE_REF = + "ghcr.io/nvidia/nemoclaw/langchain-deepagents-code-sandbox-base:latest"; + }, + ], + [ + "live base evidence ordering", + (value) => { + const steps = value.jobs.live.steps!; + const evidence = steps.find( + (step) => step.name === "Record immutable Deep Agents Code base evidence", + )!; + steps.splice(steps.indexOf(evidence), 1); + steps.push(evidence); + }, + ], + [ + "live base evidence upload", + (value) => { + const upload = value.jobs.live.steps!.find((step) => step.name === "Upload E2E artifacts")!; + upload.with!.path = String(upload.with!.path).replace( + "e2e-artifacts/live/${{ matrix.id }}/dcode-base-image.json\n", + "", + ); + }, + ], ]; it.each(mutations)("rejects %s drift (#7372)", (_case, mutate) => { diff --git a/test/e2e/support/base-image-publication.test.ts b/test/e2e/support/base-image-publication.test.ts index 1a545bc0c24..7c0b52aaaad 100644 --- a/test/e2e/support/base-image-publication.test.ts +++ b/test/e2e/support/base-image-publication.test.ts @@ -22,6 +22,7 @@ import { validatePublisherJobs, validateWorkflow, waitForBaseImagePublication, + writePublicationRunOutputs, } from "../../../tools/e2e/base-image-publication.mts"; const EXPECTED_SHA = "a".repeat(40); @@ -166,13 +167,12 @@ describe("base-image publication evidence", () => { expect(isBaseImagePublicationEvent(eventName)).toBe(true); }); - it.each([ - "schedule", - "pull_request", - undefined, - ])("rejects unsupported %s publication preflight events", (eventName) => { - expect(isBaseImagePublicationEvent(eventName)).toBe(false); - }); + it.each(["schedule", "pull_request", undefined])( + "rejects unsupported %s publication preflight events", + (eventName) => { + expect(isBaseImagePublicationEvent(eventName)).toBe(false); + }, + ); it("extracts literal paths and the reviewed managed-image input families (#7372)", () => { const source = fs.readFileSync( @@ -474,14 +474,14 @@ describe("base-image publication evidence", () => { ).toMatchObject({ state: "pending", run: { status: "in_progress" } }); }); - it.each([ - "failure", - "cancelled", - ] as const)("fails closed when publication concludes %s (#7372)", (conclusion) => { - expect(() => - selectPublicationRun(runsPayload([workflowRun({ conclusion })]), history(), WORKFLOW_ID), - ).toThrow(`base-image workflow for ${RELEVANT_SHA} concluded ${conclusion}; ${RUN_URL}`); - }); + it.each(["failure", "cancelled"] as const)( + "fails closed when publication concludes %s (#7372)", + (conclusion) => { + expect(() => + selectPublicationRun(runsPayload([workflowRun({ conclusion })]), history(), WORKFLOW_ID), + ).toThrow(`base-image workflow for ${RELEVANT_SHA} concluded ${conclusion}; ${RUN_URL}`); + }, + ); it("fails closed on ambiguous or malformed runs (#7372)", () => { expect(() => @@ -538,6 +538,22 @@ describe("base-image publication evidence", () => { ).toThrow(/changed while evidence was verified/u); }); + it("exports the selected immutable publication identity for downstream qualification (#9049)", () => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-publication-output-")); + const output = path.join(directory, "github-output"); + try { + writePublicationRunOutputs(output, selectedRun()); + expect(fs.readFileSync(output, "utf8")).toBe( + `run_id=${RUN_ID}\nrun_attempt=1\nhead_sha=${RELEVANT_SHA}\n`, + ); + expect(() => writePublicationRunOutputs("bad\npath", selectedRun())).toThrow( + /single-line path/u, + ); + } finally { + fs.rmSync(directory, { recursive: true, force: true }); + } + }); + it.each([ ["missing", successfulJobs().slice(0, 2), /missing required/u], [ diff --git a/test/e2e/support/dcode-base-image-contract.test.ts b/test/e2e/support/dcode-base-image-contract.test.ts new file mode 100644 index 00000000000..45287b1c4d8 --- /dev/null +++ b/test/e2e/support/dcode-base-image-contract.test.ts @@ -0,0 +1,92 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it, vi } from "vitest"; + +import { + validateDcodeBaseImageContract, + validateDcodeBaseImageImports, +} from "../../../tools/e2e/dcode-base-image-contract.mts"; + +const RUN_ID = 1234; +const RUN_ATTEMPT = 2; +const HEAD_SHA = "a".repeat(40); +const IMAGE = "ghcr.io/nvidia/nemoclaw/langchain-deepagents-code-sandbox-base"; +const DIGEST = `sha256:${"b".repeat(64)}`; + +function contract(overrides: Record = {}): Record { + const amd64 = `sha256:${"c".repeat(64)}`; + const arm64 = `sha256:${"d".repeat(64)}`; + return { + contractVersion: 1, + agent: "langchain-deepagents-code", + image: IMAGE, + digest: DIGEST, + reference: `${IMAGE}@${DIGEST}`, + platforms: ["linux/amd64", "linux/arm64"], + platformDigests: { "linux/amd64": amd64, "linux/arm64": arm64 }, + platformReferences: { + "linux/amd64": `${IMAGE}@${amd64}`, + "linux/arm64": `${IMAGE}@${arm64}`, + }, + sourceRevision: HEAD_SHA, + run: { id: RUN_ID, attempt: RUN_ATTEMPT }, + ...overrides, + }; +} + +const expected = { runId: RUN_ID, runAttempt: RUN_ATTEMPT, headSha: HEAD_SHA }; + +describe("Deep Agents Code E2E base contract", () => { + it("accepts the exact immutable publication contract (#9049)", () => { + expect(validateDcodeBaseImageContract(contract(), expected).reference).toBe( + `${IMAGE}@${DIGEST}`, + ); + }); + + it.each([ + ["a mutable reference", { reference: `${IMAGE}:latest` }, /reference must match/u], + ["the wrong source revision", { sourceRevision: "e".repeat(40) }, /source revision/u], + [ + "the wrong publication run", + { run: { id: RUN_ID + 1, attempt: RUN_ATTEMPT } }, + /run does not match/u, + ], + ["an extra field", { unexpected: true }, /unexpected fields/u], + ])("rejects %s (#9049)", (_case, override, message) => { + expect(() => validateDcodeBaseImageContract(contract(override), expected)).toThrow(message); + }); + + it("proves both imports from the exact digest in a locked-down container (#9049)", () => { + const runDocker = vi.fn(() => "nemoclaw-dcode-base-imports-ok"); + validateDcodeBaseImageImports(`${IMAGE}@${DIGEST}`, runDocker); + + expect(runDocker).toHaveBeenCalledWith([ + "run", + "--rm", + "--platform", + "linux/amd64", + "--network", + "none", + "--cap-drop", + "ALL", + "--security-opt", + "no-new-privileges", + "--read-only", + "--user", + "999:999", + "--entrypoint", + "/opt/venv/bin/python3", + `${IMAGE}@${DIGEST}`, + "-I", + "-c", + 'import deepagents; import deepagents_code; print("nemoclaw-dcode-base-imports-ok")', + ]); + }); + + it("rejects missing or noisy import evidence (#9049)", () => { + expect(() => validateDcodeBaseImageImports(`${IMAGE}@${DIGEST}`, () => "")).toThrow( + /did not prove both required imports/u, + ); + }); +}); diff --git a/tools/e2e/base-image-publication.mts b/tools/e2e/base-image-publication.mts index 70cb9312f66..41fff449db1 100644 --- a/tools/e2e/base-image-publication.mts +++ b/tools/e2e/base-image-publication.mts @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { execFileSync } from "node:child_process"; -import { readFileSync } from "node:fs"; +import { appendFileSync, readFileSync } from "node:fs"; import { resolve } from "node:path"; import { performance } from "node:perf_hooks"; import { fileURLToPath } from "node:url"; @@ -105,6 +105,17 @@ export interface PublicationWaitOptions { notice?: (message: string) => void; } +export function writePublicationRunOutputs(path: string, run: PublicationRun): void { + if (!path || path.includes("\r") || path.includes("\n")) { + throw new Error("GITHUB_OUTPUT must be a non-empty single-line path"); + } + appendFileSync( + path, + [`run_id=${run.id}`, `run_attempt=${run.attempt}`, `head_sha=${run.headSha}`, ""].join("\n"), + "utf8", + ); +} + export interface GithubRequestOptions { fetchImpl?: (input: string, init: RequestInit) => Promise; sleep?: (milliseconds: number) => Promise; @@ -740,6 +751,7 @@ export async function main(argv = process.argv.slice(2), env = process.env): Pro const token = env.GITHUB_TOKEN ?? ""; const expectedSha = env.EXPECTED_SHA ?? ""; + const outputPath = env.GITHUB_OUTPUT ?? ""; const workspace = env.GITHUB_WORKSPACE ?? process.cwd(); if (token.length === 0 || token.includes("\r") || token.includes("\n")) { throw new Error("GITHUB_TOKEN must be a non-empty single-line value"); @@ -767,6 +779,7 @@ export async function main(argv = process.argv.slice(2), env = process.env): Pro waitMs: waitSeconds * 1000, pollMs: pollSeconds * 1000, }); + writePublicationRunOutputs(outputPath, run); console.log( `::notice title=Base-image publication verified::${annotationValue( `All required publishers succeeded for ${run.headSha}; ${run.url}`, diff --git a/tools/e2e/dcode-base-image-contract.mts b/tools/e2e/dcode-base-image-contract.mts new file mode 100644 index 00000000000..8a782794963 --- /dev/null +++ b/tools/e2e/dcode-base-image-contract.mts @@ -0,0 +1,188 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { execFileSync } from "node:child_process"; +import { appendFileSync, readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const AGENT = "langchain-deepagents-code"; +const IMAGE = "ghcr.io/nvidia/nemoclaw/langchain-deepagents-code-sandbox-base"; +const DIGEST_PATTERN = /^sha256:[0-9a-f]{64}$/u; +const SHA_PATTERN = /^[0-9a-f]{40}$/u; +const IMPORT_MARKER = "nemoclaw-dcode-base-imports-ok"; + +type JsonRecord = Record; + +export interface DcodeBaseImageContract { + contractVersion: 1; + agent: typeof AGENT; + image: typeof IMAGE; + digest: string; + reference: string; + platforms: ["linux/amd64", "linux/arm64"]; + platformDigests: Record<"linux/amd64" | "linux/arm64", string>; + platformReferences: Record<"linux/amd64" | "linux/arm64", string>; + sourceRevision: string; + run: { id: number; attempt: number }; +} + +function record(value: unknown, label: string): JsonRecord { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error(`${label} must be a JSON object`); + } + return value as JsonRecord; +} + +function positiveInteger(value: unknown, label: string): number { + if (!Number.isSafeInteger(value) || Number(value) < 1) { + throw new Error(`${label} must be a positive integer`); + } + return Number(value); +} + +function exactKeys(value: JsonRecord, expected: readonly string[], label: string): void { + const actual = Object.keys(value).sort(); + if (JSON.stringify(actual) !== JSON.stringify([...expected].sort())) { + throw new Error(`${label} has unexpected fields`); + } +} + +export function validateDcodeBaseImageContract( + value: unknown, + expected: { runId: number; runAttempt: number; headSha: string }, +): DcodeBaseImageContract { + const contract = record(value, "Deep Agents Code base contract"); + exactKeys( + contract, + [ + "agent", + "contractVersion", + "digest", + "image", + "platformDigests", + "platformReferences", + "platforms", + "reference", + "run", + "sourceRevision", + ], + "Deep Agents Code base contract", + ); + if (contract.contractVersion !== 1) throw new Error("base contract version must be 1"); + if (contract.agent !== AGENT) throw new Error(`base contract agent must be ${AGENT}`); + if (contract.image !== IMAGE) throw new Error(`base contract image must be ${IMAGE}`); + if (typeof contract.digest !== "string" || !DIGEST_PATTERN.test(contract.digest)) { + throw new Error("base contract digest must be an immutable SHA-256 digest"); + } + if (contract.reference !== `${IMAGE}@${contract.digest}`) { + throw new Error("base contract reference must match its image and digest"); + } + if (!SHA_PATTERN.test(expected.headSha) || contract.sourceRevision !== expected.headSha) { + throw new Error("base contract source revision does not match the selected publication"); + } + if ( + !Array.isArray(contract.platforms) || + JSON.stringify(contract.platforms) !== JSON.stringify(["linux/amd64", "linux/arm64"]) + ) { + throw new Error("base contract platforms must be linux/amd64 and linux/arm64"); + } + const platformDigests = record(contract.platformDigests, "base contract platform digests"); + const platformReferences = record( + contract.platformReferences, + "base contract platform references", + ); + exactKeys(platformDigests, contract.platforms, "base contract platform digests"); + exactKeys(platformReferences, contract.platforms, "base contract platform references"); + for (const platform of contract.platforms) { + const digest = platformDigests[platform]; + if (typeof digest !== "string" || !DIGEST_PATTERN.test(digest)) { + throw new Error(`base contract ${platform} digest is invalid`); + } + if (platformReferences[platform] !== `${IMAGE}@${digest}`) { + throw new Error(`base contract ${platform} reference is invalid`); + } + } + const run = record(contract.run, "base contract run"); + exactKeys(run, ["attempt", "id"], "base contract run"); + if ( + positiveInteger(run.id, "base contract run id") !== expected.runId || + positiveInteger(run.attempt, "base contract run attempt") !== expected.runAttempt + ) { + throw new Error("base contract run does not match the selected publication"); + } + return contract as unknown as DcodeBaseImageContract; +} + +export function validateDcodeBaseImageImports( + reference: string, + runDocker: (args: string[]) => string = (args) => + execFileSync("docker", args, { + encoding: "utf8", + maxBuffer: 4 * 1024 * 1024, + stdio: ["ignore", "pipe", "pipe"], + timeout: 120_000, + }).trim(), +): void { + const output = runDocker([ + "run", + "--rm", + "--platform", + "linux/amd64", + "--network", + "none", + "--cap-drop", + "ALL", + "--security-opt", + "no-new-privileges", + "--read-only", + "--user", + "999:999", + "--entrypoint", + "/opt/venv/bin/python3", + reference, + "-I", + "-c", + `import deepagents; import deepagents_code; print("${IMPORT_MARKER}")`, + ]); + if (output.trim() !== IMPORT_MARKER) { + throw new Error("immutable Deep Agents Code base did not prove both required imports"); + } +} + +function requiredInteger(value: string | undefined, label: string): number { + if (!value || !/^[1-9][0-9]*$/u.test(value)) + throw new Error(`${label} must be a positive integer`); + return positiveInteger(Number(value), label); +} + +export function main(argv = process.argv.slice(2), env = process.env): void { + if (argv.length !== 1) throw new Error("expected one managed base contract path"); + const outputPath = env.GITHUB_OUTPUT ?? ""; + if (!outputPath || outputPath.includes("\r") || outputPath.includes("\n")) { + throw new Error("GITHUB_OUTPUT must be a non-empty single-line path"); + } + const contract = validateDcodeBaseImageContract( + JSON.parse(readFileSync(resolve(argv[0]), "utf8")) as unknown, + { + runId: requiredInteger(env.PUBLICATION_RUN_ID, "PUBLICATION_RUN_ID"), + runAttempt: requiredInteger(env.PUBLICATION_RUN_ATTEMPT, "PUBLICATION_RUN_ATTEMPT"), + headSha: env.PUBLICATION_HEAD_SHA ?? "", + }, + ); + validateDcodeBaseImageImports(contract.reference); + appendFileSync( + outputPath, + `base_ref=${contract.reference}\ncontract=${JSON.stringify(contract)}\n`, + "utf8", + ); +} + +if (resolve(process.argv[1] ?? "") === fileURLToPath(import.meta.url)) { + try { + main(); + } catch (error) { + console.error(error instanceof Error ? error.message : "unknown DCode base contract error"); + process.exitCode = 1; + } +} diff --git a/tools/e2e/operations-workflow-boundary.mts b/tools/e2e/operations-workflow-boundary.mts index c34415450ba..cd07899e74b 100644 --- a/tools/e2e/operations-workflow-boundary.mts +++ b/tools/e2e/operations-workflow-boundary.mts @@ -33,7 +33,7 @@ const PUBLICATION_CLASSIFIER_SCRIPT = " required=1", " ;;", " NVIDIA/NemoClaw:refs/heads/main:workflow_dispatch:controller)", - " required=0", + " required=1", " ;;", " *)", ' echo "::error::base-image publication mode is not trusted" >&2', @@ -70,6 +70,7 @@ type WorkflowJob = { if?: string; name?: unknown; needs?: unknown; + outputs?: Record; permissions?: WorkflowPermissions; "runs-on"?: unknown; steps?: WorkflowStep[]; @@ -434,6 +435,10 @@ export function validateBaseImagePublicationGate(workflow: OperationsWorkflow): const expectedJob = { "runs-on": "ubuntu-latest", "timeout-minutes": 55, + outputs: { + dcode_base_contract: "${{ steps.validate_dcode_base.outputs.contract }}", + dcode_base_ref: "${{ steps.validate_dcode_base.outputs.base_ref }}", + }, permissions: { actions: "read", contents: "read", @@ -470,6 +475,7 @@ export function validateBaseImagePublicationGate(workflow: OperationsWorkflow): }, }, { + id: "publication", name: "Verify applicable base-image publication", if: PUBLICATION_REQUIRED_CONDITION, env: { @@ -478,6 +484,29 @@ export function validateBaseImagePublicationGate(workflow: OperationsWorkflow): }, run: "node --experimental-strip-types --no-warnings tools/e2e/base-image-publication.mts --wait-seconds 3000 --poll-seconds 30", }, + { + name: "Download immutable Deep Agents Code base contract", + if: PUBLICATION_REQUIRED_CONDITION, + uses: DOWNLOAD_ARTIFACT_ACTION, + with: { + "github-token": "${{ github.token }}", + name: "managed-base-${{ steps.publication.outputs.run_id }}-${{ steps.publication.outputs.run_attempt }}-langchain-deepagents-code", + path: "${{ runner.temp }}/dcode-base-contract", + repository: "NVIDIA/NemoClaw", + "run-id": "${{ steps.publication.outputs.run_id }}", + }, + }, + { + id: "validate_dcode_base", + name: "Validate immutable Deep Agents Code base", + if: PUBLICATION_REQUIRED_CONDITION, + env: { + PUBLICATION_HEAD_SHA: "${{ steps.publication.outputs.head_sha }}", + PUBLICATION_RUN_ATTEMPT: "${{ steps.publication.outputs.run_attempt }}", + PUBLICATION_RUN_ID: "${{ steps.publication.outputs.run_id }}", + }, + run: 'node --experimental-strip-types --no-warnings tools/e2e/dcode-base-image-contract.mts "${RUNNER_TEMP}/dcode-base-contract/contract.json"', + }, ], }; @@ -489,6 +518,33 @@ export function validateBaseImagePublicationGate(workflow: OperationsWorkflow): if (!needs(workflow.jobs["generate-matrix"] ?? {}).includes("base-image-publication")) { errors.push("generate-matrix must wait for base-image-publication"); } + const matrixOutputs = workflow.jobs["generate-matrix"]?.outputs ?? {}; + if ( + matrixOutputs.dcode_base_contract !== + "${{ needs.base-image-publication.outputs.dcode_base_contract }}" || + matrixOutputs.dcode_base_ref !== "${{ needs.base-image-publication.outputs.dcode_base_ref }}" + ) { + errors.push("generate-matrix must preserve the immutable Deep Agents Code base outputs"); + } + const live = workflow.jobs.live ?? {}; + if ( + live.env?.NEMOCLAW_LANGCHAIN_DEEPAGENTS_CODE_SANDBOX_BASE_IMAGE_REF !== + "${{ needs.generate-matrix.outputs.dcode_base_ref }}" + ) { + errors.push("live DCode must use the selected immutable base reference"); + } + const evidence = findStep(live, "Record immutable Deep Agents Code base evidence"); + const upload = findStep(live, "Upload E2E artifacts"); + const liveSteps = live.steps ?? []; + if ( + evidence.if !== "${{ matrix.id == 'ubuntu-repo-cloud-langchain-deepagents-code' }}" || + evidence.env?.BASE_CONTRACT !== "${{ needs.generate-matrix.outputs.dcode_base_contract }}" || + !String(evidence.run ?? "").includes("dcode-base-image.json") || + liveSteps.indexOf(evidence) >= liveSteps.indexOf(findStep(live, "Run live E2E tests")) || + !String(upload.with?.path ?? "").includes("dcode-base-image.json") + ) { + errors.push("live DCode must record its immutable base contract before E2E execution"); + } return errors; }