diff --git a/.github/workflows/e2e.yaml b/.github/workflows/e2e.yaml index dbca2faa476..9e42d7b756b 100644 --- a/.github/workflows/e2e.yaml +++ b/.github/workflows/e2e.yaml @@ -172,6 +172,10 @@ jobs: generate-matrix: runs-on: ubuntu-latest timeout-minutes: 10 + permissions: + actions: read + contents: read + pull-requests: read outputs: cli_artifact_provenance: ${{ steps.record_cli_artifact.outputs.provenance }} e2e_credentials_allowed: ${{ steps.e2e_credentials.outputs.allowed }} @@ -536,6 +540,16 @@ jobs: fi fi + - name: Resolve exact PR managed-image catalog + if: ${{ inputs.checkout_sha != '' && (inputs.jobs != 'native-runtime-qualification-producer' || inputs.targets != '') }} + env: + BASE_SHA: ${{ inputs.base_sha }} + CANDIDATE_REPOSITORY: ${{ inputs.checkout_repository }} + CANDIDATE_SHA: ${{ inputs.checkout_sha }} + GITHUB_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ inputs.pr_number }} + run: node --experimental-strip-types --no-warnings tools/e2e/pr-managed-image-publication.mts "${RUNNER_TEMP}/pr-managed-image-catalog.json" + - name: Check out E2E candidate uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 if: ${{ inputs.checkout_sha == '' || inputs.jobs != 'native-runtime-qualification-producer' || inputs.targets != '' }} @@ -652,6 +666,14 @@ jobs: ' dist/build-identity.json >/dev/null || { echo "::error::candidate CLI build identity does not match the candidate commit SHA"; exit 1; } + managed_catalog="${RUNNER_TEMP}/pr-managed-image-catalog.json" + rm -f -- dist/e2e-managed-image-catalog.json + if [[ -e "$managed_catalog" ]]; then + [[ -f "$managed_catalog" && ! -L "$managed_catalog" && -s "$managed_catalog" ]] || + { echo "::error::trusted PR managed-image catalog is not a nonempty regular file"; exit 1; } + install -m 0600 "$managed_catalog" dist/e2e-managed-image-catalog.json + fi + artifact_dir="${RUNNER_TEMP}/nemoclaw-cli-artifact" install -d -m 0700 "$artifact_dir" payload="$artifact_dir/nemoclaw-cli.tar" diff --git a/.github/workflows/managed-images.yaml b/.github/workflows/managed-images.yaml index 332f1903482..98c4b486ca5 100644 --- a/.github/workflows/managed-images.yaml +++ b/.github/workflows/managed-images.yaml @@ -863,31 +863,18 @@ jobs: shell: bash run: | set -euo pipefail + npm ci --ignore-scripts mapfile -d '' contracts < <( find "$RUNNER_TEMP/managed-pr-contracts" -type f -name contract.json -print0 ) - [[ "${#contracts[@]}" -eq 3 ]] || { - echo "ERROR: exact all-agent activation requires three contracts" >&2 - exit 1 - } - jq -se \ - --arg revision "$CANDIDATE_SHA" ' - if ( - length == 3 and - ([.[].agent] | sort) == ["hermes", "langchain-deepagents-code", "openclaw"] and - ([.[].source.revision] | unique) == [$revision] and - ([.[].source.cohort] | unique | length) == 1 and - ([.[].platform] | unique) == ["linux/amd64"] - ) - then map({key: .agent, value: .}) | from_entries - else error("published PR contracts do not form one exact all-agent cohort") - end - ' "${contracts[@]}" > "$NEMOCLAW_MANAGED_ACTIVATION_CATALOG" + node --experimental-strip-types --no-warnings \ + tools/e2e/pr-managed-image-publication.mts assemble \ + "$CANDIDATE_SHA" \ + "$NEMOCLAW_MANAGED_ACTIVATION_CATALOG" \ + "${contracts[@]}" - name: Build exact candidate CLI - run: | - npm ci --ignore-scripts - npm run build:cli + run: npm run build:cli - name: Install OpenShell CLI shell: bash @@ -904,7 +891,7 @@ jobs: export PATH="$HOME/.local/bin:$HOME/.npm-global/bin:$PATH" export OPENSHELL_BIN="$(command -v openshell)" "$OPENSHELL_BIN" --version - npx tsx tools/e2e/live-vitest-invocation.mts run \ + npx --no-install tsx tools/e2e/live-vitest-invocation.mts run \ --test-path test/e2e/live/managed-image-activation-e2e.test.ts - name: Upload managed runtime activation evidence @@ -916,6 +903,134 @@ jobs: if-no-files-found: error retention-days: 1 + pr-openclaw-mcp-discovery: + name: PR exact OpenClaw trusted-private MCP discovery (pass ${{ matrix.pass }}) + needs: pr-build-and-entrypoint + if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-24.04 + timeout-minutes: 60 + permissions: + contents: read + strategy: + fail-fast: false + matrix: + pass: [1, 2] + env: + CANDIDATE_SHA: ${{ github.event.pull_request.head.sha }} + E2E_ARTIFACT_DIR: ${{ github.workspace }}/e2e-artifacts/live/pr-exact-mcp/pass-${{ matrix.pass }} + E2E_JOB: "1" + E2E_TARGET_ID: mcp-bridge + NEMOCLAW_CLI_BIN: ${{ github.workspace }}/bin/nemoclaw.js + NEMOCLAW_E2E_EXPECTED_SHA: ${{ github.event.pull_request.head.sha }} + NEMOCLAW_E2E_MANAGED_IMAGE_CATALOG: ${{ github.workspace }}/managed-pr-catalog.json + NEMOCLAW_E2E_REQUIRE_EXECUTED_TEST: "1" + NEMOCLAW_E2E_SHARD: openclaw + NEMOCLAW_MCP_BRIDGE_AGENT: openclaw + NEMOCLAW_NON_INTERACTIVE: "1" + NEMOCLAW_OPENSHELL_EXACT_MAIN_PROOF: "1" + NEMOCLAW_RUN_LIVE_E2E: "1" + NEMOCLAW_SANDBOX_NAME: e2e-pr-exact-mcp-${{ matrix.pass }} + OPENSHELL_DOCKER_SUPERVISOR_IMAGE: ghcr.io/nvidia/openshell/supervisor@sha256:b58be5e40c788977ffa0e8305a8cad9c656efdf1a3fe182582a00ca870bb0edb + steps: + - name: Checkout exact PR head + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.pull_request.head.sha }} + persist-credentials: false + + - name: Set up Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 22.19.0 + + - name: Bind E2E correlation identity + shell: bash + run: | + set -euo pipefail + correlation_id="$(node --input-type=module -e \ + 'import { randomUUID } from "node:crypto"; console.log(randomUUID())')" + [[ "$correlation_id" =~ ^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$ ]] + printf 'NEMOCLAW_E2E_CORRELATION_ID=%s\n' "$correlation_id" >> "$GITHUB_ENV" + + - name: Download exact published all-agent contracts + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: managed-pr-contract-${{ github.run_id }}-${{ github.run_attempt }}-* + path: ${{ runner.temp }}/managed-pr-contracts + merge-multiple: false + + - name: Assemble exact all-agent MCP catalog + shell: bash + run: | + set -euo pipefail + npm ci --ignore-scripts + mapfile -d '' contracts < <( + find "$RUNNER_TEMP/managed-pr-contracts" -type f -name contract.json -print0 + ) + node --experimental-strip-types --no-warnings \ + tools/e2e/pr-managed-image-publication.mts assemble \ + "$CANDIDATE_SHA" \ + "$NEMOCLAW_E2E_MANAGED_IMAGE_CATALOG" \ + "${contracts[@]}" + + - name: Build exact candidate CLI + run: npm run build:cli + + - name: Install and verify cloudflared prerequisite + env: + CLOUDFLARED_VERSION: "2026.6.1" + CLOUDFLARED_DEB_SHA256: "ccd02ec216c62bfa573395d8f72cb2e91e95cbdf8726a8acc06b3e2d9aa31526" + run: | + set -euo pipefail + cloudflared_deb="${RUNNER_TEMP}/cloudflared-${CLOUDFLARED_VERSION}-linux-amd64.deb" + curl -fL "https://github.com/cloudflare/cloudflared/releases/download/${CLOUDFLARED_VERSION}/cloudflared-linux-amd64.deb" -o "${cloudflared_deb}" + printf '%s %s\n' "${CLOUDFLARED_DEB_SHA256}" "${cloudflared_deb}" | sha256sum -c - + package="$(dpkg-deb -f "${cloudflared_deb}" Package)" + version="$(dpkg-deb -f "${cloudflared_deb}" Version)" + architecture="$(dpkg-deb -f "${cloudflared_deb}" Architecture)" + if [[ "${package}" != "cloudflared" || "${version}" != "${CLOUDFLARED_VERSION}" || "${architecture}" != "amd64" ]]; then + printf 'Unexpected cloudflared package metadata: package=%s version=%s architecture=%s\n' "${package}" "${version}" "${architecture}" >&2 + exit 1 + fi + sudo dpkg -i "${cloudflared_deb}" + cloudflared --version | grep -F "cloudflared version ${CLOUDFLARED_VERSION}" + + - name: Generate MCP test TLS + run: bash test/e2e/setup-mcp-test-tls.sh + + - name: Install OpenShell CLI + env: + NEMOCLAW_OPENSHELL_FORCE_INSTALL: "1" + run: bash scripts/install-openshell.sh + + - name: Run exact OpenClaw trusted-private MCP discovery + shell: bash + run: | + set -euo pipefail + [[ "$(git rev-parse --verify HEAD)" == "$CANDIDATE_SHA" ]] || { + echo "ERROR: MCP discovery checkout does not match the exact PR head" >&2 + exit 1 + } + export PATH="$HOME/.local/bin:$HOME/.npm-global/bin:$PATH" + export OPENSHELL_BIN="$(command -v openshell)" + "$OPENSHELL_BIN" --version + npx --no-install tsx tools/e2e/live-vitest-invocation.mts run \ + --test-path test/e2e/live/mcp-bridge.test.ts + + - id: mcp_artifact_secret_scan + name: Scan MCP artifacts for fixture credentials + if: always() + run: npx --no-install tsx tools/e2e/assert-mcp-artifact-secrets-absent.mts "$E2E_ARTIFACT_DIR" + + - name: Upload exact MCP discovery evidence + if: ${{ always() && steps.mcp_artifact_secret_scan.outcome == 'success' }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: pr-exact-openclaw-mcp-${{ github.run_id }}-${{ github.run_attempt }}-pass-${{ matrix.pass }} + path: e2e-artifacts/live/pr-exact-mcp/pass-${{ matrix.pass }}/ + if-no-files-found: error + retention-days: 1 + pi-candidate: name: Build and validate the Pi candidate managed image (${{ matrix.arch }}) if: github.repository == 'NVIDIA/NemoClaw' && github.event_name == 'pull_request' diff --git a/src/lib/onboard/managed-workload/onboard-orchestration.test.ts b/src/lib/onboard/managed-workload/onboard-orchestration.test.ts index aaa5f0dbb8f..182a325b228 100644 --- a/src/lib/onboard/managed-workload/onboard-orchestration.test.ts +++ b/src/lib/onboard/managed-workload/onboard-orchestration.test.ts @@ -1,6 +1,10 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + import { describe, expect, it, vi } from "vitest"; import { createHermesStateVolumeDockerHarness } from "../__test-helpers__/hermes-state-volume"; @@ -104,6 +108,31 @@ describe("managed workload onboard orchestration", () => { ); }); + it("binds fresh onboarding to the exact PR catalog (#9464)", async () => { + const catalogRevision = "b".repeat(40); + const fixtureRoot = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-live-e2e-catalog-")); + const catalogPath = path.join(fixtureRoot, "catalog.json"); + fs.writeFileSync(catalogPath, "{}\n", { mode: 0o600 }); + try { + const { prepared, runtime } = createFreshOnboardingRuntime({ + GITHUB_ACTIONS: "true", + NEMOCLAW_RUN_LIVE_E2E: "1", + NEMOCLAW_E2E_EXPECTED_SHA: catalogRevision, + NEMOCLAW_E2E_MANAGED_IMAGE_CATALOG: catalogPath, + }); + + await expect(runtime.ensurePreparedWorkload()).resolves.toBe(prepared); + expect(prepareSandboxWorkloadSource).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ + catalogPath, + expectedCatalogRevision: catalogRevision, + }), + ); + } finally { + fs.rmSync(fixtureRoot, { force: true, recursive: true }); + } + }); + it("omits the qualification catalog revision outside GitHub Actions (#9385)", async () => { const { prepared, runtime } = createFreshOnboardingRuntime({ E2E_MANAGED_IMAGE_REVISION: "a".repeat(40), diff --git a/src/lib/onboard/managed-workload/onboard-orchestration.ts b/src/lib/onboard/managed-workload/onboard-orchestration.ts index f8a34470fec..51c234a7f13 100644 --- a/src/lib/onboard/managed-workload/onboard-orchestration.ts +++ b/src/lib/onboard/managed-workload/onboard-orchestration.ts @@ -51,6 +51,7 @@ import { import { getSandboxReadyTimeoutSecs } from "../sandbox-gpu-create"; import type { SandboxGpuConfig } from "../sandbox-gpu-mode"; import { + liveE2eManagedImageCatalog, liveE2eManagedImageRevision, type PreparedSandboxWorkloadSource, prepareSandboxWorkloadSource, @@ -196,6 +197,10 @@ export function createManagedWorkloadOnboardRuntime( const ensurePreparedWorkload = async (): Promise => { const catalogRevision = liveE2eManagedImageRevision(input.startupProfile.environment); + const liveCatalog = liveE2eManagedImageCatalog(input.startupProfile.environment); + if (catalogRevision && liveCatalog) { + throw new Error("live E2E managed-image revision and catalog authority conflict"); + } preparedWorkloadPromise ??= input.managedWorkloadRebuild ? Promise.resolve( prepareSandboxWorkloadSourceFromRebuildHandoff( @@ -210,7 +215,8 @@ export function createManagedWorkloadOnboardRuntime( customDockerfilePath: input.customDockerfilePath, runtime: runtimeCapabilities, version: getVersion({ rootDir: input.rootDir }), - catalogPath: input.tempManagedRuntimeCatalog, + catalogPath: input.tempManagedRuntimeCatalog ?? liveCatalog?.path ?? null, + ...(liveCatalog ? { expectedCatalogRevision: liveCatalog.revision } : {}), ...(catalogRevision ? { catalogRevision } : {}), acceptedCandidateContract: isCandidateAgent(input.agentName) ? readCandidateQualificationReceipt(input.agentName) diff --git a/src/lib/onboard/sandbox-workload-preparation.test.ts b/src/lib/onboard/sandbox-workload-preparation.test.ts index 0dbbcec7172..8b4e420cbce 100644 --- a/src/lib/onboard/sandbox-workload-preparation.test.ts +++ b/src/lib/onboard/sandbox-workload-preparation.test.ts @@ -25,6 +25,7 @@ import { } from "./managed-image/contract"; import { createRuntimeProviderBundleRegistry } from "./runtime-provider/registry"; import { + liveE2eManagedImageCatalog, prepareSandboxWorkloadSource, SandboxWorkloadPreparationError, } from "./workload/preparation"; @@ -85,6 +86,67 @@ function input(agentName: string) { } describe("sandbox workload preparation", () => { + it("selects an exact embedded catalog only for live PR E2E (#9464)", () => { + const fixtureRoot = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-live-e2e-catalog-")); + const catalogPath = path.join(fixtureRoot, "catalog.json"); + const packagedCatalogPath = path.join(fixtureRoot, "dist", "e2e-managed-image-catalog.json"); + fs.mkdirSync(path.dirname(packagedCatalogPath)); + fs.writeFileSync(catalogPath, "{}\n", { mode: 0o600 }); + fs.writeFileSync(packagedCatalogPath, "{}\n", { mode: 0o600 }); + try { + expect( + liveE2eManagedImageCatalog({ + GITHUB_ACTIONS: "true", + NEMOCLAW_RUN_LIVE_E2E: "1", + NEMOCLAW_E2E_EXPECTED_SHA: REVISION, + NEMOCLAW_E2E_MANAGED_IMAGE_CATALOG: catalogPath, + }), + ).toEqual({ path: catalogPath, revision: REVISION }); + expect( + liveE2eManagedImageCatalog({ + GITHUB_ACTIONS: "true", + GITHUB_WORKSPACE: fixtureRoot, + NEMOCLAW_RUN_LIVE_E2E: "1", + NEMOCLAW_E2E_EXPECTED_SHA: REVISION, + }), + ).toEqual({ path: packagedCatalogPath, revision: REVISION }); + expect( + liveE2eManagedImageCatalog({ + NEMOCLAW_RUN_LIVE_E2E: "1", + NEMOCLAW_E2E_EXPECTED_SHA: REVISION, + NEMOCLAW_E2E_MANAGED_IMAGE_CATALOG: catalogPath, + }), + ).toBeNull(); + expect( + liveE2eManagedImageCatalog({ + GITHUB_ACTIONS: "true", + NEMOCLAW_RUN_LIVE_E2E: "1", + NEMOCLAW_E2E_EXPECTED_SHA: REVISION, + NEMOCLAW_E2E_MANAGED_IMAGE_CATALOG: path.join(fixtureRoot, "missing.json"), + }), + ).toBeNull(); + } finally { + fs.rmSync(fixtureRoot, { force: true, recursive: true }); + } + }); + + it("rejects an embedded catalog without an exact candidate revision (#9464)", () => { + const fixtureRoot = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-live-e2e-catalog-")); + const catalogPath = path.join(fixtureRoot, "catalog.json"); + fs.writeFileSync(catalogPath, "{}\n", { mode: 0o600 }); + try { + expect(() => + liveE2eManagedImageCatalog({ + GITHUB_ACTIONS: "true", + NEMOCLAW_RUN_LIVE_E2E: "1", + NEMOCLAW_E2E_MANAGED_IMAGE_CATALOG: catalogPath, + }), + ).toThrow("requires an exact candidate revision"); + } finally { + fs.rmSync(fixtureRoot, { force: true, recursive: true }); + } + }); + it.each( SHIPPED_MANAGED_IMAGE_AGENTS, )("resolves the complete release catalog and exact %s image (#7744)", async (agent) => { @@ -122,6 +184,23 @@ describe("sandbox workload preparation", () => { }); }); + it("rejects an exact catalog from another PR commit (#9464)", async () => { + const fixtureRoot = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-managed-catalog-")); + const catalogPath = path.join(fixtureRoot, "catalog.json"); + fs.writeFileSync(catalogPath, JSON.stringify(CATALOG), { mode: 0o600 }); + try { + await expect( + prepareSandboxWorkloadSource({ + ...input("openclaw"), + catalogPath, + expectedCatalogRevision: "b".repeat(40), + }), + ).rejects.toThrow("does not match the live E2E candidate revision"); + } finally { + fs.rmSync(fixtureRoot, { force: true, recursive: true }); + } + }); + it("loads an exact local all-agent catalog without using the registry resolver (#7744)", async () => { const fixtureRoot = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-managed-catalog-")); const catalogPath = path.join(fixtureRoot, "catalog.json"); diff --git a/src/lib/onboard/sandbox-workload-rebuild.test.ts b/src/lib/onboard/sandbox-workload-rebuild.test.ts index adfd82cb631..e270034fbee 100644 --- a/src/lib/onboard/sandbox-workload-rebuild.test.ts +++ b/src/lib/onboard/sandbox-workload-rebuild.test.ts @@ -2,6 +2,9 @@ // 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 { afterEach, describe, expect, it, vi } from "vitest"; @@ -331,6 +334,38 @@ describe("managed workload rebuild preflight", () => { }); }); + it("retains the exact PR catalog during rebuild preflight (#9464)", async () => { + const prepare = vi.fn(async () => replacement("openclaw")); + const fixtureRoot = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-live-e2e-catalog-")); + const catalogPath = path.join(fixtureRoot, "catalog.json"); + fs.writeFileSync(catalogPath, "{}\n", { mode: 0o600 }); + managedWorkloadRebuildDependencies.prepareSandboxWorkloadSource = prepare; + vi.stubEnv("GITHUB_ACTIONS", "true"); + vi.stubEnv("NEMOCLAW_RUN_LIVE_E2E", "1"); + vi.stubEnv("NEMOCLAW_E2E_EXPECTED_SHA", "a".repeat(40)); + vi.stubEnv("NEMOCLAW_E2E_MANAGED_IMAGE_CATALOG", catalogPath); + + try { + await prepareManagedWorkloadRebuildHandoff(entry("openclaw"), { + runtime: runtime(), + provider: provider(), + version: "0.0.100", + }); + + expect(prepare).toHaveBeenCalledExactlyOnceWith({ + agentName: "openclaw", + legacyDockerfilePath: "managed-rebuild-must-not-stage-this-dockerfile", + runtime: runtime(), + version: "0.0.100", + policy: "require-managed", + catalogPath, + expectedCatalogRevision: "a".repeat(40), + }); + } finally { + fs.rmSync(fixtureRoot, { force: true, recursive: true }); + } + }); + it("rejects a qualification revision that conflicts with durable authority (#9385)", async () => { const prepare = vi.fn(async () => replacement("langchain-deepagents-code")); managedWorkloadRebuildDependencies.prepareSandboxWorkloadSource = prepare; @@ -347,6 +382,31 @@ describe("managed workload rebuild preflight", () => { expect(prepare).not.toHaveBeenCalled(); }); + it("rejects a PR catalog revision that conflicts with durable authority (#9464)", async () => { + const prepare = vi.fn(async () => replacement("openclaw")); + const fixtureRoot = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-live-e2e-catalog-")); + const catalogPath = path.join(fixtureRoot, "catalog.json"); + fs.writeFileSync(catalogPath, "{}\n", { mode: 0o600 }); + managedWorkloadRebuildDependencies.prepareSandboxWorkloadSource = prepare; + vi.stubEnv("GITHUB_ACTIONS", "true"); + vi.stubEnv("NEMOCLAW_RUN_LIVE_E2E", "1"); + vi.stubEnv("NEMOCLAW_E2E_EXPECTED_SHA", "c".repeat(40)); + vi.stubEnv("NEMOCLAW_E2E_MANAGED_IMAGE_CATALOG", catalogPath); + + try { + await expect( + prepareManagedWorkloadRebuildHandoff(entry("openclaw"), { + runtime: runtime(), + provider: provider(), + version: "0.0.100", + }), + ).rejects.toThrow("live qualification revision does not match the durable workload receipt"); + expect(prepare).not.toHaveBeenCalled(); + } finally { + fs.rmSync(fixtureRoot, { force: true, recursive: true }); + } + }); + it("keeps release-catalog rebuild behavior outside GitHub Actions (#9385)", async () => { const prepare = vi.fn(async () => replacement("openclaw")); managedWorkloadRebuildDependencies.prepareSandboxWorkloadSource = prepare; diff --git a/src/lib/onboard/workload/preparation.ts b/src/lib/onboard/workload/preparation.ts index 010655ea8e5..c35dfc4f9e7 100644 --- a/src/lib/onboard/workload/preparation.ts +++ b/src/lib/onboard/workload/preparation.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import fs from "node:fs"; +import path from "node:path"; import { isDeepStrictEqual } from "node:util"; import { @@ -42,6 +43,7 @@ export interface PrepareSandboxWorkloadSourceInput { readonly version: string; readonly policy?: ManagedImageSelectionPolicy; readonly catalogPath?: string | null; + readonly expectedCatalogRevision?: string | null; readonly catalogRevision?: string | null; /** Contract from the repository-accepted candidate qualification receipt. */ readonly acceptedCandidateContract?: ManagedImageContractV1 | null; @@ -53,6 +55,42 @@ export function liveE2eManagedImageRevision(environment: NodeJS.ProcessEnv): str return revision ? revision : null; } +export interface LiveE2eManagedImageCatalog { + readonly path: string; + readonly revision: string; +} + +/** Select the trusted PR catalog only for an exact live E2E candidate. */ +export function liveE2eManagedImageCatalog( + environment: NodeJS.ProcessEnv, +): LiveE2eManagedImageCatalog | null { + if (environment.GITHUB_ACTIONS !== "true" || environment.NEMOCLAW_RUN_LIVE_E2E !== "1") { + return null; + } + const configuredPath = environment.NEMOCLAW_E2E_MANAGED_IMAGE_CATALOG?.trim(); + const workspace = environment.GITHUB_WORKSPACE?.trim(); + const catalogPath = + configuredPath || + (workspace ? path.join(workspace, "dist", "e2e-managed-image-catalog.json") : ""); + if (!catalogPath) return null; + try { + fs.lstatSync(catalogPath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; + throw new SandboxWorkloadPreparationError( + "the live E2E managed-image catalog path could not be inspected", + { cause: error }, + ); + } + const revision = environment.NEMOCLAW_E2E_EXPECTED_SHA?.trim() ?? ""; + if (!/^[0-9a-f]{40}$/u.test(revision)) { + throw new SandboxWorkloadPreparationError( + "the live E2E managed-image catalog requires an exact candidate revision", + ); + } + return { path: catalogPath, revision }; +} + function readExactManagedImageCatalog(catalogPath: string): ManagedImageContractCatalog { let descriptor: number | null = null; try { @@ -146,7 +184,7 @@ function requireCompleteManagedImageCatalog( catalog: ManagedImageContractCatalog, expectedRelease: string, expectedPlatform: ManagedImagePlatform, -): void { +): string { let cohortRevision: string | null = null; let publicationCohort: string | null = null; for (const agent of SHIPPED_MANAGED_IMAGE_AGENTS) { @@ -183,6 +221,7 @@ function requireCompleteManagedImageCatalog( ); } } + return cohortRevision!; } function requireCandidateManagedImageCatalog( @@ -313,7 +352,12 @@ export async function prepareSandboxWorkloadSource( acceptedCandidateContract, ); } else { - requireCompleteManagedImageCatalog(catalog, release, platform); + const catalogRevision = requireCompleteManagedImageCatalog(catalog, release, platform); + if (input.expectedCatalogRevision && catalogRevision !== input.expectedCatalogRevision) { + throw new SandboxWorkloadPreparationError( + "managed image catalog source revision does not match the live E2E candidate revision", + ); + } } return { diff --git a/src/lib/onboard/workload/rebuild.ts b/src/lib/onboard/workload/rebuild.ts index 3efb32f8fe7..03a6e3a69f2 100644 --- a/src/lib/onboard/workload/rebuild.ts +++ b/src/lib/onboard/workload/rebuild.ts @@ -36,6 +36,7 @@ import { export type { ManagedWorkloadReceipt } from "./authority"; import { + liveE2eManagedImageCatalog, liveE2eManagedImageRevision, type PreparedSandboxWorkloadSource, prepareSandboxWorkloadSource, @@ -174,9 +175,16 @@ export async function prepareManagedWorkloadRebuildHandoff( } } else { const qualificationRevision = liveE2eManagedImageRevision(process.env); + const liveCatalog = liveE2eManagedImageCatalog(process.env); + if (qualificationRevision && liveCatalog) { + throw new ManagedWorkloadRebuildError( + "live E2E managed-image revision and catalog authority conflict", + ); + } + const qualificationSourceRevision = liveCatalog?.revision ?? qualificationRevision; if ( - qualificationRevision !== null && - qualificationRevision !== authority.receipt.sourceRevision + qualificationSourceRevision !== null && + qualificationSourceRevision !== authority.receipt.sourceRevision ) { throw new ManagedWorkloadRebuildError( "the live qualification revision does not match the durable workload receipt", @@ -189,9 +197,13 @@ export async function prepareManagedWorkloadRebuildHandoff( runtime: options.runtime, version: options.version ?? getVersion(), policy: "require-managed", - ...(qualificationRevision - ? { catalogRevision: authority.receipt.sourceRevision } + ...(liveCatalog + ? { + catalogPath: liveCatalog.path, + expectedCatalogRevision: liveCatalog.revision, + } : {}), + ...(qualificationRevision ? { catalogRevision: authority.receipt.sourceRevision } : {}), }); } catch (error) { throw new ManagedWorkloadRebuildError( diff --git a/test/e2e/README.md b/test/e2e/README.md index 080b7ffe833..07be3a60283 100644 --- a/test/e2e/README.md +++ b/test/e2e/README.md @@ -86,6 +86,23 @@ After the checks pass, the action restores root `dist/` and `nemoclaw/dist/share If the version command fails, the action stops before the live test runs. This boundary keeps candidate source separate from the trusted workflow implementation. +For a same-repository PR that changes a managed-image workflow path, the trusted planner also +requires one successful `Images / Managed Images` run for the candidate commit. Before candidate +checkout, the planner downloads the three nonexpired contract artifacts by immutable artifact ID. +It verifies each artifact digest, producer run, attempt, and candidate commit. The planner rejects a +missing, incomplete, or mixed all-agent publication before E2E jobs start. + +The planner adds the exact all-agent catalog to `dist/` after the candidate CLI build completes. +Each live E2E consumer verifies that the catalog source revision matches `checkout_sha`. A PR that +does not change a managed-image workflow path keeps the released catalog behavior. The GitHub token +is available only to the trusted planner job and is not included in the candidate CLI artifact. + +The same-repository `Images / Managed Images` PR workflow also runs the complete OpenClaw +`mcp-bridge` shard in two independent matrix jobs. Each job assembles one exact candidate catalog +from the workflow's published contracts, uses a fresh runner and sandbox, records the existing +trusted-private discovery diagnostics, scans the evidence for fixture credentials, and must pass. +These are two required acceptance executions, not retries; either failure remains a failed check. + #### Timing Baseline The pre-change baseline uses GitHub Actions `Build CLI` step timings from these workflow runs: diff --git a/test/e2e/RETRY_INVENTORY.md b/test/e2e/RETRY_INVENTORY.md index 7b8516370d5..b7219b1cf53 100644 --- a/test/e2e/RETRY_INVENTORY.md +++ b/test/e2e/RETRY_INVENTORY.md @@ -18,7 +18,8 @@ Exhaustion remains failed. | `pr-rerun-reconciliation` | PR E2E dispatch reconciliation; `tools/e2e/pr-e2e-dispatch-reconciliation.mts`, `tools/e2e/pr-e2e-retry-receipt.mts` | Trusted dispatch receipt state | Contract-defined single reconciliation | Reconciles workflow and commit identity before action | GitHub Actions | Receipt-specific terminal states | Signed workflow identity and receipt | External scope; governed by #7206 | | `github-publication-read` | GitHub API reads; `tools/e2e/base-image-publication.mts` | Fetch error, 408, rate limit, or 5xx | 3 attempts; Retry-After/rate-limit reset or linear delay capped at 10s | Read-only | GitHub API | Returned parsed selection on success; thrown terminal HTTP/fetch error on failure or exhaustion | Caller artifact records the returned publication selection; terminal errors identify exhausted fetch or HTTP status without response content | Eligible bounded read; existing implementation retained | | `trusted-controller-collaborator-permission-read` | Collaborator-permission reads for manual PR dispatch and Launchable E2E dispatch; `.github/workflows/e2e.yaml` | Curl exit 5, 6, 7, 16, 18, 28, 35, 52, 55, 56, 92, 95, or 96; HTTP 408, 429, or 5xx | 3 attempts; linear 1s then 2s | Read-only GitHub API request | GitHub API | Transient API read versus terminal authentication, authorization, actor, or response failure | Operation name, attempt number, and sanitized failure class or HTTP status; no response body, header, or token | Eligible bounded read; HTTP 401, 403, 404, and 422, malformed responses, actor failures, and insufficient roles remain terminal; no cached permission or workflow rerun | -| `github-exact-artifact-content-read` | Bound Deep Agents Code contract artifact; `tools/e2e/exact-artifact-download.mts` | Transport failure, HTTP 408, HTTP 429, or HTTP 5xx while reading one pre-bound artifact ID | 3 attempts; Retry-After or linear delay capped at 10s | Read-only request against one immutable artifact ID, name, size, digest, producer run, attempt, and head | GitHub artifact service | `passed-first-attempt`, `passed-after-retry`, `exhausted` for transient exhaustion, or `failed-no-retry` for terminal HTTP; identity, size, digest, archive, and contract failures throw without an aggregate outcome or `failureClass` | Content-read attempts log only the sanitized operation, attempt, HTTP status or transport class, and outcome; thrown validation failures expose only their bounded error message, never headers, body, token, signed URL, or artifact content | Standalone bounded content read; it does not use `retry-policy.ts` or `RetryEvidence`, and all identity, integrity, archive, and contract failures remain terminal | +| `pr-exact-openclaw-mcp-repetition` | Complete OpenClaw trusted-private MCP bridge acceptance; `.github/workflows/managed-images.yaml`, `test/e2e/live/mcp-bridge.test.ts` | Either independent matrix execution fails | 2 required executions on fresh runners; 0 workflow or test retries | Each execution creates and cleans up its own sandbox against the same exact candidate publication cohort | NemoClaw | Each execution passes or fails independently; both must pass | Existing redacted MCP diagnostics, request ledger, cleanup evidence, and fixture-credential scan for each matrix pass | Fixed acceptance repetition required by #8746; not a retry, and one pass never masks the other | +| `github-exact-artifact-content-read` | Bound base-image or PR managed-image contract artifact; `tools/e2e/exact-artifact-download.mts`, `tools/e2e/pr-managed-image-publication.mts` | Transport failure, HTTP 408, HTTP 429, or HTTP 5xx while reading one pre-bound artifact ID | 3 attempts; Retry-After or linear delay capped at 10s | Read-only request against one immutable artifact ID, name, size, digest, producer run, attempt, and producer commit | GitHub artifact service | `passed-first-attempt`, `passed-after-retry`, `exhausted` for transient exhaustion, or `failed-no-retry` for terminal HTTP; identity, size, digest, archive, and contract failures throw without an aggregate outcome or `failureClass` | Content-read attempts log only the sanitized operation, attempt, HTTP status or transport class, and outcome; thrown validation failures expose only their bounded error message, never headers, body, token, signed URL, or artifact content | Standalone bounded content read; it does not use `retry-policy.ts` or `RetryEvidence`, and all identity, integrity, archive, and contract failures remain terminal | | `inference-set-route-convergence` | Sandbox inference probe after one OpenShell route selection; `src/lib/actions/inference-set-provider.ts`, `src/lib/actions/inference-set.ts` | HTTP 400 or 404 only when the selected API family changes; authentication, authorization, unsafe or malformed input, every other HTTP status, transport failure, and probe failure are terminal | 3 probes; 2s then 4s | Each retry repeats only the read-only sandbox inference probe after one route mutation | OpenShell route cache | Converged, terminal failure, or exhausted rollback | Retry progress records only HTTP status, attempt number, and delay; the final command error stays redacted, and focused tests assert the exact attempt count and rollback | The 6s final-probe window covers one full 5s OpenShell 0.0.101 cache-refresh interval; exhaustion restores the prior route, removes the uncommitted provider, and remains failed | | `inference-switch-ts` | Verified inference route update; `test/e2e/fixtures/inference-switch-retry.ts` | Timeout, reset, DNS/connectivity/connect error, request transport error, or exact 502/503/504 status; authentication, authorization, policy, malformed-input, and invalid-request signals take precedence | 1-10 attempts; linear 5s | Setting the same desired provider/model is idempotent | Inference provider | Shared `RetryEvidence` classifications | Every attempt classification and aggregate outcome; command artifacts remain separate and redacted | Uses `runBoundedRetry`; deterministic verification mismatches stop; no `--no-verify` exhaustion bypass | | `inference-switch-shell` | Verified shell inference route update; `test/e2e/lib/inference-switch-retry.sh` | Same bounded transient and terminal-precedence signatures as the TypeScript helper | 1-10 attempts; linear 5s | Setting the same desired provider/model is idempotent | Inference provider | Exit status remains failed on exhaustion | Existing command output and retry progress | Bounded compatibility helper; no `--no-verify` exhaustion bypass | diff --git a/test/e2e/support/exact-artifact-download.test.ts b/test/e2e/support/exact-artifact-download.test.ts index c21b127a501..c6d83782481 100644 --- a/test/e2e/support/exact-artifact-download.test.ts +++ b/test/e2e/support/exact-artifact-download.test.ts @@ -10,6 +10,7 @@ import { describe, expect, it, vi } from "vitest"; import { bindExactArtifact, + bindNamedExactArtifact, downloadBoundArtifact, exactArtifactName, materializeContractArchive, @@ -80,6 +81,18 @@ describe("exact artifact download (#9340)", () => { }); }); + it("binds another exact contract name without weakening artifact identity", () => { + const bytes = archive(); + const name = "managed-pr-contract-7001-2-openclaw"; + const value = metadata(bytes, { name }); + + expect(bindNamedExactArtifact(value, EXPECTED, name)).toMatchObject({ + ...EXPECTED, + id: 9001, + name, + }); + }); + it.each([ ["expired", { expired: true }, "non-expired"], ["artifact id URL", { id: 9002 }, "archive URL does not match artifact id"], diff --git a/test/e2e/support/pr-managed-image-publication.test.ts b/test/e2e/support/pr-managed-image-publication.test.ts new file mode 100644 index 00000000000..ebc4cfebed9 --- /dev/null +++ b/test/e2e/support/pr-managed-image-publication.test.ts @@ -0,0 +1,195 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { + MANAGED_IMAGE_CAPABILITY_CONTRACT_VERSION, + MANAGED_IMAGE_CONTRACT_VERSION, + MANAGED_IMAGE_REPOSITORIES, + MANAGED_IMAGE_SOURCE_REPOSITORY, + MANAGED_IMAGE_STARTUP_PROFILE_CONTRACT_VERSION, + type ManagedImageAgent, + type ManagedImageContractV1, + SHIPPED_MANAGED_IMAGE_AGENTS, +} from "../../../src/lib/onboard/managed-image/contract"; +import { + assembleManagedImageCatalog, + main, + managedImagePublicationRequired, + parseManagedImagePullRequestPaths, + selectManagedImagePublicationRun, +} from "../../../tools/e2e/pr-managed-image-publication.mts"; + +const CANDIDATE_SHA = "a".repeat(40); +const PR_NUMBER = 8746; +const WORKFLOW_ID = 12345; + +function contract(agent: ManagedImageAgent, index: number): ManagedImageContractV1 { + const image = MANAGED_IMAGE_REPOSITORIES[agent]; + const digest = `sha256:${String(index + 1).repeat(64)}` as const; + return { + contractVersion: MANAGED_IMAGE_CONTRACT_VERSION, + agent, + platform: "linux/amd64", + image, + digest, + reference: `${image}@${digest}`, + source: { + repository: MANAGED_IMAGE_SOURCE_REPOSITORY, + revision: CANDIDATE_SHA, + release: "v0.0.110", + cohort: "ghrun-32144654845-1", + }, + startupProfileContractVersion: MANAGED_IMAGE_STARTUP_PROFILE_CONTRACT_VERSION, + capabilityContractVersion: MANAGED_IMAGE_CAPABILITY_CONTRACT_VERSION, + }; +} + +function run(overrides: Record = {}): unknown { + return { + total_count: 1, + workflow_runs: [ + { + id: 32144654845, + run_attempt: 1, + workflow_id: WORKFLOW_ID, + name: "Images / Managed Images", + path: ".github/workflows/managed-images.yaml", + event: "pull_request", + head_sha: CANDIDATE_SHA, + status: "completed", + conclusion: "success", + repository: { full_name: "NVIDIA/NemoClaw" }, + head_repository: { full_name: "NVIDIA/NemoClaw" }, + pull_requests: [{ number: PR_NUMBER }], + ...overrides, + }, + ], + }; +} + +describe("exact PR managed-image publication (#8746, #9464)", () => { + it("derives applicability from the trusted managed-image workflow", () => { + const patterns = parseManagedImagePullRequestPaths( + fs.readFileSync(".github/workflows/managed-images.yaml", "utf8"), + ); + + expect( + managedImagePublicationRequired(["src/lib/onboard/workload/preparation.ts"], patterns), + ).toBe(true); + expect( + managedImagePublicationRequired(["tools/mcp-tool-discovery-runtime/server.mts"], patterns), + ).toBe(true); + expect(managedImagePublicationRequired(["docs/My Guide.md"], patterns)).toBe(false); + expect(() => + managedImagePublicationRequired(["src/lib/onboard/file.ts\nother"], patterns), + ).toThrow("changed-file path is invalid"); + }); + + it("rejects an unreviewed path-filter glob", () => { + expect(() => + parseManagedImagePullRequestPaths(` +on: + pull_request: + paths: + - ".github/workflows/managed-images.yaml" + - "src/**/nested/**" +`), + ).toThrow("unsupported glob"); + }); + + it("selects one successful workflow run for the candidate commit", () => { + expect( + selectManagedImagePublicationRun(run(), { + headSha: CANDIDATE_SHA, + prNumber: PR_NUMBER, + workflowId: WORKFLOW_ID, + }), + ).toEqual({ id: 32144654845, attempt: 1, headSha: CANDIDATE_SHA }); + }); + + it.each([ + ["pending", { status: "in_progress", conclusion: null }, "must complete successfully"], + ["failed", { conclusion: "failure" }, "must complete successfully"], + ["different commit", { head_sha: "b".repeat(40) }, "commit must be"], + ["different PR", { pull_requests: [{ number: 9464 }] }, "PR number"], + ])("rejects a %s publication run", (_label, overrides, message) => { + expect(() => + selectManagedImagePublicationRun(run(overrides), { + headSha: CANDIDATE_SHA, + prNumber: PR_NUMBER, + workflowId: WORKFLOW_ID, + }), + ).toThrow(message); + }); + + it("assembles one exact all-agent catalog", () => { + const contracts = SHIPPED_MANAGED_IMAGE_AGENTS.map(contract); + + expect(assembleManagedImageCatalog(contracts, CANDIDATE_SHA)).toEqual( + Object.fromEntries(contracts.map((value) => [value.agent, value])), + ); + }); + + it("writes a validated catalog through the shared assembly command", async () => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-pr-catalog-test-")); + try { + const contracts = SHIPPED_MANAGED_IMAGE_AGENTS.map((agent, index) => { + const contractPath = path.join(directory, `${agent}.json`); + fs.writeFileSync(contractPath, JSON.stringify(contract(agent, index))); + return contractPath; + }); + const outputPath = path.join(directory, "catalog.json"); + + await main(["assemble", CANDIDATE_SHA, outputPath, ...contracts], {}); + + expect(JSON.parse(fs.readFileSync(outputPath, "utf8"))).toEqual( + Object.fromEntries( + SHIPPED_MANAGED_IMAGE_AGENTS.map((agent, index) => [agent, contract(agent, index)]), + ), + ); + expect(fs.statSync(outputPath).mode & 0o777).toBe(0o600); + } finally { + fs.rmSync(directory, { force: true, recursive: true }); + } + }); + + it.each([ + [ + "candidate revision", + SHIPPED_MANAGED_IMAGE_AGENTS.map((agent, index) => + index === 0 + ? { + ...contract(agent, index), + source: { ...contract(agent, index).source, revision: "b".repeat(40) }, + } + : contract(agent, index), + ), + "candidate commit", + ], + [ + "publication cohort", + SHIPPED_MANAGED_IMAGE_AGENTS.map((agent, index) => + index === 0 + ? { + ...contract(agent, index), + source: { ...contract(agent, index).source, cohort: "ghrun-32144654845-2" }, + } + : contract(agent, index), + ), + "publication cohort", + ], + [ + "agent set", + [contract("openclaw", 0), contract("openclaw", 0), contract("hermes", 1)], + "every shipped agent", + ], + ])("rejects mixed %s authority", (_label, contracts, message) => { + expect(() => assembleManagedImageCatalog(contracts, CANDIDATE_SHA)).toThrow(message); + }); +}); diff --git a/test/helpers/managed-image-publication-workflow-types.ts b/test/helpers/managed-image-publication-workflow-types.ts index e7e3de25cd3..39ea6c0dde1 100644 --- a/test/helpers/managed-image-publication-workflow-types.ts +++ b/test/helpers/managed-image-publication-workflow-types.ts @@ -38,7 +38,7 @@ export type Job = { steps?: Step[]; strategy?: { "fail-fast"?: boolean; - matrix?: { include?: MatrixEntry[] }; + matrix?: { include?: MatrixEntry[]; pass?: number[] }; }; "timeout-minutes"?: number; uses?: string; diff --git a/test/managed-image-publication-workflow.test.ts b/test/managed-image-publication-workflow.test.ts index 14754628c91..862047b40ae 100644 --- a/test/managed-image-publication-workflow.test.ts +++ b/test/managed-image-publication-workflow.test.ts @@ -126,6 +126,10 @@ function managedPrActivation(workflow: Workflow): Job { ); } +function managedPrOpenClawMcpDiscovery(workflow: Workflow): Job { + return required(workflow.jobs?.["pr-openclaw-mcp-discovery"], "missing exact PR MCP gate"); +} + function managedPromoter(workflow: Workflow): Job { return required( workflow.jobs?.promote, @@ -791,6 +795,7 @@ describe("complete managed-image publication workflow", () => { expect(step(activation, "Checkout exact PR head").with?.ref).toBe( "${{ github.event.pull_request.head.sha }}", ); + expect(step(activation, "Assemble exact all-agent activation catalog").run).toMatch(/npm ci --ignore-scripts[\s\S]*pr-managed-image-publication\.mts assemble[\s\S]*"\$CANDIDATE_SHA"[\s\S]*"\$\{contracts\[@\]\}"/u); expect(step(activation, "Build exact candidate CLI").run).toContain("npm run build:cli"); expect(step(activation, "Install OpenShell CLI").run).toContain("scripts/install-openshell.sh"); const run = step(activation, "Run real all-agent managed runtime activation").run ?? ""; @@ -799,6 +804,42 @@ describe("complete managed-image publication workflow", () => { expect(steps.map(({ name }) => name)).toContain("Upload managed runtime activation evidence"); }); + it("passes the reported OpenClaw trusted-private MCP discovery twice on one exact PR cohort (#8746)", () => { + const workflow = readWorkflow("managed-images.yaml"); + const discovery = managedPrOpenClawMcpDiscovery(workflow); + expect(discovery.needs).toBe("pr-build-and-entrypoint"); + expect(discovery.if).toContain( + "github.event.pull_request.head.repo.full_name == github.repository", + ); + expect(discovery.permissions).toEqual({ contents: "read" }); + expect(discovery.strategy?.["fail-fast"]).toBe(false); + expect(discovery.strategy?.matrix?.pass).toEqual([1, 2]); + expect(discovery.env?.CANDIDATE_SHA).toBe("${{ github.event.pull_request.head.sha }}"); + expect(discovery.env?.NEMOCLAW_E2E_EXPECTED_SHA).toBe( + "${{ github.event.pull_request.head.sha }}", + ); + expect(discovery.env?.NEMOCLAW_E2E_MANAGED_IMAGE_CATALOG).toContain("managed-pr-catalog.json"); + expect(discovery.env?.NEMOCLAW_MCP_BRIDGE_AGENT).toBe("openclaw"); + expect(discovery.env?.NEMOCLAW_E2E_REQUIRE_EXECUTED_TEST).toBe("1"); + expect(discovery.env?.NEMOCLAW_E2E_SHARD).toBe("openclaw"); + expect(discovery.env?.NEMOCLAW_RUN_LIVE_E2E).toBe("1"); + expect(discovery.env).not.toHaveProperty("E2E_MANAGED_IMAGE_REVISION"); + expect(JSON.stringify(discovery)).not.toContain("secrets."); + expect(JSON.stringify(discovery)).not.toContain("github.token"); + expect(step(discovery, "Checkout exact PR head").with?.ref).toBe( + "${{ github.event.pull_request.head.sha }}", + ); + expect(step(discovery, "Bind E2E correlation identity").run).toContain("randomUUID()"); + const assemble = step(discovery, "Assemble exact all-agent MCP catalog").run ?? ""; + expect(assemble).toMatch(/npm ci --ignore-scripts[\s\S]*pr-managed-image-publication\.mts assemble[\s\S]*"\$CANDIDATE_SHA"[\s\S]*"\$\{contracts\[@\]\}"/u); + const run = step(discovery, "Run exact OpenClaw trusted-private MCP discovery").run ?? ""; + expect(run).toContain('[[ "$(git rev-parse --verify HEAD)" == "$CANDIDATE_SHA" ]]'); + expect(JSON.stringify(discovery)).not.toContain("jq "); + expect(run).toMatch(/npx --no-install tsx[\s\S]*test\/e2e\/live\/mcp-bridge\.test\.ts/u); + expect(run).not.toContain("--selector"); + expect(step(discovery, "Scan MCP artifacts for fixture credentials").if).toBe("always()"); + }); + it("keeps the activation proof outside mocked runtime boundaries (#7744)", () => { const source = fs.readFileSync( path.join(repoRoot, "test/e2e/live/managed-image-activation-e2e-helpers.ts"), diff --git a/tools/e2e/exact-artifact-download.mts b/tools/e2e/exact-artifact-download.mts index 303173b1526..45a7a5967af 100644 --- a/tools/e2e/exact-artifact-download.mts +++ b/tools/e2e/exact-artifact-download.mts @@ -71,18 +71,21 @@ export function exactArtifactName(expected: ExactArtifactExpectation): string { return `managed-base-${expected.runId}-${expected.runAttempt}-langchain-deepagents-code`; } -/** Bind exactly one artifact and validate every immutable producer attribute. */ -export function bindExactArtifact( +/** Bind one named artifact and validate every immutable producer attribute. */ +export function bindNamedExactArtifact( value: unknown, expected: ExactArtifactExpectation, + expectedName: string, ): BoundArtifactIdentity { positiveInteger(expected.runId, "expected run id"); positiveInteger(expected.runAttempt, "expected run attempt"); if (!SHA_PATTERN.test(expected.headSha)) throw new Error("expected head SHA is invalid"); + if (!expectedName || expectedName.includes("/") || expectedName.includes("\\")) { + throw new Error("expected artifact name is invalid"); + } const page = record(value, "artifact response"); if (!Array.isArray(page.artifacts)) throw new Error("artifact response must contain artifacts"); - const expectedName = exactArtifactName(expected); const matches = page.artifacts .map((artifact) => record(artifact, "artifact")) .filter((artifact) => artifact.name === expectedName); @@ -121,6 +124,14 @@ export function bindExactArtifact( }; } +/** Bind the immutable Deep Agents Code base-image contract artifact. */ +export function bindExactArtifact( + value: unknown, + expected: ExactArtifactExpectation, +): BoundArtifactIdentity { + return bindNamedExactArtifact(value, expected, exactArtifactName(expected)); +} + /** Calculate bounded server-directed or linear retry delay. */ function retryDelay(response: Response, attempt: number, now: () => number): number { const retryAfter = response.headers.get("retry-after"); diff --git a/tools/e2e/pr-managed-image-publication.mts b/tools/e2e/pr-managed-image-publication.mts new file mode 100644 index 00000000000..d5ac63a0b0a --- /dev/null +++ b/tools/e2e/pr-managed-image-publication.mts @@ -0,0 +1,424 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import YAML from "yaml"; + +import { + parseManagedImageContractV1, + SHIPPED_MANAGED_IMAGE_AGENTS, + type ManagedImageContractCatalog, + type ManagedImageContractV1, +} from "../../src/lib/onboard/managed-image/contract.ts"; +import { githubRequest } from "./base-image-publication.mts"; +import { + bindNamedExactArtifact, + downloadBoundArtifact, + materializeContractArchive, +} from "./exact-artifact-download.mts"; + +const REPOSITORY = "NVIDIA/NemoClaw"; +const WORKFLOW_PATH = ".github/workflows/managed-images.yaml"; +const WORKFLOW_FILE = "managed-images.yaml"; +const WORKFLOW_NAME = "Images / Managed Images"; +const MAX_CHANGED_FILES = 3_000; +const PAGE_SIZE = 100; +const SHA_PATTERN = /^[0-9a-f]{40}$/u; +const SAFE_PATH_PATTERN = /^[A-Za-z0-9._/*-]+$/u; + +type JsonRecord = Record; + +export interface ManagedImagePublicationRun { + readonly id: number; + readonly attempt: number; + readonly headSha: string; +} + +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 exactString(value: unknown, expected: string, label: string): void { + if (value !== expected) throw new Error(`${label} must be ${expected}`); +} + +function compileManagedImagePath(pattern: string): RegExp { + if ( + !SAFE_PATH_PATTERN.test(pattern) || + pattern.startsWith("/") || + pattern.includes("//") || + pattern.split("/").some((segment) => segment === "" || segment === "." || segment === "..") + ) { + throw new Error(`managed-image PR path '${pattern}' is invalid`); + } + const stars = [...pattern.matchAll(/\*/gu)].map((match) => match.index); + if (stars.length === 0) { + return new RegExp(`^${pattern.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&")}$`, "u"); + } + if (pattern.endsWith("/**") && stars.length === 2) { + const prefix = pattern.slice(0, -3).replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"); + return new RegExp(`^${prefix}/.+$`, "u"); + } + if (stars.length === 1) { + const escaped = pattern.replace(/[.+?^${}()|[\]\\]/gu, "\\$&").replaceAll("*", "[^/]*"); + return new RegExp(`^${escaped}$`, "u"); + } + throw new Error(`managed-image PR path '${pattern}' uses an unsupported glob`); +} + +/** Read the managed-image workflow path filter from the trusted workflow source. */ +export function parseManagedImagePullRequestPaths(source: string): string[] { + const workflow = record(YAML.parse(source), "managed-image workflow"); + const triggers = record(workflow.on, "managed-image workflow on block"); + const pullRequest = record(triggers.pull_request, "managed-image pull_request trigger"); + if (!Array.isArray(pullRequest.paths) || pullRequest.paths.length === 0) { + throw new Error("managed-image pull_request trigger must declare paths"); + } + const paths = pullRequest.paths.map((value) => { + if (typeof value !== "string" || value.length === 0 || value.trim() !== value) { + throw new Error("managed-image PR paths must be non-empty strings"); + } + compileManagedImagePath(value); + return value; + }); + if (new Set(paths).size !== paths.length) { + throw new Error("managed-image PR paths must be unique"); + } + if (!paths.includes(WORKFLOW_PATH)) { + throw new Error(`managed-image PR paths must include ${WORKFLOW_PATH}`); + } + return paths; +} + +/** Determine whether changed PR files require exact managed-image publication. */ +export function managedImagePublicationRequired( + changedFiles: readonly string[], + patterns: readonly string[], +): boolean { + if (changedFiles.length > MAX_CHANGED_FILES * 2) { + throw new Error(`PR changed-path count exceeds ${MAX_CHANGED_FILES * 2}`); + } + const matchers = patterns.map(compileManagedImagePath); + for (const file of changedFiles) { + if ( + file.length === 0 || + file.length > 4_096 || + /[\0\r\n]/u.test(file) || + file.startsWith("/") || + file.includes("//") || + file.split("/").some((segment) => segment === "" || segment === "." || segment === "..") + ) { + throw new Error("PR changed-file path is invalid"); + } + if (matchers.some((matcher) => matcher.test(file))) return true; + } + return false; +} + +/** Select the unique successful managed-image workflow run for one PR commit. */ +export function selectManagedImagePublicationRun( + payload: unknown, + expected: { readonly headSha: string; readonly prNumber: number; readonly workflowId: number }, +): ManagedImagePublicationRun { + if (!SHA_PATTERN.test(expected.headSha)) throw new Error("candidate SHA is invalid"); + positiveInteger(expected.prNumber, "PR number"); + positiveInteger(expected.workflowId, "managed-image workflow id"); + const response = record(payload, "managed-image workflow runs"); + if (response.total_count !== 1 || !Array.isArray(response.workflow_runs)) { + throw new Error("exact managed-image workflow run is missing or ambiguous"); + } + if (response.workflow_runs.length !== 1) { + throw new Error("exact managed-image workflow run listing is incomplete"); + } + const run = record(response.workflow_runs[0], "managed-image workflow run"); + const id = positiveInteger(run.id, "managed-image workflow run id"); + const attempt = positiveInteger(run.run_attempt, "managed-image workflow run attempt"); + if (run.workflow_id !== expected.workflowId) { + throw new Error("managed-image workflow run does not match the trusted workflow"); + } + exactString(run.name, WORKFLOW_NAME, "managed-image workflow run name"); + exactString(run.path, WORKFLOW_PATH, "managed-image workflow run path"); + exactString(run.event, "pull_request", "managed-image workflow run event"); + exactString(run.head_sha, expected.headSha, "managed-image workflow run commit"); + exactString( + record(run.repository, "managed-image workflow repository").full_name, + REPOSITORY, + "managed-image workflow repository", + ); + exactString( + record(run.head_repository, "managed-image workflow source repository").full_name, + REPOSITORY, + "managed-image workflow source repository", + ); + if ( + !Array.isArray(run.pull_requests) || + run.pull_requests.length !== 1 || + record(run.pull_requests[0], "managed-image workflow pull request").number !== expected.prNumber + ) { + throw new Error("managed-image workflow run does not match the PR number"); + } + if (run.status !== "completed" || run.conclusion !== "success") { + throw new Error( + `managed-image workflow for candidate ${expected.headSha} must complete successfully before live E2E`, + ); + } + return { id, attempt, headSha: expected.headSha }; +} + +/** Assemble one all-agent catalog and reject mixed publication authority. */ +export function assembleManagedImageCatalog( + values: readonly unknown[], + candidateSha: string, +): ManagedImageContractCatalog { + if (!SHA_PATTERN.test(candidateSha)) throw new Error("candidate SHA is invalid"); + if (values.length !== SHIPPED_MANAGED_IMAGE_AGENTS.length) { + throw new Error( + `exact PR managed-image publication requires ${SHIPPED_MANAGED_IMAGE_AGENTS.length} contracts`, + ); + } + const contracts = values.map((value) => + parseManagedImageContractV1(value, undefined, "linux/amd64"), + ); + const byAgent = new Map(contracts.map((contract) => [contract.agent, contract])); + if ( + byAgent.size !== SHIPPED_MANAGED_IMAGE_AGENTS.length || + SHIPPED_MANAGED_IMAGE_AGENTS.some((agent) => !byAgent.has(agent)) + ) { + throw new Error("exact PR managed-image publication must contain every shipped agent once"); + } + const revisions = new Set(contracts.map((contract) => contract.source.revision)); + const releases = new Set(contracts.map((contract) => contract.source.release)); + const cohorts = new Set(contracts.map((contract) => contract.source.cohort)); + if (!revisions.has(candidateSha) || revisions.size !== 1) { + throw new Error("exact PR managed-image contracts do not match the candidate commit"); + } + if (releases.size !== 1 || cohorts.size !== 1) { + throw new Error("exact PR managed-image contracts do not form one publication cohort"); + } + return Object.fromEntries( + SHIPPED_MANAGED_IMAGE_AGENTS.map((agent) => [agent, byAgent.get(agent)!]), + ); +} + +/** Write one validated exact-candidate catalog from local contract paths. */ +export function writeManagedImageCatalog( + contractPaths: readonly string[], + candidateSha: string, + outputPath: string, +): void { + const contracts = contractPaths.map( + (contractPath) => JSON.parse(fs.readFileSync(contractPath, "utf8")) as unknown, + ); + const catalog = assembleManagedImageCatalog(contracts, candidateSha); + fs.mkdirSync(path.dirname(path.resolve(outputPath)), { mode: 0o700, recursive: true }); + fs.writeFileSync(outputPath, `${JSON.stringify(catalog)}\n`, { + encoding: "utf8", + flag: "wx", + mode: 0o600, + }); +} + +function validateWorkflow(payload: unknown): number { + const workflow = record(payload, "managed-image workflow"); + const id = positiveInteger(workflow.id, "managed-image workflow id"); + exactString(workflow.name, WORKFLOW_NAME, "managed-image workflow name"); + exactString(workflow.path, WORKFLOW_PATH, "managed-image workflow path"); + exactString(workflow.state, "active", "managed-image workflow state"); + return id; +} + +async function readChangedFiles( + prNumber: number, + count: number, + request: (path: string) => Promise, +): Promise { + if (!Number.isSafeInteger(count) || count < 0 || count > MAX_CHANGED_FILES) { + throw new Error("PR changed-file count is invalid"); + } + const files: string[] = []; + let listedFiles = 0; + for (let page = 1; listedFiles < count; page += 1) { + if (page > Math.ceil(MAX_CHANGED_FILES / PAGE_SIZE)) { + throw new Error("PR changed-file pagination exceeded the safety cap"); + } + const payload = await request( + `/repos/${REPOSITORY}/pulls/${prNumber}/files?per_page=${PAGE_SIZE}&page=${page}`, + ); + if (!Array.isArray(payload) || payload.length === 0 || payload.length > PAGE_SIZE) { + throw new Error("PR changed-file page is invalid or incomplete"); + } + for (const value of payload) { + const file = record(value, "PR changed file"); + if (typeof file.filename !== "string") throw new Error("PR changed-file name is invalid"); + files.push(file.filename); + if (file.previous_filename !== undefined) { + if (typeof file.previous_filename !== "string") { + throw new Error("PR previous changed-file name is invalid"); + } + files.push(file.previous_filename); + } + listedFiles += 1; + } + } + if (listedFiles !== count) { + throw new Error("PR changed-file listing is incomplete"); + } + return [...new Set(files)]; +} + +function validatePr( + payload: unknown, + expected: { readonly baseSha: string; readonly candidateSha: string; readonly prNumber: number }, +): number { + const pull = record(payload, "pull request"); + exactString(pull.state, "open", "pull request state"); + exactString( + record(pull.base, "pull request base").sha, + expected.baseSha, + "pull request base commit", + ); + exactString( + record(pull.head, "pull request source").sha, + expected.candidateSha, + "pull request source commit", + ); + exactString( + record(record(pull.base, "pull request base").repo, "pull request base repository").full_name, + REPOSITORY, + "pull request base repository", + ); + exactString( + record(record(pull.head, "pull request source").repo, "pull request source repository") + .full_name, + REPOSITORY, + "pull request source repository", + ); + return positiveInteger(pull.changed_files, "PR changed-file count"); +} + +/** Resolve and download the exact all-agent catalog before candidate code executes. */ +export async function resolvePrManagedImageCatalog( + input: { + readonly baseSha: string; + readonly candidateRepository: string; + readonly candidateSha: string; + readonly outputPath: string; + readonly prNumber: number; + readonly token: string; + readonly workflowSource: string; + }, + request: (path: string) => Promise = (apiPath) => githubRequest(apiPath, input.token), +): Promise<"not-required" | "written"> { + if (input.candidateRepository !== REPOSITORY) return "not-required"; + if (!SHA_PATTERN.test(input.baseSha) || !SHA_PATTERN.test(input.candidateSha)) { + throw new Error("PR base and candidate SHAs are required"); + } + positiveInteger(input.prNumber, "PR number"); + if (!input.token) throw new Error("GITHUB_TOKEN is required"); + const changedCount = validatePr( + await request(`/repos/${REPOSITORY}/pulls/${input.prNumber}`), + input, + ); + const changedFiles = await readChangedFiles(input.prNumber, changedCount, request); + const patterns = parseManagedImagePullRequestPaths(input.workflowSource); + if (!managedImagePublicationRequired(changedFiles, patterns)) return "not-required"; + + const workflowId = validateWorkflow( + await request(`/repos/${REPOSITORY}/actions/workflows/${WORKFLOW_FILE}`), + ); + const runs = await request( + `/repos/${REPOSITORY}/actions/workflows/${WORKFLOW_FILE}/runs?event=pull_request&head_sha=${input.candidateSha}&per_page=100`, + ); + const run = selectManagedImagePublicationRun(runs, { + headSha: input.candidateSha, + prNumber: input.prNumber, + workflowId, + }); + + const tempDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-pr-managed-catalog-")); + try { + const contracts: ManagedImageContractV1[] = []; + for (const agent of SHIPPED_MANAGED_IMAGE_AGENTS) { + const name = `managed-pr-contract-${run.id}-${run.attempt}-${agent}`; + const metadata = await request( + `/repos/${REPOSITORY}/actions/runs/${run.id}/artifacts?name=${encodeURIComponent(name)}&per_page=100`, + ); + const identity = bindNamedExactArtifact( + metadata, + { + headSha: run.headSha, + runAttempt: run.attempt, + runId: run.id, + }, + name, + ); + const archive = await downloadBoundArtifact(identity, input.token); + const contractPath = materializeContractArchive(archive, path.join(tempDirectory, agent)); + contracts.push( + JSON.parse(fs.readFileSync(contractPath, "utf8")) as unknown as ManagedImageContractV1, + ); + } + const catalog = assembleManagedImageCatalog(contracts, input.candidateSha); + fs.mkdirSync(path.dirname(path.resolve(input.outputPath)), { mode: 0o700, recursive: true }); + fs.writeFileSync(input.outputPath, `${JSON.stringify(catalog)}\n`, { + encoding: "utf8", + flag: "wx", + mode: 0o600, + }); + return "written"; + } finally { + fs.rmSync(tempDirectory, { force: true, recursive: true }); + } +} + +function requiredInteger(value: string | undefined, label: string): number { + if (!value || !/^[1-9][0-9]*$/u.test(value)) throw new Error(`${label} is required`); + return positiveInteger(Number(value), label); +} + +export async function main(argv = process.argv.slice(2), env = process.env): Promise { + if (argv[0] === "assemble") { + if (argv.length < 4) { + throw new Error("expected candidate SHA, output path, and managed-image contract paths"); + } + writeManagedImageCatalog(argv.slice(3), argv[1], argv[2]); + console.log("pr-managed-image-catalog outcome=assembled"); + return; + } + if (argv.length !== 1) throw new Error("expected one managed-image catalog output path"); + const candidateSha = env.CANDIDATE_SHA ?? ""; + if (!candidateSha) return; + const result = await resolvePrManagedImageCatalog({ + baseSha: env.BASE_SHA ?? "", + candidateRepository: env.CANDIDATE_REPOSITORY ?? "", + candidateSha, + outputPath: argv[0], + prNumber: requiredInteger(env.PR_NUMBER, "PR_NUMBER"), + token: env.GITHUB_TOKEN ?? "", + workflowSource: fs.readFileSync(WORKFLOW_PATH, "utf8"), + }); + console.log(`pr-managed-image-catalog outcome=${result}`); +} + +if (path.resolve(process.argv[1] ?? "") === fileURLToPath(import.meta.url)) { + try { + await main(); + } catch (error) { + console.error(error instanceof Error ? error.message : "unknown PR managed-image error"); + process.exitCode = 1; + } +}