From 02f1ecc6754e50ff5e8e1ba0825106791b8de498 Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Tue, 25 Aug 2026 23:02:17 -0700 Subject: [PATCH 01/27] ci(openshell): enable trusted SDK package reads Signed-off-by: Apurv Kumaria --- .../actions/ci-build-typecheck/action.yaml | 4 + .../actions/ci-cli-coverage-shard/action.yaml | 4 + .github/actions/ci-install-dependencies.sh | 6 ++ .../ci-installer-integration/action.yaml | 4 + .../actions/ci-plugin-coverage/action.yaml | 4 + .github/workflows/main.yaml | 10 ++ .github/workflows/pr.yaml | 10 ++ ci/reviewed-npm-audit.json | 8 ++ scripts/audit-reviewed-npm-graph.mts | 39 +++++++- scripts/lib/reviewed-npm-archive.mts | 38 ++++++- .../pr-workflow-contract.test.ts | 17 +++- .../reviewed-npm-audit-workflow.test.ts | 98 ++++++++++++++++++- .../ci-install-dependencies.test.ts | 66 ++++++++++--- 13 files changed, 286 insertions(+), 22 deletions(-) diff --git a/.github/actions/ci-build-typecheck/action.yaml b/.github/actions/ci-build-typecheck/action.yaml index 03e12309ee6..ea101d82e61 100644 --- a/.github/actions/ci-build-typecheck/action.yaml +++ b/.github/actions/ci-build-typecheck/action.yaml @@ -12,12 +12,16 @@ runs: with: node-version: "22" cache: npm + registry-url: https://npm.pkg.github.com + scope: "@nvidia" cache-dependency-path: | package-lock.json nemoclaw/package-lock.json - name: Install dependencies shell: bash + env: + NODE_AUTH_TOKEN: ${{ github.token }} run: bash "$GITHUB_ACTION_PATH/../ci-install-dependencies.sh" - name: Build TypeScript plugin diff --git a/.github/actions/ci-cli-coverage-shard/action.yaml b/.github/actions/ci-cli-coverage-shard/action.yaml index 63aedad52ac..a267bfdbf70 100644 --- a/.github/actions/ci-cli-coverage-shard/action.yaml +++ b/.github/actions/ci-cli-coverage-shard/action.yaml @@ -56,6 +56,8 @@ runs: with: node-version: "22" cache: npm + registry-url: https://npm.pkg.github.com + scope: "@nvidia" cache-dependency-path: | package-lock.json nemoclaw/package-lock.json @@ -101,6 +103,8 @@ runs: - name: Install dependencies shell: bash + env: + NODE_AUTH_TOKEN: ${{ github.token }} run: bash "$GITHUB_ACTION_PATH/../ci-install-dependencies.sh" - name: Validate changed live E2E mock parity diff --git a/.github/actions/ci-install-dependencies.sh b/.github/actions/ci-install-dependencies.sh index 2dc2d1bff3f..9dfd4194638 100755 --- a/.github/actions/ci-install-dependencies.sh +++ b/.github/actions/ci-install-dependencies.sh @@ -4,5 +4,11 @@ set -euo pipefail +candidate_npmrc="$(find . -path './.git' -prune -o -name .npmrc -print -quit)" +if [ -n "$candidate_npmrc" ]; then + echo "Candidate repository npm configuration is not allowed during trusted dependency installation." >&2 + exit 1 +fi + npm ci --ignore-scripts npm --prefix nemoclaw ci --ignore-scripts diff --git a/.github/actions/ci-installer-integration/action.yaml b/.github/actions/ci-installer-integration/action.yaml index b8d4487932d..d994900dad4 100644 --- a/.github/actions/ci-installer-integration/action.yaml +++ b/.github/actions/ci-installer-integration/action.yaml @@ -12,12 +12,16 @@ runs: with: node-version: "22" cache: npm + registry-url: https://npm.pkg.github.com + scope: "@nvidia" cache-dependency-path: | package-lock.json nemoclaw/package-lock.json - name: Install dependencies shell: bash + env: + NODE_AUTH_TOKEN: ${{ github.token }} run: bash "$GITHUB_ACTION_PATH/../ci-install-dependencies.sh" - name: Build installer integration artifacts diff --git a/.github/actions/ci-plugin-coverage/action.yaml b/.github/actions/ci-plugin-coverage/action.yaml index 9af48f0b627..ac7c2a2d5d8 100644 --- a/.github/actions/ci-plugin-coverage/action.yaml +++ b/.github/actions/ci-plugin-coverage/action.yaml @@ -12,12 +12,16 @@ runs: with: node-version: "22" cache: npm + registry-url: https://npm.pkg.github.com + scope: "@nvidia" cache-dependency-path: | package-lock.json nemoclaw/package-lock.json - name: Install dependencies shell: bash + env: + NODE_AUTH_TOKEN: ${{ github.token }} run: bash "$GITHUB_ACTION_PATH/../ci-install-dependencies.sh" - name: Run plugin coverage diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index 1b89e6fa04b..fceaf4711f9 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -34,6 +34,9 @@ jobs: uses: ./.github/actions/ci-static-checks build-typecheck: + permissions: + contents: read + packages: read runs-on: ubuntu-latest timeout-minutes: 10 steps: @@ -46,6 +49,9 @@ jobs: uses: ./.github/actions/ci-build-typecheck installer-integration: + permissions: + contents: read + packages: read runs-on: ubuntu-latest timeout-minutes: 10 steps: @@ -139,6 +145,9 @@ jobs: run: npx vitest run --project integration test/agents/openclaw/openclaw-security-audit-suppressions-real.test.ts --silent=false --reporter=default cli-test-shards: + permissions: + contents: read + packages: read runs-on: ubuntu-24.04 # Keep the post-merge budget aligned with pull requests so the same # duration-weighted coverage roster can finish and upload its artifacts. @@ -241,6 +250,7 @@ jobs: permissions: code-quality: write contents: read + packages: read pull-requests: read runs-on: ubuntu-latest timeout-minutes: 10 diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index 1b65a5a7cc0..80fcb981b5c 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -129,6 +129,9 @@ jobs: build-typecheck: needs: changes if: needs.changes.outputs.code == 'true' + permissions: + contents: read + packages: read runs-on: ubuntu-latest timeout-minutes: 10 steps: @@ -159,6 +162,9 @@ jobs: installer-integration: needs: changes if: needs.changes.outputs.code == 'true' + permissions: + contents: read + packages: read runs-on: ubuntu-latest timeout-minutes: 10 steps: @@ -254,6 +260,9 @@ jobs: cli-test-shards: needs: changes if: needs.changes.outputs.code == 'true' + permissions: + contents: read + packages: read runs-on: ubuntu-24.04 # Coverage startup plus the stable, duration-weighted roster can exceed # the former 15-minute cap before Vitest writes its shard artifacts. @@ -392,6 +401,7 @@ jobs: permissions: code-quality: write contents: read + packages: read pull-requests: read runs-on: ubuntu-latest timeout-minutes: 10 diff --git a/ci/reviewed-npm-audit.json b/ci/reviewed-npm-audit.json index 416f9c8c07b..cdb51143ca2 100644 --- a/ci/reviewed-npm-audit.json +++ b/ci/reviewed-npm-audit.json @@ -2,6 +2,14 @@ "schemaVersion": 2, "nodeVersion": "22.23.2", "registryOrigin": "https://registry.npmjs.org/", + "sourceRegistryPackages": [ + { + "label": "OpenShell TypeScript SDK 0.0.106", + "packageSpec": "@nvidia/openshell-sdk@0.0.106", + "integrity": "sha512-dB4mLex23Pnw61caGMR2CMHQihy9bj7IK2elJJd718k3yevm+fOt/vG6dJg8/5us4la2BwcOdRwLvOia3tdwFw==", + "tarballUrl": "https://npm.pkg.github.com/download/@nvidia/openshell-sdk/0.0.106/dc32180ba1d658fc4ec309bdf89d2b162196928d" + } + ], "severityThreshold": "high", "exceptionFile": "ci/npm-audit-exceptions.json", "archiveGraphId": "reviewed-archive-graph", diff --git a/scripts/audit-reviewed-npm-graph.mts b/scripts/audit-reviewed-npm-graph.mts index a73660d33ec..d9d48c142e0 100755 --- a/scripts/audit-reviewed-npm-graph.mts +++ b/scripts/audit-reviewed-npm-graph.mts @@ -47,6 +47,7 @@ type AuditConfig = Readonly<{ registryOrigin: string; schemaVersion: 2; severityThreshold: Severity; + sourceRegistryPackages: readonly ReviewedPackage[]; }>; type ReviewedAuditReport = Readonly<{ label: string; result: AuditPolicyResult }>; @@ -143,6 +144,20 @@ export function parseAuditConfig(contents: string): AuditConfig { !parsed.registryOrigin || !Array.isArray(parsed.archivePackages) || !Array.isArray(parsed.lockedGraphs) || + !Array.isArray(parsed.sourceRegistryPackages) || + parsed.sourceRegistryPackages.some( + (reviewed) => + typeof reviewed.label !== "string" || + !reviewed.label || + typeof reviewed.packageSpec !== "string" || + !reviewed.packageSpec || + typeof reviewed.integrity !== "string" || + !reviewed.integrity || + typeof reviewed.tarballUrl !== "string" || + !reviewed.tarballUrl, + ) || + new Set(parsed.sourceRegistryPackages.map(({ packageSpec }) => packageSpec)).size !== + parsed.sourceRegistryPackages.length || parsed.lockedGraphs.some( (graph) => typeof graph.id !== "string" || @@ -306,17 +321,33 @@ function assertRegularFile(file: string, label: string): void { } } +function installProductionSourceDependencies(directory: string): void { + run("npm", ["ci", "--ignore-scripts", "--omit=dev", "--no-audit", "--no-fund"], directory); +} + export function materializeSourceGraph( sourcePackage: string, sourceLock: string, destination: string, registryOrigin: string, - installProductionDependencies: (directory: string) => void = (directory) => - void run("npm", ["ci", "--ignore-scripts", "--omit=dev", "--no-audit", "--no-fund"], directory), + installProductionDependencies: (directory: string) => void = installProductionSourceDependencies, + sourceRegistryPackages: readonly ReviewedPackage[] = [], ): string { assertRegularFile(sourcePackage, "NemoClaw CLI package manifest"); assertRegularFile(sourceLock, "NemoClaw CLI lockfile"); - verifyReviewedNpmLockPackages({ lockfilePath: sourceLock, omitDev: true, registryOrigin }); + verifyReviewedNpmLockPackages({ + lockfilePath: sourceLock, + omitDev: true, + registryOrigin, + reviewedRegistryPackages: sourceRegistryPackages.map( + ({ integrity, label, packageSpec, tarballUrl }) => ({ + expectedIntegrity: integrity, + label, + packageSpec, + tarballUrl, + }), + ), + }); const lockSha256 = createHash("sha256").update(fs.readFileSync(sourceLock)).digest("hex"); fs.mkdirSync(destination); fs.copyFileSync(sourcePackage, path.join(destination, "package.json")); @@ -453,6 +484,8 @@ function auditSourceGraph( sourceLock, path.join(tempRoot, "source-graph"), config.registryOrigin, + installProductionSourceDependencies, + config.sourceRegistryPackages, ); return auditMaterializedSourceGraph({ directory, diff --git a/scripts/lib/reviewed-npm-archive.mts b/scripts/lib/reviewed-npm-archive.mts index f69cf63722d..e59876ef111 100755 --- a/scripts/lib/reviewed-npm-archive.mts +++ b/scripts/lib/reviewed-npm-archive.mts @@ -421,9 +421,31 @@ function readReviewedLockPackages( omitDev = false, allowEmpty = false, allowNestedShrinkwrap = false, + reviewedRegistryPackages: readonly ReviewedNpmArchiveRequest[] = [], ): readonly ReviewedNpmArchiveRequest[] { const reviewed: ReviewedNpmArchiveRequest[] = []; const identities = new Map(); + const reviewedRegistryIdentities = new Map(); + for (const reviewedPackage of reviewedRegistryPackages) { + requireReviewedRequest(reviewedPackage); + let parsedTarball: URL; + try { + parsedTarball = new URL(reviewedPackage.tarballUrl); + } catch { + throw new Error(`${reviewedPackage.label} must use a valid reviewed npm tarball URL`); + } + if ( + parsedTarball.protocol !== "https:" || + parsedTarball.username || + parsedTarball.password || + reviewedRegistryIdentities.has(reviewedPackage.packageSpec) + ) { + throw new Error( + `${reviewedPackage.label} must use one credential-free HTTPS package identity`, + ); + } + reviewedRegistryIdentities.set(reviewedPackage.packageSpec, reviewedPackage); + } const productionLocations = omitDev ? productionLockLocations(packages) : undefined; for (const [location, value] of Object.entries(packages)) { if (location === "") continue; @@ -456,7 +478,19 @@ function readReviewedLockPackages( } catch { throw new Error(`reviewed npm lock has an invalid tarball URL: ${location}`); } - if ( + const reviewedRegistryIdentity = reviewedRegistryIdentities.get(packageSpec); + if (reviewedRegistryIdentity) { + if ( + reviewedRegistryIdentity.expectedIntegrity !== expectedIntegrity || + reviewedRegistryIdentity.tarballUrl !== tarballUrl || + parsedTarball.username || + parsedTarball.password + ) { + throw new Error( + `reviewed npm lock package does not match its approved registry identity: ${location}`, + ); + } + } else if ( parsedTarball.origin !== registryOrigin || parsedTarball.username || parsedTarball.password @@ -490,6 +524,7 @@ export function verifyReviewedNpmLockPackages( allowNestedShrinkwrap?: boolean; lockfilePath: string; omitDev?: boolean; + reviewedRegistryPackages?: readonly ReviewedNpmArchiveRequest[]; registryOrigin: string; }>, ): readonly string[] { @@ -501,6 +536,7 @@ export function verifyReviewedNpmLockPackages( request.omitDev, true, request.allowNestedShrinkwrap, + request.reviewedRegistryPackages, ).map(({ packageSpec }) => packageSpec); } diff --git a/test/automation/pull-requests/pr-workflow-contract.test.ts b/test/automation/pull-requests/pr-workflow-contract.test.ts index 824275c5e73..6e7a66ce31b 100644 --- a/test/automation/pull-requests/pr-workflow-contract.test.ts +++ b/test/automation/pull-requests/pr-workflow-contract.test.ts @@ -365,9 +365,20 @@ describe("pull request and main workflow contracts", () => { ["pull_request", prWorkflow], ["main", mainWorkflow], ] as const)("keeps the %s CLI coverage shard budget aligned", (_workflowName, workflow) => { - expect(workflow.jobs["cli-test-shards"]?.["timeout-minutes"]).toBe( - cliShardTimeoutMinutes, - ); + expect(workflow.jobs["cli-test-shards"]?.["timeout-minutes"]).toBe(cliShardTimeoutMinutes); + }); + + it.each([ + ["pull_request", prWorkflow], + ["main", mainWorkflow], + ] as const)("limits %s package reads to dependency-install jobs", (_workflowName, workflow) => { + expect(workflow.permissions).toEqual({ contents: "read" }); + expect( + Object.entries(workflow.jobs) + .filter(([, job]) => job.permissions?.packages === "read") + .map(([jobName]) => jobName) + .sort(), + ).toEqual(["build-typecheck", "cli-test-shards", "installer-integration", "plugin-tests"]); }); // source-shape-contract: security -- PR base SHA action execution prevents pull-request code from authorizing installer hashes diff --git a/test/automation/releases/reviewed-npm-audit-workflow.test.ts b/test/automation/releases/reviewed-npm-audit-workflow.test.ts index 79d5cc0ba9f..f31a617e55b 100644 --- a/test/automation/releases/reviewed-npm-audit-workflow.test.ts +++ b/test/automation/releases/reviewed-npm-audit-workflow.test.ts @@ -54,10 +54,13 @@ function writeProductionSourceGraph( root: string, packageRecord: Readonly>, additionalPackageRecords: Readonly>>> = {}, + optional = false, ): Readonly<{ sourceLock: string; sourcePackage: string }> { const source = path.join(root, "source"); const manifest = { - dependencies: { "fixture-package": "1.0.0" }, + ...(optional + ? { optionalDependencies: { "fixture-package": "1.0.0" } } + : { dependencies: { "fixture-package": "1.0.0" } }), name: "source-graph-fixture", private: true, version: "1.0.0", @@ -124,6 +127,7 @@ describe("trusted reviewed npm audit workflow (#5896)", () => { registryOrigin: "https://registry.npmjs.org/", schemaVersion: 2, severityThreshold: "high", + sourceRegistryPackages: [], }; expect(() => parseAuditConfig(JSON.stringify(config))).toThrow( @@ -136,6 +140,16 @@ describe("trusted reviewed npm audit workflow (#5896)", () => { const config = parseAuditConfig(fs.readFileSync(configFile, "utf-8")); expect(config.archiveTarVersion).toBe("7.5.21"); + expect(config.sourceRegistryPackages).toEqual([ + { + integrity: + "sha512-dB4mLex23Pnw61caGMR2CMHQihy9bj7IK2elJJd718k3yevm+fOt/vG6dJg8/5us4la2BwcOdRwLvOia3tdwFw==", + label: "OpenShell TypeScript SDK 0.0.106", + packageSpec: "@nvidia/openshell-sdk@0.0.106", + tarballUrl: + "https://npm.pkg.github.com/download/@nvidia/openshell-sdk/0.0.106/dc32180ba1d658fc4ec309bdf89d2b162196928d", + }, + ]); expect(reviewedArchiveGraphManifest(config.archiveTarVersion)).toEqual({ name: "nemoclaw-reviewed-production-graph", overrides: { tar: "7.5.21" }, @@ -385,6 +399,88 @@ esac } }); + it("accepts one exact package identity from an approved additional registry", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-source-graph-reviewed-registry-")); + const destination = path.join(root, "materialized"); + const integrity = "sha512-fixture"; + const tarballUrl = "https://npm.pkg.github.com/download/fixture-package/1.0.0/revision"; + const { sourceLock, sourcePackage } = writeProductionSourceGraph( + root, + { + integrity, + optional: true, + resolved: tarballUrl, + version: "1.0.0", + }, + {}, + true, + ); + let installCalled = false; + try { + expect( + materializeSourceGraph( + sourcePackage, + sourceLock, + destination, + "https://registry.npmjs.org", + () => { + installCalled = true; + }, + [ + { + integrity, + label: "reviewed fixture package", + packageSpec: "fixture-package@1.0.0", + tarballUrl, + }, + ], + ), + ).toBe(destination); + expect(installCalled).toBe(true); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + it("rejects drift from an approved additional-registry package identity", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-source-graph-registry-drift-")); + const destination = path.join(root, "materialized"); + const tarballUrl = "https://npm.pkg.github.com/download/fixture-package/1.0.0/revision"; + const { sourceLock, sourcePackage } = writeProductionSourceGraph(root, { + integrity: "sha512-fixture", + resolved: tarballUrl, + version: "1.0.0", + }); + let installCalled = false; + try { + expect(() => + materializeSourceGraph( + sourcePackage, + sourceLock, + destination, + "https://registry.npmjs.org", + () => { + installCalled = true; + }, + [ + { + integrity: "sha512-another-value", + label: "reviewed fixture package", + packageSpec: "fixture-package@1.0.0", + tarballUrl, + }, + ], + ), + ).toThrow( + "reviewed npm lock package does not match its approved registry identity: node_modules/fixture-package", + ); + expect(installCalled).toBe(false); + expect(fs.existsSync(destination)).toBe(false); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + it("rejects dev: true when root production dependencies reach the package (#8116)", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-source-graph-dev-flag-")); const destination = path.join(root, "materialized"); diff --git a/test/repository/ci-install-dependencies.test.ts b/test/repository/ci-install-dependencies.test.ts index 8316b21023d..860ef64556d 100644 --- a/test/repository/ci-install-dependencies.test.ts +++ b/test/repository/ci-install-dependencies.test.ts @@ -2,13 +2,34 @@ // SPDX-License-Identifier: Apache-2.0 import { spawnSync } from "node:child_process"; -import { chmodSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { + chmodSync, + existsSync, + mkdtempSync, + mkdirSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; const temporaryRoots: string[] = []; +const installer = join(import.meta.dirname, "../../.github/actions/ci-install-dependencies.sh"); + +function makeFixture(): { root: string; trace: string; path: string } { + const root = mkdtempSync(join(tmpdir(), "nemoclaw-ci-install-")); + temporaryRoots.push(root); + const bin = join(root, "bin"); + const trace = join(root, "npm.trace"); + mkdirSync(bin); + const npm = join(bin, "npm"); + writeFileSync(npm, `#!/bin/sh\nprintf '%s\n' "$*" >> "$NPM_TRACE"\n`); + chmodSync(npm, 0o755); + return { root, trace, path: `${bin}:${process.env.PATH || ""}` }; +} afterEach(() => { for (const root of temporaryRoots.splice(0)) rmSync(root, { force: true, recursive: true }); @@ -16,25 +37,42 @@ afterEach(() => { describe("shared CI dependency installer", () => { it("installs root and plugin dependencies from lockfiles without lifecycle scripts", () => { - const root = mkdtempSync(join(tmpdir(), "nemoclaw-ci-install-")); - temporaryRoots.push(root); - const bin = join(root, "bin"); - const trace = join(root, "npm.trace"); - mkdirSync(bin); - const npm = join(bin, "npm"); - writeFileSync(npm, `#!/bin/sh\nprintf '%s\n' "$*" >> "$NPM_TRACE"\n`); - chmodSync(npm, 0o755); - - const result = spawnSync("bash", [".github/actions/ci-install-dependencies.sh"], { - cwd: join(import.meta.dirname, "../.."), + const fixture = makeFixture(); + + const result = spawnSync("bash", [installer], { + cwd: fixture.root, encoding: "utf8", - env: { ...process.env, NPM_TRACE: trace, PATH: `${bin}:${process.env.PATH || ""}` }, + env: { ...process.env, NPM_TRACE: fixture.trace, PATH: fixture.path }, }); expect(result.status, result.stderr).toBe(0); - expect(readFileSync(trace, "utf8").trim().split("\n")).toEqual([ + expect(readFileSync(fixture.trace, "utf8").trim().split("\n")).toEqual([ "ci --ignore-scripts", "--prefix nemoclaw ci --ignore-scripts", ]); }); + + it("rejects candidate npm configuration before npm receives the package token", () => { + const fixture = makeFixture(); + mkdirSync(join(fixture.root, "nemoclaw")); + writeFileSync(join(fixture.root, "nemoclaw", ".npmrc"), "@nvidia:registry=https://example.invalid\n"); + + const result = spawnSync("bash", [installer], { + cwd: fixture.root, + encoding: "utf8", + env: { + ...process.env, + NODE_AUTH_TOKEN: "credential-sentinel", + NPM_TRACE: fixture.trace, + PATH: fixture.path, + }, + }); + + expect(result.status).toBe(1); + expect(result.stderr).toBe( + "Candidate repository npm configuration is not allowed during trusted dependency installation.\n", + ); + expect(result.stderr).not.toContain("credential-sentinel"); + expect(existsSync(fixture.trace)).toBe(false); + }); }); From 6d16a8ace31bad69c61bea421ba46310321b094c Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Wed, 26 Aug 2026 01:32:53 -0700 Subject: [PATCH 02/27] test(ci): enforce package permission values Signed-off-by: Apurv Kumaria --- .../pull-requests/pr-workflow-contract.test.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/test/automation/pull-requests/pr-workflow-contract.test.ts b/test/automation/pull-requests/pr-workflow-contract.test.ts index 6e7a66ce31b..54c203e86dc 100644 --- a/test/automation/pull-requests/pr-workflow-contract.test.ts +++ b/test/automation/pull-requests/pr-workflow-contract.test.ts @@ -375,10 +375,15 @@ describe("pull request and main workflow contracts", () => { expect(workflow.permissions).toEqual({ contents: "read" }); expect( Object.entries(workflow.jobs) - .filter(([, job]) => job.permissions?.packages === "read") - .map(([jobName]) => jobName) - .sort(), - ).toEqual(["build-typecheck", "cli-test-shards", "installer-integration", "plugin-tests"]); + .filter(([, job]) => job.permissions?.packages !== undefined) + .map(([jobName, job]) => [jobName, job.permissions?.packages] as const) + .sort(([left], [right]) => left.localeCompare(right)), + ).toEqual([ + ["build-typecheck", "read"], + ["cli-test-shards", "read"], + ["installer-integration", "read"], + ["plugin-tests", "read"], + ]); }); // source-shape-contract: security -- PR base SHA action execution prevents pull-request code from authorizing installer hashes From 3d57390cf78c70b9b07211d10e44cf8070a312b3 Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Wed, 26 Aug 2026 03:39:27 -0700 Subject: [PATCH 03/27] fix(ci): isolate SDK package credentials Signed-off-by: Apurv Kumaria --- .../actions/ci-build-typecheck/action.yaml | 4 +- .../actions/ci-cli-coverage-shard/action.yaml | 4 +- .github/actions/ci-install-dependencies.sh | 51 ++++- .../ci-installer-integration/action.yaml | 4 +- .../actions/ci-plugin-coverage/action.yaml | 4 +- .github/actions/ci-static-checks/action.yaml | 11 +- .github/workflows/main.yaml | 3 + .../workflows/openshell-sdk-package-pr.yaml | 62 ++++++ .github/workflows/pr.yaml | 201 +++++++++++++++++- ci/reviewed-npm-audit.json | 19 ++ ci/source-shape-test-budget.json | 30 +++ scripts/audit-reviewed-npm-graph.mts | 37 +++- .../checks/package-openshell-sdk-for-pr.mts | 56 +++++ scripts/checks/prepare-ci-npm-install.mts | 197 +++++++++++++++++ scripts/lib/reviewed-npm-archive.mts | 52 ++++- .../openshell-sdk-package-workflow.test.ts | 67 ++++++ .../pr-workflow-contract.test.ts | 86 +++++++- .../reviewed-npm-audit-workflow.test.ts | 13 ++ .../ci-install-dependencies.test.ts | 114 +++++++++- .../repository/prepare-ci-npm-install.test.ts | 120 +++++++++++ 20 files changed, 1083 insertions(+), 52 deletions(-) create mode 100644 .github/workflows/openshell-sdk-package-pr.yaml create mode 100755 scripts/checks/package-openshell-sdk-for-pr.mts create mode 100755 scripts/checks/prepare-ci-npm-install.mts create mode 100644 test/automation/pull-requests/openshell-sdk-package-workflow.test.ts create mode 100644 test/repository/prepare-ci-npm-install.test.ts diff --git a/.github/actions/ci-build-typecheck/action.yaml b/.github/actions/ci-build-typecheck/action.yaml index ea101d82e61..db06972694c 100644 --- a/.github/actions/ci-build-typecheck/action.yaml +++ b/.github/actions/ci-build-typecheck/action.yaml @@ -12,8 +12,6 @@ runs: with: node-version: "22" cache: npm - registry-url: https://npm.pkg.github.com - scope: "@nvidia" cache-dependency-path: | package-lock.json nemoclaw/package-lock.json @@ -21,7 +19,7 @@ runs: - name: Install dependencies shell: bash env: - NODE_AUTH_TOKEN: ${{ github.token }} + NODE_AUTH_TOKEN: ${{ github.event_name == 'push' && github.token || '' }} run: bash "$GITHUB_ACTION_PATH/../ci-install-dependencies.sh" - name: Build TypeScript plugin diff --git a/.github/actions/ci-cli-coverage-shard/action.yaml b/.github/actions/ci-cli-coverage-shard/action.yaml index a267bfdbf70..f6b6a85eb90 100644 --- a/.github/actions/ci-cli-coverage-shard/action.yaml +++ b/.github/actions/ci-cli-coverage-shard/action.yaml @@ -56,8 +56,6 @@ runs: with: node-version: "22" cache: npm - registry-url: https://npm.pkg.github.com - scope: "@nvidia" cache-dependency-path: | package-lock.json nemoclaw/package-lock.json @@ -104,7 +102,7 @@ runs: - name: Install dependencies shell: bash env: - NODE_AUTH_TOKEN: ${{ github.token }} + NODE_AUTH_TOKEN: ${{ github.event_name == 'push' && github.token || '' }} run: bash "$GITHUB_ACTION_PATH/../ci-install-dependencies.sh" - name: Validate changed live E2E mock parity diff --git a/.github/actions/ci-install-dependencies.sh b/.github/actions/ci-install-dependencies.sh index 9dfd4194638..5597e198365 100755 --- a/.github/actions/ci-install-dependencies.sh +++ b/.github/actions/ci-install-dependencies.sh @@ -10,5 +10,52 @@ if [ -n "$candidate_npmrc" ]; then exit 1 fi -npm ci --ignore-scripts -npm --prefix nemoclaw ci --ignore-scripts +for shrinkwrap in npm-shrinkwrap.json nemoclaw/npm-shrinkwrap.json; do + if [ -e "$shrinkwrap" ]; then + echo "Candidate npm shrinkwrap files are not allowed during trusted dependency installation." >&2 + exit 1 + fi +done + +event_name="${GITHUB_EVENT_NAME:-local}" +package_mode="registry" +if [ "$event_name" = "pull_request" ]; then + package_mode="artifact" + if [ -n "${NODE_AUTH_TOKEN:-}" ]; then + echo "Pull request dependency installation must not receive a package credential." >&2 + exit 1 + fi +fi + +target_root="$(pwd -P)" +action_path="${GITHUB_ACTION_PATH:-$(cd "$(dirname "$0")" && pwd -P)}" +trusted_root="$(cd "$action_path/../.." && pwd -P)" +npm_cache="${NPM_CONFIG_CACHE:-${RUNNER_TEMP:-$target_root/.ci-cache}/npm}" +mkdir -p "$npm_cache" + +NEMOCLAW_CI_NPM_CACHE="$npm_cache" \ + NEMOCLAW_CI_NPM_PACKAGE_MODE="$package_mode" \ + NEMOCLAW_CI_TARGET_ROOT="$target_root" \ + NEMOCLAW_OPEN_SHELL_SDK_ARTIFACT_DIRECTORY="${RUNNER_TEMP:-$target_root/.ci-artifacts}/openshell-sdk" \ + node --experimental-strip-types "$trusted_root/scripts/checks/prepare-ci-npm-install.mts" + +trusted_npmrc="" +cleanup() { + if [ -n "$trusted_npmrc" ]; then + rm -f "$trusted_npmrc" + fi +} +trap cleanup EXIT + +if [ "$package_mode" = "registry" ] && [ -n "${NODE_AUTH_TOKEN:-}" ]; then + trusted_npmrc="${RUNNER_TEMP:-$target_root/.ci-cache}/trusted-npmrc" + mkdir -p "$(dirname "$trusted_npmrc")" + umask 077 + printf '%s\n' \ + '@nvidia:registry=https://npm.pkg.github.com' \ + "//npm.pkg.github.com/:_authToken=\${NODE_AUTH_TOKEN}" >"$trusted_npmrc" + export NPM_CONFIG_USERCONFIG="$trusted_npmrc" +fi + +npm ci --ignore-scripts --prefer-offline --cache "$npm_cache" +npm --prefix nemoclaw ci --ignore-scripts --prefer-offline --cache "$npm_cache" diff --git a/.github/actions/ci-installer-integration/action.yaml b/.github/actions/ci-installer-integration/action.yaml index d994900dad4..dcc1e22ed75 100644 --- a/.github/actions/ci-installer-integration/action.yaml +++ b/.github/actions/ci-installer-integration/action.yaml @@ -12,8 +12,6 @@ runs: with: node-version: "22" cache: npm - registry-url: https://npm.pkg.github.com - scope: "@nvidia" cache-dependency-path: | package-lock.json nemoclaw/package-lock.json @@ -21,7 +19,7 @@ runs: - name: Install dependencies shell: bash env: - NODE_AUTH_TOKEN: ${{ github.token }} + NODE_AUTH_TOKEN: ${{ github.event_name == 'push' && github.token || '' }} run: bash "$GITHUB_ACTION_PATH/../ci-install-dependencies.sh" - name: Build installer integration artifacts diff --git a/.github/actions/ci-plugin-coverage/action.yaml b/.github/actions/ci-plugin-coverage/action.yaml index ac7c2a2d5d8..0f78c17d3be 100644 --- a/.github/actions/ci-plugin-coverage/action.yaml +++ b/.github/actions/ci-plugin-coverage/action.yaml @@ -12,8 +12,6 @@ runs: with: node-version: "22" cache: npm - registry-url: https://npm.pkg.github.com - scope: "@nvidia" cache-dependency-path: | package-lock.json nemoclaw/package-lock.json @@ -21,7 +19,7 @@ runs: - name: Install dependencies shell: bash env: - NODE_AUTH_TOKEN: ${{ github.token }} + NODE_AUTH_TOKEN: ${{ github.event_name == 'push' && github.token || '' }} run: bash "$GITHUB_ACTION_PATH/../ci-install-dependencies.sh" - name: Run plugin coverage diff --git a/.github/actions/ci-static-checks/action.yaml b/.github/actions/ci-static-checks/action.yaml index a98eb4b9e3d..6ae0a731265 100644 --- a/.github/actions/ci-static-checks/action.yaml +++ b/.github/actions/ci-static-checks/action.yaml @@ -12,6 +12,9 @@ runs: with: node-version: "22" cache: npm + cache-dependency-path: | + package-lock.json + nemoclaw/package-lock.json - name: Install base-trusted createRequire verifier dependencies shell: bash @@ -33,13 +36,11 @@ runs: [ "$HADOLINT_SHA256" = "$ACTUAL" ] || { echo "::error::hadolint checksum mismatch"; exit 1; } chmod +x /usr/local/bin/hadolint - - name: Validate sandbox payload lockfile - shell: bash - run: npm --prefix nemoclaw ci --ignore-scripts --dry-run - - name: Install dependencies shell: bash - run: npm install --ignore-scripts + env: + NODE_AUTH_TOKEN: ${{ github.event_name == 'push' && github.token || '' }} + run: bash "$GITHUB_ACTION_PATH/../ci-install-dependencies.sh" - name: Verify reviewed runtime bundles shell: bash diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index fceaf4711f9..6dbbcac715f 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -22,6 +22,9 @@ concurrency: jobs: static-checks: + permissions: + contents: read + packages: read runs-on: ubuntu-latest timeout-minutes: 10 steps: diff --git a/.github/workflows/openshell-sdk-package-pr.yaml b/.github/workflows/openshell-sdk-package-pr.yaml new file mode 100644 index 00000000000..d0f33459dd2 --- /dev/null +++ b/.github/workflows/openshell-sdk-package-pr.yaml @@ -0,0 +1,62 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +name: Security / Package OpenShell SDK for PR + +run-name: "OpenShell SDK PR #${{ github.event.pull_request.number }} head ${{ github.event.pull_request.head.sha }} base ${{ github.event.pull_request.base.sha }}" + +# This workflow is loaded from the pull request base branch. It must never +# check out or execute pull request content because its token can read packages. +on: + pull_request_target: + types: [opened, synchronize, reopened] + +permissions: + contents: read + +concurrency: + group: openshell-sdk-package-${{ github.event.pull_request.number }}-${{ github.event.pull_request.head.sha }} + cancel-in-progress: true + +jobs: + package-openshell-sdk: + permissions: + contents: read + packages: read + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Checkout base-controlled package verifier + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.pull_request.base.sha }} + persist-credentials: false + sparse-checkout: | + ci/reviewed-npm-audit.json + scripts/audit-reviewed-npm-graph.mts + scripts/checks/package-openshell-sdk-for-pr.mts + scripts/lib/openclaw-npm-remediation.mts + scripts/lib/reviewed-npm-archive.mts + scripts/lib/reviewed-npm-audit.mts + sparse-checkout-cone-mode: false + + - name: Setup Node.js for reviewed package download + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: "22" + registry-url: https://npm.pkg.github.com + scope: "@nvidia" + + - name: Download and verify exact OpenShell SDK package + env: + NEMOCLAW_OPEN_SHELL_SDK_OUTPUT_DIRECTORY: ${{ runner.temp }}/openshell-sdk + NODE_AUTH_TOKEN: ${{ github.token }} + run: node --experimental-strip-types scripts/checks/package-openshell-sdk-for-pr.mts + + - name: Upload verified OpenShell SDK archive + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: openshell-sdk-${{ github.event.pull_request.head.sha }} + path: ${{ runner.temp }}/openshell-sdk/nvidia-openshell-sdk-0.0.106.tgz + if-no-files-found: error + retention-days: 1 diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index 80fcb981b5c..b3761f16f04 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -96,7 +96,7 @@ jobs: run: npm run docs static-checks: - needs: changes + needs: [changes, openshell-sdk-package] if: needs.changes.outputs.code == 'true' runs-on: ubuntu-latest timeout-minutes: 10 @@ -121,17 +121,145 @@ jobs: .github/actions/ci-plugin-coverage .github/actions/ci-installer-integration .github/actions/ci-install-dependencies.sh + ci/reviewed-npm-audit.json + scripts/audit-reviewed-npm-graph.mts + scripts/checks/prepare-ci-npm-install.mts + scripts/lib/openclaw-npm-remediation.mts + scripts/lib/reviewed-npm-archive.mts + scripts/lib/reviewed-npm-audit.mts sparse-checkout-cone-mode: false + - name: Download verified OpenShell SDK archive + if: needs.openshell-sdk-package.outputs.required == 'true' + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: openshell-sdk-package + path: ${{ runner.temp }}/openshell-sdk + - name: Run static checks uses: ./.trusted-ci-actions/.github/actions/ci-static-checks - build-typecheck: + openshell-sdk-package: needs: changes if: needs.changes.outputs.code == 'true' + permissions: + actions: read + contents: read + outputs: + required: ${{ steps.locate.outputs.required }} + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Checkout pull request lockfiles + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + sparse-checkout: | + package-lock.json + nemoclaw/package-lock.json + sparse-checkout-cone-mode: false + + - name: Checkout base package workflow identity + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.pull_request.base.sha }} + path: .trusted-sdk-package-workflow + persist-credentials: false + sparse-checkout: .github/workflows/openshell-sdk-package-pr.yaml + sparse-checkout-cone-mode: false + + - name: Locate exact base-controlled SDK package run + id: locate + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + GH_TOKEN: ${{ github.token }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + set -euo pipefail + + sdk_locked="$(node - <<'NODE' + const fs = require('node:fs'); + const expected = '@nvidia/openshell-sdk@0.0.106'; + let found = false; + for (const path of ['package-lock.json', 'nemoclaw/package-lock.json']) { + const lock = JSON.parse(fs.readFileSync(path, 'utf8')); + for (const [location, value] of Object.entries(lock.packages || {})) { + if (!location || !value || typeof value !== 'object') continue; + const marker = 'node_modules/'; + const index = location.lastIndexOf(marker); + const name = typeof value.name === 'string' ? value.name : location.slice(index + marker.length); + if (`${name}@${value.version || ''}` === expected) found = true; + } + } + process.stdout.write(String(found)); + NODE + )" + if [ "$sdk_locked" != "true" ]; then + echo "required=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + if [ ! -f .trusted-sdk-package-workflow/.github/workflows/openshell-sdk-package-pr.yaml ]; then + echo "::error title=Missing trusted SDK package workflow::The pull request base cannot package the approved OpenShell SDK." + exit 1 + fi + + echo "required=true" >> "$GITHUB_OUTPUT" + for attempt in $(seq 1 24); do + runs="$(gh api \ + "repos/$GITHUB_REPOSITORY/actions/workflows/openshell-sdk-package-pr.yaml/runs?event=pull_request_target&per_page=100")" + match="$(jq -cer \ + --arg base "$BASE_SHA" \ + --arg head "$HEAD_SHA" \ + --argjson pr "$PR_NUMBER" ' + [.workflow_runs[] | + select(.event == "pull_request_target") | + select(any(.pull_requests[]?; + .number == $pr and .head.sha == $head and .base.sha == $base))] | + sort_by(.created_at) | reverse | .[0] // empty + ' <<<"$runs" 2>/dev/null || true)" + if [ -n "$match" ]; then + status="$(jq -r '.status' <<<"$match")" + if [ "$status" = "completed" ]; then + conclusion="$(jq -r '.conclusion' <<<"$match")" + if [ "$conclusion" != "success" ]; then + echo "::error title=SDK package workflow failed::Expected success, got ${conclusion}." + exit 1 + fi + run_id="$(jq -er '.id | select(type == "number" and . > 0 and . <= 9007199254740991)' <<<"$match")" + echo "run_id=$run_id" >> "$GITHUB_OUTPUT" + exit 0 + fi + fi + sleep 5 + done + echo "::error title=SDK package workflow timed out::No successful exact-head package run was available." + exit 1 + + - name: Download exact base-controlled SDK archive + if: steps.locate.outputs.required == 'true' + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + github-token: ${{ github.token }} + repository: ${{ github.repository }} + run-id: ${{ steps.locate.outputs.run_id }} + name: openshell-sdk-${{ github.event.pull_request.head.sha }} + path: ${{ runner.temp }}/openshell-sdk + + - name: Publish SDK archive inside this CI run + if: steps.locate.outputs.required == 'true' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: openshell-sdk-package + path: ${{ runner.temp }}/openshell-sdk/nvidia-openshell-sdk-0.0.106.tgz + if-no-files-found: error + retention-days: 1 + + build-typecheck: + needs: [changes, openshell-sdk-package] + if: needs.changes.outputs.code == 'true' permissions: contents: read - packages: read runs-on: ubuntu-latest timeout-minutes: 10 steps: @@ -154,17 +282,29 @@ jobs: .github/actions/ci-plugin-coverage .github/actions/ci-installer-integration .github/actions/ci-install-dependencies.sh + ci/reviewed-npm-audit.json + scripts/audit-reviewed-npm-graph.mts + scripts/checks/prepare-ci-npm-install.mts + scripts/lib/openclaw-npm-remediation.mts + scripts/lib/reviewed-npm-archive.mts + scripts/lib/reviewed-npm-audit.mts sparse-checkout-cone-mode: false + - name: Download verified OpenShell SDK archive + if: needs.openshell-sdk-package.outputs.required == 'true' + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: openshell-sdk-package + path: ${{ runner.temp }}/openshell-sdk + - name: Run build and type checks uses: ./.trusted-ci-actions/.github/actions/ci-build-typecheck installer-integration: - needs: changes + needs: [changes, openshell-sdk-package] if: needs.changes.outputs.code == 'true' permissions: contents: read - packages: read runs-on: ubuntu-latest timeout-minutes: 10 steps: @@ -182,8 +322,21 @@ jobs: sparse-checkout: | .github/actions/ci-installer-integration .github/actions/ci-install-dependencies.sh + ci/reviewed-npm-audit.json + scripts/audit-reviewed-npm-graph.mts + scripts/checks/prepare-ci-npm-install.mts + scripts/lib/openclaw-npm-remediation.mts + scripts/lib/reviewed-npm-archive.mts + scripts/lib/reviewed-npm-audit.mts sparse-checkout-cone-mode: false + - name: Download verified OpenShell SDK archive + if: needs.openshell-sdk-package.outputs.required == 'true' + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: openshell-sdk-package + path: ${{ runner.temp }}/openshell-sdk + - name: Run installer integration tests uses: ./.trusted-ci-actions/.github/actions/ci-installer-integration @@ -258,11 +411,10 @@ jobs: report-dir: artifacts/reviewed-npm-audit cli-test-shards: - needs: changes + needs: [changes, openshell-sdk-package] if: needs.changes.outputs.code == 'true' permissions: contents: read - packages: read runs-on: ubuntu-24.04 # Coverage startup plus the stable, duration-weighted roster can exceed # the former 15-minute cap before Vitest writes its shard artifacts. @@ -292,8 +444,21 @@ jobs: .github/actions/ci-plugin-coverage .github/actions/ci-installer-integration .github/actions/ci-install-dependencies.sh + ci/reviewed-npm-audit.json + scripts/audit-reviewed-npm-graph.mts + scripts/checks/prepare-ci-npm-install.mts + scripts/lib/openclaw-npm-remediation.mts + scripts/lib/reviewed-npm-archive.mts + scripts/lib/reviewed-npm-audit.mts sparse-checkout-cone-mode: false + - name: Download verified OpenShell SDK archive + if: needs.openshell-sdk-package.outputs.required == 'true' + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: openshell-sdk-package + path: ${{ runner.temp }}/openshell-sdk + - name: Run CLI coverage shard uses: ./.trusted-ci-actions/.github/actions/ci-cli-coverage-shard with: @@ -396,12 +561,11 @@ jobs: shard-count: "12" plugin-tests: - needs: changes + needs: [changes, openshell-sdk-package] if: needs.changes.outputs.code == 'true' permissions: code-quality: write contents: read - packages: read pull-requests: read runs-on: ubuntu-latest timeout-minutes: 10 @@ -425,8 +589,21 @@ jobs: .github/actions/ci-plugin-coverage .github/actions/ci-installer-integration .github/actions/ci-install-dependencies.sh + ci/reviewed-npm-audit.json + scripts/audit-reviewed-npm-graph.mts + scripts/checks/prepare-ci-npm-install.mts + scripts/lib/openclaw-npm-remediation.mts + scripts/lib/reviewed-npm-archive.mts + scripts/lib/reviewed-npm-audit.mts sparse-checkout-cone-mode: false + - name: Download verified OpenShell SDK archive + if: needs.openshell-sdk-package.outputs.required == 'true' + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: openshell-sdk-package + path: ${{ runner.temp }}/openshell-sdk + - name: Run plugin coverage uses: ./.trusted-ci-actions/.github/actions/ci-plugin-coverage @@ -439,6 +616,7 @@ jobs: - installer-integration - wechat-runtime-audit - reviewed-npm-audit + - openshell-sdk-package - cli-tests - plugin-tests if: always() @@ -458,6 +636,7 @@ jobs: INSTALLER_INTEGRATION_RESULT: ${{ needs['installer-integration'].result }} WECHAT_RUNTIME_AUDIT_RESULT: ${{ needs['wechat-runtime-audit'].result }} REVIEWED_NPM_AUDIT_RESULT: ${{ needs['reviewed-npm-audit'].result }} + OPEN_SHELL_SDK_PACKAGE_RESULT: ${{ needs['openshell-sdk-package'].result }} CLI_TESTS_RESULT: ${{ needs['cli-tests'].result }} GH_TOKEN: ${{ github.token }} PLUGIN_TESTS_RESULT: ${{ needs['plugin-tests'].result }} @@ -503,7 +682,7 @@ jobs: local job_id="" dependency_url="$RUN_URL" case "$name" in - changes|docs-only-checks|static-checks|build-typecheck|installer-integration|wechat-runtime-audit|reviewed-npm-audit|cli-tests|plugin-tests) ;; + changes|docs-only-checks|static-checks|build-typecheck|installer-integration|wechat-runtime-audit|reviewed-npm-audit|openshell-sdk-package|cli-tests|plugin-tests) ;; *) return ;; esac load_job_listing @@ -560,6 +739,7 @@ jobs: require_success "installer-integration" "$INSTALLER_INTEGRATION_RESULT" require_success "wechat-runtime-audit" "$WECHAT_RUNTIME_AUDIT_RESULT" require_success "reviewed-npm-audit" "$REVIEWED_NPM_AUDIT_RESULT" + require_success "openshell-sdk-package" "$OPEN_SHELL_SDK_PACKAGE_RESULT" require_success "cli-tests" "$CLI_TESTS_RESULT" require_success "plugin-tests" "$PLUGIN_TESTS_RESULT" else @@ -569,6 +749,7 @@ jobs: allow_success_or_skipped "installer-integration" "$INSTALLER_INTEGRATION_RESULT" allow_success_or_skipped "wechat-runtime-audit" "$WECHAT_RUNTIME_AUDIT_RESULT" allow_success_or_skipped "reviewed-npm-audit" "$REVIEWED_NPM_AUDIT_RESULT" + allow_success_or_skipped "openshell-sdk-package" "$OPEN_SHELL_SDK_PACKAGE_RESULT" allow_success_or_skipped "cli-tests" "$CLI_TESTS_RESULT" allow_success_or_skipped "plugin-tests" "$PLUGIN_TESTS_RESULT" fi diff --git a/ci/reviewed-npm-audit.json b/ci/reviewed-npm-audit.json index cdb51143ca2..9188db562a1 100644 --- a/ci/reviewed-npm-audit.json +++ b/ci/reviewed-npm-audit.json @@ -4,12 +4,31 @@ "registryOrigin": "https://registry.npmjs.org/", "sourceRegistryPackages": [ { + "artifactName": "nvidia-openshell-sdk-0.0.106.tgz", "label": "OpenShell TypeScript SDK 0.0.106", "packageSpec": "@nvidia/openshell-sdk@0.0.106", "integrity": "sha512-dB4mLex23Pnw61caGMR2CMHQihy9bj7IK2elJJd718k3yevm+fOt/vG6dJg8/5us4la2BwcOdRwLvOia3tdwFw==", "tarballUrl": "https://npm.pkg.github.com/download/@nvidia/openshell-sdk/0.0.106/dc32180ba1d658fc4ec309bdf89d2b162196928d" } ], + "sourceNestedShrinkwrapPackages": ["@earendil-works/pi-coding-agent@0.80.6"], + "sourceRegistryPackagesWithoutIntegrity": [ + { + "label": "Pi agent core 0.80.6 nested shrinkwrap entry", + "packageSpec": "@earendil-works/pi-agent-core@0.80.6", + "tarballUrl": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.80.6.tgz" + }, + { + "label": "Pi AI 0.80.6 nested shrinkwrap entry", + "packageSpec": "@earendil-works/pi-ai@0.80.6", + "tarballUrl": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.80.6.tgz" + }, + { + "label": "Pi TUI 0.80.6 nested shrinkwrap entry", + "packageSpec": "@earendil-works/pi-tui@0.80.6", + "tarballUrl": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.80.6.tgz" + } + ], "severityThreshold": "high", "exceptionFile": "ci/npm-audit-exceptions.json", "archiveGraphId": "reviewed-archive-graph", diff --git a/ci/source-shape-test-budget.json b/ci/source-shape-test-budget.json index cafe3abc025..e73f968de60 100644 --- a/ci/source-shape-test-budget.json +++ b/ci/source-shape-test-budget.json @@ -161,6 +161,36 @@ "test": "executes pull request installer hash checks only from the PR base SHA", "category": "security" }, + { + "file": "test/automation/pull-requests/openshell-sdk-package-workflow.test.ts", + "test": "keeps package access out of pull request controlled execution", + "category": "security" + }, + { + "file": "test/automation/pull-requests/pr-workflow-contract.test.ts", + "test": "does not grant package access to pull request jobs", + "category": "security" + }, + { + "file": "test/automation/pull-requests/pr-workflow-contract.test.ts", + "test": "limits main package reads to dependency-install jobs", + "category": "security" + }, + { + "file": "test/automation/pull-requests/pr-workflow-contract.test.ts", + "test": "provides the package token only to trusted main dependency installation", + "category": "security" + }, + { + "file": "test/automation/pull-requests/pr-workflow-contract.test.ts", + "test": "passes only the base-packaged SDK archive to pull request dependency jobs", + "category": "security" + }, + { + "file": "test/automation/pull-requests/pr-workflow-contract.test.ts", + "test": "passes the verified SDK archive to %s", + "category": "security" + }, { "file": "test/automation/releases/release-daily-brev-image.test.ts", "test": "attests one daily request before the isolated dispatch job (#9799)", diff --git a/scripts/audit-reviewed-npm-graph.mts b/scripts/audit-reviewed-npm-graph.mts index d9d48c142e0..29df91c77b5 100755 --- a/scripts/audit-reviewed-npm-graph.mts +++ b/scripts/audit-reviewed-npm-graph.mts @@ -29,6 +29,12 @@ type ReviewedPackage = Readonly<{ packageSpec: string; tarballUrl: string; }>; +type SourceRegistryPackage = ReviewedPackage & Readonly<{ artifactName: string }>; +type PackageWithoutIntegrity = Readonly<{ + label: string; + packageSpec: string; + tarballUrl: string; +}>; type LockedGraph = ReviewedPackage & Readonly<{ directory: string; @@ -47,7 +53,9 @@ type AuditConfig = Readonly<{ registryOrigin: string; schemaVersion: 2; severityThreshold: Severity; - sourceRegistryPackages: readonly ReviewedPackage[]; + sourceNestedShrinkwrapPackages: readonly string[]; + sourceRegistryPackages: readonly SourceRegistryPackage[]; + sourceRegistryPackagesWithoutIntegrity: readonly PackageWithoutIntegrity[]; }>; type ReviewedAuditReport = Readonly<{ label: string; result: AuditPolicyResult }>; @@ -144,9 +152,33 @@ export function parseAuditConfig(contents: string): AuditConfig { !parsed.registryOrigin || !Array.isArray(parsed.archivePackages) || !Array.isArray(parsed.lockedGraphs) || + !Array.isArray(parsed.sourceNestedShrinkwrapPackages) || + parsed.sourceNestedShrinkwrapPackages.some( + (packageSpec) => + typeof packageSpec !== "string" || + !/^(?:@[a-z0-9][a-z0-9._-]*\/[a-z0-9][a-z0-9._-]*|[a-z0-9][a-z0-9._-]*)@[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?$/.test( + packageSpec, + ), + ) || + new Set(parsed.sourceNestedShrinkwrapPackages).size !== + parsed.sourceNestedShrinkwrapPackages.length || + !Array.isArray(parsed.sourceRegistryPackagesWithoutIntegrity) || + parsed.sourceRegistryPackagesWithoutIntegrity.some( + (reviewed) => + typeof reviewed.label !== "string" || + !reviewed.label || + typeof reviewed.packageSpec !== "string" || + !reviewed.packageSpec || + typeof reviewed.tarballUrl !== "string" || + !reviewed.tarballUrl, + ) || + new Set(parsed.sourceRegistryPackagesWithoutIntegrity.map(({ packageSpec }) => packageSpec)) + .size !== parsed.sourceRegistryPackagesWithoutIntegrity.length || !Array.isArray(parsed.sourceRegistryPackages) || parsed.sourceRegistryPackages.some( (reviewed) => + typeof reviewed.artifactName !== "string" || + !/^[a-z0-9][a-z0-9._-]*\.tgz$/.test(reviewed.artifactName) || typeof reviewed.label !== "string" || !reviewed.label || typeof reviewed.packageSpec !== "string" || @@ -332,10 +364,12 @@ export function materializeSourceGraph( registryOrigin: string, installProductionDependencies: (directory: string) => void = installProductionSourceDependencies, sourceRegistryPackages: readonly ReviewedPackage[] = [], + sourceNestedShrinkwrapPackages: readonly string[] = [], ): string { assertRegularFile(sourcePackage, "NemoClaw CLI package manifest"); assertRegularFile(sourceLock, "NemoClaw CLI lockfile"); verifyReviewedNpmLockPackages({ + allowedNestedShrinkwrapPackages: sourceNestedShrinkwrapPackages, lockfilePath: sourceLock, omitDev: true, registryOrigin, @@ -486,6 +520,7 @@ function auditSourceGraph( config.registryOrigin, installProductionSourceDependencies, config.sourceRegistryPackages, + config.sourceNestedShrinkwrapPackages, ); return auditMaterializedSourceGraph({ directory, diff --git a/scripts/checks/package-openshell-sdk-for-pr.mts b/scripts/checks/package-openshell-sdk-for-pr.mts new file mode 100755 index 00000000000..486f6f6ecee --- /dev/null +++ b/scripts/checks/package-openshell-sdk-for-pr.mts @@ -0,0 +1,56 @@ +#!/usr/bin/env -S node --experimental-strip-types +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { copyFileSync, mkdirSync, readFileSync, rmSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +import { parseAuditConfig } from "../audit-reviewed-npm-graph.mts"; +import { packReviewedNpmArchive, removeReviewedNpmArchive } from "../lib/reviewed-npm-archive.mts"; + +const TRUSTED_REPOSITORY_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); + +export function packageReviewedOpenShellSdk(outputDirectory: string): string { + if (!outputDirectory) { + throw new Error("reviewed OpenShell SDK output directory is required"); + } + const config = parseAuditConfig( + readFileSync(join(TRUSTED_REPOSITORY_ROOT, "ci/reviewed-npm-audit.json"), "utf8"), + ); + if (config.sourceRegistryPackages.length !== 1) { + throw new Error("reviewed npm configuration must name one source-registry package"); + } + const reviewed = config.sourceRegistryPackages[0]; + const archive = packReviewedNpmArchive({ + env: process.env, + expectedIntegrity: reviewed.integrity, + label: reviewed.label, + packageSpec: reviewed.packageSpec, + tarballUrl: reviewed.tarballUrl, + }); + const output = resolve(outputDirectory); + try { + rmSync(output, { force: true, recursive: true }); + mkdirSync(output, { recursive: true }); + const artifact = join(output, reviewed.artifactName); + copyFileSync(archive.archivePath, artifact); + return artifact; + } finally { + removeReviewedNpmArchive(archive); + } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { + const outputDirectory = process.env.NEMOCLAW_OPEN_SHELL_SDK_OUTPUT_DIRECTORY; + if (!outputDirectory) { + console.error("NEMOCLAW_OPEN_SHELL_SDK_OUTPUT_DIRECTORY is required"); + process.exit(1); + } + try { + process.stdout.write(`${packageReviewedOpenShellSdk(outputDirectory)}\n`); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); + } +} diff --git a/scripts/checks/prepare-ci-npm-install.mts b/scripts/checks/prepare-ci-npm-install.mts new file mode 100755 index 00000000000..8bcc2a585e4 --- /dev/null +++ b/scripts/checks/prepare-ci-npm-install.mts @@ -0,0 +1,197 @@ +#!/usr/bin/env -S node --experimental-strip-types +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { createHash } from "node:crypto"; +import { + closeSync, + existsSync, + fstatSync, + lstatSync, + openSync, + readFileSync, + readdirSync, +} from "node:fs"; +import { createRequire } from "node:module"; +import { dirname, isAbsolute, join, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +import { parseAuditConfig } from "../audit-reviewed-npm-graph.mts"; +import { verifyReviewedNpmLockPackages } from "../lib/reviewed-npm-archive.mts"; +import type { CachePut } from "../lib/seed-reviewed-npm-cache.mts"; + +const TRUSTED_REPOSITORY_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); +const MAXIMUM_ARCHIVE_BYTES = 32 * 1024 * 1024; + +type PreparationRequest = Readonly<{ + artifactDirectory?: string; + cacheDirectory: string; + mode: "artifact" | "registry"; + targetRoot: string; +}>; + +export type ReviewedSourceRegistryPackage = Readonly<{ + artifactName: string; + integrity: string; + label: string; + packageSpec: string; + tarballUrl: string; +}>; + +export type ReviewedSourceRegistryArtifactRequest = Readonly<{ + artifactDirectory: string; + cacheDirectory: string; + reviewed: ReviewedSourceRegistryPackage; +}>; + +function loadCachePut(): CachePut { + const require = createRequire(import.meta.url); + const npmRoot = String( + require("node:child_process").execFileSync("npm", ["root", "-g"], { encoding: "utf8" }), + ).trim(); + const cacachePath = require.resolve("cacache", { paths: [join(npmRoot, "npm", "node_modules")] }); + return (require(cacachePath) as Readonly<{ put: CachePut }>).put; +} + +function readRegularArchive(file: string): Buffer { + let descriptor: number | undefined; + try { + descriptor = openSync(file, "r"); + const opened = fstatSync(descriptor); + const pathEntry = lstatSync(file); + if ( + !opened.isFile() || + !pathEntry.isFile() || + pathEntry.isSymbolicLink() || + opened.dev !== pathEntry.dev || + opened.ino !== pathEntry.ino || + opened.size > MAXIMUM_ARCHIVE_BYTES + ) { + throw new Error("archive must be one bounded non-symlink regular file"); + } + return readFileSync(descriptor); + } finally { + if (descriptor !== undefined) closeSync(descriptor); + } +} + +export async function seedReviewedSourceRegistryArtifact( + request: ReviewedSourceRegistryArtifactRequest, + put?: CachePut, +): Promise { + if (!isAbsolute(request.artifactDirectory)) { + throw new Error("reviewed OpenShell SDK artifact directory must be absolute"); + } + const artifactDirectory = resolve(request.artifactDirectory); + if (!existsSync(artifactDirectory)) { + throw new Error("reviewed OpenShell SDK artifact is required"); + } + const directoryEntry = lstatSync(artifactDirectory); + if (!directoryEntry.isDirectory() || directoryEntry.isSymbolicLink()) { + throw new Error("reviewed OpenShell SDK artifact path must be a non-symlink directory"); + } + const entries = readdirSync(artifactDirectory); + if (entries.length !== 1 || entries[0] !== request.reviewed.artifactName) { + throw new Error("reviewed OpenShell SDK artifact directory has unexpected contents"); + } + const archive = readRegularArchive(join(artifactDirectory, request.reviewed.artifactName)); + const actualIntegrity = `sha512-${createHash("sha512").update(archive).digest("base64")}`; + if (actualIntegrity !== request.reviewed.integrity) { + throw new Error( + "reviewed OpenShell SDK artifact integrity does not match the approved package", + ); + } + const cachePut = put ?? loadCachePut(); + const cachePath = join(resolve(request.cacheDirectory), "_cacache"); + await cachePut( + cachePath, + `make-fetch-happen:request-cache:${request.reviewed.tarballUrl}`, + archive, + { + metadata: { + options: { compress: true }, + reqHeaders: {}, + resHeaders: { + "cache-control": "public, immutable, max-age=31557600", + "content-type": "application/octet-stream", + }, + time: 0, + url: request.reviewed.tarballUrl, + }, + }, + ); + await cachePut(cachePath, `pacote:tarball:${request.reviewed.packageSpec}`, archive); +} + +export async function prepareCiNpmInstall( + request: PreparationRequest, + put?: CachePut, +): Promise { + const targetRoot = resolve(request.targetRoot); + const cacheDirectory = resolve(request.cacheDirectory); + const config = parseAuditConfig( + readFileSync(join(TRUSTED_REPOSITORY_ROOT, "ci/reviewed-npm-audit.json"), "utf8"), + ); + if (config.sourceRegistryPackages.length !== 1) { + throw new Error("reviewed npm configuration must name one source-registry package"); + } + const reviewed = config.sourceRegistryPackages[0]; + const reviewedRegistryPackages = [ + { + expectedIntegrity: reviewed.integrity, + label: reviewed.label, + packageSpec: reviewed.packageSpec, + tarballUrl: reviewed.tarballUrl, + }, + ]; + const packageSets = ["package-lock.json", "nemoclaw/package-lock.json"].map((relativePath) => + verifyReviewedNpmLockPackages({ + allowedNestedShrinkwrapPackages: config.sourceNestedShrinkwrapPackages, + lockfilePath: join(targetRoot, relativePath), + registryOrigin: config.registryOrigin, + reviewedRegistryPackages, + reviewedPackagesWithoutIntegrity: config.sourceRegistryPackagesWithoutIntegrity, + }), + ); + const sdkIsLocked = packageSets.some((packages) => packages.includes(reviewed.packageSpec)); + + if (request.mode === "registry") return; + if (!request.artifactDirectory) { + if (sdkIsLocked) throw new Error("reviewed OpenShell SDK artifact is required"); + return; + } + if (!isAbsolute(request.artifactDirectory)) { + throw new Error("reviewed OpenShell SDK artifact directory must be absolute"); + } + const artifactDirectory = resolve(request.artifactDirectory); + if (!existsSync(artifactDirectory)) { + if (sdkIsLocked) throw new Error("reviewed OpenShell SDK artifact is required"); + return; + } + if (!sdkIsLocked) { + throw new Error("reviewed OpenShell SDK artifact is not used by either lockfile"); + } + await seedReviewedSourceRegistryArtifact({ artifactDirectory, cacheDirectory, reviewed }, put); +} + +function requestFromEnvironment(): PreparationRequest { + const mode = process.env.NEMOCLAW_CI_NPM_PACKAGE_MODE; + const targetRoot = process.env.NEMOCLAW_CI_TARGET_ROOT; + const cacheDirectory = process.env.NEMOCLAW_CI_NPM_CACHE; + if ((mode !== "artifact" && mode !== "registry") || !targetRoot || !cacheDirectory) { + throw new Error("trusted CI npm preparation environment is incomplete"); + } + return { + artifactDirectory: process.env.NEMOCLAW_OPEN_SHELL_SDK_ARTIFACT_DIRECTORY, + cacheDirectory, + mode, + targetRoot, + }; +} + +if (process.argv[1] && pathToFileURL(resolve(process.argv[1])).href === import.meta.url) { + prepareCiNpmInstall(requestFromEnvironment()).catch((error) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); + }); +} diff --git a/scripts/lib/reviewed-npm-archive.mts b/scripts/lib/reviewed-npm-archive.mts index e59876ef111..9543c4b0562 100755 --- a/scripts/lib/reviewed-npm-archive.mts +++ b/scripts/lib/reviewed-npm-archive.mts @@ -61,6 +61,12 @@ export type ReviewedNpmMetadata = Readonly<{ tarballUrl: string; }>; +export type ReviewedNpmPackageWithoutIntegrity = Readonly<{ + label: string; + packageSpec: string; + tarballUrl: string; +}>; + export type ReviewedNpmArchive = Readonly<{ archivePath: string; rootDirectory: string; @@ -422,10 +428,16 @@ function readReviewedLockPackages( allowEmpty = false, allowNestedShrinkwrap = false, reviewedRegistryPackages: readonly ReviewedNpmArchiveRequest[] = [], + allowedNestedShrinkwrapPackages: readonly string[] = [], + reviewedPackagesWithoutIntegrity: readonly ReviewedNpmPackageWithoutIntegrity[] = [], ): readonly ReviewedNpmArchiveRequest[] { const reviewed: ReviewedNpmArchiveRequest[] = []; const identities = new Map(); const reviewedRegistryIdentities = new Map(); + const allowedNestedShrinkwrapIdentities = new Set(allowedNestedShrinkwrapPackages); + const reviewedPackagesWithoutIntegrityBySpec = new Map( + reviewedPackagesWithoutIntegrity.map((reviewed) => [reviewed.packageSpec, reviewed]), + ); for (const reviewedPackage of reviewedRegistryPackages) { requireReviewedRequest(reviewedPackage); let parsedTarball: URL; @@ -455,23 +467,37 @@ function readReviewedLockPackages( const record = value as Record; assertNotProductionDev(productionLocations, location, record); if (omitDev && record.dev === true) continue; - if (!allowNestedShrinkwrap && Object.prototype.hasOwnProperty.call(record, "hasShrinkwrap")) { - throw new Error( - `reviewed npm lock package must not delegate to nested shrinkwrap: ${location}`, - ); - } const locationName = packageNameFromLockLocation(location); const packageName = typeof record.name === "string" ? record.name : locationName; const version = typeof record.version === "string" ? record.version : ""; const packageSpec = `${packageName}@${version}`; + if ( + !allowNestedShrinkwrap && + Object.prototype.hasOwnProperty.call(record, "hasShrinkwrap") && + !allowedNestedShrinkwrapIdentities.has(packageSpec) + ) { + throw new Error( + `reviewed npm lock package must not delegate to nested shrinkwrap: ${location}`, + ); + } const expectedIntegrity = typeof record.integrity === "string" ? record.integrity : ""; const tarballUrl = typeof record.resolved === "string" ? record.resolved : ""; - requireReviewedRequest({ - expectedIntegrity, - label: `locked npm package ${packageSpec}`, - packageSpec, - tarballUrl, - }); + const reviewedPackageWithoutIntegrity = reviewedPackagesWithoutIntegrityBySpec.get(packageSpec); + if (expectedIntegrity) { + requireReviewedRequest({ + expectedIntegrity, + label: `locked npm package ${packageSpec}`, + packageSpec, + tarballUrl, + }); + } else if ( + !reviewedPackageWithoutIntegrity || + reviewedPackageWithoutIntegrity.tarballUrl !== tarballUrl + ) { + throw new Error( + `locked npm package ${packageSpec} must use a committed sha512 npm integrity value`, + ); + } let parsedTarball: URL; try { parsedTarball = new URL(tarballUrl); @@ -521,10 +547,12 @@ function readReviewedLockPackages( export function verifyReviewedNpmLockPackages( request: Readonly<{ + allowedNestedShrinkwrapPackages?: readonly string[]; allowNestedShrinkwrap?: boolean; lockfilePath: string; omitDev?: boolean; reviewedRegistryPackages?: readonly ReviewedNpmArchiveRequest[]; + reviewedPackagesWithoutIntegrity?: readonly ReviewedNpmPackageWithoutIntegrity[]; registryOrigin: string; }>, ): readonly string[] { @@ -537,6 +565,8 @@ export function verifyReviewedNpmLockPackages( true, request.allowNestedShrinkwrap, request.reviewedRegistryPackages, + request.allowedNestedShrinkwrapPackages, + request.reviewedPackagesWithoutIntegrity, ).map(({ packageSpec }) => packageSpec); } diff --git a/test/automation/pull-requests/openshell-sdk-package-workflow.test.ts b/test/automation/pull-requests/openshell-sdk-package-workflow.test.ts new file mode 100644 index 00000000000..bc912516a00 --- /dev/null +++ b/test/automation/pull-requests/openshell-sdk-package-workflow.test.ts @@ -0,0 +1,67 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; + +import { readYaml, type WorkflowJob } from "../../helpers/e2e-workflow-contract"; + +type Workflow = Readonly<{ + concurrency?: Readonly>; + jobs: Readonly>; + on?: Readonly>; + permissions?: Readonly>; +}>; + +const workflow = readYaml(".github/workflows/openshell-sdk-package-pr.yaml"); +const job = workflow.jobs["package-openshell-sdk"]; + +function step(name: string) { + const value = job.steps?.find((candidate) => candidate.name === name); + expect(value, `Missing package workflow step: ${name}`).toBeDefined(); + return value as NonNullable; +} + +describe("base-controlled OpenShell SDK package workflow", () => { + // source-shape-contract: security -- The package credential must remain in a base-loaded workflow that uploads only the verified SDK archive + it("keeps package access out of pull request controlled execution", () => { + expect(workflow.on).toEqual({ + pull_request_target: { types: ["opened", "synchronize", "reopened"] }, + }); + expect(workflow.permissions).toEqual({ contents: "read" }); + expect(job.permissions).toEqual({ contents: "read", packages: "read" }); + expect(job["timeout-minutes"]).toBe(5); + + const checkout = step("Checkout base-controlled package verifier"); + expect(checkout.uses).toBe("actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1"); + expect(checkout.with).toMatchObject({ + ref: "${{ github.event.pull_request.base.sha }}", + "persist-credentials": false, + }); + expect(String(checkout.with?.["sparse-checkout"])).not.toContain("pull_request.head"); + + const fetch = step("Download and verify exact OpenShell SDK package"); + expect(fetch.env).toEqual({ + NEMOCLAW_OPEN_SHELL_SDK_OUTPUT_DIRECTORY: "${{ runner.temp }}/openshell-sdk", + NODE_AUTH_TOKEN: "${{ github.token }}", + }); + expect(fetch.run).toBe( + "node --experimental-strip-types scripts/checks/package-openshell-sdk-for-pr.mts", + ); + expect( + (job.steps ?? []) + .filter((candidate) => candidate.name !== fetch.name) + .map((candidate) => candidate.env?.NODE_AUTH_TOKEN), + ).toEqual( + (job.steps ?? []).filter((candidate) => candidate.name !== fetch.name).map(() => undefined), + ); + + const upload = step("Upload verified OpenShell SDK archive"); + expect(upload.uses).toBe("actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a"); + expect(upload.with).toMatchObject({ + name: "openshell-sdk-${{ github.event.pull_request.head.sha }}", + path: "${{ runner.temp }}/openshell-sdk/nvidia-openshell-sdk-0.0.106.tgz", + "if-no-files-found": "error", + "retention-days": 1, + }); + }); +}); diff --git a/test/automation/pull-requests/pr-workflow-contract.test.ts b/test/automation/pull-requests/pr-workflow-contract.test.ts index 54c203e86dc..85e0c096819 100644 --- a/test/automation/pull-requests/pr-workflow-contract.test.ts +++ b/test/automation/pull-requests/pr-workflow-contract.test.ts @@ -82,6 +82,13 @@ const trustedActionDirs = [ const cliShardCount = "12"; const cliShardTimeoutMinutes = 30; +const dependencyInstallJobs = [ + "build-typecheck", + "installer-integration", + "cli-test-shards", + "plugin-tests", + "static-checks", +] as const; function stepRuns(jobOrAction: WorkflowJob | CompositeAction): string[] { const steps = "runs" in jobOrAction ? jobOrAction.runs.steps : (jobOrAction.steps ?? []); @@ -368,13 +375,19 @@ describe("pull request and main workflow contracts", () => { expect(workflow.jobs["cli-test-shards"]?.["timeout-minutes"]).toBe(cliShardTimeoutMinutes); }); - it.each([ - ["pull_request", prWorkflow], - ["main", mainWorkflow], - ] as const)("limits %s package reads to dependency-install jobs", (_workflowName, workflow) => { - expect(workflow.permissions).toEqual({ contents: "read" }); + // source-shape-contract: security -- Pull request jobs must never receive the GitHub Packages credential + it("does not grant package access to pull request jobs", () => { + expect(prWorkflow.permissions).toEqual({ contents: "read" }); expect( - Object.entries(workflow.jobs) + Object.entries(prWorkflow.jobs).filter(([, job]) => job.permissions?.packages !== undefined), + ).toEqual([]); + }); + + // source-shape-contract: security -- Trusted main jobs may read packages only where the reviewed installer consumes the token + it("limits main package reads to dependency-install jobs", () => { + expect(mainWorkflow.permissions).toEqual({ contents: "read" }); + expect( + Object.entries(mainWorkflow.jobs) .filter(([, job]) => job.permissions?.packages !== undefined) .map(([jobName, job]) => [jobName, job.permissions?.packages] as const) .sort(([left], [right]) => left.localeCompare(right)), @@ -383,9 +396,68 @@ describe("pull request and main workflow contracts", () => { ["cli-test-shards", "read"], ["installer-integration", "read"], ["plugin-tests", "read"], + ["static-checks", "read"], ]); }); + // source-shape-contract: security -- The shared action must pass a package token only on trusted main pushes + it("provides the package token only to trusted main dependency installation", () => { + const actions = [ + sharedActions.staticChecks, + sharedActions.buildTypecheck, + sharedActions.installerIntegration, + sharedActions.cliCoverageShard, + sharedActions.pluginCoverage, + ]; + expect( + actions.map((action) => requiredStep(action, "Setup Node.js").with?.["registry-url"]), + ).toEqual(actions.map(() => undefined)); + expect(actions.map((action) => requiredStep(action, "Setup Node.js").with?.scope)).toEqual( + actions.map(() => undefined), + ); + expect(actions.map((action) => requiredStep(action, "Install dependencies").env)).toEqual( + actions.map(() => ({ + NODE_AUTH_TOKEN: "${{ github.event_name == 'push' && github.token || '' }}", + })), + ); + expect(actions.map((action) => requiredStep(action, "Install dependencies").run)).toEqual( + actions.map(() => 'bash "$GITHUB_ACTION_PATH/../ci-install-dependencies.sh"'), + ); + }); + + // source-shape-contract: security -- The PR workflow must select an exact base-controlled package run before publishing its archive internally + it("passes only the base-packaged SDK archive to pull request dependency jobs", () => { + const packageJob = prWorkflow.jobs["openshell-sdk-package"]; + expect(packageJob.permissions).toEqual({ actions: "read", contents: "read" }); + expect(packageJob.outputs).toEqual({ required: "${{ steps.locate.outputs.required }}" }); + expect( + requiredWorkflowStep(packageJob, "Checkout base package workflow identity").with, + ).toMatchObject({ + ref: "${{ github.event.pull_request.base.sha }}", + "sparse-checkout": ".github/workflows/openshell-sdk-package-pr.yaml", + }); + const locate = requiredWorkflowStep(packageJob, "Locate exact base-controlled SDK package run"); + expect(locate.run).toContain("actions/workflows/openshell-sdk-package-pr.yaml/runs"); + expect(locate.run).toContain(".head.sha == $head and .base.sha == $base"); + expect(locate.run).toContain("required=false"); + }); + + // source-shape-contract: security -- Every PR dependency consumer must receive the verified archive without package access + it.each(dependencyInstallJobs)("passes the verified SDK archive to %s", (jobName) => { + const job = prWorkflow.jobs[jobName]; + expect(job.needs).toEqual(["changes", "openshell-sdk-package"]); + expect(job.permissions?.packages).toBeUndefined(); + const download = requiredWorkflowStep(job, "Download verified OpenShell SDK archive"); + expect(download.uses).toBe( + "actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c", + ); + expect(download.if).toBe("needs.openshell-sdk-package.outputs.required == 'true'"); + expect(download.with).toMatchObject({ + name: "openshell-sdk-package", + path: "${{ runner.temp }}/openshell-sdk", + }); + }); + // source-shape-contract: security -- PR base SHA action execution prevents pull-request code from authorizing installer hashes it("executes pull request installer hash checks only from the PR base SHA", () => { expect(installerHashTrustViolations(installerHashWorkflow)).toEqual([]); @@ -643,6 +715,7 @@ describe("pull request and main workflow contracts", () => { CODE_CHANGED: "true", DOCS_ONLY_RESULT: "skipped", INSTALLER_INTEGRATION_RESULT: "success", + OPEN_SHELL_SDK_PACKAGE_RESULT: "success", PLUGIN_TESTS_RESULT: "success", REVIEWED_NPM_AUDIT_RESULT: "success", STATIC_RESULT: "success", @@ -680,6 +753,7 @@ describe("pull request and main workflow contracts", () => { CODE_CHANGED: "false", DOCS_ONLY_RESULT: "success", INSTALLER_INTEGRATION_RESULT: "skipped", + OPEN_SHELL_SDK_PACKAGE_RESULT: "skipped", PLUGIN_TESTS_RESULT: "skipped", REVIEWED_NPM_AUDIT_RESULT: "skipped", STATIC_RESULT: "skipped", diff --git a/test/automation/releases/reviewed-npm-audit-workflow.test.ts b/test/automation/releases/reviewed-npm-audit-workflow.test.ts index f31a617e55b..3845cc3f4ba 100644 --- a/test/automation/releases/reviewed-npm-audit-workflow.test.ts +++ b/test/automation/releases/reviewed-npm-audit-workflow.test.ts @@ -127,7 +127,9 @@ describe("trusted reviewed npm audit workflow (#5896)", () => { registryOrigin: "https://registry.npmjs.org/", schemaVersion: 2, severityThreshold: "high", + sourceNestedShrinkwrapPackages: [], sourceRegistryPackages: [], + sourceRegistryPackagesWithoutIntegrity: [], }; expect(() => parseAuditConfig(JSON.stringify(config))).toThrow( @@ -142,6 +144,7 @@ describe("trusted reviewed npm audit workflow (#5896)", () => { expect(config.archiveTarVersion).toBe("7.5.21"); expect(config.sourceRegistryPackages).toEqual([ { + artifactName: "nvidia-openshell-sdk-0.0.106.tgz", integrity: "sha512-dB4mLex23Pnw61caGMR2CMHQihy9bj7IK2elJJd718k3yevm+fOt/vG6dJg8/5us4la2BwcOdRwLvOia3tdwFw==", label: "OpenShell TypeScript SDK 0.0.106", @@ -150,6 +153,16 @@ describe("trusted reviewed npm audit workflow (#5896)", () => { "https://npm.pkg.github.com/download/@nvidia/openshell-sdk/0.0.106/dc32180ba1d658fc4ec309bdf89d2b162196928d", }, ]); + expect(config.sourceNestedShrinkwrapPackages).toEqual([ + "@earendil-works/pi-coding-agent@0.80.6", + ]); + expect( + config.sourceRegistryPackagesWithoutIntegrity.map(({ packageSpec }) => packageSpec), + ).toEqual([ + "@earendil-works/pi-agent-core@0.80.6", + "@earendil-works/pi-ai@0.80.6", + "@earendil-works/pi-tui@0.80.6", + ]); expect(reviewedArchiveGraphManifest(config.archiveTarVersion)).toEqual({ name: "nemoclaw-reviewed-production-graph", overrides: { tar: "7.5.21" }, diff --git a/test/repository/ci-install-dependencies.test.ts b/test/repository/ci-install-dependencies.test.ts index 860ef64556d..ca36910e3b8 100644 --- a/test/repository/ci-install-dependencies.test.ts +++ b/test/repository/ci-install-dependencies.test.ts @@ -28,6 +28,16 @@ function makeFixture(): { root: string; trace: string; path: string } { const npm = join(bin, "npm"); writeFileSync(npm, `#!/bin/sh\nprintf '%s\n' "$*" >> "$NPM_TRACE"\n`); chmodSync(npm, 0o755); + mkdirSync(join(root, "nemoclaw")); + const lock = JSON.stringify({ + lockfileVersion: 3, + name: "fixture", + packages: { "": { name: "fixture", version: "1.0.0" } }, + requires: true, + version: "1.0.0", + }); + writeFileSync(join(root, "package-lock.json"), `${lock}\n`); + writeFileSync(join(root, "nemoclaw", "package-lock.json"), `${lock}\n`); return { root, trace, path: `${bin}:${process.env.PATH || ""}` }; } @@ -42,20 +52,28 @@ describe("shared CI dependency installer", () => { const result = spawnSync("bash", [installer], { cwd: fixture.root, encoding: "utf8", - env: { ...process.env, NPM_TRACE: fixture.trace, PATH: fixture.path }, + env: { + ...process.env, + GITHUB_EVENT_NAME: "pull_request", + NPM_CONFIG_CACHE: join(fixture.root, "npm-cache"), + NPM_TRACE: fixture.trace, + PATH: fixture.path, + }, }); expect(result.status, result.stderr).toBe(0); expect(readFileSync(fixture.trace, "utf8").trim().split("\n")).toEqual([ - "ci --ignore-scripts", - "--prefix nemoclaw ci --ignore-scripts", + `ci --ignore-scripts --prefer-offline --cache ${join(fixture.root, "npm-cache")}`, + `--prefix nemoclaw ci --ignore-scripts --prefer-offline --cache ${join(fixture.root, "npm-cache")}`, ]); }); it("rejects candidate npm configuration before npm receives the package token", () => { const fixture = makeFixture(); - mkdirSync(join(fixture.root, "nemoclaw")); - writeFileSync(join(fixture.root, "nemoclaw", ".npmrc"), "@nvidia:registry=https://example.invalid\n"); + writeFileSync( + join(fixture.root, "nemoclaw", ".npmrc"), + "@nvidia:registry=https://example.invalid\n", + ); const result = spawnSync("bash", [installer], { cwd: fixture.root, @@ -75,4 +93,90 @@ describe("shared CI dependency installer", () => { expect(result.stderr).not.toContain("credential-sentinel"); expect(existsSync(fixture.trace)).toBe(false); }); + + it("rejects a package credential in pull request jobs before npm runs", () => { + const fixture = makeFixture(); + const result = spawnSync("bash", [installer], { + cwd: fixture.root, + encoding: "utf8", + env: { + ...process.env, + GITHUB_EVENT_NAME: "pull_request", + NODE_AUTH_TOKEN: "credential-sentinel", + NPM_TRACE: fixture.trace, + PATH: fixture.path, + }, + }); + + expect(result.status).toBe(1); + expect(result.stderr).toBe( + "Pull request dependency installation must not receive a package credential.\n", + ); + expect(result.stderr).not.toContain("credential-sentinel"); + expect(existsSync(fixture.trace)).toBe(false); + }); + + it.each(["npm-shrinkwrap.json", "nemoclaw/npm-shrinkwrap.json"])( + "rejects candidate %s before npm runs", + (relativePath) => { + const fixture = makeFixture(); + writeFileSync(join(fixture.root, relativePath), "{}\n"); + + const result = spawnSync("bash", [installer], { + cwd: fixture.root, + encoding: "utf8", + env: { + ...process.env, + GITHUB_EVENT_NAME: "push", + NODE_AUTH_TOKEN: "credential-sentinel", + NPM_TRACE: fixture.trace, + PATH: fixture.path, + }, + }); + + expect(result.status).toBe(1); + expect(result.stderr).toBe( + "Candidate npm shrinkwrap files are not allowed during trusted dependency installation.\n", + ); + expect(existsSync(fixture.trace)).toBe(false); + }, + ); + + it.each(["package-lock.json", "nemoclaw/package-lock.json"])( + "rejects an unreviewed dev package in %s before npm runs", + (relativePath) => { + const fixture = makeFixture(); + const lock = { + lockfileVersion: 3, + name: "fixture", + packages: { + "": { + devDependencies: { "unreviewed-package": "1.0.0" }, + name: "fixture", + version: "1.0.0", + }, + "node_modules/unreviewed-package": { + dev: true, + integrity: "sha512-dGVzdA==", + resolved: "https://packages.example.invalid/unreviewed-package.tgz", + version: "1.0.0", + }, + }, + requires: true, + version: "1.0.0", + }; + writeFileSync(join(fixture.root, relativePath), `${JSON.stringify(lock)}\n`); + + const result = spawnSync("bash", [installer], { + cwd: fixture.root, + encoding: "utf8", + env: { ...process.env, NPM_TRACE: fixture.trace, PATH: fixture.path }, + }); + + expect(result.status).toBe(1); + expect(result.stderr).toContain("must use the reviewed registry"); + expect(result.stderr).not.toContain("credential-sentinel"); + expect(existsSync(fixture.trace)).toBe(false); + }, + ); }); diff --git a/test/repository/prepare-ci-npm-install.test.ts b/test/repository/prepare-ci-npm-install.test.ts new file mode 100644 index 00000000000..7cfdbe41b22 --- /dev/null +++ b/test/repository/prepare-ci-npm-install.test.ts @@ -0,0 +1,120 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { createHash } from "node:crypto"; +import { mkdtempSync, mkdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + seedReviewedSourceRegistryArtifact, + type ReviewedSourceRegistryPackage, +} from "../../scripts/checks/prepare-ci-npm-install.mts"; + +const temporaryRoots: string[] = []; +const archiveBytes = Buffer.from("reviewed OpenShell SDK fixture"); +const artifactName = "nvidia-openshell-sdk-0.0.106.tgz"; +const reviewed: ReviewedSourceRegistryPackage = { + artifactName, + integrity: `sha512-${createHash("sha512").update(archiveBytes).digest("base64")}`, + label: "OpenShell TypeScript SDK 0.0.106", + packageSpec: "@nvidia/openshell-sdk@0.0.106", + tarballUrl: "https://npm.pkg.github.com/download/@nvidia/openshell-sdk/0.0.106/reviewed-fixture", +}; + +function fixture() { + const root = mkdtempSync(join(tmpdir(), "nemoclaw-reviewed-sdk-artifact-")); + temporaryRoots.push(root); + const artifactDirectory = join(root, "artifact"); + const cacheDirectory = join(root, "cache"); + mkdirSync(artifactDirectory); + mkdirSync(cacheDirectory); + writeFileSync(join(artifactDirectory, artifactName), archiveBytes); + return { artifactDirectory, cacheDirectory, root }; +} + +afterEach(() => { + vi.restoreAllMocks(); + for (const root of temporaryRoots.splice(0)) rmSync(root, { force: true, recursive: true }); +}); + +describe("trusted OpenShell SDK archive preparation", () => { + it("seeds only the exact reviewed tarball request and package identity", async () => { + const source = fixture(); + const calls: Array = []; + const put = vi.fn(async (cache: string, key: string, data: Buffer, options?: unknown) => { + calls.push([cache, key, data, options]); + }); + + await seedReviewedSourceRegistryArtifact( + { + artifactDirectory: source.artifactDirectory, + cacheDirectory: source.cacheDirectory, + reviewed, + }, + put, + ); + + expect(put).toHaveBeenCalledTimes(2); + expect(calls.map((call) => call[1])).toEqual([ + `make-fetch-happen:request-cache:${reviewed.tarballUrl}`, + `pacote:tarball:${reviewed.packageSpec}`, + ]); + expect(calls.every((call) => call[2].equals(archiveBytes))).toBe(true); + }); + + it("rejects changed bytes before writing the npm cache", async () => { + const source = fixture(); + const put = vi.fn(async () => undefined); + writeFileSync(join(source.artifactDirectory, artifactName), "changed archive"); + + await expect( + seedReviewedSourceRegistryArtifact( + { + artifactDirectory: source.artifactDirectory, + cacheDirectory: source.cacheDirectory, + reviewed, + }, + put, + ), + ).rejects.toThrow("integrity does not match"); + expect(put).not.toHaveBeenCalled(); + }); + + it("rejects symlinked or additional artifact content before writing the npm cache", async () => { + const source = fixture(); + const put = vi.fn(async () => undefined); + writeFileSync(join(source.root, "outside.tgz"), archiveBytes); + rmSync(join(source.artifactDirectory, artifactName)); + symlinkSync(join(source.root, "outside.tgz"), join(source.artifactDirectory, artifactName)); + + await expect( + seedReviewedSourceRegistryArtifact( + { + artifactDirectory: source.artifactDirectory, + cacheDirectory: source.cacheDirectory, + reviewed, + }, + put, + ), + ).rejects.toThrow("non-symlink regular file"); + expect(put).not.toHaveBeenCalled(); + + rmSync(join(source.artifactDirectory, artifactName)); + writeFileSync(join(source.artifactDirectory, artifactName), archiveBytes); + writeFileSync(join(source.artifactDirectory, "unexpected.tgz"), archiveBytes); + await expect( + seedReviewedSourceRegistryArtifact( + { + artifactDirectory: source.artifactDirectory, + cacheDirectory: source.cacheDirectory, + reviewed, + }, + put, + ), + ).rejects.toThrow("unexpected contents"); + expect(put).not.toHaveBeenCalled(); + }); +}); From 56c3cd8fb497f6374e17434793e931bad6aff4fa Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Wed, 26 Aug 2026 04:12:12 -0700 Subject: [PATCH 04/27] fix(ci): complete SDK package isolation Signed-off-by: Apurv Kumaria --- .../actions/ci-cli-coverage-merge/action.yaml | 4 +- .github/workflows/main.yaml | 1 + .../workflows/openshell-sdk-package-pr.yaml | 12 +- .github/workflows/pr.yaml | 71 +++++--- ci/reviewed-npm-audit.json | 16 +- ci/source-shape-test-budget.json | 10 + scripts/audit-reviewed-npm-graph.mts | 53 +++--- .../checks/package-openshell-sdk-for-pr.mts | 5 +- scripts/checks/prepare-ci-npm-install.mts | 172 +++++++++--------- scripts/lib/seed-reviewed-npm-cache.mts | 70 +++++-- .../openshell-sdk-package-workflow.test.ts | 17 +- .../pr-workflow-contract.test.ts | 16 +- .../reviewed-npm-audit-workflow.test.ts | 68 ++++--- .../repository/prepare-ci-npm-install.test.ts | 92 ++++++---- 14 files changed, 360 insertions(+), 247 deletions(-) diff --git a/.github/actions/ci-cli-coverage-merge/action.yaml b/.github/actions/ci-cli-coverage-merge/action.yaml index 05c6af4799d..6e8bb427c81 100644 --- a/.github/actions/ci-cli-coverage-merge/action.yaml +++ b/.github/actions/ci-cli-coverage-merge/action.yaml @@ -39,7 +39,9 @@ runs: - name: Install dependencies shell: bash - run: npm install --ignore-scripts + env: + NODE_AUTH_TOKEN: ${{ github.event_name == 'push' && github.token || '' }} + run: bash "$GITHUB_ACTION_PATH/../ci-install-dependencies.sh" - name: Download compiled CLI artifact uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index fbb017d194e..000ec615da8 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -187,6 +187,7 @@ jobs: actions: read code-quality: write contents: read + packages: read pull-requests: read runs-on: ubuntu-latest timeout-minutes: 10 diff --git a/.github/workflows/openshell-sdk-package-pr.yaml b/.github/workflows/openshell-sdk-package-pr.yaml index d0f33459dd2..c0c9bb86a0c 100644 --- a/.github/workflows/openshell-sdk-package-pr.yaml +++ b/.github/workflows/openshell-sdk-package-pr.yaml @@ -9,7 +9,7 @@ run-name: "OpenShell SDK PR #${{ github.event.pull_request.number }} head ${{ gi # check out or execute pull request content because its token can read packages. on: pull_request_target: - types: [opened, synchronize, reopened] + types: [opened, synchronize, reopened, edited] permissions: contents: read @@ -20,6 +20,7 @@ concurrency: jobs: package-openshell-sdk: + if: ${{ github.event.action != 'edited' || github.event.changes.base != null }} permissions: contents: read packages: read @@ -48,15 +49,20 @@ jobs: scope: "@nvidia" - name: Download and verify exact OpenShell SDK package + id: package env: NEMOCLAW_OPEN_SHELL_SDK_OUTPUT_DIRECTORY: ${{ runner.temp }}/openshell-sdk NODE_AUTH_TOKEN: ${{ github.token }} - run: node --experimental-strip-types scripts/checks/package-openshell-sdk-for-pr.mts + run: | + set -euo pipefail + artifact_path="$(node --experimental-strip-types scripts/checks/package-openshell-sdk-for-pr.mts)" + [ -n "$artifact_path" ] + printf 'artifact_path=%s\n' "$artifact_path" >> "$GITHUB_OUTPUT" - name: Upload verified OpenShell SDK archive uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: openshell-sdk-${{ github.event.pull_request.head.sha }} - path: ${{ runner.temp }}/openshell-sdk/nvidia-openshell-sdk-0.0.106.tgz + path: ${{ steps.package.outputs.artifact_path }} if-no-files-found: error retention-days: 1 diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index 47e6e85b67b..619d3e0475e 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -126,10 +126,12 @@ jobs: .github/actions/ci-install-dependencies.sh ci/reviewed-npm-audit.json scripts/audit-reviewed-npm-graph.mts + scripts/checks/materialize-locked-npm-cache-seed.mts scripts/checks/prepare-ci-npm-install.mts scripts/lib/openclaw-npm-remediation.mts scripts/lib/reviewed-npm-archive.mts scripts/lib/reviewed-npm-audit.mts + scripts/lib/seed-reviewed-npm-cache.mts sparse-checkout-cone-mode: false - name: Download verified OpenShell SDK archive @@ -162,13 +164,22 @@ jobs: nemoclaw/package-lock.json sparse-checkout-cone-mode: false - - name: Checkout base package workflow identity + - name: Checkout base package decision uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ github.event.pull_request.base.sha }} - path: .trusted-sdk-package-workflow + path: .trusted-sdk-package-decision persist-credentials: false - sparse-checkout: .github/workflows/openshell-sdk-package-pr.yaml + sparse-checkout: | + .github/workflows/openshell-sdk-package-pr.yaml + ci/reviewed-npm-audit.json + scripts/audit-reviewed-npm-graph.mts + scripts/checks/materialize-locked-npm-cache-seed.mts + scripts/checks/prepare-ci-npm-install.mts + scripts/lib/openclaw-npm-remediation.mts + scripts/lib/reviewed-npm-archive.mts + scripts/lib/reviewed-npm-audit.mts + scripts/lib/seed-reviewed-npm-cache.mts sparse-checkout-cone-mode: false - name: Locate exact base-controlled SDK package run @@ -181,28 +192,20 @@ jobs: run: | set -euo pipefail - sdk_locked="$(node - <<'NODE' - const fs = require('node:fs'); - const expected = '@nvidia/openshell-sdk@0.0.106'; - let found = false; - for (const path of ['package-lock.json', 'nemoclaw/package-lock.json']) { - const lock = JSON.parse(fs.readFileSync(path, 'utf8')); - for (const [location, value] of Object.entries(lock.packages || {})) { - if (!location || !value || typeof value !== 'object') continue; - const marker = 'node_modules/'; - const index = location.lastIndexOf(marker); - const name = typeof value.name === 'string' ? value.name : location.slice(index + marker.length); - if (`${name}@${value.version || ''}` === expected) found = true; - } - } - process.stdout.write(String(found)); - NODE + decision="$( + NEMOCLAW_CI_NPM_PACKAGE_MODE=inspect \ + NEMOCLAW_CI_TARGET_ROOT="$GITHUB_WORKSPACE" \ + node --experimental-strip-types \ + .trusted-sdk-package-decision/scripts/checks/prepare-ci-npm-install.mts )" - if [ "$sdk_locked" != "true" ]; then + required="$(jq -er '.required | select(type == "boolean")' <<<"$decision")" + artifact_name="$(jq -er '.artifactName | select(type == "string" and test("^[a-z0-9][a-z0-9._-]*\\.tgz$"))' <<<"$decision")" + printf 'artifact_name=%s\n' "$artifact_name" >> "$GITHUB_OUTPUT" + if [ "$required" != "true" ]; then echo "required=false" >> "$GITHUB_OUTPUT" exit 0 fi - if [ ! -f .trusted-sdk-package-workflow/.github/workflows/openshell-sdk-package-pr.yaml ]; then + if [ ! -f .trusted-sdk-package-decision/.github/workflows/openshell-sdk-package-pr.yaml ]; then echo "::error title=Missing trusted SDK package workflow::The pull request base cannot package the approved OpenShell SDK." exit 1 fi @@ -254,7 +257,7 @@ jobs: uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: openshell-sdk-package - path: ${{ runner.temp }}/openshell-sdk/nvidia-openshell-sdk-0.0.106.tgz + path: ${{ runner.temp }}/openshell-sdk/${{ steps.locate.outputs.artifact_name }} if-no-files-found: error retention-days: 1 @@ -288,10 +291,12 @@ jobs: .github/actions/ci-install-dependencies.sh ci/reviewed-npm-audit.json scripts/audit-reviewed-npm-graph.mts + scripts/checks/materialize-locked-npm-cache-seed.mts scripts/checks/prepare-ci-npm-install.mts scripts/lib/openclaw-npm-remediation.mts scripts/lib/reviewed-npm-archive.mts scripts/lib/reviewed-npm-audit.mts + scripts/lib/seed-reviewed-npm-cache.mts sparse-checkout-cone-mode: false - name: Download verified OpenShell SDK archive @@ -329,10 +334,12 @@ jobs: .github/actions/ci-install-dependencies.sh ci/reviewed-npm-audit.json scripts/audit-reviewed-npm-graph.mts + scripts/checks/materialize-locked-npm-cache-seed.mts scripts/checks/prepare-ci-npm-install.mts scripts/lib/openclaw-npm-remediation.mts scripts/lib/reviewed-npm-archive.mts scripts/lib/reviewed-npm-audit.mts + scripts/lib/seed-reviewed-npm-cache.mts sparse-checkout-cone-mode: false - name: Download verified OpenShell SDK archive @@ -454,10 +461,12 @@ jobs: .github/actions/ci-install-dependencies.sh ci/reviewed-npm-audit.json scripts/audit-reviewed-npm-graph.mts + scripts/checks/materialize-locked-npm-cache-seed.mts scripts/checks/prepare-ci-npm-install.mts scripts/lib/openclaw-npm-remediation.mts scripts/lib/reviewed-npm-archive.mts scripts/lib/reviewed-npm-audit.mts + scripts/lib/seed-reviewed-npm-cache.mts sparse-checkout-cone-mode: false - name: Download verified OpenShell SDK archive @@ -478,6 +487,7 @@ jobs: needs: - changes - cli-test-shards + - openshell-sdk-package if: ${{ always() && needs.changes.outputs.code == 'true' }} permissions: actions: read @@ -562,8 +572,23 @@ jobs: .github/actions/ci-plugin-coverage .github/actions/ci-installer-integration .github/actions/ci-install-dependencies.sh + ci/reviewed-npm-audit.json + scripts/audit-reviewed-npm-graph.mts + scripts/checks/materialize-locked-npm-cache-seed.mts + scripts/checks/prepare-ci-npm-install.mts + scripts/lib/openclaw-npm-remediation.mts + scripts/lib/reviewed-npm-archive.mts + scripts/lib/reviewed-npm-audit.mts + scripts/lib/seed-reviewed-npm-cache.mts sparse-checkout-cone-mode: false + - name: Download verified OpenShell SDK archive + if: needs.openshell-sdk-package.outputs.required == 'true' + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: openshell-sdk-package + path: ${{ runner.temp }}/openshell-sdk + - name: Merge CLI coverage uses: ./.trusted-ci-actions/.github/actions/ci-cli-coverage-merge with: @@ -601,10 +626,12 @@ jobs: .github/actions/ci-install-dependencies.sh ci/reviewed-npm-audit.json scripts/audit-reviewed-npm-graph.mts + scripts/checks/materialize-locked-npm-cache-seed.mts scripts/checks/prepare-ci-npm-install.mts scripts/lib/openclaw-npm-remediation.mts scripts/lib/reviewed-npm-archive.mts scripts/lib/reviewed-npm-audit.mts + scripts/lib/seed-reviewed-npm-cache.mts sparse-checkout-cone-mode: false - name: Download verified OpenShell SDK archive diff --git a/ci/reviewed-npm-audit.json b/ci/reviewed-npm-audit.json index 9188db562a1..bf6cd17a2bd 100644 --- a/ci/reviewed-npm-audit.json +++ b/ci/reviewed-npm-audit.json @@ -2,15 +2,13 @@ "schemaVersion": 2, "nodeVersion": "22.23.2", "registryOrigin": "https://registry.npmjs.org/", - "sourceRegistryPackages": [ - { - "artifactName": "nvidia-openshell-sdk-0.0.106.tgz", - "label": "OpenShell TypeScript SDK 0.0.106", - "packageSpec": "@nvidia/openshell-sdk@0.0.106", - "integrity": "sha512-dB4mLex23Pnw61caGMR2CMHQihy9bj7IK2elJJd718k3yevm+fOt/vG6dJg8/5us4la2BwcOdRwLvOia3tdwFw==", - "tarballUrl": "https://npm.pkg.github.com/download/@nvidia/openshell-sdk/0.0.106/dc32180ba1d658fc4ec309bdf89d2b162196928d" - } - ], + "sourceRegistryPackage": { + "artifactName": "nvidia-openshell-sdk-0.0.106.tgz", + "label": "OpenShell TypeScript SDK 0.0.106", + "packageSpec": "@nvidia/openshell-sdk@0.0.106", + "integrity": "sha512-dB4mLex23Pnw61caGMR2CMHQihy9bj7IK2elJJd718k3yevm+fOt/vG6dJg8/5us4la2BwcOdRwLvOia3tdwFw==", + "tarballUrl": "https://npm.pkg.github.com/download/@nvidia/openshell-sdk/0.0.106/dc32180ba1d658fc4ec309bdf89d2b162196928d" + }, "sourceNestedShrinkwrapPackages": ["@earendil-works/pi-coding-agent@0.80.6"], "sourceRegistryPackagesWithoutIntegrity": [ { diff --git a/ci/source-shape-test-budget.json b/ci/source-shape-test-budget.json index e73f968de60..8668a34f1d5 100644 --- a/ci/source-shape-test-budget.json +++ b/ci/source-shape-test-budget.json @@ -166,6 +166,11 @@ "test": "keeps package access out of pull request controlled execution", "category": "security" }, + { + "file": "test/automation/pull-requests/openshell-sdk-package-workflow.test.ts", + "test": "derives the package and archive identity from the base-controlled decision", + "category": "security" + }, { "file": "test/automation/pull-requests/pr-workflow-contract.test.ts", "test": "does not grant package access to pull request jobs", @@ -201,6 +206,11 @@ "test": "keeps the LKG credential on the production-only dispatch step (#9798)", "category": "security" }, + { + "file": "test/automation/releases/reviewed-npm-audit-workflow.test.ts", + "test": "rejects the removed plural source-registry package shape", + "category": "security" + }, { "file": "test/agents/hermes/reviewed-hermes-platform-action.test.ts", "test": "publishes the verified native manifest digest", diff --git a/scripts/audit-reviewed-npm-graph.mts b/scripts/audit-reviewed-npm-graph.mts index 29df91c77b5..dd89b88ff86 100755 --- a/scripts/audit-reviewed-npm-graph.mts +++ b/scripts/audit-reviewed-npm-graph.mts @@ -54,7 +54,7 @@ type AuditConfig = Readonly<{ schemaVersion: 2; severityThreshold: Severity; sourceNestedShrinkwrapPackages: readonly string[]; - sourceRegistryPackages: readonly SourceRegistryPackage[]; + sourceRegistryPackage: SourceRegistryPackage; sourceRegistryPackagesWithoutIntegrity: readonly PackageWithoutIntegrity[]; }>; type ReviewedAuditReport = Readonly<{ label: string; result: AuditPolicyResult }>; @@ -174,22 +174,19 @@ export function parseAuditConfig(contents: string): AuditConfig { ) || new Set(parsed.sourceRegistryPackagesWithoutIntegrity.map(({ packageSpec }) => packageSpec)) .size !== parsed.sourceRegistryPackagesWithoutIntegrity.length || - !Array.isArray(parsed.sourceRegistryPackages) || - parsed.sourceRegistryPackages.some( - (reviewed) => - typeof reviewed.artifactName !== "string" || - !/^[a-z0-9][a-z0-9._-]*\.tgz$/.test(reviewed.artifactName) || - typeof reviewed.label !== "string" || - !reviewed.label || - typeof reviewed.packageSpec !== "string" || - !reviewed.packageSpec || - typeof reviewed.integrity !== "string" || - !reviewed.integrity || - typeof reviewed.tarballUrl !== "string" || - !reviewed.tarballUrl, - ) || - new Set(parsed.sourceRegistryPackages.map(({ packageSpec }) => packageSpec)).size !== - parsed.sourceRegistryPackages.length || + typeof parsed.sourceRegistryPackage !== "object" || + parsed.sourceRegistryPackage === null || + Array.isArray(parsed.sourceRegistryPackage) || + typeof parsed.sourceRegistryPackage.artifactName !== "string" || + !/^[a-z0-9][a-z0-9._-]*\.tgz$/.test(parsed.sourceRegistryPackage.artifactName) || + typeof parsed.sourceRegistryPackage.label !== "string" || + !parsed.sourceRegistryPackage.label || + typeof parsed.sourceRegistryPackage.packageSpec !== "string" || + !parsed.sourceRegistryPackage.packageSpec || + typeof parsed.sourceRegistryPackage.integrity !== "string" || + !parsed.sourceRegistryPackage.integrity || + typeof parsed.sourceRegistryPackage.tarballUrl !== "string" || + !parsed.sourceRegistryPackage.tarballUrl || parsed.lockedGraphs.some( (graph) => typeof graph.id !== "string" || @@ -363,7 +360,7 @@ export function materializeSourceGraph( destination: string, registryOrigin: string, installProductionDependencies: (directory: string) => void = installProductionSourceDependencies, - sourceRegistryPackages: readonly ReviewedPackage[] = [], + sourceRegistryPackage?: ReviewedPackage, sourceNestedShrinkwrapPackages: readonly string[] = [], ): string { assertRegularFile(sourcePackage, "NemoClaw CLI package manifest"); @@ -373,14 +370,16 @@ export function materializeSourceGraph( lockfilePath: sourceLock, omitDev: true, registryOrigin, - reviewedRegistryPackages: sourceRegistryPackages.map( - ({ integrity, label, packageSpec, tarballUrl }) => ({ - expectedIntegrity: integrity, - label, - packageSpec, - tarballUrl, - }), - ), + reviewedRegistryPackages: sourceRegistryPackage + ? [ + { + expectedIntegrity: sourceRegistryPackage.integrity, + label: sourceRegistryPackage.label, + packageSpec: sourceRegistryPackage.packageSpec, + tarballUrl: sourceRegistryPackage.tarballUrl, + }, + ] + : [], }); const lockSha256 = createHash("sha256").update(fs.readFileSync(sourceLock)).digest("hex"); fs.mkdirSync(destination); @@ -519,7 +518,7 @@ function auditSourceGraph( path.join(tempRoot, "source-graph"), config.registryOrigin, installProductionSourceDependencies, - config.sourceRegistryPackages, + config.sourceRegistryPackage, config.sourceNestedShrinkwrapPackages, ); return auditMaterializedSourceGraph({ diff --git a/scripts/checks/package-openshell-sdk-for-pr.mts b/scripts/checks/package-openshell-sdk-for-pr.mts index 486f6f6ecee..9fe1787d4a5 100755 --- a/scripts/checks/package-openshell-sdk-for-pr.mts +++ b/scripts/checks/package-openshell-sdk-for-pr.mts @@ -18,10 +18,7 @@ export function packageReviewedOpenShellSdk(outputDirectory: string): string { const config = parseAuditConfig( readFileSync(join(TRUSTED_REPOSITORY_ROOT, "ci/reviewed-npm-audit.json"), "utf8"), ); - if (config.sourceRegistryPackages.length !== 1) { - throw new Error("reviewed npm configuration must name one source-registry package"); - } - const reviewed = config.sourceRegistryPackages[0]; + const reviewed = config.sourceRegistryPackage; const archive = packReviewedNpmArchive({ env: process.env, expectedIntegrity: reviewed.integrity, diff --git a/scripts/checks/prepare-ci-npm-install.mts b/scripts/checks/prepare-ci-npm-install.mts index 8bcc2a585e4..65509ec4375 100755 --- a/scripts/checks/prepare-ci-npm-install.mts +++ b/scripts/checks/prepare-ci-npm-install.mts @@ -2,23 +2,13 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { createHash } from "node:crypto"; -import { - closeSync, - existsSync, - fstatSync, - lstatSync, - openSync, - readFileSync, - readdirSync, -} from "node:fs"; -import { createRequire } from "node:module"; +import { existsSync, lstatSync, readFileSync, readdirSync } from "node:fs"; import { dirname, isAbsolute, join, resolve } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import { parseAuditConfig } from "../audit-reviewed-npm-graph.mts"; import { verifyReviewedNpmLockPackages } from "../lib/reviewed-npm-archive.mts"; -import type { CachePut } from "../lib/seed-reviewed-npm-cache.mts"; +import { type CachePut, seedReviewedNpmCache } from "../lib/seed-reviewed-npm-cache.mts"; const TRUSTED_REPOSITORY_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); const MAXIMUM_ARCHIVE_BYTES = 32 * 1024 * 1024; @@ -39,42 +29,19 @@ export type ReviewedSourceRegistryPackage = Readonly<{ }>; export type ReviewedSourceRegistryArtifactRequest = Readonly<{ + allowedNestedShrinkwrapPackages: readonly string[]; artifactDirectory: string; cacheDirectory: string; + lockfilePath: string; reviewed: ReviewedSourceRegistryPackage; + reviewedPackagesWithoutIntegrity: readonly Readonly<{ + label: string; + packageSpec: string; + tarballUrl: string; + }>[]; + registryOrigin: string; }>; -function loadCachePut(): CachePut { - const require = createRequire(import.meta.url); - const npmRoot = String( - require("node:child_process").execFileSync("npm", ["root", "-g"], { encoding: "utf8" }), - ).trim(); - const cacachePath = require.resolve("cacache", { paths: [join(npmRoot, "npm", "node_modules")] }); - return (require(cacachePath) as Readonly<{ put: CachePut }>).put; -} - -function readRegularArchive(file: string): Buffer { - let descriptor: number | undefined; - try { - descriptor = openSync(file, "r"); - const opened = fstatSync(descriptor); - const pathEntry = lstatSync(file); - if ( - !opened.isFile() || - !pathEntry.isFile() || - pathEntry.isSymbolicLink() || - opened.dev !== pathEntry.dev || - opened.ino !== pathEntry.ino || - opened.size > MAXIMUM_ARCHIVE_BYTES - ) { - throw new Error("archive must be one bounded non-symlink regular file"); - } - return readFileSync(descriptor); - } finally { - if (descriptor !== undefined) closeSync(descriptor); - } -} - export async function seedReviewedSourceRegistryArtifact( request: ReviewedSourceRegistryArtifactRequest, put?: CachePut, @@ -94,48 +61,37 @@ export async function seedReviewedSourceRegistryArtifact( if (entries.length !== 1 || entries[0] !== request.reviewed.artifactName) { throw new Error("reviewed OpenShell SDK artifact directory has unexpected contents"); } - const archive = readRegularArchive(join(artifactDirectory, request.reviewed.artifactName)); - const actualIntegrity = `sha512-${createHash("sha512").update(archive).digest("base64")}`; - if (actualIntegrity !== request.reviewed.integrity) { - throw new Error( - "reviewed OpenShell SDK artifact integrity does not match the approved package", - ); - } - const cachePut = put ?? loadCachePut(); - const cachePath = join(resolve(request.cacheDirectory), "_cacache"); - await cachePut( - cachePath, - `make-fetch-happen:request-cache:${request.reviewed.tarballUrl}`, - archive, + const archivePath = resolve(join(artifactDirectory, request.reviewed.artifactName)); + await seedReviewedNpmCache( { - metadata: { - options: { compress: true }, - reqHeaders: {}, - resHeaders: { - "cache-control": "public, immutable, max-age=31557600", - "content-type": "application/octet-stream", + allowedNestedShrinkwrapPackages: request.allowedNestedShrinkwrapPackages, + allowNestedShrinkwrap: false, + archives: new Map([[request.reviewed.packageSpec, archivePath]]), + cacheDirectory: request.cacheDirectory, + lockfilePath: request.lockfilePath, + maximumArchiveBytes: MAXIMUM_ARCHIVE_BYTES, + registryOrigin: request.registryOrigin, + reviewedPackagesWithoutIntegrity: request.reviewedPackagesWithoutIntegrity, + reviewedRegistryPackages: [ + { + expectedIntegrity: request.reviewed.integrity, + label: request.reviewed.label, + packageSpec: request.reviewed.packageSpec, + tarballUrl: request.reviewed.tarballUrl, }, - time: 0, - url: request.reviewed.tarballUrl, - }, + ], + selectedPackageSpecs: new Set([request.reviewed.packageSpec]), + tarballsOnly: true, }, + put, ); - await cachePut(cachePath, `pacote:tarball:${request.reviewed.packageSpec}`, archive); } -export async function prepareCiNpmInstall( - request: PreparationRequest, - put?: CachePut, -): Promise { - const targetRoot = resolve(request.targetRoot); - const cacheDirectory = resolve(request.cacheDirectory); +function inspectReviewedLocks(targetRoot: string) { const config = parseAuditConfig( readFileSync(join(TRUSTED_REPOSITORY_ROOT, "ci/reviewed-npm-audit.json"), "utf8"), ); - if (config.sourceRegistryPackages.length !== 1) { - throw new Error("reviewed npm configuration must name one source-registry package"); - } - const reviewed = config.sourceRegistryPackages[0]; + const reviewed = config.sourceRegistryPackage; const reviewedRegistryPackages = [ { expectedIntegrity: reviewed.integrity, @@ -144,16 +100,41 @@ export async function prepareCiNpmInstall( tarballUrl: reviewed.tarballUrl, }, ]; - const packageSets = ["package-lock.json", "nemoclaw/package-lock.json"].map((relativePath) => - verifyReviewedNpmLockPackages({ + const lockfiles = ["package-lock.json", "nemoclaw/package-lock.json"].map((relativePath) => { + const lockfilePath = join(targetRoot, relativePath); + const packages = verifyReviewedNpmLockPackages({ allowedNestedShrinkwrapPackages: config.sourceNestedShrinkwrapPackages, - lockfilePath: join(targetRoot, relativePath), + lockfilePath, registryOrigin: config.registryOrigin, - reviewedRegistryPackages, reviewedPackagesWithoutIntegrity: config.sourceRegistryPackagesWithoutIntegrity, - }), - ); - const sdkIsLocked = packageSets.some((packages) => packages.includes(reviewed.packageSpec)); + reviewedRegistryPackages, + }); + return { lockfilePath, packages }; + }); + return { + config, + reviewed, + reviewedLockfilePath: lockfiles.find(({ packages }) => packages.includes(reviewed.packageSpec)) + ?.lockfilePath, + }; +} + +export function inspectCiNpmInstall(targetRoot: string) { + const inspected = inspectReviewedLocks(resolve(targetRoot)); + return { + artifactName: inspected.reviewed.artifactName, + required: inspected.reviewedLockfilePath !== undefined, + } as const; +} + +export async function prepareCiNpmInstall( + request: PreparationRequest, + put?: CachePut, +): Promise { + const targetRoot = resolve(request.targetRoot); + const cacheDirectory = resolve(request.cacheDirectory); + const { config, reviewed, reviewedLockfilePath } = inspectReviewedLocks(targetRoot); + const sdkIsLocked = reviewedLockfilePath !== undefined; if (request.mode === "registry") return; if (!request.artifactDirectory) { @@ -168,10 +149,21 @@ export async function prepareCiNpmInstall( if (sdkIsLocked) throw new Error("reviewed OpenShell SDK artifact is required"); return; } - if (!sdkIsLocked) { + if (!reviewedLockfilePath) { throw new Error("reviewed OpenShell SDK artifact is not used by either lockfile"); } - await seedReviewedSourceRegistryArtifact({ artifactDirectory, cacheDirectory, reviewed }, put); + await seedReviewedSourceRegistryArtifact( + { + allowedNestedShrinkwrapPackages: config.sourceNestedShrinkwrapPackages, + artifactDirectory, + cacheDirectory, + lockfilePath: reviewedLockfilePath, + registryOrigin: config.registryOrigin, + reviewed, + reviewedPackagesWithoutIntegrity: config.sourceRegistryPackagesWithoutIntegrity, + }, + put, + ); } function requestFromEnvironment(): PreparationRequest { @@ -190,7 +182,15 @@ function requestFromEnvironment(): PreparationRequest { } if (process.argv[1] && pathToFileURL(resolve(process.argv[1])).href === import.meta.url) { - prepareCiNpmInstall(requestFromEnvironment()).catch((error) => { + const mode = process.env.NEMOCLAW_CI_NPM_PACKAGE_MODE; + const targetRoot = process.env.NEMOCLAW_CI_TARGET_ROOT; + const task = + mode === "inspect" && targetRoot + ? Promise.resolve(inspectCiNpmInstall(targetRoot)).then((result) => + process.stdout.write(`${JSON.stringify(result)}\n`), + ) + : prepareCiNpmInstall(requestFromEnvironment()); + task.catch((error) => { console.error(error instanceof Error ? error.message : String(error)); process.exit(1); }); diff --git a/scripts/lib/seed-reviewed-npm-cache.mts b/scripts/lib/seed-reviewed-npm-cache.mts index 9de0d14b581..d5186c48217 100755 --- a/scripts/lib/seed-reviewed-npm-cache.mts +++ b/scripts/lib/seed-reviewed-npm-cache.mts @@ -21,7 +21,11 @@ import { lockedArchives, type NpmPlatformTarget, } from "../checks/materialize-locked-npm-cache-seed.mts"; -import { verifyReviewedNpmLockPackages } from "./reviewed-npm-archive.mts"; +import { + type ReviewedNpmArchiveRequest, + type ReviewedNpmPackageWithoutIntegrity, + verifyReviewedNpmLockPackages, +} from "./reviewed-npm-archive.mts"; export type CachePut = ( cachePath: string, @@ -31,12 +35,18 @@ export type CachePut = ( ) => Promise; export type ReviewedNpmCacheSeedRequest = Readonly<{ + allowedNestedShrinkwrapPackages?: readonly string[]; + allowNestedShrinkwrap?: boolean; archives: ReadonlyMap; cacheDirectory: string; lockfilePath: string; + maximumArchiveBytes?: number; packumentsOnly?: boolean; + reviewedPackagesWithoutIntegrity?: readonly ReviewedNpmPackageWithoutIntegrity[]; + reviewedRegistryPackages?: readonly ReviewedNpmArchiveRequest[]; registryOrigin: string; selectedPackageSpecs?: ReadonlySet; + tarballsOnly?: boolean; }>; type LockedPackage = Readonly<{ @@ -72,11 +82,21 @@ function requireObject(value: unknown, label: string): Record { function readLockedPackages( lockfilePath: string, registryOrigin: string, + request: Pick< + ReviewedNpmCacheSeedRequest, + | "allowedNestedShrinkwrapPackages" + | "allowNestedShrinkwrap" + | "reviewedPackagesWithoutIntegrity" + | "reviewedRegistryPackages" + > = {}, ): readonly LockedPackage[] { const expectedSpecs = new Set( verifyReviewedNpmLockPackages({ - allowNestedShrinkwrap: true, + allowedNestedShrinkwrapPackages: request.allowedNestedShrinkwrapPackages, + allowNestedShrinkwrap: request.allowNestedShrinkwrap ?? true, lockfilePath, + reviewedPackagesWithoutIntegrity: request.reviewedPackagesWithoutIntegrity, + reviewedRegistryPackages: request.reviewedRegistryPackages, registryOrigin, }), ); @@ -135,7 +155,11 @@ function readLockedPackages( return locked; } -function readArchive(archivePath: string, packageSpec: string): Buffer { +function readArchive( + archivePath: string, + packageSpec: string, + maximumArchiveBytes?: number, +): Buffer { if (!isAbsolute(archivePath)) { throw new Error(`reviewed npm cache seed archive must be absolute: ${packageSpec}`); } @@ -154,6 +178,9 @@ function readArchive(archivePath: string, packageSpec: string): Buffer { ) { throw new Error("archive must be a non-symlink regular file"); } + if (maximumArchiveBytes !== undefined && opened.size > maximumArchiveBytes) { + throw new Error("archive must be a bounded regular file"); + } return readFileSync(descriptor); } catch (error) { throw new Error( @@ -257,7 +284,10 @@ export async function seedReviewedNpmCache( ); } const registryOrigin = parsedRegistry.origin; - const locked = readLockedPackages(request.lockfilePath, registryOrigin); + if (request.packumentsOnly && request.tarballsOnly) { + throw new Error("reviewed npm cache seed cannot select both packuments-only and tarballs-only"); + } + const locked = readLockedPackages(request.lockfilePath, registryOrigin, request); const selectedPackageSpecs = request.selectedPackageSpecs ?? new Set(locked.map(({ name, version }) => `${name}@${version}`)); @@ -277,7 +307,7 @@ export async function seedReviewedNpmCache( throw new Error(`reviewed npm cache seed archive is missing: ${packageSpec}`); expectedArchives.delete(packageSpec); unexpectedArchives.delete(packageSpec); - const archive = readArchive(archivePath, packageSpec); + const archive = readArchive(archivePath, packageSpec, request.maximumArchiveBytes); const actualIntegrity = `sha512-${createHash("sha512").update(archive).digest("base64")}`; if (actualIntegrity !== entry.integrity) { throw new Error( @@ -299,20 +329,22 @@ export async function seedReviewedNpmCache( await put(cachePath, `pacote:tarball:${packageSpec}`, archive); } - const version = { - ...(entry.bundleDependencies ? { bundleDependencies: entry.bundleDependencies } : {}), - ...(entry.dependencies ? { dependencies: entry.dependencies } : {}), - dist: { integrity: entry.integrity, tarball: entry.resolved }, - ...(entry.hasShrinkwrap ? { hasShrinkwrap: true } : {}), - name: entry.name, - ...(entry.optionalDependencies ? { optionalDependencies: entry.optionalDependencies } : {}), - ...(entry.peerDependencies ? { peerDependencies: entry.peerDependencies } : {}), - ...(entry.peerDependenciesMeta ? { peerDependenciesMeta: entry.peerDependenciesMeta } : {}), - version: entry.version, - }; - const versions = packumentVersions.get(entry.name) ?? {}; - versions[entry.version] = version; - packumentVersions.set(entry.name, versions); + if (!request.tarballsOnly) { + const version = { + ...(entry.bundleDependencies ? { bundleDependencies: entry.bundleDependencies } : {}), + ...(entry.dependencies ? { dependencies: entry.dependencies } : {}), + dist: { integrity: entry.integrity, tarball: entry.resolved }, + ...(entry.hasShrinkwrap ? { hasShrinkwrap: true } : {}), + name: entry.name, + ...(entry.optionalDependencies ? { optionalDependencies: entry.optionalDependencies } : {}), + ...(entry.peerDependencies ? { peerDependencies: entry.peerDependencies } : {}), + ...(entry.peerDependenciesMeta ? { peerDependenciesMeta: entry.peerDependenciesMeta } : {}), + version: entry.version, + }; + const versions = packumentVersions.get(entry.name) ?? {}; + versions[entry.version] = version; + packumentVersions.set(entry.name, versions); + } seeded.push(packageSpec); } for (const [packageName, versions] of [...packumentVersions].sort(([left], [right]) => diff --git a/test/automation/pull-requests/openshell-sdk-package-workflow.test.ts b/test/automation/pull-requests/openshell-sdk-package-workflow.test.ts index bc912516a00..1b4b43df8cb 100644 --- a/test/automation/pull-requests/openshell-sdk-package-workflow.test.ts +++ b/test/automation/pull-requests/openshell-sdk-package-workflow.test.ts @@ -25,10 +25,13 @@ describe("base-controlled OpenShell SDK package workflow", () => { // source-shape-contract: security -- The package credential must remain in a base-loaded workflow that uploads only the verified SDK archive it("keeps package access out of pull request controlled execution", () => { expect(workflow.on).toEqual({ - pull_request_target: { types: ["opened", "synchronize", "reopened"] }, + pull_request_target: { types: ["opened", "synchronize", "reopened", "edited"] }, }); expect(workflow.permissions).toEqual({ contents: "read" }); expect(job.permissions).toEqual({ contents: "read", packages: "read" }); + expect(job.if).toBe( + "${{ github.event.action != 'edited' || github.event.changes.base != null }}", + ); expect(job["timeout-minutes"]).toBe(5); const checkout = step("Checkout base-controlled package verifier"); @@ -44,9 +47,10 @@ describe("base-controlled OpenShell SDK package workflow", () => { NEMOCLAW_OPEN_SHELL_SDK_OUTPUT_DIRECTORY: "${{ runner.temp }}/openshell-sdk", NODE_AUTH_TOKEN: "${{ github.token }}", }); - expect(fetch.run).toBe( + expect(fetch.run).toContain( "node --experimental-strip-types scripts/checks/package-openshell-sdk-for-pr.mts", ); + expect(fetch.run).toContain("artifact_path="); expect( (job.steps ?? []) .filter((candidate) => candidate.name !== fetch.name) @@ -59,9 +63,16 @@ describe("base-controlled OpenShell SDK package workflow", () => { expect(upload.uses).toBe("actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a"); expect(upload.with).toMatchObject({ name: "openshell-sdk-${{ github.event.pull_request.head.sha }}", - path: "${{ runner.temp }}/openshell-sdk/nvidia-openshell-sdk-0.0.106.tgz", + path: "${{ steps.package.outputs.artifact_path }}", "if-no-files-found": "error", "retention-days": 1, }); }); + + // source-shape-contract: security -- The credential-bearing workflow must derive one package identity from reviewed base data instead of duplicating package coordinates + it("derives the package and archive identity from the base-controlled decision", () => { + const serialized = JSON.stringify(workflow); + expect(serialized).not.toContain("@nvidia/openshell-sdk@0.0.106"); + expect(serialized).not.toContain("nvidia-openshell-sdk-0.0.106.tgz"); + }); }); diff --git a/test/automation/pull-requests/pr-workflow-contract.test.ts b/test/automation/pull-requests/pr-workflow-contract.test.ts index 85e0c096819..221a6ec55f9 100644 --- a/test/automation/pull-requests/pr-workflow-contract.test.ts +++ b/test/automation/pull-requests/pr-workflow-contract.test.ts @@ -84,6 +84,7 @@ const cliShardCount = "12"; const cliShardTimeoutMinutes = 30; const dependencyInstallJobs = [ "build-typecheck", + "cli-tests", "installer-integration", "cli-test-shards", "plugin-tests", @@ -394,6 +395,7 @@ describe("pull request and main workflow contracts", () => { ).toEqual([ ["build-typecheck", "read"], ["cli-test-shards", "read"], + ["cli-tests", "read"], ["installer-integration", "read"], ["plugin-tests", "read"], ["static-checks", "read"], @@ -405,6 +407,7 @@ describe("pull request and main workflow contracts", () => { const actions = [ sharedActions.staticChecks, sharedActions.buildTypecheck, + sharedActions.cliCoverageMerge, sharedActions.installerIntegration, sharedActions.cliCoverageShard, sharedActions.pluginCoverage, @@ -430,22 +433,25 @@ describe("pull request and main workflow contracts", () => { const packageJob = prWorkflow.jobs["openshell-sdk-package"]; expect(packageJob.permissions).toEqual({ actions: "read", contents: "read" }); expect(packageJob.outputs).toEqual({ required: "${{ steps.locate.outputs.required }}" }); - expect( - requiredWorkflowStep(packageJob, "Checkout base package workflow identity").with, - ).toMatchObject({ + expect(requiredWorkflowStep(packageJob, "Checkout base package decision").with).toMatchObject({ ref: "${{ github.event.pull_request.base.sha }}", - "sparse-checkout": ".github/workflows/openshell-sdk-package-pr.yaml", + path: ".trusted-sdk-package-decision", }); const locate = requiredWorkflowStep(packageJob, "Locate exact base-controlled SDK package run"); + expect(locate.run).toContain( + ".trusted-sdk-package-decision/scripts/checks/prepare-ci-npm-install.mts", + ); expect(locate.run).toContain("actions/workflows/openshell-sdk-package-pr.yaml/runs"); expect(locate.run).toContain(".head.sha == $head and .base.sha == $base"); expect(locate.run).toContain("required=false"); + expect(locate.run).not.toContain("@nvidia/openshell-sdk@0.0.106"); + expect(locate.run).not.toContain("nvidia-openshell-sdk-0.0.106.tgz"); }); // source-shape-contract: security -- Every PR dependency consumer must receive the verified archive without package access it.each(dependencyInstallJobs)("passes the verified SDK archive to %s", (jobName) => { const job = prWorkflow.jobs[jobName]; - expect(job.needs).toEqual(["changes", "openshell-sdk-package"]); + expect(job.needs).toEqual(expect.arrayContaining(["changes", "openshell-sdk-package"])); expect(job.permissions?.packages).toBeUndefined(); const download = requiredWorkflowStep(job, "Download verified OpenShell SDK archive"); expect(download.uses).toBe( diff --git a/test/automation/releases/reviewed-npm-audit-workflow.test.ts b/test/automation/releases/reviewed-npm-audit-workflow.test.ts index 3845cc3f4ba..8cf7ff81ad1 100644 --- a/test/automation/releases/reviewed-npm-audit-workflow.test.ts +++ b/test/automation/releases/reviewed-npm-audit-workflow.test.ts @@ -128,7 +128,13 @@ describe("trusted reviewed npm audit workflow (#5896)", () => { schemaVersion: 2, severityThreshold: "high", sourceNestedShrinkwrapPackages: [], - sourceRegistryPackages: [], + sourceRegistryPackage: { + artifactName: "reviewed-package-1.0.0.tgz", + integrity: "sha512-reviewedintegrity", + label: "reviewed package 1.0.0", + packageSpec: "@example/reviewed@1.0.0", + tarballUrl: "https://npm.pkg.github.com/download/@example/reviewed/1.0.0/reviewed", + }, sourceRegistryPackagesWithoutIntegrity: [], }; @@ -142,17 +148,15 @@ describe("trusted reviewed npm audit workflow (#5896)", () => { const config = parseAuditConfig(fs.readFileSync(configFile, "utf-8")); expect(config.archiveTarVersion).toBe("7.5.21"); - expect(config.sourceRegistryPackages).toEqual([ - { - artifactName: "nvidia-openshell-sdk-0.0.106.tgz", - integrity: - "sha512-dB4mLex23Pnw61caGMR2CMHQihy9bj7IK2elJJd718k3yevm+fOt/vG6dJg8/5us4la2BwcOdRwLvOia3tdwFw==", - label: "OpenShell TypeScript SDK 0.0.106", - packageSpec: "@nvidia/openshell-sdk@0.0.106", - tarballUrl: - "https://npm.pkg.github.com/download/@nvidia/openshell-sdk/0.0.106/dc32180ba1d658fc4ec309bdf89d2b162196928d", - }, - ]); + expect(config.sourceRegistryPackage).toEqual({ + artifactName: "nvidia-openshell-sdk-0.0.106.tgz", + integrity: + "sha512-dB4mLex23Pnw61caGMR2CMHQihy9bj7IK2elJJd718k3yevm+fOt/vG6dJg8/5us4la2BwcOdRwLvOia3tdwFw==", + label: "OpenShell TypeScript SDK 0.0.106", + packageSpec: "@nvidia/openshell-sdk@0.0.106", + tarballUrl: + "https://npm.pkg.github.com/download/@nvidia/openshell-sdk/0.0.106/dc32180ba1d658fc4ec309bdf89d2b162196928d", + }); expect(config.sourceNestedShrinkwrapPackages).toEqual([ "@earendil-works/pi-coding-agent@0.80.6", ]); @@ -171,6 +175,18 @@ describe("trusted reviewed npm audit workflow (#5896)", () => { }); }); + // source-shape-contract: security -- One reviewed package field prevents a second package identity from bypassing the credential-isolation workflow + it("rejects the removed plural source-registry package shape", () => { + const configFile = path.join(REPO_ROOT, "ci", "reviewed-npm-audit.json"); + const config = JSON.parse(fs.readFileSync(configFile, "utf-8")) as Record; + config.sourceRegistryPackages = [config.sourceRegistryPackage]; + delete config.sourceRegistryPackage; + + expect(() => parseAuditConfig(JSON.stringify(config))).toThrow( + "ci/reviewed-npm-audit.json is invalid", + ); + }); + it("rejects an affected tar release for the reviewed archive graph", () => { expect(() => reviewedArchiveGraphManifest("7.5.20")).toThrow( "reviewed archive graph tar version must be exactly 7.5.21", @@ -439,14 +455,12 @@ esac () => { installCalled = true; }, - [ - { - integrity, - label: "reviewed fixture package", - packageSpec: "fixture-package@1.0.0", - tarballUrl, - }, - ], + { + integrity, + label: "reviewed fixture package", + packageSpec: "fixture-package@1.0.0", + tarballUrl, + }, ), ).toBe(destination); expect(installCalled).toBe(true); @@ -475,14 +489,12 @@ esac () => { installCalled = true; }, - [ - { - integrity: "sha512-another-value", - label: "reviewed fixture package", - packageSpec: "fixture-package@1.0.0", - tarballUrl, - }, - ], + { + integrity: "sha512-another-value", + label: "reviewed fixture package", + packageSpec: "fixture-package@1.0.0", + tarballUrl, + }, ), ).toThrow( "reviewed npm lock package does not match its approved registry identity: node_modules/fixture-package", diff --git a/test/repository/prepare-ci-npm-install.test.ts b/test/repository/prepare-ci-npm-install.test.ts index 7cfdbe41b22..56c4443931f 100644 --- a/test/repository/prepare-ci-npm-install.test.ts +++ b/test/repository/prepare-ci-npm-install.test.ts @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { createHash } from "node:crypto"; -import { mkdtempSync, mkdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { mkdtempSync, mkdirSync, rmSync, symlinkSync, truncateSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -29,10 +29,39 @@ function fixture() { temporaryRoots.push(root); const artifactDirectory = join(root, "artifact"); const cacheDirectory = join(root, "cache"); + const lockfilePath = join(root, "package-lock.json"); mkdirSync(artifactDirectory); mkdirSync(cacheDirectory); writeFileSync(join(artifactDirectory, artifactName), archiveBytes); - return { artifactDirectory, cacheDirectory, root }; + writeFileSync( + lockfilePath, + JSON.stringify({ + lockfileVersion: 3, + name: "reviewed-sdk-artifact-fixture", + packages: { + "": { dependencies: { "@nvidia/openshell-sdk": "0.0.106" } }, + "node_modules/@nvidia/openshell-sdk": { + integrity: reviewed.integrity, + resolved: reviewed.tarballUrl, + version: "0.0.106", + }, + }, + version: "1.0.0", + }), + ); + return { artifactDirectory, cacheDirectory, lockfilePath, root }; +} + +function request(source: ReturnType) { + return { + allowedNestedShrinkwrapPackages: [], + artifactDirectory: source.artifactDirectory, + cacheDirectory: source.cacheDirectory, + lockfilePath: source.lockfilePath, + registryOrigin: "https://registry.npmjs.org/", + reviewed, + reviewedPackagesWithoutIntegrity: [], + }; } afterEach(() => { @@ -48,14 +77,7 @@ describe("trusted OpenShell SDK archive preparation", () => { calls.push([cache, key, data, options]); }); - await seedReviewedSourceRegistryArtifact( - { - artifactDirectory: source.artifactDirectory, - cacheDirectory: source.cacheDirectory, - reviewed, - }, - put, - ); + await seedReviewedSourceRegistryArtifact(request(source), put); expect(put).toHaveBeenCalledTimes(2); expect(calls.map((call) => call[1])).toEqual([ @@ -70,16 +92,9 @@ describe("trusted OpenShell SDK archive preparation", () => { const put = vi.fn(async () => undefined); writeFileSync(join(source.artifactDirectory, artifactName), "changed archive"); - await expect( - seedReviewedSourceRegistryArtifact( - { - artifactDirectory: source.artifactDirectory, - cacheDirectory: source.cacheDirectory, - reviewed, - }, - put, - ), - ).rejects.toThrow("integrity does not match"); + await expect(seedReviewedSourceRegistryArtifact(request(source), put)).rejects.toThrow( + "integrity mismatch", + ); expect(put).not.toHaveBeenCalled(); }); @@ -90,31 +105,28 @@ describe("trusted OpenShell SDK archive preparation", () => { rmSync(join(source.artifactDirectory, artifactName)); symlinkSync(join(source.root, "outside.tgz"), join(source.artifactDirectory, artifactName)); - await expect( - seedReviewedSourceRegistryArtifact( - { - artifactDirectory: source.artifactDirectory, - cacheDirectory: source.cacheDirectory, - reviewed, - }, - put, - ), - ).rejects.toThrow("non-symlink regular file"); + await expect(seedReviewedSourceRegistryArtifact(request(source), put)).rejects.toThrow( + "non-symlink regular file", + ); expect(put).not.toHaveBeenCalled(); rmSync(join(source.artifactDirectory, artifactName)); writeFileSync(join(source.artifactDirectory, artifactName), archiveBytes); writeFileSync(join(source.artifactDirectory, "unexpected.tgz"), archiveBytes); - await expect( - seedReviewedSourceRegistryArtifact( - { - artifactDirectory: source.artifactDirectory, - cacheDirectory: source.cacheDirectory, - reviewed, - }, - put, - ), - ).rejects.toThrow("unexpected contents"); + await expect(seedReviewedSourceRegistryArtifact(request(source), put)).rejects.toThrow( + "unexpected contents", + ); + expect(put).not.toHaveBeenCalled(); + }); + + it("rejects an oversized artifact before writing the npm cache", async () => { + const source = fixture(); + const put = vi.fn(async () => undefined); + truncateSync(join(source.artifactDirectory, artifactName), 32 * 1024 * 1024 + 1); + + await expect(seedReviewedSourceRegistryArtifact(request(source), put)).rejects.toThrow( + "bounded regular file", + ); expect(put).not.toHaveBeenCalled(); }); }); From 6016736140d17b99850c4196178e064588c342d2 Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Wed, 26 Aug 2026 04:19:42 -0700 Subject: [PATCH 05/27] fix(ci): restrict reviewed SDK artifacts Signed-off-by: Apurv Kumaria --- .github/actions/ci-install-dependencies.sh | 3 +- .../workflows/openshell-sdk-package-pr.yaml | 2 +- .github/workflows/pr.yaml | 5 +++ scripts/audit-reviewed-npm-graph.mts | 3 ++ scripts/lib/reviewed-npm-archive.mts | 2 +- .../openshell-sdk-package-workflow.test.ts | 2 +- .../pr-workflow-contract.test.ts | 5 +++ .../reviewed-npm-audit-workflow.test.ts | 38 ++++++++++++++++++ test/install/reviewed-npm-archive.test.ts | 40 +++++++++++++++++++ .../ci-install-dependencies.test.ts | 4 +- 10 files changed, 98 insertions(+), 6 deletions(-) diff --git a/.github/actions/ci-install-dependencies.sh b/.github/actions/ci-install-dependencies.sh index 5597e198365..1721875fe7d 100755 --- a/.github/actions/ci-install-dependencies.sh +++ b/.github/actions/ci-install-dependencies.sh @@ -28,8 +28,7 @@ if [ "$event_name" = "pull_request" ]; then fi target_root="$(pwd -P)" -action_path="${GITHUB_ACTION_PATH:-$(cd "$(dirname "$0")" && pwd -P)}" -trusted_root="$(cd "$action_path/../.." && pwd -P)" +trusted_root="$(cd "$(dirname "$0")/../.." && pwd -P)" npm_cache="${NPM_CONFIG_CACHE:-${RUNNER_TEMP:-$target_root/.ci-cache}/npm}" mkdir -p "$npm_cache" diff --git a/.github/workflows/openshell-sdk-package-pr.yaml b/.github/workflows/openshell-sdk-package-pr.yaml index c0c9bb86a0c..ce20087eb79 100644 --- a/.github/workflows/openshell-sdk-package-pr.yaml +++ b/.github/workflows/openshell-sdk-package-pr.yaml @@ -20,7 +20,7 @@ concurrency: jobs: package-openshell-sdk: - if: ${{ github.event.action != 'edited' || github.event.changes.base != null }} + if: ${{ github.event.pull_request.head.repo.full_name == github.repository && (github.event.action != 'edited' || github.event.changes.base != null) }} permissions: contents: read packages: read diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index 619d3e0475e..48694f90d5e 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -188,6 +188,7 @@ jobs: BASE_SHA: ${{ github.event.pull_request.base.sha }} GH_TOKEN: ${{ github.token }} HEAD_SHA: ${{ github.event.pull_request.head.sha }} + HEAD_REPOSITORY: ${{ github.event.pull_request.head.repo.full_name }} PR_NUMBER: ${{ github.event.pull_request.number }} run: | set -euo pipefail @@ -205,6 +206,10 @@ jobs: echo "required=false" >> "$GITHUB_OUTPUT" exit 0 fi + if [ "$HEAD_REPOSITORY" != "$GITHUB_REPOSITORY" ]; then + echo "::error title=SDK package unavailable::The reviewed OpenShell SDK is available only to same-repository pull requests." + exit 1 + fi if [ ! -f .trusted-sdk-package-decision/.github/workflows/openshell-sdk-package-pr.yaml ]; then echo "::error title=Missing trusted SDK package workflow::The pull request base cannot package the approved OpenShell SDK." exit 1 diff --git a/scripts/audit-reviewed-npm-graph.mts b/scripts/audit-reviewed-npm-graph.mts index dd89b88ff86..76508176ca2 100755 --- a/scripts/audit-reviewed-npm-graph.mts +++ b/scripts/audit-reviewed-npm-graph.mts @@ -362,6 +362,7 @@ export function materializeSourceGraph( installProductionDependencies: (directory: string) => void = installProductionSourceDependencies, sourceRegistryPackage?: ReviewedPackage, sourceNestedShrinkwrapPackages: readonly string[] = [], + sourceRegistryPackagesWithoutIntegrity: readonly PackageWithoutIntegrity[] = [], ): string { assertRegularFile(sourcePackage, "NemoClaw CLI package manifest"); assertRegularFile(sourceLock, "NemoClaw CLI lockfile"); @@ -380,6 +381,7 @@ export function materializeSourceGraph( }, ] : [], + reviewedPackagesWithoutIntegrity: sourceRegistryPackagesWithoutIntegrity, }); const lockSha256 = createHash("sha256").update(fs.readFileSync(sourceLock)).digest("hex"); fs.mkdirSync(destination); @@ -520,6 +522,7 @@ function auditSourceGraph( installProductionSourceDependencies, config.sourceRegistryPackage, config.sourceNestedShrinkwrapPackages, + config.sourceRegistryPackagesWithoutIntegrity, ); return auditMaterializedSourceGraph({ directory, diff --git a/scripts/lib/reviewed-npm-archive.mts b/scripts/lib/reviewed-npm-archive.mts index 9543c4b0562..d859005217e 100755 --- a/scripts/lib/reviewed-npm-archive.mts +++ b/scripts/lib/reviewed-npm-archive.mts @@ -537,7 +537,7 @@ function readReviewedLockPackages( tarballUrl, }; identities.set(packageSpec, request); - reviewed.push(request); + if (!reviewedPackageWithoutIntegrity) reviewed.push(request); } if (!allowEmpty && reviewed.length === 0) { throw new Error(`reviewed npm lock contains no packages: ${lockfilePath}`); diff --git a/test/automation/pull-requests/openshell-sdk-package-workflow.test.ts b/test/automation/pull-requests/openshell-sdk-package-workflow.test.ts index 1b4b43df8cb..5b8c6ce860d 100644 --- a/test/automation/pull-requests/openshell-sdk-package-workflow.test.ts +++ b/test/automation/pull-requests/openshell-sdk-package-workflow.test.ts @@ -30,7 +30,7 @@ describe("base-controlled OpenShell SDK package workflow", () => { expect(workflow.permissions).toEqual({ contents: "read" }); expect(job.permissions).toEqual({ contents: "read", packages: "read" }); expect(job.if).toBe( - "${{ github.event.action != 'edited' || github.event.changes.base != null }}", + "${{ github.event.pull_request.head.repo.full_name == github.repository && (github.event.action != 'edited' || github.event.changes.base != null) }}", ); expect(job["timeout-minutes"]).toBe(5); diff --git a/test/automation/pull-requests/pr-workflow-contract.test.ts b/test/automation/pull-requests/pr-workflow-contract.test.ts index 221a6ec55f9..75256193532 100644 --- a/test/automation/pull-requests/pr-workflow-contract.test.ts +++ b/test/automation/pull-requests/pr-workflow-contract.test.ts @@ -438,12 +438,17 @@ describe("pull request and main workflow contracts", () => { path: ".trusted-sdk-package-decision", }); const locate = requiredWorkflowStep(packageJob, "Locate exact base-controlled SDK package run"); + expect(locate.env?.HEAD_REPOSITORY).toBe( + "${{ github.event.pull_request.head.repo.full_name }}", + ); expect(locate.run).toContain( ".trusted-sdk-package-decision/scripts/checks/prepare-ci-npm-install.mts", ); expect(locate.run).toContain("actions/workflows/openshell-sdk-package-pr.yaml/runs"); expect(locate.run).toContain(".head.sha == $head and .base.sha == $base"); expect(locate.run).toContain("required=false"); + expect(locate.run).toContain('[ "$HEAD_REPOSITORY" != "$GITHUB_REPOSITORY" ]'); + expect(locate.run).toContain("available only to same-repository pull requests"); expect(locate.run).not.toContain("@nvidia/openshell-sdk@0.0.106"); expect(locate.run).not.toContain("nvidia-openshell-sdk-0.0.106.tgz"); }); diff --git a/test/automation/releases/reviewed-npm-audit-workflow.test.ts b/test/automation/releases/reviewed-npm-audit-workflow.test.ts index 8cf7ff81ad1..c1d240efead 100644 --- a/test/automation/releases/reviewed-npm-audit-workflow.test.ts +++ b/test/automation/releases/reviewed-npm-audit-workflow.test.ts @@ -469,6 +469,44 @@ esac } }); + it("accepts one exact package without registry integrity metadata", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-source-graph-without-integrity-")); + const destination = path.join(root, "materialized"); + const tarballUrl = "https://registry.npmjs.org/fixture-package/-/fixture-package-1.0.0.tgz"; + const { sourceLock, sourcePackage } = writeProductionSourceGraph( + root, + { optional: true, resolved: tarballUrl, version: "1.0.0" }, + {}, + true, + ); + let installCalled = false; + try { + expect( + materializeSourceGraph( + sourcePackage, + sourceLock, + destination, + "https://registry.npmjs.org", + () => { + installCalled = true; + }, + undefined, + [], + [ + { + label: "reviewed fixture package without integrity", + packageSpec: "fixture-package@1.0.0", + tarballUrl, + }, + ], + ), + ).toBe(destination); + expect(installCalled).toBe(true); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + it("rejects drift from an approved additional-registry package identity", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-source-graph-registry-drift-")); const destination = path.join(root, "materialized"); diff --git a/test/install/reviewed-npm-archive.test.ts b/test/install/reviewed-npm-archive.test.ts index 6c205978bc6..9895dee6b6a 100644 --- a/test/install/reviewed-npm-archive.test.ts +++ b/test/install/reviewed-npm-archive.test.ts @@ -255,6 +255,46 @@ describe("reviewed npm archive", () => { ]); }); + it("validates but does not archive an approved package without integrity", () => { + const reviewed = cacheRequest(); + const lockfilePath = path.join( + reviewed.tempDirectory as string, + "package-without-integrity-lock.json", + ); + const packageWithoutIntegrity = { + label: "reviewed package without integrity", + packageSpec: "fixture-without-integrity@1.0.0", + tarballUrl: + "https://registry.npmjs.org/fixture-without-integrity/-/fixture-without-integrity-1.0.0.tgz", + }; + fs.writeFileSync( + lockfilePath, + `${JSON.stringify({ + lockfileVersion: 3, + packages: { + "": {}, + "node_modules/@example/reviewed": { + integrity: INTEGRITY, + resolved: TARBALL_URL, + version: "1.2.3", + }, + "node_modules/fixture-without-integrity": { + resolved: packageWithoutIntegrity.tarballUrl, + version: "1.0.0", + }, + }, + })}\n`, + ); + + expect( + verifyReviewedNpmLockPackages({ + lockfilePath, + registryOrigin: "https://registry.npmjs.org/", + reviewedPackagesWithoutIntegrity: [packageWithoutIntegrity], + }), + ).toEqual([PACKAGE_SPEC]); + }); + it("rejects an off-origin locked archive before npm can read the cache", () => { const reviewed = cacheRequest(); const lock = JSON.parse(fs.readFileSync(WECHAT_LOCK, "utf-8")); diff --git a/test/repository/ci-install-dependencies.test.ts b/test/repository/ci-install-dependencies.test.ts index ca36910e3b8..359e5e8f11f 100644 --- a/test/repository/ci-install-dependencies.test.ts +++ b/test/repository/ci-install-dependencies.test.ts @@ -18,6 +18,7 @@ import { afterEach, describe, expect, it } from "vitest"; const temporaryRoots: string[] = []; const installer = join(import.meta.dirname, "../../.github/actions/ci-install-dependencies.sh"); +const compositeActionPath = join(import.meta.dirname, "../../.github/actions/ci-build-typecheck"); function makeFixture(): { root: string; trace: string; path: string } { const root = mkdtempSync(join(tmpdir(), "nemoclaw-ci-install-")); @@ -46,7 +47,7 @@ afterEach(() => { }); describe("shared CI dependency installer", () => { - it("installs root and plugin dependencies from lockfiles without lifecycle scripts", () => { + it("installs from a composite-action path without lifecycle scripts", () => { const fixture = makeFixture(); const result = spawnSync("bash", [installer], { @@ -54,6 +55,7 @@ describe("shared CI dependency installer", () => { encoding: "utf8", env: { ...process.env, + GITHUB_ACTION_PATH: compositeActionPath, GITHUB_EVENT_NAME: "pull_request", NPM_CONFIG_CACHE: join(fixture.root, "npm-cache"), NPM_TRACE: fixture.trace, From 24c6fe9e85f2bd40b5b22ae2a1ef8345f8073a03 Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Wed, 26 Aug 2026 04:32:03 -0700 Subject: [PATCH 06/27] fix(ci): allow SDK workflow bootstrap Signed-off-by: Apurv Kumaria --- .github/workflows/pr.yaml | 32 ++++- .../pr-workflow-contract.test.ts | 122 +++++++++++++++++- 2 files changed, 148 insertions(+), 6 deletions(-) diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index 48694f90d5e..7c3866a9c54 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -193,15 +193,35 @@ jobs: run: | set -euo pipefail + trusted_inspector=.trusted-sdk-package-decision/scripts/checks/prepare-ci-npm-install.mts + trusted_workflow=.trusted-sdk-package-decision/.github/workflows/openshell-sdk-package-pr.yaml + if [ ! -f "$trusted_inspector" ]; then + if [ -f "$trusted_workflow" ]; then + echo "::error title=Incomplete SDK package support::The pull request base has a package workflow without its trusted inspector." + exit 1 + fi + if ! jq -se ' + length == 2 and + all(.[]; + type == "object" and + (.lockfileVersion | type == "number") and + (.packages | type == "object")) and + ([.[] | .. | objects | select(has("resolved")) | .resolved] | + all(type == "string" and startswith("https://registry.npmjs.org/"))) + ' package-lock.json nemoclaw/package-lock.json >/dev/null; then + echo "::error title=SDK package unavailable::The pull request base requires two valid public-registry npm lockfiles." + exit 1 + fi + echo "required=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + decision="$( NEMOCLAW_CI_NPM_PACKAGE_MODE=inspect \ NEMOCLAW_CI_TARGET_ROOT="$GITHUB_WORKSPACE" \ - node --experimental-strip-types \ - .trusted-sdk-package-decision/scripts/checks/prepare-ci-npm-install.mts + node --experimental-strip-types "$trusted_inspector" )" required="$(jq -er '.required | select(type == "boolean")' <<<"$decision")" - artifact_name="$(jq -er '.artifactName | select(type == "string" and test("^[a-z0-9][a-z0-9._-]*\\.tgz$"))' <<<"$decision")" - printf 'artifact_name=%s\n' "$artifact_name" >> "$GITHUB_OUTPUT" if [ "$required" != "true" ]; then echo "required=false" >> "$GITHUB_OUTPUT" exit 0 @@ -210,11 +230,13 @@ jobs: echo "::error title=SDK package unavailable::The reviewed OpenShell SDK is available only to same-repository pull requests." exit 1 fi - if [ ! -f .trusted-sdk-package-decision/.github/workflows/openshell-sdk-package-pr.yaml ]; then + if [ ! -f "$trusted_workflow" ]; then echo "::error title=Missing trusted SDK package workflow::The pull request base cannot package the approved OpenShell SDK." exit 1 fi + artifact_name="$(jq -er '.artifactName | select(type == "string" and test("^[a-z0-9][a-z0-9._-]*\\.tgz$"))' <<<"$decision")" + printf 'artifact_name=%s\n' "$artifact_name" >> "$GITHUB_OUTPUT" echo "required=true" >> "$GITHUB_OUTPUT" for attempt in $(seq 1 24); do runs="$(gh api \ diff --git a/test/automation/pull-requests/pr-workflow-contract.test.ts b/test/automation/pull-requests/pr-workflow-contract.test.ts index 75256193532..fa404e5b24d 100644 --- a/test/automation/pull-requests/pr-workflow-contract.test.ts +++ b/test/automation/pull-requests/pr-workflow-contract.test.ts @@ -442,8 +442,14 @@ describe("pull request and main workflow contracts", () => { "${{ github.event.pull_request.head.repo.full_name }}", ); expect(locate.run).toContain( - ".trusted-sdk-package-decision/scripts/checks/prepare-ci-npm-install.mts", + "trusted_inspector=.trusted-sdk-package-decision/scripts/checks/prepare-ci-npm-install.mts", ); + expect(locate.run).toContain('if [ ! -f "$trusted_inspector" ]'); + expect(locate.run).toContain('if [ -f "$trusted_workflow" ]'); + expect(locate.run).toContain( + 'all(type == "string" and startswith("https://registry.npmjs.org/"))', + ); + expect(locate.run).toContain("requires two valid public-registry npm lockfiles"); expect(locate.run).toContain("actions/workflows/openshell-sdk-package-pr.yaml/runs"); expect(locate.run).toContain(".head.sha == $head and .base.sha == $base"); expect(locate.run).toContain("required=false"); @@ -453,6 +459,120 @@ describe("pull request and main workflow contracts", () => { expect(locate.run).not.toContain("nvidia-openshell-sdk-0.0.106.tgz"); }); + // The one-time bootstrap may proceed only while both lockfiles use the public registry. + it("allows the package workflow bootstrap without a private registry lock", () => { + const tempRoot = mkdtempSync(join(tmpdir(), "nemoclaw-sdk-package-bootstrap-")); + try { + mkdirSync(join(tempRoot, "nemoclaw"), { recursive: true }); + const lock = JSON.stringify({ + lockfileVersion: 3, + packages: { + "node_modules/example": { + resolved: "https://registry.npmjs.org/example/-/example-1.0.0.tgz", + }, + }, + }); + writeFileSync(join(tempRoot, "package-lock.json"), lock); + writeFileSync(join(tempRoot, "nemoclaw/package-lock.json"), lock); + const outputPath = join(tempRoot, "github-output"); + const locate = requiredWorkflowStep( + prWorkflow.jobs["openshell-sdk-package"], + "Locate exact base-controlled SDK package run", + ); + + const result = runWorkflowShellStep( + locate, + { + GITHUB_OUTPUT: outputPath, + GITHUB_REPOSITORY: "NVIDIA/NemoClaw", + GITHUB_WORKSPACE: tempRoot, + HEAD_REPOSITORY: "NVIDIA/NemoClaw", + }, + tempRoot, + ); + + expect(result).toMatchObject({ status: 0, stderr: "" }); + expect(readFileSync(outputPath, "utf8")).toBe("required=false\n"); + } finally { + rmSync(tempRoot, { recursive: true, force: true }); + } + }); + + // A private registry lock cannot bypass a base that lacks the trusted package workflow. + it("rejects a private registry lock during the package workflow bootstrap", () => { + const tempRoot = mkdtempSync(join(tmpdir(), "nemoclaw-sdk-package-bootstrap-")); + try { + mkdirSync(join(tempRoot, "nemoclaw"), { recursive: true }); + writeFileSync( + join(tempRoot, "package-lock.json"), + JSON.stringify({ lockfileVersion: 3, packages: {} }), + ); + writeFileSync( + join(tempRoot, "nemoclaw/package-lock.json"), + JSON.stringify({ + lockfileVersion: 3, + packages: { + "node_modules/private": { + resolved: "https://npm.pkg.github.com/download/private/package/1.0.0/archive", + }, + }, + }), + ); + const locate = requiredWorkflowStep( + prWorkflow.jobs["openshell-sdk-package"], + "Locate exact base-controlled SDK package run", + ); + + const result = runWorkflowShellStep( + locate, + { + GITHUB_OUTPUT: join(tempRoot, "github-output"), + GITHUB_REPOSITORY: "NVIDIA/NemoClaw", + GITHUB_WORKSPACE: tempRoot, + HEAD_REPOSITORY: "NVIDIA/NemoClaw", + }, + tempRoot, + ); + + expect(result.status).toBe(1); + expect(result.stdout).toContain("requires two valid public-registry npm lockfiles"); + } finally { + rmSync(tempRoot, { recursive: true, force: true }); + } + }); + + it("rejects a malformed lockfile during the package workflow bootstrap", () => { + const tempRoot = mkdtempSync(join(tmpdir(), "nemoclaw-sdk-package-bootstrap-")); + try { + mkdirSync(join(tempRoot, "nemoclaw"), { recursive: true }); + writeFileSync( + join(tempRoot, "package-lock.json"), + JSON.stringify({ lockfileVersion: 3, packages: {} }), + ); + writeFileSync(join(tempRoot, "nemoclaw/package-lock.json"), "not JSON"); + const locate = requiredWorkflowStep( + prWorkflow.jobs["openshell-sdk-package"], + "Locate exact base-controlled SDK package run", + ); + + const result = runWorkflowShellStep( + locate, + { + GITHUB_OUTPUT: join(tempRoot, "github-output"), + GITHUB_REPOSITORY: "NVIDIA/NemoClaw", + GITHUB_WORKSPACE: tempRoot, + HEAD_REPOSITORY: "NVIDIA/NemoClaw", + }, + tempRoot, + ); + + expect(result.status).toBe(1); + expect(result.stdout).toContain("requires two valid public-registry npm lockfiles"); + } finally { + rmSync(tempRoot, { recursive: true, force: true }); + } + }); + // source-shape-contract: security -- Every PR dependency consumer must receive the verified archive without package access it.each(dependencyInstallJobs)("passes the verified SDK archive to %s", (jobName) => { const job = prWorkflow.jobs[jobName]; From 8f28226695d0f9b2f56d14b5b5b551758e9214ee Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Wed, 26 Aug 2026 04:43:00 -0700 Subject: [PATCH 07/27] fix(ci): validate reviewed package specs Signed-off-by: Apurv Kumaria --- ci/source-shape-test-budget.json | 5 +++++ scripts/audit-reviewed-npm-graph.mts | 10 +++++----- .../reviewed-npm-audit-workflow.test.ts | 20 +++++++++++++++++++ 3 files changed, 30 insertions(+), 5 deletions(-) diff --git a/ci/source-shape-test-budget.json b/ci/source-shape-test-budget.json index 8668a34f1d5..50ab51fac47 100644 --- a/ci/source-shape-test-budget.json +++ b/ci/source-shape-test-budget.json @@ -211,6 +211,11 @@ "test": "rejects the removed plural source-registry package shape", "category": "security" }, + { + "file": "test/automation/releases/reviewed-npm-audit-workflow.test.ts", + "test": "rejects malformed reviewed source package specifications", + "category": "security" + }, { "file": "test/agents/hermes/reviewed-hermes-platform-action.test.ts", "test": "publishes the verified native manifest digest", diff --git a/scripts/audit-reviewed-npm-graph.mts b/scripts/audit-reviewed-npm-graph.mts index 76508176ca2..1a5d42e518e 100755 --- a/scripts/audit-reviewed-npm-graph.mts +++ b/scripts/audit-reviewed-npm-graph.mts @@ -65,6 +65,8 @@ const TARGET_REPO_ROOT = fs.realpathSync( ); const CONFIG_PATH = resolveTrustedAuditConfigPath(TRUSTED_REPO_ROOT); const SEVERITIES: readonly Severity[] = ["info", "low", "moderate", "high", "critical"]; +const EXACT_NPM_PACKAGE_SPEC = + /^(?:@[a-z0-9][a-z0-9._-]*\/[a-z0-9][a-z0-9._-]*|[a-z0-9][a-z0-9._-]*)@[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?$/; const SOURCE_GRAPH = { id: "nemoclaw-cli", label: "NemoClaw CLI locked production graph", @@ -156,9 +158,7 @@ export function parseAuditConfig(contents: string): AuditConfig { parsed.sourceNestedShrinkwrapPackages.some( (packageSpec) => typeof packageSpec !== "string" || - !/^(?:@[a-z0-9][a-z0-9._-]*\/[a-z0-9][a-z0-9._-]*|[a-z0-9][a-z0-9._-]*)@[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?$/.test( - packageSpec, - ), + !EXACT_NPM_PACKAGE_SPEC.test(packageSpec), ) || new Set(parsed.sourceNestedShrinkwrapPackages).size !== parsed.sourceNestedShrinkwrapPackages.length || @@ -168,7 +168,7 @@ export function parseAuditConfig(contents: string): AuditConfig { typeof reviewed.label !== "string" || !reviewed.label || typeof reviewed.packageSpec !== "string" || - !reviewed.packageSpec || + !EXACT_NPM_PACKAGE_SPEC.test(reviewed.packageSpec) || typeof reviewed.tarballUrl !== "string" || !reviewed.tarballUrl, ) || @@ -182,7 +182,7 @@ export function parseAuditConfig(contents: string): AuditConfig { typeof parsed.sourceRegistryPackage.label !== "string" || !parsed.sourceRegistryPackage.label || typeof parsed.sourceRegistryPackage.packageSpec !== "string" || - !parsed.sourceRegistryPackage.packageSpec || + !EXACT_NPM_PACKAGE_SPEC.test(parsed.sourceRegistryPackage.packageSpec) || typeof parsed.sourceRegistryPackage.integrity !== "string" || !parsed.sourceRegistryPackage.integrity || typeof parsed.sourceRegistryPackage.tarballUrl !== "string" || diff --git a/test/automation/releases/reviewed-npm-audit-workflow.test.ts b/test/automation/releases/reviewed-npm-audit-workflow.test.ts index c1d240efead..365036f8cc6 100644 --- a/test/automation/releases/reviewed-npm-audit-workflow.test.ts +++ b/test/automation/releases/reviewed-npm-audit-workflow.test.ts @@ -187,6 +187,26 @@ describe("trusted reviewed npm audit workflow (#5896)", () => { ); }); + // source-shape-contract: security -- Exact package specifications must fail before malformed reviewed identities can authorize dependency installation + it("rejects malformed reviewed source package specifications", () => { + const configFile = path.join(REPO_ROOT, "ci", "reviewed-npm-audit.json"); + const config = JSON.parse(fs.readFileSync(configFile, "utf-8")) as { + sourceRegistryPackage: { packageSpec: string }; + sourceRegistryPackagesWithoutIntegrity: Array<{ packageSpec: string }>; + }; + + config.sourceRegistryPackage.packageSpec = "@nvidia/openshell-sdk@latest"; + expect(() => parseAuditConfig(JSON.stringify(config))).toThrow( + "ci/reviewed-npm-audit.json is invalid", + ); + + config.sourceRegistryPackage.packageSpec = "@nvidia/openshell-sdk@0.0.106"; + config.sourceRegistryPackagesWithoutIntegrity[0]!.packageSpec = "not-an-exact-spec"; + expect(() => parseAuditConfig(JSON.stringify(config))).toThrow( + "ci/reviewed-npm-audit.json is invalid", + ); + }); + it("rejects an affected tar release for the reviewed archive graph", () => { expect(() => reviewedArchiveGraphManifest("7.5.20")).toThrow( "reviewed archive graph tar version must be exactly 7.5.21", From 2da330a8d62de75b175bcff7f3f66f19d5262cc8 Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Wed, 26 Aug 2026 04:59:52 -0700 Subject: [PATCH 08/27] fix(ci): validate exact package semver Signed-off-by: Apurv Kumaria --- scripts/audit-reviewed-npm-graph.mts | 7 +++-- .../reviewed-npm-audit-workflow.test.ts | 30 +++++++++++++++---- 2 files changed, 30 insertions(+), 7 deletions(-) diff --git a/scripts/audit-reviewed-npm-graph.mts b/scripts/audit-reviewed-npm-graph.mts index 1a5d42e518e..57c5de8c7db 100755 --- a/scripts/audit-reviewed-npm-graph.mts +++ b/scripts/audit-reviewed-npm-graph.mts @@ -65,8 +65,11 @@ const TARGET_REPO_ROOT = fs.realpathSync( ); const CONFIG_PATH = resolveTrustedAuditConfigPath(TRUSTED_REPO_ROOT); const SEVERITIES: readonly Severity[] = ["info", "low", "moderate", "high", "critical"]; -const EXACT_NPM_PACKAGE_SPEC = - /^(?:@[a-z0-9][a-z0-9._-]*\/[a-z0-9][a-z0-9._-]*|[a-z0-9][a-z0-9._-]*)@[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?$/; +const SEMVER_NUMERIC_IDENTIFIER = String.raw`(?:0|[1-9][0-9]*)`; +const SEMVER_PRERELEASE_IDENTIFIER = String.raw`(?:${SEMVER_NUMERIC_IDENTIFIER}|[0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*)`; +const EXACT_NPM_PACKAGE_SPEC = new RegExp( + String.raw`^(?:@[a-z0-9][a-z0-9._-]*\/[a-z0-9][a-z0-9._-]*|[a-z0-9][a-z0-9._-]*)@${SEMVER_NUMERIC_IDENTIFIER}\.${SEMVER_NUMERIC_IDENTIFIER}\.${SEMVER_NUMERIC_IDENTIFIER}(?:-${SEMVER_PRERELEASE_IDENTIFIER}(?:\.${SEMVER_PRERELEASE_IDENTIFIER})*)?$`, +); const SOURCE_GRAPH = { id: "nemoclaw-cli", label: "NemoClaw CLI locked production graph", diff --git a/test/automation/releases/reviewed-npm-audit-workflow.test.ts b/test/automation/releases/reviewed-npm-audit-workflow.test.ts index 365036f8cc6..2a81bf731a1 100644 --- a/test/automation/releases/reviewed-npm-audit-workflow.test.ts +++ b/test/automation/releases/reviewed-npm-audit-workflow.test.ts @@ -190,17 +190,37 @@ describe("trusted reviewed npm audit workflow (#5896)", () => { // source-shape-contract: security -- Exact package specifications must fail before malformed reviewed identities can authorize dependency installation it("rejects malformed reviewed source package specifications", () => { const configFile = path.join(REPO_ROOT, "ci", "reviewed-npm-audit.json"); - const config = JSON.parse(fs.readFileSync(configFile, "utf-8")) as { - sourceRegistryPackage: { packageSpec: string }; - sourceRegistryPackagesWithoutIntegrity: Array<{ packageSpec: string }>; - }; + const readConfig = () => + JSON.parse(fs.readFileSync(configFile, "utf-8")) as { + sourceRegistryPackage: { packageSpec: string }; + sourceRegistryPackagesWithoutIntegrity: Array<{ packageSpec: string }>; + }; + let config = readConfig(); config.sourceRegistryPackage.packageSpec = "@nvidia/openshell-sdk@latest"; expect(() => parseAuditConfig(JSON.stringify(config))).toThrow( "ci/reviewed-npm-audit.json is invalid", ); - config.sourceRegistryPackage.packageSpec = "@nvidia/openshell-sdk@0.0.106"; + config = readConfig(); + config.sourceRegistryPackage.packageSpec = "@nvidia/openshell-sdk@01.2.3"; + expect(() => parseAuditConfig(JSON.stringify(config))).toThrow( + "ci/reviewed-npm-audit.json is invalid", + ); + + config = readConfig(); + config.sourceRegistryPackage.packageSpec = "@nvidia/openshell-sdk@1.2.3-01"; + expect(() => parseAuditConfig(JSON.stringify(config))).toThrow( + "ci/reviewed-npm-audit.json is invalid", + ); + + config = readConfig(); + config.sourceRegistryPackage.packageSpec = "@nvidia/openshell-sdk@1.2.3-foo..bar"; + expect(() => parseAuditConfig(JSON.stringify(config))).toThrow( + "ci/reviewed-npm-audit.json is invalid", + ); + + config = readConfig(); config.sourceRegistryPackagesWithoutIntegrity[0]!.packageSpec = "not-an-exact-spec"; expect(() => parseAuditConfig(JSON.stringify(config))).toThrow( "ci/reviewed-npm-audit.json is invalid", From 91d86c7db466a8fc559fc51831d6d2bf08a9beb2 Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Wed, 26 Aug 2026 06:47:33 -0700 Subject: [PATCH 09/27] chore(ci): format value stream analyzer Signed-off-by: Apurv Kumaria --- .dsh/tools/analyze_pr_value_stream/index.ts | 303 ++++++++++---------- 1 file changed, 152 insertions(+), 151 deletions(-) diff --git a/.dsh/tools/analyze_pr_value_stream/index.ts b/.dsh/tools/analyze_pr_value_stream/index.ts index 94350c42504..768e976fba4 100644 --- a/.dsh/tools/analyze_pr_value_stream/index.ts +++ b/.dsh/tools/analyze_pr_value_stream/index.ts @@ -410,160 +410,162 @@ export default async function analyze_pr_value_stream(input: { for (let index = 0; index < waterfallRuns.length; index += 4) { const batch = await Promise.all( waterfallRuns.slice(index, index + 4).map(async (run: any) => { - const runResult = await tools.run_github_cli({ - workdir: input.workdir, - args: [ - "api", - "repos/" + repository + "/actions/runs/" + run.id, - "--jq", - "{id,name,event,run_attempt,head_sha,status,conclusion,created_at,run_started_at,updated_at,html_url}", - ], - }); - const runDetails = JSON.parse(runResult.stdout); - if ( - runDetails?.id !== run.id || - runDetails?.head_sha !== pull.headRefOid || - !Number.isSafeInteger(runDetails?.run_attempt) - ) - throw new Error("workflow run did not match the exact-head waterfall contract"); - const jobsResult = await tools.run_github_cli({ - workdir: input.workdir, - args: [ - "api", - "repos/" + repository + "/actions/runs/" + run.id + "/jobs?filter=latest&per_page=100", - "--jq", - "{total_count,jobs:[.jobs[] | {id,name,status,conclusion,created_at,started_at,completed_at,runner_name,runner_group_name,labels,html_url,steps}]}", - ], - }); - const jobsPayload = JSON.parse(jobsResult.stdout); - if ( - !Number.isSafeInteger(jobsPayload?.total_count) || - !Array.isArray(jobsPayload?.jobs) || - jobsPayload.total_count > 100 || - jobsPayload.jobs.length !== jobsPayload.total_count - ) - throw new Error("workflow job list exceeded the complete bounded waterfall contract"); - const runCreated = parseTime(run.created_at, "workflow createdAt"); - const shardJobs = jobsPayload.jobs.filter((job: any) => shardJobName.test(job?.name)); - let artifactsByName = new Map(); - if (shardJobs.length > 0 && maxTestArtifacts > 0) { - try { - const artifactsResult = await tools.run_github_cli({ - workdir: input.workdir, - args: [ - "api", - "repos/" + repository + "/actions/runs/" + run.id + "/artifacts?per_page=100", - "--jq", - "{total_count,artifacts:[.artifacts[] | {id,name,size_in_bytes,expired,workflow_run_id:.workflow_run.id,workflow_run_head_sha:.workflow_run.head_sha}]}", - ], - }); - const payload = JSON.parse(artifactsResult.stdout); - if ( - Number.isSafeInteger(payload?.total_count) && - payload.total_count <= 100 && - Array.isArray(payload?.artifacts) && - payload.artifacts.length === payload.total_count - ) { - const counts = new Map(); - for (const artifact of payload.artifacts) - counts.set(artifact?.name, (counts.get(artifact?.name) ?? 0) + 1); - artifactsByName = new Map( - payload.artifacts - .filter((artifact: any) => counts.get(artifact?.name) === 1) - .map((artifact: any) => [artifact.name, artifact]), - ); + const runResult = await tools.run_github_cli({ + workdir: input.workdir, + args: [ + "api", + "repos/" + repository + "/actions/runs/" + run.id, + "--jq", + "{id,name,event,run_attempt,head_sha,status,conclusion,created_at,run_started_at,updated_at,html_url}", + ], + }); + const runDetails = JSON.parse(runResult.stdout); + if ( + runDetails?.id !== run.id || + runDetails?.head_sha !== pull.headRefOid || + !Number.isSafeInteger(runDetails?.run_attempt) + ) + throw new Error("workflow run did not match the exact-head waterfall contract"); + const jobsResult = await tools.run_github_cli({ + workdir: input.workdir, + args: [ + "api", + "repos/" + repository + "/actions/runs/" + run.id + "/jobs?filter=latest&per_page=100", + "--jq", + "{total_count,jobs:[.jobs[] | {id,name,status,conclusion,created_at,started_at,completed_at,runner_name,runner_group_name,labels,html_url,steps}]}", + ], + }); + const jobsPayload = JSON.parse(jobsResult.stdout); + if ( + !Number.isSafeInteger(jobsPayload?.total_count) || + !Array.isArray(jobsPayload?.jobs) || + jobsPayload.total_count > 100 || + jobsPayload.jobs.length !== jobsPayload.total_count + ) + throw new Error("workflow job list exceeded the complete bounded waterfall contract"); + const runCreated = parseTime(run.created_at, "workflow createdAt"); + const shardJobs = jobsPayload.jobs.filter((job: any) => shardJobName.test(job?.name)); + let artifactsByName = new Map(); + if (shardJobs.length > 0 && maxTestArtifacts > 0) { + try { + const artifactsResult = await tools.run_github_cli({ + workdir: input.workdir, + args: [ + "api", + "repos/" + repository + "/actions/runs/" + run.id + "/artifacts?per_page=100", + "--jq", + "{total_count,artifacts:[.artifacts[] | {id,name,size_in_bytes,expired,workflow_run_id:.workflow_run.id,workflow_run_head_sha:.workflow_run.head_sha}]}", + ], + }); + const payload = JSON.parse(artifactsResult.stdout); + if ( + Number.isSafeInteger(payload?.total_count) && + payload.total_count <= 100 && + Array.isArray(payload?.artifacts) && + payload.artifacts.length === payload.total_count + ) { + const counts = new Map(); + for (const artifact of payload.artifacts) + counts.set(artifact?.name, (counts.get(artifact?.name) ?? 0) + 1); + artifactsByName = new Map( + payload.artifacts + .filter((artifact: any) => counts.get(artifact?.name) === 1) + .map((artifact: any) => [artifact.name, artifact]), + ); + } + } catch { + artifactsByName = new Map(); } - } catch { - artifactsByName = new Map(); } - } - const jobs = []; - for (const job of jobsPayload.jobs) { - if (!Number.isSafeInteger(job?.id) || !Array.isArray(job?.steps)) - throw new Error("workflow job did not match the waterfall contract"); - const jobCreated = parseTime(job.created_at, "job createdAt"); - const jobStarted = parseTime(job.started_at, "job startedAt"); - const jobCompleted = - job.completed_at === null ? null : parseTime(job.completed_at, "job completedAt"); - let testRun = null; - const match = shardJobName.exec(job.name); - if (match !== null && testArtifactsRead < maxTestArtifacts) { - const artifact = artifactsByName.get("cli-blob-report-" + match[1]); - if (artifact !== undefined) { - testArtifactsRead += 1; - testRun = await readTestRun(run.id, pull.headRefOid, Number(match[1]), artifact); + const jobs = []; + for (const job of jobsPayload.jobs) { + if (!Number.isSafeInteger(job?.id) || !Array.isArray(job?.steps)) + throw new Error("workflow job did not match the waterfall contract"); + const jobCreated = parseTime(job.created_at, "job createdAt"); + const jobStarted = parseTime(job.started_at, "job startedAt"); + const jobCompleted = + job.completed_at === null ? null : parseTime(job.completed_at, "job completedAt"); + let testRun = null; + const match = shardJobName.exec(job.name); + if (match !== null && testArtifactsRead < maxTestArtifacts) { + const artifact = artifactsByName.get("cli-blob-report-" + match[1]); + if (artifact !== undefined) { + testArtifactsRead += 1; + testRun = await readTestRun(run.id, pull.headRefOid, Number(match[1]), artifact); + } } - } - const steps = job.steps.map((step: any) => { - if (!Number.isSafeInteger(step?.number)) - throw new Error("workflow step did not match the waterfall contract"); - const stepStarted = parseTime(step.started_at, "step startedAt"); - const stepCompleted = - step.completed_at === null ? null : parseTime(step.completed_at, "step completedAt"); - return { - number: step.number, - name: String(step.name ?? "").slice(0, 200), - status: String(step.status ?? "").slice(0, 40), - conclusion: - step.conclusion === null ? null : String(step.conclusion ?? "").slice(0, 40), - startedAt: iso(stepStarted), - completedAt: stepCompleted === null ? null : iso(stepCompleted), - offsetSeconds: offsetSeconds(headObserved, stepStarted), - durationSeconds: stepCompleted === null ? null : seconds(stepStarted, stepCompleted), + const steps = job.steps.map((step: any) => { + if (!Number.isSafeInteger(step?.number)) + throw new Error("workflow step did not match the waterfall contract"); + const stepStarted = parseTime(step.started_at, "step startedAt"); + const stepCompleted = + step.completed_at === null ? null : parseTime(step.completed_at, "step completedAt"); + return { + number: step.number, + name: String(step.name ?? "").slice(0, 200), + status: String(step.status ?? "").slice(0, 40), + conclusion: + step.conclusion === null ? null : String(step.conclusion ?? "").slice(0, 40), + startedAt: iso(stepStarted), + completedAt: stepCompleted === null ? null : iso(stepCompleted), + offsetSeconds: offsetSeconds(headObserved, stepStarted), + durationSeconds: stepCompleted === null ? null : seconds(stepStarted, stepCompleted), + }; + }); + const normalizedJob = { + id: job.id, + name: String(job.name ?? "").slice(0, 200), + status: String(job.status ?? "").slice(0, 40), + conclusion: job.conclusion === null ? null : String(job.conclusion ?? "").slice(0, 40), + url: String(job.html_url ?? "").slice(0, 500), + runner: job.runner_name === null ? null : String(job.runner_name ?? "").slice(0, 200), + runnerGroup: + job.runner_group_name === null + ? null + : String(job.runner_group_name ?? "").slice(0, 200), + labels: job.labels.slice(0, 20).map((label: any) => String(label).slice(0, 100)), + createdAt: iso(jobCreated), + startedAt: iso(jobStarted), + completedAt: jobCompleted === null ? null : iso(jobCompleted), + offsetSeconds: offsetSeconds(headObserved, jobStarted), + queueSeconds: jobCreated <= jobStarted ? seconds(jobCreated, jobStarted) : null, + durationSeconds: jobCompleted === null ? null : seconds(jobStarted, jobCompleted), + testRun, + steps, }; - }); - const normalizedJob = { - id: job.id, - name: String(job.name ?? "").slice(0, 200), - status: String(job.status ?? "").slice(0, 40), - conclusion: job.conclusion === null ? null : String(job.conclusion ?? "").slice(0, 40), - url: String(job.html_url ?? "").slice(0, 500), - runner: job.runner_name === null ? null : String(job.runner_name ?? "").slice(0, 200), - runnerGroup: - job.runner_group_name === null + jobs.push(normalizedJob); + } + const earliestJobStart = + jobs.length > 0 + ? Math.min( + ...jobs.map((job: any) => parseTime(job.startedAt, "normalized job startedAt")), + ) + : parseTime(runDetails.run_started_at, "workflow startedAt"); + const runStarted = Math.min( + parseTime(runDetails.run_started_at, "workflow startedAt"), + earliestJobStart, + ); + const runCompleted = + runDetails.status === "completed" + ? parseTime(runDetails.updated_at, "workflow completedAt") + : null; + return { + id: runDetails.id, + name: String(runDetails.name ?? "").slice(0, 200), + event: String(runDetails.event ?? "").slice(0, 60), + attempt: runDetails.run_attempt, + status: String(runDetails.status ?? "").slice(0, 40), + conclusion: + runDetails.conclusion === null ? null - : String(job.runner_group_name ?? "").slice(0, 200), - labels: job.labels.slice(0, 20).map((label: any) => String(label).slice(0, 100)), - createdAt: iso(jobCreated), - startedAt: iso(jobStarted), - completedAt: jobCompleted === null ? null : iso(jobCompleted), - offsetSeconds: offsetSeconds(headObserved, jobStarted), - queueSeconds: jobCreated <= jobStarted ? seconds(jobCreated, jobStarted) : null, - durationSeconds: jobCompleted === null ? null : seconds(jobStarted, jobCompleted), - testRun, - steps, - }; - jobs.push(normalizedJob); - } - const earliestJobStart = - jobs.length > 0 - ? Math.min( - ...jobs.map((job: any) => parseTime(job.startedAt, "normalized job startedAt")), - ) - : parseTime(runDetails.run_started_at, "workflow startedAt"); - const runStarted = Math.min( - parseTime(runDetails.run_started_at, "workflow startedAt"), - earliestJobStart, - ); - const runCompleted = - runDetails.status === "completed" - ? parseTime(runDetails.updated_at, "workflow completedAt") - : null; - return { - id: runDetails.id, - name: String(runDetails.name ?? "").slice(0, 200), - event: String(runDetails.event ?? "").slice(0, 60), - attempt: runDetails.run_attempt, - status: String(runDetails.status ?? "").slice(0, 40), - conclusion: - runDetails.conclusion === null ? null : String(runDetails.conclusion ?? "").slice(0, 40), - url: String(runDetails.html_url ?? "").slice(0, 500), - createdAt: iso(runCreated), - startedAt: iso(runStarted), - completedAt: runCompleted === null ? null : iso(runCompleted), - offsetSeconds: offsetSeconds(headObserved, runCreated), - queueSeconds: seconds(runCreated, runStarted), - durationSeconds: runCompleted === null ? null : seconds(runStarted, runCompleted), + : String(runDetails.conclusion ?? "").slice(0, 40), + url: String(runDetails.html_url ?? "").slice(0, 500), + createdAt: iso(runCreated), + startedAt: iso(runStarted), + completedAt: runCompleted === null ? null : iso(runCompleted), + offsetSeconds: offsetSeconds(headObserved, runCreated), + queueSeconds: seconds(runCreated, runStarted), + durationSeconds: runCompleted === null ? null : seconds(runStarted, runCompleted), jobs, }; }), @@ -686,8 +688,7 @@ export default async function analyze_pr_value_stream(input: { .flatMap((run: any) => run.jobs) .filter((job: any) => job.queueSeconds !== null) .sort( - (a: any, b: any) => - b.queueSeconds - a.queueSeconds || a.name.localeCompare(b.name), + (a: any, b: any) => b.queueSeconds - a.queueSeconds || a.name.localeCompare(b.name), )[0] ?? null; const longestChecks = checkRows .slice() From 9948e8cb026265d93787377f0eb30b1ab87260de Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Wed, 26 Aug 2026 06:58:59 -0700 Subject: [PATCH 10/27] fix(ci): validate npm cache implementation version Signed-off-by: Apurv Kumaria --- scripts/lib/seed-reviewed-npm-cache.mts | 7 ++++++ test/install/seed-reviewed-npm-cache.test.ts | 23 +++++++++++++++++++- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/scripts/lib/seed-reviewed-npm-cache.mts b/scripts/lib/seed-reviewed-npm-cache.mts index d5186c48217..259152102f3 100755 --- a/scripts/lib/seed-reviewed-npm-cache.mts +++ b/scripts/lib/seed-reviewed-npm-cache.mts @@ -63,6 +63,7 @@ type LockedPackage = Readonly<{ }>; const INSTALL_ACCEPT = "application/vnd.npm.install-v1+json; q=1.0, application/json; q=0.8, */*"; +const REVIEWED_CI_NPM_VERSIONS = new Set(["10.9.4", "11.17.0"]); function packageNameFromLockLocation(location: string): string { const marker = "node_modules/"; @@ -248,6 +249,12 @@ function packumentUrl(registryOrigin: string, packageName: string): string { } function loadCachePut(): CachePut { + const npmVersion = execFileSync("npm", ["--version"], { encoding: "utf8" }).trim(); + if (!REVIEWED_CI_NPM_VERSIONS.has(npmVersion)) { + throw new Error( + `reviewed npm cache seed does not support npm@${npmVersion}; expected npm@10.9.4 or npm@11.17.0`, + ); + } const npmRoot = execFileSync("npm", ["root", "-g"], { encoding: "utf8" }).trim(); const require = createRequire(import.meta.url); const cacachePath = require.resolve("cacache", { diff --git a/test/install/seed-reviewed-npm-cache.test.ts b/test/install/seed-reviewed-npm-cache.test.ts index f02acbfaeb5..04c0552ee5a 100644 --- a/test/install/seed-reviewed-npm-cache.test.ts +++ b/test/install/seed-reviewed-npm-cache.test.ts @@ -6,7 +6,7 @@ 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 } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { type CachePut, @@ -72,6 +72,7 @@ function request( } afterEach(() => { + vi.unstubAllEnvs(); for (const root of roots.splice(0)) fs.rmSync(root, { force: true, recursive: true }); }); @@ -201,6 +202,26 @@ describe("reviewed npm cache seed", () => { expect(integrity).toBe(input.integrity); }); + it("rejects an unreviewed npm version before loading npm cache internals", async () => { + const input = fixture(); + const binDirectory = path.join(input.root, "bin"); + const tracePath = path.join(input.root, "npm.trace"); + const npmPath = path.join(binDirectory, "npm"); + fs.mkdirSync(binDirectory); + fs.writeFileSync( + npmPath, + `#!/bin/sh\nprintf '%s\\n' "$*" >> "$NPM_TRACE"\nprintf '99.0.0\\n'\n`, + ); + fs.chmodSync(npmPath, 0o755); + vi.stubEnv("PATH", `${binDirectory}:${process.env.PATH ?? ""}`); + vi.stubEnv("NPM_TRACE", tracePath); + + await expect(seedReviewedNpmCache(request(input))).rejects.toThrow( + "reviewed npm cache seed does not support npm@99.0.0; expected npm@10.9.4 or npm@11.17.0", + ); + expect(fs.readFileSync(tracePath, "utf8")).toBe("--version\n"); + }); + it("rejects missing, extra, and integrity-mismatched archives", async () => { const input = fixture(); await expect( From 78f31f738bbddc2e513f047a2bbc0c8a0c08630f Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Wed, 26 Aug 2026 07:21:28 -0700 Subject: [PATCH 11/27] fix(ci): support managed-image npm cache Signed-off-by: Apurv Kumaria --- scripts/lib/seed-reviewed-npm-cache.mts | 4 ++-- test/install/seed-reviewed-npm-cache.test.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/scripts/lib/seed-reviewed-npm-cache.mts b/scripts/lib/seed-reviewed-npm-cache.mts index 259152102f3..c66ce5971e8 100755 --- a/scripts/lib/seed-reviewed-npm-cache.mts +++ b/scripts/lib/seed-reviewed-npm-cache.mts @@ -63,7 +63,7 @@ type LockedPackage = Readonly<{ }>; const INSTALL_ACCEPT = "application/vnd.npm.install-v1+json; q=1.0, application/json; q=0.8, */*"; -const REVIEWED_CI_NPM_VERSIONS = new Set(["10.9.4", "11.17.0"]); +const REVIEWED_CI_NPM_VERSIONS = new Set(["10.9.4", "10.9.8", "11.17.0"]); function packageNameFromLockLocation(location: string): string { const marker = "node_modules/"; @@ -252,7 +252,7 @@ function loadCachePut(): CachePut { const npmVersion = execFileSync("npm", ["--version"], { encoding: "utf8" }).trim(); if (!REVIEWED_CI_NPM_VERSIONS.has(npmVersion)) { throw new Error( - `reviewed npm cache seed does not support npm@${npmVersion}; expected npm@10.9.4 or npm@11.17.0`, + `reviewed npm cache seed does not support npm@${npmVersion}; expected npm@10.9.4, npm@10.9.8, or npm@11.17.0`, ); } const npmRoot = execFileSync("npm", ["root", "-g"], { encoding: "utf8" }).trim(); diff --git a/test/install/seed-reviewed-npm-cache.test.ts b/test/install/seed-reviewed-npm-cache.test.ts index 04c0552ee5a..ff8ac750f3e 100644 --- a/test/install/seed-reviewed-npm-cache.test.ts +++ b/test/install/seed-reviewed-npm-cache.test.ts @@ -217,7 +217,7 @@ describe("reviewed npm cache seed", () => { vi.stubEnv("NPM_TRACE", tracePath); await expect(seedReviewedNpmCache(request(input))).rejects.toThrow( - "reviewed npm cache seed does not support npm@99.0.0; expected npm@10.9.4 or npm@11.17.0", + "reviewed npm cache seed does not support npm@99.0.0; expected npm@10.9.4, npm@10.9.8, or npm@11.17.0", ); expect(fs.readFileSync(tracePath, "utf8")).toBe("--version\n"); }); From 1ac4d14ccb807e06c7c9a3ba41c9016d1c756ae7 Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Wed, 26 Aug 2026 17:33:09 -0700 Subject: [PATCH 12/27] test(ci): keep SDK workflow checks on balanced shard Signed-off-by: Apurv Kumaria --- ci/source-shape-test-budget.json | 4 +- .../openshell-sdk-package-workflow.test.ts | 78 ------------------- .../pr-workflow-contract.test.ts | 73 +++++++++++++++++ 3 files changed, 75 insertions(+), 80 deletions(-) delete mode 100644 test/automation/pull-requests/openshell-sdk-package-workflow.test.ts diff --git a/ci/source-shape-test-budget.json b/ci/source-shape-test-budget.json index 50ab51fac47..c8b4485a9d0 100644 --- a/ci/source-shape-test-budget.json +++ b/ci/source-shape-test-budget.json @@ -162,12 +162,12 @@ "category": "security" }, { - "file": "test/automation/pull-requests/openshell-sdk-package-workflow.test.ts", + "file": "test/automation/pull-requests/pr-workflow-contract.test.ts", "test": "keeps package access out of pull request controlled execution", "category": "security" }, { - "file": "test/automation/pull-requests/openshell-sdk-package-workflow.test.ts", + "file": "test/automation/pull-requests/pr-workflow-contract.test.ts", "test": "derives the package and archive identity from the base-controlled decision", "category": "security" }, diff --git a/test/automation/pull-requests/openshell-sdk-package-workflow.test.ts b/test/automation/pull-requests/openshell-sdk-package-workflow.test.ts deleted file mode 100644 index 5b8c6ce860d..00000000000 --- a/test/automation/pull-requests/openshell-sdk-package-workflow.test.ts +++ /dev/null @@ -1,78 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { describe, expect, it } from "vitest"; - -import { readYaml, type WorkflowJob } from "../../helpers/e2e-workflow-contract"; - -type Workflow = Readonly<{ - concurrency?: Readonly>; - jobs: Readonly>; - on?: Readonly>; - permissions?: Readonly>; -}>; - -const workflow = readYaml(".github/workflows/openshell-sdk-package-pr.yaml"); -const job = workflow.jobs["package-openshell-sdk"]; - -function step(name: string) { - const value = job.steps?.find((candidate) => candidate.name === name); - expect(value, `Missing package workflow step: ${name}`).toBeDefined(); - return value as NonNullable; -} - -describe("base-controlled OpenShell SDK package workflow", () => { - // source-shape-contract: security -- The package credential must remain in a base-loaded workflow that uploads only the verified SDK archive - it("keeps package access out of pull request controlled execution", () => { - expect(workflow.on).toEqual({ - pull_request_target: { types: ["opened", "synchronize", "reopened", "edited"] }, - }); - expect(workflow.permissions).toEqual({ contents: "read" }); - expect(job.permissions).toEqual({ contents: "read", packages: "read" }); - expect(job.if).toBe( - "${{ github.event.pull_request.head.repo.full_name == github.repository && (github.event.action != 'edited' || github.event.changes.base != null) }}", - ); - expect(job["timeout-minutes"]).toBe(5); - - const checkout = step("Checkout base-controlled package verifier"); - expect(checkout.uses).toBe("actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1"); - expect(checkout.with).toMatchObject({ - ref: "${{ github.event.pull_request.base.sha }}", - "persist-credentials": false, - }); - expect(String(checkout.with?.["sparse-checkout"])).not.toContain("pull_request.head"); - - const fetch = step("Download and verify exact OpenShell SDK package"); - expect(fetch.env).toEqual({ - NEMOCLAW_OPEN_SHELL_SDK_OUTPUT_DIRECTORY: "${{ runner.temp }}/openshell-sdk", - NODE_AUTH_TOKEN: "${{ github.token }}", - }); - expect(fetch.run).toContain( - "node --experimental-strip-types scripts/checks/package-openshell-sdk-for-pr.mts", - ); - expect(fetch.run).toContain("artifact_path="); - expect( - (job.steps ?? []) - .filter((candidate) => candidate.name !== fetch.name) - .map((candidate) => candidate.env?.NODE_AUTH_TOKEN), - ).toEqual( - (job.steps ?? []).filter((candidate) => candidate.name !== fetch.name).map(() => undefined), - ); - - const upload = step("Upload verified OpenShell SDK archive"); - expect(upload.uses).toBe("actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a"); - expect(upload.with).toMatchObject({ - name: "openshell-sdk-${{ github.event.pull_request.head.sha }}", - path: "${{ steps.package.outputs.artifact_path }}", - "if-no-files-found": "error", - "retention-days": 1, - }); - }); - - // source-shape-contract: security -- The credential-bearing workflow must derive one package identity from reviewed base data instead of duplicating package coordinates - it("derives the package and archive identity from the base-controlled decision", () => { - const serialized = JSON.stringify(workflow); - expect(serialized).not.toContain("@nvidia/openshell-sdk@0.0.106"); - expect(serialized).not.toContain("nvidia-openshell-sdk-0.0.106.tgz"); - }); -}); diff --git a/test/automation/pull-requests/pr-workflow-contract.test.ts b/test/automation/pull-requests/pr-workflow-contract.test.ts index fa404e5b24d..6f15339dd33 100644 --- a/test/automation/pull-requests/pr-workflow-contract.test.ts +++ b/test/automation/pull-requests/pr-workflow-contract.test.ts @@ -22,6 +22,13 @@ type CiWorkflow = { jobs: Record; }; +type SdkPackageWorkflow = Readonly<{ + concurrency?: Readonly>; + jobs: Readonly>; + on?: Readonly>; + permissions?: Readonly>; +}>; + type InstallerHashAction = CompositeAction & { inputs?: Record; }; @@ -333,6 +340,10 @@ describe("pull request and main workflow contracts", () => { const mainWorkflow = readYaml(".github/workflows/main.yaml"); const dcoWorkflow = readYaml(".github/workflows/dco-check.yaml"); const installerHashWorkflow = readYaml(".github/workflows/installer-hash-check.yaml"); + const sdkPackageWorkflow = readYaml( + ".github/workflows/openshell-sdk-package-pr.yaml", + ); + const sdkPackageJob = sdkPackageWorkflow.jobs["package-openshell-sdk"]; const installerHashAction = readYaml( ".github/actions/ci-installer-hash-check/action.yaml", @@ -589,6 +600,68 @@ describe("pull request and main workflow contracts", () => { }); }); + // source-shape-contract: security -- The package credential must remain in a base-loaded workflow that uploads only the verified SDK archive + it("keeps package access out of pull request controlled execution", () => { + expect(sdkPackageWorkflow.on).toEqual({ + pull_request_target: { types: ["opened", "synchronize", "reopened", "edited"] }, + }); + expect(sdkPackageWorkflow.permissions).toEqual({ contents: "read" }); + expect(sdkPackageJob.permissions).toEqual({ contents: "read", packages: "read" }); + expect(sdkPackageJob.if).toBe( + "${{ github.event.pull_request.head.repo.full_name == github.repository && (github.event.action != 'edited' || github.event.changes.base != null) }}", + ); + expect(sdkPackageJob["timeout-minutes"]).toBe(5); + + const checkout = requiredWorkflowStep( + sdkPackageJob, + "Checkout base-controlled package verifier", + ); + expect(checkout.uses).toBe(trustedCheckoutAction); + expect(checkout.with).toMatchObject({ + ref: "${{ github.event.pull_request.base.sha }}", + "persist-credentials": false, + }); + expect(String(checkout.with?.["sparse-checkout"])).not.toContain("pull_request.head"); + + const fetch = requiredWorkflowStep( + sdkPackageJob, + "Download and verify exact OpenShell SDK package", + ); + expect(fetch.env).toEqual({ + NEMOCLAW_OPEN_SHELL_SDK_OUTPUT_DIRECTORY: "${{ runner.temp }}/openshell-sdk", + NODE_AUTH_TOKEN: "${{ github.token }}", + }); + expect(fetch.run).toContain( + "node --experimental-strip-types scripts/checks/package-openshell-sdk-for-pr.mts", + ); + expect(fetch.run).toContain("artifact_path="); + expect( + (sdkPackageJob.steps ?? []) + .filter((candidate) => candidate.name !== fetch.name) + .map((candidate) => candidate.env?.NODE_AUTH_TOKEN), + ).toEqual( + (sdkPackageJob.steps ?? []) + .filter((candidate) => candidate.name !== fetch.name) + .map(() => undefined), + ); + + const upload = requiredWorkflowStep(sdkPackageJob, "Upload verified OpenShell SDK archive"); + expect(upload.uses).toBe("actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a"); + expect(upload.with).toMatchObject({ + name: "openshell-sdk-${{ github.event.pull_request.head.sha }}", + path: "${{ steps.package.outputs.artifact_path }}", + "if-no-files-found": "error", + "retention-days": 1, + }); + }); + + // source-shape-contract: security -- The credential-bearing workflow must derive one package identity from reviewed base data instead of duplicating package coordinates + it("derives the package and archive identity from the base-controlled decision", () => { + const serialized = JSON.stringify(sdkPackageWorkflow); + expect(serialized).not.toContain("@nvidia/openshell-sdk@0.0.106"); + expect(serialized).not.toContain("nvidia-openshell-sdk-0.0.106.tgz"); + }); + // source-shape-contract: security -- PR base SHA action execution prevents pull-request code from authorizing installer hashes it("executes pull request installer hash checks only from the PR base SHA", () => { expect(installerHashTrustViolations(installerHashWorkflow)).toEqual([]); From 40678a833ccfa566f812bbdba5b3b308b99617b8 Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Thu, 27 Aug 2026 12:38:49 -0700 Subject: [PATCH 13/27] fix(ci): close SDK package readiness gaps Signed-off-by: Apurv Kumaria --- .github/workflows/pr.yaml | 4 +- scripts/checks/prepare-ci-npm-install.mts | 31 +++- .../pr-workflow-contract.test.ts | 3 + .../reviewed-npm-audit-workflow.test.ts | 32 ---- .../repository/prepare-ci-npm-install.test.ts | 153 ++++++++++++++++-- 5 files changed, 169 insertions(+), 54 deletions(-) diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index 80525745d18..b77469208fd 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -150,7 +150,7 @@ jobs: outputs: required: ${{ steps.locate.outputs.required }} runs-on: ubuntu-latest - timeout-minutes: 5 + timeout-minutes: 10 steps: - name: Checkout pull request lockfiles uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -235,7 +235,7 @@ jobs: artifact_name="$(jq -er '.artifactName | select(type == "string" and test("^[a-z0-9][a-z0-9._-]*\\.tgz$"))' <<<"$decision")" printf 'artifact_name=%s\n' "$artifact_name" >> "$GITHUB_OUTPUT" echo "required=true" >> "$GITHUB_OUTPUT" - for attempt in $(seq 1 24); do + for attempt in $(seq 1 96); do runs="$(gh api \ "repos/$GITHUB_REPOSITORY/actions/workflows/openshell-sdk-package-pr.yaml/runs?event=pull_request_target&per_page=100")" match="$(jq -cer \ diff --git a/scripts/checks/prepare-ci-npm-install.mts b/scripts/checks/prepare-ci-npm-install.mts index 65509ec4375..9343a8cddfa 100755 --- a/scripts/checks/prepare-ci-npm-install.mts +++ b/scripts/checks/prepare-ci-npm-install.mts @@ -20,6 +20,8 @@ type PreparationRequest = Readonly<{ targetRoot: string; }>; +type AuditConfig = ReturnType; + export type ReviewedSourceRegistryPackage = Readonly<{ artifactName: string; integrity: string; @@ -87,10 +89,13 @@ export async function seedReviewedSourceRegistryArtifact( ); } -function inspectReviewedLocks(targetRoot: string) { - const config = parseAuditConfig( +function readTrustedAuditConfig(): AuditConfig { + return parseAuditConfig( readFileSync(join(TRUSTED_REPOSITORY_ROOT, "ci/reviewed-npm-audit.json"), "utf8"), ); +} + +function inspectReviewedLocks(targetRoot: string, config: AuditConfig) { const reviewed = config.sourceRegistryPackage; const reviewedRegistryPackages = [ { @@ -120,20 +125,21 @@ function inspectReviewedLocks(targetRoot: string) { } export function inspectCiNpmInstall(targetRoot: string) { - const inspected = inspectReviewedLocks(resolve(targetRoot)); + const inspected = inspectReviewedLocks(resolve(targetRoot), readTrustedAuditConfig()); return { artifactName: inspected.reviewed.artifactName, required: inspected.reviewedLockfilePath !== undefined, } as const; } -export async function prepareCiNpmInstall( +async function prepareCiNpmInstallWithConfig( request: PreparationRequest, + config: AuditConfig, put?: CachePut, ): Promise { const targetRoot = resolve(request.targetRoot); const cacheDirectory = resolve(request.cacheDirectory); - const { config, reviewed, reviewedLockfilePath } = inspectReviewedLocks(targetRoot); + const { reviewed, reviewedLockfilePath } = inspectReviewedLocks(targetRoot, config); const sdkIsLocked = reviewedLockfilePath !== undefined; if (request.mode === "registry") return; @@ -166,6 +172,21 @@ export async function prepareCiNpmInstall( ); } +export async function prepareCiNpmInstallWithReviewedConfig( + request: PreparationRequest, + reviewedConfigSource: string, + put?: CachePut, +): Promise { + return prepareCiNpmInstallWithConfig(request, parseAuditConfig(reviewedConfigSource), put); +} + +export async function prepareCiNpmInstall( + request: PreparationRequest, + put?: CachePut, +): Promise { + return prepareCiNpmInstallWithConfig(request, readTrustedAuditConfig(), put); +} + function requestFromEnvironment(): PreparationRequest { const mode = process.env.NEMOCLAW_CI_NPM_PACKAGE_MODE; const targetRoot = process.env.NEMOCLAW_CI_TARGET_ROOT; diff --git a/test/automation/pull-requests/pr-workflow-contract.test.ts b/test/automation/pull-requests/pr-workflow-contract.test.ts index ebf60c68866..be90d3e4bee 100644 --- a/test/automation/pull-requests/pr-workflow-contract.test.ts +++ b/test/automation/pull-requests/pr-workflow-contract.test.ts @@ -430,6 +430,7 @@ describe("pull request and main workflow contracts", () => { // source-shape-contract: security -- The PR workflow must select an exact base-controlled package run before publishing its archive internally it("passes only the base-packaged SDK archive to pull request dependency jobs", () => { const packageJob = prWorkflow.jobs["openshell-sdk-package"]; + expect(packageJob["timeout-minutes"]).toBe(10); expect(packageJob.permissions).toEqual({ actions: "read", contents: "read" }); expect(packageJob.outputs).toEqual({ required: "${{ steps.locate.outputs.required }}" }); expect(requiredWorkflowStep(packageJob, "Checkout base package decision").with).toMatchObject({ @@ -450,6 +451,8 @@ describe("pull request and main workflow contracts", () => { ); expect(locate.run).toContain("requires two valid public-registry npm lockfiles"); expect(locate.run).toContain("actions/workflows/openshell-sdk-package-pr.yaml/runs"); + expect(locate.run).toContain("for attempt in $(seq 1 96)"); + expect(locate.run).toContain("sleep 5"); expect(locate.run).toContain(".head.sha == $head and .base.sha == $base"); expect(locate.run).toContain("required=false"); expect(locate.run).toContain('[ "$HEAD_REPOSITORY" != "$GITHUB_REPOSITORY" ]'); diff --git a/test/automation/releases/reviewed-npm-audit-workflow.test.ts b/test/automation/releases/reviewed-npm-audit-workflow.test.ts index 2a81bf731a1..257d40bccc1 100644 --- a/test/automation/releases/reviewed-npm-audit-workflow.test.ts +++ b/test/automation/releases/reviewed-npm-audit-workflow.test.ts @@ -143,38 +143,6 @@ describe("trusted reviewed npm audit workflow (#5896)", () => { ); }); - it("pins the reviewed archive graph to the first tar release outside the advisory", () => { - const configFile = path.join(REPO_ROOT, "ci", "reviewed-npm-audit.json"); - const config = parseAuditConfig(fs.readFileSync(configFile, "utf-8")); - - expect(config.archiveTarVersion).toBe("7.5.21"); - expect(config.sourceRegistryPackage).toEqual({ - artifactName: "nvidia-openshell-sdk-0.0.106.tgz", - integrity: - "sha512-dB4mLex23Pnw61caGMR2CMHQihy9bj7IK2elJJd718k3yevm+fOt/vG6dJg8/5us4la2BwcOdRwLvOia3tdwFw==", - label: "OpenShell TypeScript SDK 0.0.106", - packageSpec: "@nvidia/openshell-sdk@0.0.106", - tarballUrl: - "https://npm.pkg.github.com/download/@nvidia/openshell-sdk/0.0.106/dc32180ba1d658fc4ec309bdf89d2b162196928d", - }); - expect(config.sourceNestedShrinkwrapPackages).toEqual([ - "@earendil-works/pi-coding-agent@0.80.6", - ]); - expect( - config.sourceRegistryPackagesWithoutIntegrity.map(({ packageSpec }) => packageSpec), - ).toEqual([ - "@earendil-works/pi-agent-core@0.80.6", - "@earendil-works/pi-ai@0.80.6", - "@earendil-works/pi-tui@0.80.6", - ]); - expect(reviewedArchiveGraphManifest(config.archiveTarVersion)).toEqual({ - name: "nemoclaw-reviewed-production-graph", - overrides: { tar: "7.5.21" }, - private: true, - version: "1.0.0", - }); - }); - // source-shape-contract: security -- One reviewed package field prevents a second package identity from bypassing the credential-isolation workflow it("rejects the removed plural source-registry package shape", () => { const configFile = path.join(REPO_ROOT, "ci", "reviewed-npm-audit.json"); diff --git a/test/repository/prepare-ci-npm-install.test.ts b/test/repository/prepare-ci-npm-install.test.ts index 56c4443931f..9318be1bf27 100644 --- a/test/repository/prepare-ci-npm-install.test.ts +++ b/test/repository/prepare-ci-npm-install.test.ts @@ -9,6 +9,7 @@ import { join } from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import { + prepareCiNpmInstallWithReviewedConfig, seedReviewedSourceRegistryArtifact, type ReviewedSourceRegistryPackage, } from "../../scripts/checks/prepare-ci-npm-install.mts"; @@ -24,6 +25,49 @@ const reviewed: ReviewedSourceRegistryPackage = { tarballUrl: "https://npm.pkg.github.com/download/@nvidia/openshell-sdk/0.0.106/reviewed-fixture", }; +function reviewedLock() { + return { + lockfileVersion: 3, + name: "reviewed-sdk-artifact-fixture", + packages: { + "": { dependencies: { "@nvidia/openshell-sdk": "0.0.106" } }, + "node_modules/@nvidia/openshell-sdk": { + integrity: reviewed.integrity, + resolved: reviewed.tarballUrl, + version: "0.0.106", + }, + }, + version: "1.0.0", + }; +} + +function publicLock() { + return { + lockfileVersion: 3, + name: "public-lock-fixture", + packages: { "": {} }, + version: "1.0.0", + }; +} + +function reviewedConfigSource() { + return JSON.stringify({ + archiveGraphId: "reviewed-archive-graph", + archivePackages: [], + archiveTarVersion: "7.5.21", + artifactDirectory: "artifacts/reviewed-npm-audit", + exceptionFile: "ci/npm-audit-exceptions.json", + lockedGraphs: [], + nodeVersion: "22.23.2", + registryOrigin: "https://registry.npmjs.org/", + schemaVersion: 2, + severityThreshold: "high", + sourceNestedShrinkwrapPackages: [], + sourceRegistryPackage: reviewed, + sourceRegistryPackagesWithoutIntegrity: [], + }); +} + function fixture() { const root = mkdtempSync(join(tmpdir(), "nemoclaw-reviewed-sdk-artifact-")); temporaryRoots.push(root); @@ -33,23 +77,32 @@ function fixture() { mkdirSync(artifactDirectory); mkdirSync(cacheDirectory); writeFileSync(join(artifactDirectory, artifactName), archiveBytes); + writeFileSync(lockfilePath, JSON.stringify(reviewedLock())); + return { artifactDirectory, cacheDirectory, lockfilePath, root }; +} + +function installFixture(reviewedLocation: "root" | "nemoclaw") { + const source = fixture(); + const nestedRoot = join(source.root, "nemoclaw"); + mkdirSync(nestedRoot); writeFileSync( - lockfilePath, - JSON.stringify({ - lockfileVersion: 3, - name: "reviewed-sdk-artifact-fixture", - packages: { - "": { dependencies: { "@nvidia/openshell-sdk": "0.0.106" } }, - "node_modules/@nvidia/openshell-sdk": { - integrity: reviewed.integrity, - resolved: reviewed.tarballUrl, - version: "0.0.106", - }, - }, - version: "1.0.0", - }), + source.lockfilePath, + JSON.stringify(reviewedLocation === "root" ? reviewedLock() : publicLock()), ); - return { artifactDirectory, cacheDirectory, lockfilePath, root }; + writeFileSync( + join(nestedRoot, "package-lock.json"), + JSON.stringify(reviewedLocation === "nemoclaw" ? reviewedLock() : publicLock()), + ); + return source; +} + +function installRequest(source: ReturnType, mode: "artifact" | "registry") { + return { + artifactDirectory: source.artifactDirectory, + cacheDirectory: source.cacheDirectory, + mode, + targetRoot: source.root, + } as const; } function request(source: ReturnType) { @@ -70,6 +123,76 @@ afterEach(() => { }); describe("trusted OpenShell SDK archive preparation", () => { + it("requires the reviewed archive when the root lock uses the SDK", async () => { + const source = installFixture("root"); + const put = vi.fn(async () => undefined); + rmSync(source.artifactDirectory, { force: true, recursive: true }); + + await expect( + prepareCiNpmInstallWithReviewedConfig( + installRequest(source, "artifact"), + reviewedConfigSource(), + put, + ), + ).rejects.toThrow("reviewed OpenShell SDK artifact is required"); + expect(put).not.toHaveBeenCalled(); + }); + + it("requires the reviewed archive when the plugin lock uses the SDK", async () => { + const source = installFixture("nemoclaw"); + const put = vi.fn(async () => undefined); + rmSync(source.artifactDirectory, { force: true, recursive: true }); + + await expect( + prepareCiNpmInstallWithReviewedConfig( + installRequest(source, "artifact"), + reviewedConfigSource(), + put, + ), + ).rejects.toThrow("reviewed OpenShell SDK artifact is required"); + expect(put).not.toHaveBeenCalled(); + }); + + it("passes the verified archive from the root lock to npm cache preparation", async () => { + const source = installFixture("root"); + const put = vi.fn(async () => undefined); + + await prepareCiNpmInstallWithReviewedConfig( + installRequest(source, "artifact"), + reviewedConfigSource(), + put, + ); + + expect(put).toHaveBeenCalledTimes(2); + }); + + it("passes the verified archive from the plugin lock to npm cache preparation", async () => { + const source = installFixture("nemoclaw"); + const put = vi.fn(async () => undefined); + + await prepareCiNpmInstallWithReviewedConfig( + installRequest(source, "artifact"), + reviewedConfigSource(), + put, + ); + + expect(put).toHaveBeenCalledTimes(2); + }); + + it("uses registry mode without requiring or caching an archive", async () => { + const source = installFixture("root"); + const put = vi.fn(async () => undefined); + rmSync(source.artifactDirectory, { force: true, recursive: true }); + + await prepareCiNpmInstallWithReviewedConfig( + installRequest(source, "registry"), + reviewedConfigSource(), + put, + ); + + expect(put).not.toHaveBeenCalled(); + }); + it("seeds only the exact reviewed tarball request and package identity", async () => { const source = fixture(); const calls: Array = []; From 3249a5d13a2771b5129efaee88e2f1d86570fecc Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Thu, 27 Aug 2026 13:17:19 -0700 Subject: [PATCH 14/27] fix(ci): stage reviewed SDK with npm Signed-off-by: Apurv Kumaria --- scripts/checks/prepare-ci-npm-install.mts | 118 ++++++++---- scripts/lib/reviewed-npm-archive.mts | 44 +++++ .../repository/prepare-ci-npm-install.test.ts | 174 +++++++++++++----- 3 files changed, 258 insertions(+), 78 deletions(-) diff --git a/scripts/checks/prepare-ci-npm-install.mts b/scripts/checks/prepare-ci-npm-install.mts index 9343a8cddfa..e39b60de792 100755 --- a/scripts/checks/prepare-ci-npm-install.mts +++ b/scripts/checks/prepare-ci-npm-install.mts @@ -2,13 +2,18 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import { spawnSync } from "node:child_process"; import { existsSync, lstatSync, readFileSync, readdirSync } from "node:fs"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; import { dirname, isAbsolute, join, resolve } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import { parseAuditConfig } from "../audit-reviewed-npm-graph.mts"; -import { verifyReviewedNpmLockPackages } from "../lib/reviewed-npm-archive.mts"; -import { type CachePut, seedReviewedNpmCache } from "../lib/seed-reviewed-npm-cache.mts"; +import { + readReviewedNpmArchiveFile, + verifyReviewedNpmLockPackages, +} from "../lib/reviewed-npm-archive.mts"; const TRUSTED_REPOSITORY_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); const MAXIMUM_ARCHIVE_BYTES = 32 * 1024 * 1024; @@ -22,6 +27,14 @@ type PreparationRequest = Readonly<{ type AuditConfig = ReturnType; +type NpmCacheStageRequest = Readonly<{ + archive: Buffer; + artifactName: string; + cacheDirectory: string; +}>; + +type NpmCacheStager = (request: NpmCacheStageRequest) => void; + export type ReviewedSourceRegistryPackage = Readonly<{ artifactName: string; integrity: string; @@ -44,9 +57,41 @@ export type ReviewedSourceRegistryArtifactRequest = Readonly<{ registryOrigin: string; }>; +function stageReviewedArchiveWithNpm(request: NpmCacheStageRequest): void { + const stagingRoot = mkdtempSync(join(tmpdir(), "nemoclaw-reviewed-npm-cache-add-")); + try { + const archivePath = join(stagingRoot, request.artifactName); + writeFileSync(archivePath, request.archive, { mode: 0o600 }); + const result = spawnSync( + "npm", + [ + "cache", + "add", + archivePath, + "--cache", + request.cacheDirectory, + "--offline", + "--ignore-scripts", + ], + { + encoding: "utf8", + env: { ...process.env, NPM_CONFIG_UPDATE_NOTIFIER: "false" }, + maxBuffer: 16 * 1024 * 1024, + stdio: ["ignore", "pipe", "pipe"], + }, + ); + if (result.error) throw result.error; + if (result.status !== 0) { + throw new Error("npm could not stage the reviewed OpenShell SDK archive"); + } + } finally { + rmSync(stagingRoot, { force: true, recursive: true }); + } +} + export async function seedReviewedSourceRegistryArtifact( request: ReviewedSourceRegistryArtifactRequest, - put?: CachePut, + stage: NpmCacheStager = stageReviewedArchiveWithNpm, ): Promise { if (!isAbsolute(request.artifactDirectory)) { throw new Error("reviewed OpenShell SDK artifact directory must be absolute"); @@ -64,29 +109,38 @@ export async function seedReviewedSourceRegistryArtifact( throw new Error("reviewed OpenShell SDK artifact directory has unexpected contents"); } const archivePath = resolve(join(artifactDirectory, request.reviewed.artifactName)); - await seedReviewedNpmCache( - { - allowedNestedShrinkwrapPackages: request.allowedNestedShrinkwrapPackages, - allowNestedShrinkwrap: false, - archives: new Map([[request.reviewed.packageSpec, archivePath]]), - cacheDirectory: request.cacheDirectory, - lockfilePath: request.lockfilePath, - maximumArchiveBytes: MAXIMUM_ARCHIVE_BYTES, - registryOrigin: request.registryOrigin, - reviewedPackagesWithoutIntegrity: request.reviewedPackagesWithoutIntegrity, - reviewedRegistryPackages: [ - { - expectedIntegrity: request.reviewed.integrity, - label: request.reviewed.label, - packageSpec: request.reviewed.packageSpec, - tarballUrl: request.reviewed.tarballUrl, - }, - ], - selectedPackageSpecs: new Set([request.reviewed.packageSpec]), - tarballsOnly: true, - }, - put, - ); + const reviewedRegistryPackage = { + expectedIntegrity: request.reviewed.integrity, + label: request.reviewed.label, + packageSpec: request.reviewed.packageSpec, + tarballUrl: request.reviewed.tarballUrl, + }; + const lockedPackages = verifyReviewedNpmLockPackages({ + allowedNestedShrinkwrapPackages: request.allowedNestedShrinkwrapPackages, + allowNestedShrinkwrap: false, + lockfilePath: request.lockfilePath, + registryOrigin: request.registryOrigin, + reviewedPackagesWithoutIntegrity: request.reviewedPackagesWithoutIntegrity, + reviewedRegistryPackages: [reviewedRegistryPackage], + }); + if (!lockedPackages.includes(request.reviewed.packageSpec)) { + throw new Error("reviewed OpenShell SDK artifact is not used by the selected lockfile"); + } + const cacheDirectory = resolve(request.cacheDirectory); + if ( + !isAbsolute(request.cacheDirectory) || + !existsSync(cacheDirectory) || + !lstatSync(cacheDirectory).isDirectory() + ) { + throw new Error("reviewed OpenShell SDK cache must be an existing absolute directory"); + } + const archive = readReviewedNpmArchiveFile({ + archivePath, + expectedIntegrity: request.reviewed.integrity, + label: request.reviewed.label, + maximumBytes: MAXIMUM_ARCHIVE_BYTES, + }); + stage({ archive, artifactName: request.reviewed.artifactName, cacheDirectory }); } function readTrustedAuditConfig(): AuditConfig { @@ -135,7 +189,7 @@ export function inspectCiNpmInstall(targetRoot: string) { async function prepareCiNpmInstallWithConfig( request: PreparationRequest, config: AuditConfig, - put?: CachePut, + stage?: NpmCacheStager, ): Promise { const targetRoot = resolve(request.targetRoot); const cacheDirectory = resolve(request.cacheDirectory); @@ -168,23 +222,23 @@ async function prepareCiNpmInstallWithConfig( reviewed, reviewedPackagesWithoutIntegrity: config.sourceRegistryPackagesWithoutIntegrity, }, - put, + stage, ); } export async function prepareCiNpmInstallWithReviewedConfig( request: PreparationRequest, reviewedConfigSource: string, - put?: CachePut, + stage?: NpmCacheStager, ): Promise { - return prepareCiNpmInstallWithConfig(request, parseAuditConfig(reviewedConfigSource), put); + return prepareCiNpmInstallWithConfig(request, parseAuditConfig(reviewedConfigSource), stage); } export async function prepareCiNpmInstall( request: PreparationRequest, - put?: CachePut, + stage?: NpmCacheStager, ): Promise { - return prepareCiNpmInstallWithConfig(request, readTrustedAuditConfig(), put); + return prepareCiNpmInstallWithConfig(request, readTrustedAuditConfig(), stage); } function requestFromEnvironment(): PreparationRequest { diff --git a/scripts/lib/reviewed-npm-archive.mts b/scripts/lib/reviewed-npm-archive.mts index d859005217e..f516a224b63 100755 --- a/scripts/lib/reviewed-npm-archive.mts +++ b/scripts/lib/reviewed-npm-archive.mts @@ -72,6 +72,13 @@ export type ReviewedNpmArchive = Readonly<{ rootDirectory: string; }>; +export type ReviewedNpmArchiveFileRequest = Readonly<{ + archivePath: string; + expectedIntegrity: string; + label: string; + maximumBytes: number; +}>; + type NpmRunner = (args: readonly string[], request: ReviewedNpmArchiveRequest) => string; function runNpm(args: readonly string[], request: ReviewedNpmArchiveRequest): string { @@ -103,6 +110,43 @@ function requireReviewedRequest(request: ReviewedNpmArchiveRequest): void { } } +export function readReviewedNpmArchiveFile(request: ReviewedNpmArchiveFileRequest): Buffer { + if (!isAbsolute(request.archivePath)) { + throw new Error(`${request.label} archive path must be absolute`); + } + let descriptor: number | undefined; + try { + const archivePath = resolve(request.archivePath); + descriptor = openSync(archivePath, "r"); + const opened = fstatSync(descriptor); + const pathEntry = lstatSync(archivePath); + if ( + !opened.isFile() || + !pathEntry.isFile() || + pathEntry.isSymbolicLink() || + opened.dev !== pathEntry.dev || + opened.ino !== pathEntry.ino + ) { + throw new Error("archive must be a non-symlink regular file"); + } + if (opened.size > request.maximumBytes) { + throw new Error("archive must be a bounded regular file"); + } + const archive = readFileSync(descriptor); + const actualIntegrity = `sha512-${createHash("sha512").update(archive).digest("base64")}`; + if (actualIntegrity !== request.expectedIntegrity) { + throw new Error( + `${request.label} archive integrity mismatch\nExpected: ${request.expectedIntegrity}\nActual: ${actualIntegrity}`, + ); + } + return archive; + } catch (error) { + throw new Error(`${request.label} archive is unreadable: ${String(error)}`); + } finally { + if (descriptor !== undefined) closeSync(descriptor); + } +} + export function verifyReviewedNpmMetadata( request: ReviewedNpmArchiveRequest, npmRunner: NpmRunner = runNpm, diff --git a/test/repository/prepare-ci-npm-install.test.ts b/test/repository/prepare-ci-npm-install.test.ts index 9318be1bf27..bd7e6c863e1 100644 --- a/test/repository/prepare-ci-npm-install.test.ts +++ b/test/repository/prepare-ci-npm-install.test.ts @@ -1,8 +1,17 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import { execFileSync } from "node:child_process"; import { createHash } from "node:crypto"; -import { mkdtempSync, mkdirSync, rmSync, symlinkSync, truncateSync, writeFileSync } from "node:fs"; +import { + mkdtempSync, + mkdirSync, + readFileSync, + rmSync, + symlinkSync, + truncateSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -25,15 +34,25 @@ const reviewed: ReviewedSourceRegistryPackage = { tarballUrl: "https://npm.pkg.github.com/download/@nvidia/openshell-sdk/0.0.106/reviewed-fixture", }; -function reviewedLock() { +type CacheStageRequest = Readonly<{ + archive: Buffer; + artifactName: string; + cacheDirectory: string; +}>; + +function cacheStageMock() { + return vi.fn((_request: CacheStageRequest) => undefined); +} + +function reviewedLock(packageIdentity: ReviewedSourceRegistryPackage = reviewed) { return { lockfileVersion: 3, name: "reviewed-sdk-artifact-fixture", packages: { "": { dependencies: { "@nvidia/openshell-sdk": "0.0.106" } }, "node_modules/@nvidia/openshell-sdk": { - integrity: reviewed.integrity, - resolved: reviewed.tarballUrl, + integrity: packageIdentity.integrity, + resolved: packageIdentity.tarballUrl, version: "0.0.106", }, }, @@ -50,7 +69,7 @@ function publicLock() { }; } -function reviewedConfigSource() { +function reviewedConfigSource(packageIdentity: ReviewedSourceRegistryPackage = reviewed) { return JSON.stringify({ archiveGraphId: "reviewed-archive-graph", archivePackages: [], @@ -63,7 +82,7 @@ function reviewedConfigSource() { schemaVersion: 2, severityThreshold: "high", sourceNestedShrinkwrapPackages: [], - sourceRegistryPackage: reviewed, + sourceRegistryPackage: packageIdentity, sourceRegistryPackagesWithoutIntegrity: [], }); } @@ -81,21 +100,59 @@ function fixture() { return { artifactDirectory, cacheDirectory, lockfilePath, root }; } -function installFixture(reviewedLocation: "root" | "nemoclaw") { +function installFixture( + reviewedLocation: "root" | "nemoclaw", + packageIdentity: ReviewedSourceRegistryPackage = reviewed, +) { const source = fixture(); const nestedRoot = join(source.root, "nemoclaw"); mkdirSync(nestedRoot); writeFileSync( source.lockfilePath, - JSON.stringify(reviewedLocation === "root" ? reviewedLock() : publicLock()), + JSON.stringify(reviewedLocation === "root" ? reviewedLock(packageIdentity) : publicLock()), ); writeFileSync( join(nestedRoot, "package-lock.json"), - JSON.stringify(reviewedLocation === "nemoclaw" ? reviewedLock() : publicLock()), + JSON.stringify(reviewedLocation === "nemoclaw" ? reviewedLock(packageIdentity) : publicLock()), ); return source; } +function packedInstallFixture() { + const source = installFixture("root"); + const packageRoot = join(source.root, "sdk-package"); + mkdirSync(packageRoot); + writeFileSync( + join(packageRoot, "package.json"), + JSON.stringify({ name: "@nvidia/openshell-sdk", version: "0.0.106" }), + ); + writeFileSync(join(packageRoot, "index.js"), "export {};\n"); + rmSync(join(source.artifactDirectory, artifactName)); + const packed = JSON.parse( + execFileSync( + "npm", + ["pack", packageRoot, "--pack-destination", source.artifactDirectory, "--json"], + { encoding: "utf8" }, + ), + ) as Array<{ filename?: string; integrity?: string }>; + expect(packed).toHaveLength(1); + const entry = packed[0]!; + expect(entry.filename).toBe(artifactName); + expect(entry.integrity).toMatch(/^sha512-/); + const packageIdentity = { ...reviewed, integrity: entry.integrity! }; + writeFileSync(source.lockfilePath, JSON.stringify(reviewedLock(packageIdentity))); + writeFileSync( + join(source.root, "package.json"), + JSON.stringify({ + dependencies: { "@nvidia/openshell-sdk": "0.0.106" }, + name: "reviewed-sdk-install-fixture", + private: true, + version: "1.0.0", + }), + ); + return { packageIdentity, source }; +} + function installRequest(source: ReturnType, mode: "artifact" | "registry") { return { artifactDirectory: source.artifactDirectory, @@ -125,131 +182,156 @@ afterEach(() => { describe("trusted OpenShell SDK archive preparation", () => { it("requires the reviewed archive when the root lock uses the SDK", async () => { const source = installFixture("root"); - const put = vi.fn(async () => undefined); + const stage = cacheStageMock(); rmSync(source.artifactDirectory, { force: true, recursive: true }); await expect( prepareCiNpmInstallWithReviewedConfig( installRequest(source, "artifact"), reviewedConfigSource(), - put, + stage, ), ).rejects.toThrow("reviewed OpenShell SDK artifact is required"); - expect(put).not.toHaveBeenCalled(); + expect(stage).not.toHaveBeenCalled(); }); it("requires the reviewed archive when the plugin lock uses the SDK", async () => { const source = installFixture("nemoclaw"); - const put = vi.fn(async () => undefined); + const stage = cacheStageMock(); rmSync(source.artifactDirectory, { force: true, recursive: true }); await expect( prepareCiNpmInstallWithReviewedConfig( installRequest(source, "artifact"), reviewedConfigSource(), - put, + stage, ), ).rejects.toThrow("reviewed OpenShell SDK artifact is required"); - expect(put).not.toHaveBeenCalled(); + expect(stage).not.toHaveBeenCalled(); }); it("passes the verified archive from the root lock to npm cache preparation", async () => { const source = installFixture("root"); - const put = vi.fn(async () => undefined); + const stage = cacheStageMock(); await prepareCiNpmInstallWithReviewedConfig( installRequest(source, "artifact"), reviewedConfigSource(), - put, + stage, ); - expect(put).toHaveBeenCalledTimes(2); + expect(stage).toHaveBeenCalledOnce(); + expect(stage.mock.calls[0]?.[0].archive.equals(archiveBytes)).toBe(true); }); it("passes the verified archive from the plugin lock to npm cache preparation", async () => { const source = installFixture("nemoclaw"); - const put = vi.fn(async () => undefined); + const stage = cacheStageMock(); await prepareCiNpmInstallWithReviewedConfig( installRequest(source, "artifact"), reviewedConfigSource(), - put, + stage, ); - expect(put).toHaveBeenCalledTimes(2); + expect(stage).toHaveBeenCalledOnce(); }); it("uses registry mode without requiring or caching an archive", async () => { const source = installFixture("root"); - const put = vi.fn(async () => undefined); + const stage = cacheStageMock(); rmSync(source.artifactDirectory, { force: true, recursive: true }); await prepareCiNpmInstallWithReviewedConfig( installRequest(source, "registry"), reviewedConfigSource(), - put, + stage, ); - expect(put).not.toHaveBeenCalled(); + expect(stage).not.toHaveBeenCalled(); }); - it("seeds only the exact reviewed tarball request and package identity", async () => { + it("stages only the exact reviewed tarball request and package identity", async () => { const source = fixture(); - const calls: Array = []; - const put = vi.fn(async (cache: string, key: string, data: Buffer, options?: unknown) => { - calls.push([cache, key, data, options]); + const stage = cacheStageMock(); + + await seedReviewedSourceRegistryArtifact(request(source), stage); + + expect(stage).toHaveBeenCalledOnce(); + expect(stage.mock.calls[0]?.[0]).toMatchObject({ + artifactName, + cacheDirectory: source.cacheDirectory, }); + expect(stage.mock.calls[0]?.[0].archive.equals(archiveBytes)).toBe(true); + }); + + it("installs the reviewed archive offline after npm stages it", async () => { + const { packageIdentity, source } = packedInstallFixture(); - await seedReviewedSourceRegistryArtifact(request(source), put); + await prepareCiNpmInstallWithReviewedConfig( + installRequest(source, "artifact"), + reviewedConfigSource(packageIdentity), + ); + execFileSync( + "npm", + [ + "ci", + "--offline", + "--ignore-scripts", + "--no-audit", + "--no-fund", + "--cache", + source.cacheDirectory, + ], + { cwd: source.root, encoding: "utf8" }, + ); - expect(put).toHaveBeenCalledTimes(2); - expect(calls.map((call) => call[1])).toEqual([ - `make-fetch-happen:request-cache:${reviewed.tarballUrl}`, - `pacote:tarball:${reviewed.packageSpec}`, - ]); - expect(calls.every((call) => call[2].equals(archiveBytes))).toBe(true); + const installed = JSON.parse( + readFileSync(join(source.root, "node_modules/@nvidia/openshell-sdk/package.json"), "utf8"), + ) as { version?: string }; + expect(installed.version).toBe("0.0.106"); }); it("rejects changed bytes before writing the npm cache", async () => { const source = fixture(); - const put = vi.fn(async () => undefined); + const stage = cacheStageMock(); writeFileSync(join(source.artifactDirectory, artifactName), "changed archive"); - await expect(seedReviewedSourceRegistryArtifact(request(source), put)).rejects.toThrow( + await expect(seedReviewedSourceRegistryArtifact(request(source), stage)).rejects.toThrow( "integrity mismatch", ); - expect(put).not.toHaveBeenCalled(); + expect(stage).not.toHaveBeenCalled(); }); it("rejects symlinked or additional artifact content before writing the npm cache", async () => { const source = fixture(); - const put = vi.fn(async () => undefined); + const stage = cacheStageMock(); writeFileSync(join(source.root, "outside.tgz"), archiveBytes); rmSync(join(source.artifactDirectory, artifactName)); symlinkSync(join(source.root, "outside.tgz"), join(source.artifactDirectory, artifactName)); - await expect(seedReviewedSourceRegistryArtifact(request(source), put)).rejects.toThrow( + await expect(seedReviewedSourceRegistryArtifact(request(source), stage)).rejects.toThrow( "non-symlink regular file", ); - expect(put).not.toHaveBeenCalled(); + expect(stage).not.toHaveBeenCalled(); rmSync(join(source.artifactDirectory, artifactName)); writeFileSync(join(source.artifactDirectory, artifactName), archiveBytes); writeFileSync(join(source.artifactDirectory, "unexpected.tgz"), archiveBytes); - await expect(seedReviewedSourceRegistryArtifact(request(source), put)).rejects.toThrow( + await expect(seedReviewedSourceRegistryArtifact(request(source), stage)).rejects.toThrow( "unexpected contents", ); - expect(put).not.toHaveBeenCalled(); + expect(stage).not.toHaveBeenCalled(); }); it("rejects an oversized artifact before writing the npm cache", async () => { const source = fixture(); - const put = vi.fn(async () => undefined); + const stage = cacheStageMock(); truncateSync(join(source.artifactDirectory, artifactName), 32 * 1024 * 1024 + 1); - await expect(seedReviewedSourceRegistryArtifact(request(source), put)).rejects.toThrow( + await expect(seedReviewedSourceRegistryArtifact(request(source), stage)).rejects.toThrow( "bounded regular file", ); - expect(put).not.toHaveBeenCalled(); + expect(stage).not.toHaveBeenCalled(); }); }); From 25a339e687be683b4a78159603e171aa1a1363b5 Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Thu, 27 Aug 2026 13:24:38 -0700 Subject: [PATCH 15/27] test(ci): make SDK package coverage self-contained Signed-off-by: Apurv Kumaria --- .github/workflows/pr.yaml | 14 -------- test/install/reviewed-npm-archive.test.ts | 40 ++++++++++++++++------- 2 files changed, 28 insertions(+), 26 deletions(-) diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index b77469208fd..e6fc68749c1 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -123,12 +123,10 @@ jobs: .github/actions/ci-install-dependencies.sh ci/reviewed-npm-audit.json scripts/audit-reviewed-npm-graph.mts - scripts/checks/materialize-locked-npm-cache-seed.mts scripts/checks/prepare-ci-npm-install.mts scripts/lib/openclaw-npm-remediation.mts scripts/lib/reviewed-npm-archive.mts scripts/lib/reviewed-npm-audit.mts - scripts/lib/seed-reviewed-npm-cache.mts sparse-checkout-cone-mode: false - name: Download verified OpenShell SDK archive @@ -171,12 +169,10 @@ jobs: .github/workflows/openshell-sdk-package-pr.yaml ci/reviewed-npm-audit.json scripts/audit-reviewed-npm-graph.mts - scripts/checks/materialize-locked-npm-cache-seed.mts scripts/checks/prepare-ci-npm-install.mts scripts/lib/openclaw-npm-remediation.mts scripts/lib/reviewed-npm-archive.mts scripts/lib/reviewed-npm-audit.mts - scripts/lib/seed-reviewed-npm-cache.mts sparse-checkout-cone-mode: false - name: Locate exact base-controlled SDK package run @@ -314,12 +310,10 @@ jobs: .github/actions/ci-install-dependencies.sh ci/reviewed-npm-audit.json scripts/audit-reviewed-npm-graph.mts - scripts/checks/materialize-locked-npm-cache-seed.mts scripts/checks/prepare-ci-npm-install.mts scripts/lib/openclaw-npm-remediation.mts scripts/lib/reviewed-npm-archive.mts scripts/lib/reviewed-npm-audit.mts - scripts/lib/seed-reviewed-npm-cache.mts sparse-checkout-cone-mode: false - name: Download verified OpenShell SDK archive @@ -356,12 +350,10 @@ jobs: .github/actions/ci-install-dependencies.sh ci/reviewed-npm-audit.json scripts/audit-reviewed-npm-graph.mts - scripts/checks/materialize-locked-npm-cache-seed.mts scripts/checks/prepare-ci-npm-install.mts scripts/lib/openclaw-npm-remediation.mts scripts/lib/reviewed-npm-archive.mts scripts/lib/reviewed-npm-audit.mts - scripts/lib/seed-reviewed-npm-cache.mts sparse-checkout-cone-mode: false - name: Download verified OpenShell SDK archive @@ -480,12 +472,10 @@ jobs: .github/actions/ci-install-dependencies.sh ci/reviewed-npm-audit.json scripts/audit-reviewed-npm-graph.mts - scripts/checks/materialize-locked-npm-cache-seed.mts scripts/checks/prepare-ci-npm-install.mts scripts/lib/openclaw-npm-remediation.mts scripts/lib/reviewed-npm-archive.mts scripts/lib/reviewed-npm-audit.mts - scripts/lib/seed-reviewed-npm-cache.mts sparse-checkout-cone-mode: false - name: Download verified OpenShell SDK archive @@ -592,12 +582,10 @@ jobs: .github/actions/ci-install-dependencies.sh ci/reviewed-npm-audit.json scripts/audit-reviewed-npm-graph.mts - scripts/checks/materialize-locked-npm-cache-seed.mts scripts/checks/prepare-ci-npm-install.mts scripts/lib/openclaw-npm-remediation.mts scripts/lib/reviewed-npm-archive.mts scripts/lib/reviewed-npm-audit.mts - scripts/lib/seed-reviewed-npm-cache.mts sparse-checkout-cone-mode: false - name: Download verified OpenShell SDK archive @@ -643,12 +631,10 @@ jobs: .github/actions/ci-install-dependencies.sh ci/reviewed-npm-audit.json scripts/audit-reviewed-npm-graph.mts - scripts/checks/materialize-locked-npm-cache-seed.mts scripts/checks/prepare-ci-npm-install.mts scripts/lib/openclaw-npm-remediation.mts scripts/lib/reviewed-npm-archive.mts scripts/lib/reviewed-npm-audit.mts - scripts/lib/seed-reviewed-npm-cache.mts sparse-checkout-cone-mode: false - name: Download verified OpenShell SDK archive diff --git a/test/install/reviewed-npm-archive.test.ts b/test/install/reviewed-npm-archive.test.ts index 9895dee6b6a..1447923d9a4 100644 --- a/test/install/reviewed-npm-archive.test.ts +++ b/test/install/reviewed-npm-archive.test.ts @@ -51,6 +51,22 @@ function cacheRequest(): ReviewedNpmCacheRequest { }; } +function writeSyntheticLock( + reviewed: ReviewedNpmCacheRequest, + filename: string, + packageRecord: Readonly>, +): string { + const lockfilePath = path.join(reviewed.tempDirectory as string, filename); + fs.writeFileSync( + lockfilePath, + `${JSON.stringify({ + lockfileVersion: 3, + packages: { "": {}, "node_modules/@example/reviewed": packageRecord }, + })}\n`, + ); + return lockfilePath; +} + function cachedArchiveRunner( calls: Array<{ args: readonly string[]; request: ReviewedNpmArchiveRequest }>, mutation?: Readonly<{ filename?: string; integrity?: string; packageSpec: string }>, @@ -239,19 +255,19 @@ describe("reviewed npm archive", () => { it("allows nested shrinkwrap metadata only for explicit cache-seed inspection", () => { const reviewed = cacheRequest(); - const lock = JSON.parse(fs.readFileSync(WECHAT_LOCK, "utf-8")); - lock.packages["node_modules/@tencent-weixin/openclaw-weixin"].hasShrinkwrap = true; - const lockfilePath = path.join(reviewed.tempDirectory as string, "shrinkwrap-seed-lock.json"); - fs.writeFileSync(lockfilePath, `${JSON.stringify(lock, null, 2)}\n`); + const lockfilePath = writeSyntheticLock(reviewed, "shrinkwrap-seed-lock.json", { + hasShrinkwrap: true, + integrity: INTEGRITY, + resolved: TARBALL_URL, + version: "1.2.3", + }); const request = { lockfilePath, registryOrigin: "https://registry.npmjs.org/" }; expect(() => verifyReviewedNpmLockPackages(request)).toThrow( "must not delegate to nested shrinkwrap", ); expect(verifyReviewedNpmLockPackages({ ...request, allowNestedShrinkwrap: true })).toEqual([ - "@tencent-weixin/openclaw-weixin@2.4.3", - "qrcode-terminal@0.12.0", - "zod@4.4.3", + PACKAGE_SPEC, ]); }); @@ -297,11 +313,11 @@ describe("reviewed npm archive", () => { it("rejects an off-origin locked archive before npm can read the cache", () => { const reviewed = cacheRequest(); - const lock = JSON.parse(fs.readFileSync(WECHAT_LOCK, "utf-8")); - lock.packages["node_modules/qrcode-terminal"].resolved = - "https://registry.example.test/qrcode-terminal-0.12.0.tgz"; - const lockfilePath = path.join(reviewed.tempDirectory as string, "off-origin-lock.json"); - fs.writeFileSync(lockfilePath, `${JSON.stringify(lock, null, 2)}\n`); + const lockfilePath = writeSyntheticLock(reviewed, "off-origin-lock.json", { + integrity: INTEGRITY, + resolved: "https://registry.example.test/reviewed-1.2.3.tgz", + version: "1.2.3", + }); let npmCalled = false; expect(() => From 20ad4ab73958f0c03b14df5aa4d16a6f2cb4675c Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Thu, 27 Aug 2026 13:35:02 -0700 Subject: [PATCH 16/27] fix(ci): make SDK artifact recovery reliable Signed-off-by: Apurv Kumaria --- .github/workflows/pr.yaml | 13 ++++ scripts/lib/reviewed-npm-archive.mts | 4 +- scripts/lib/seed-reviewed-npm-cache.mts | 61 +++--------------- .../pr-workflow-contract.test.ts | 64 +++++++++++++++++++ test/install/seed-reviewed-npm-cache.test.ts | 13 +++- 5 files changed, 99 insertions(+), 56 deletions(-) diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index e6fc68749c1..3daaf2be8f7 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -253,6 +253,19 @@ jobs: exit 1 fi run_id="$(jq -er '.id | select(type == "number" and . > 0 and . <= 9007199254740991)' <<<"$match")" + run_url="$(jq -er '.html_url | select(type == "string" and startswith("https://github.com/"))' <<<"$match")" + artifact_listing_url="repos/$GITHUB_REPOSITORY/actions/runs/$run_id/artifacts?per_page=100" + if ! artifacts="$(gh api "$artifact_listing_url")"; then + echo "::error title=SDK package artifact unavailable::Could not inspect the reviewed SDK archive from $run_url. Rerun the SDK package workflow for this latest PR commit." + exit 1 + fi + expected_artifact="openshell-sdk-$HEAD_SHA" + if ! jq -e --arg name "$expected_artifact" ' + [.artifacts[]? | select(.name == $name and .expired == false)] | length == 1 + ' <<<"$artifacts" >/dev/null; then + echo "::error title=SDK package artifact unavailable::The reviewed SDK archive from $run_url is missing or expired. Rerun the SDK package workflow for this latest PR commit." + exit 1 + fi echo "run_id=$run_id" >> "$GITHUB_OUTPUT" exit 0 fi diff --git a/scripts/lib/reviewed-npm-archive.mts b/scripts/lib/reviewed-npm-archive.mts index f516a224b63..2e879f6cc6e 100755 --- a/scripts/lib/reviewed-npm-archive.mts +++ b/scripts/lib/reviewed-npm-archive.mts @@ -76,7 +76,7 @@ export type ReviewedNpmArchiveFileRequest = Readonly<{ archivePath: string; expectedIntegrity: string; label: string; - maximumBytes: number; + maximumBytes?: number; }>; type NpmRunner = (args: readonly string[], request: ReviewedNpmArchiveRequest) => string; @@ -129,7 +129,7 @@ export function readReviewedNpmArchiveFile(request: ReviewedNpmArchiveFileReques ) { throw new Error("archive must be a non-symlink regular file"); } - if (opened.size > request.maximumBytes) { + if (request.maximumBytes !== undefined && opened.size > request.maximumBytes) { throw new Error("archive must be a bounded regular file"); } const archive = readFileSync(descriptor); diff --git a/scripts/lib/seed-reviewed-npm-cache.mts b/scripts/lib/seed-reviewed-npm-cache.mts index c66ce5971e8..b9e9de690d1 100755 --- a/scripts/lib/seed-reviewed-npm-cache.mts +++ b/scripts/lib/seed-reviewed-npm-cache.mts @@ -3,16 +3,7 @@ // SPDX-License-Identifier: Apache-2.0 import { execFileSync } from "node:child_process"; -import { createHash } from "node:crypto"; -import { - closeSync, - existsSync, - fstatSync, - lstatSync, - openSync, - readdirSync, - readFileSync, -} from "node:fs"; +import { existsSync, lstatSync, readdirSync, readFileSync } from "node:fs"; import { createRequire } from "node:module"; import { isAbsolute, join, resolve } from "node:path"; import { pathToFileURL } from "node:url"; @@ -22,6 +13,7 @@ import { type NpmPlatformTarget, } from "../checks/materialize-locked-npm-cache-seed.mts"; import { + readReviewedNpmArchiveFile, type ReviewedNpmArchiveRequest, type ReviewedNpmPackageWithoutIntegrity, verifyReviewedNpmLockPackages, @@ -156,42 +148,6 @@ function readLockedPackages( return locked; } -function readArchive( - archivePath: string, - packageSpec: string, - maximumArchiveBytes?: number, -): Buffer { - if (!isAbsolute(archivePath)) { - throw new Error(`reviewed npm cache seed archive must be absolute: ${packageSpec}`); - } - const resolvedPath = resolve(archivePath); - let descriptor: number | undefined; - try { - descriptor = openSync(resolvedPath, "r"); - const opened = fstatSync(descriptor); - const pathEntry = lstatSync(resolvedPath); - if ( - !opened.isFile() || - !pathEntry.isFile() || - pathEntry.isSymbolicLink() || - opened.dev !== pathEntry.dev || - opened.ino !== pathEntry.ino - ) { - throw new Error("archive must be a non-symlink regular file"); - } - if (maximumArchiveBytes !== undefined && opened.size > maximumArchiveBytes) { - throw new Error("archive must be a bounded regular file"); - } - return readFileSync(descriptor); - } catch (error) { - throw new Error( - `reviewed npm cache seed archive is unreadable: ${packageSpec}: ${String(error)}`, - ); - } finally { - if (descriptor !== undefined) closeSync(descriptor); - } -} - export function lockedArchivesFromDirectory( archiveDirectory: string, lockfilePath: string, @@ -314,13 +270,12 @@ export async function seedReviewedNpmCache( throw new Error(`reviewed npm cache seed archive is missing: ${packageSpec}`); expectedArchives.delete(packageSpec); unexpectedArchives.delete(packageSpec); - const archive = readArchive(archivePath, packageSpec, request.maximumArchiveBytes); - const actualIntegrity = `sha512-${createHash("sha512").update(archive).digest("base64")}`; - if (actualIntegrity !== entry.integrity) { - throw new Error( - `reviewed npm cache seed integrity mismatch for ${packageSpec}\nExpected: ${entry.integrity}\nActual: ${actualIntegrity}`, - ); - } + const archive = readReviewedNpmArchiveFile({ + archivePath, + expectedIntegrity: entry.integrity, + label: `reviewed npm cache seed ${packageSpec}`, + maximumBytes: request.maximumArchiveBytes, + }); await put(cachePath, `make-fetch-happen:request-cache:${entry.resolved}`, archive, { metadata: { options: { compress: true }, diff --git a/test/automation/pull-requests/pr-workflow-contract.test.ts b/test/automation/pull-requests/pr-workflow-contract.test.ts index be90d3e4bee..45d78465237 100644 --- a/test/automation/pull-requests/pr-workflow-contract.test.ts +++ b/test/automation/pull-requests/pr-workflow-contract.test.ts @@ -575,6 +575,70 @@ describe("pull request and main workflow contracts", () => { } }); + it("explains how to recover when the exact SDK package artifact expired", () => { + const tempRoot = mkdtempSync(join(tmpdir(), "nemoclaw-sdk-package-expired-")); + try { + const trustedRoot = join(tempRoot, ".trusted-sdk-package-decision"); + const inspectorDirectory = join(trustedRoot, "scripts/checks"); + const workflowDirectory = join(trustedRoot, ".github/workflows"); + const fakeBin = join(tempRoot, "bin"); + mkdirSync(inspectorDirectory, { recursive: true }); + mkdirSync(workflowDirectory, { recursive: true }); + mkdirSync(fakeBin); + writeFileSync( + join(inspectorDirectory, "prepare-ci-npm-install.mts"), + 'process.stdout.write(JSON.stringify({ required: true, artifactName: "reviewed-sdk.tgz" }));\n', + ); + writeFileSync(join(workflowDirectory, "openshell-sdk-package-pr.yaml"), "name: test\n"); + writeFileSync( + join(fakeBin, "gh"), + [ + "#!/usr/bin/env node", + 'const request = process.argv.slice(2).join(" ");', + 'if (request.includes("actions/workflows/openshell-sdk-package-pr.yaml/runs")) {', + ' process.stdout.write(JSON.stringify({ workflow_runs: [{ id: 321, event: "pull_request_target", status: "completed", conclusion: "success", created_at: "2026-08-27T00:00:00Z", html_url: "https://github.com/NVIDIA/NemoClaw/actions/runs/321", pull_requests: [{ number: 10368, head: { sha: "head-sha" }, base: { sha: "base-sha" } }] }] }));', + '} else if (request.includes("actions/runs/321/artifacts")) {', + ' process.stdout.write(JSON.stringify({ artifacts: [{ name: "openshell-sdk-head-sha", expired: true }] }));', + "} else {", + " process.exit(64);", + "}", + ].join("\n"), + { mode: 0o755 }, + ); + const outputPath = join(tempRoot, "github-output"); + const locate = requiredWorkflowStep( + prWorkflow.jobs["openshell-sdk-package"], + "Locate exact base-controlled SDK package run", + ); + + const result = runWorkflowShellStep( + locate, + { + BASE_SHA: "base-sha", + GH_TOKEN: "test-token", + GITHUB_OUTPUT: outputPath, + GITHUB_REPOSITORY: "NVIDIA/NemoClaw", + GITHUB_WORKSPACE: tempRoot, + HEAD_REPOSITORY: "NVIDIA/NemoClaw", + HEAD_SHA: "head-sha", + PATH: `${fakeBin}:${process.env.PATH ?? ""}`, + PR_NUMBER: "10368", + }, + tempRoot, + ); + + expect(result.status).toBe(1); + expect(result.stdout).toContain("reviewed SDK archive"); + expect(result.stdout).toContain("https://github.com/NVIDIA/NemoClaw/actions/runs/321"); + expect(result.stdout).toContain("Rerun the SDK package workflow"); + expect(existsSync(outputPath) ? readFileSync(outputPath, "utf8") : "").not.toContain( + "run_id=", + ); + } finally { + rmSync(tempRoot, { recursive: true, force: true }); + } + }); + // source-shape-contract: security -- Every PR dependency consumer must receive the verified archive without package access it.each(dependencyInstallJobs)("passes the verified SDK archive to %s", (jobName) => { const job = prWorkflow.jobs[jobName]; diff --git a/test/install/seed-reviewed-npm-cache.test.ts b/test/install/seed-reviewed-npm-cache.test.ts index ff8ac750f3e..1b28baf12e7 100644 --- a/test/install/seed-reviewed-npm-cache.test.ts +++ b/test/install/seed-reviewed-npm-cache.test.ts @@ -241,7 +241,7 @@ describe("reviewed npm cache seed", () => { ).rejects.toThrow("received unlocked archives: unexpected@9.9.9"); fs.writeFileSync(input.archivePath, "drifted archive bytes"); await expect(seedReviewedNpmCache(request(input), async () => undefined)).rejects.toThrow( - `integrity mismatch for ${PACKAGE_SPEC}`, + `${PACKAGE_SPEC} archive integrity mismatch`, ); }); @@ -262,4 +262,15 @@ describe("reviewed npm cache seed", () => { ), ).rejects.toThrow("registry origin is invalid"); }); + + it("rejects an archive larger than the configured seed limit", async () => { + const input = fixture(); + + await expect( + seedReviewedNpmCache( + { ...request(input), maximumArchiveBytes: input.archive.length - 1 }, + async () => undefined, + ), + ).rejects.toThrow("archive must be a bounded regular file"); + }); }); From 2caf01085324a108337441155e51b19d86920c40 Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Thu, 27 Aug 2026 13:45:29 -0700 Subject: [PATCH 17/27] fix(ci): recover reviewed SDK artifacts Signed-off-by: Apurv Kumaria --- .github/workflows/pr.yaml | 38 ++-- .../pr-workflow-contract.test.ts | 198 +++++++++++++----- test/install/reviewed-npm-archive.test.ts | 40 ++-- 3 files changed, 193 insertions(+), 83 deletions(-) diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index 3daaf2be8f7..86d607deaea 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -234,7 +234,7 @@ jobs: for attempt in $(seq 1 96); do runs="$(gh api \ "repos/$GITHUB_REPOSITORY/actions/workflows/openshell-sdk-package-pr.yaml/runs?event=pull_request_target&per_page=100")" - match="$(jq -cer \ + matches="$(jq -cer \ --arg base "$BASE_SHA" \ --arg head "$HEAD_SHA" \ --argjson pr "$PR_NUMBER" ' @@ -242,32 +242,42 @@ jobs: select(.event == "pull_request_target") | select(any(.pull_requests[]?; .number == $pr and .head.sha == $head and .base.sha == $base))] | - sort_by(.created_at) | reverse | .[0] // empty + sort_by(.created_at) | reverse ' <<<"$runs" 2>/dev/null || true)" - if [ -n "$match" ]; then - status="$(jq -r '.status' <<<"$match")" - if [ "$status" = "completed" ]; then + if jq -e 'length > 0' <<<"$matches" >/dev/null 2>&1; then + pending_run=false + newest_completed_url="" + while IFS= read -r match; do + status="$(jq -r '.status' <<<"$match")" + if [ "$status" != "completed" ]; then + pending_run=true + continue + fi + run_url="$(jq -er '.html_url | select(type == "string" and startswith("https://github.com/"))' <<<"$match")" + if [ -z "$newest_completed_url" ]; then + newest_completed_url="$run_url" + fi conclusion="$(jq -r '.conclusion' <<<"$match")" if [ "$conclusion" != "success" ]; then - echo "::error title=SDK package workflow failed::Expected success, got ${conclusion}." - exit 1 + continue fi run_id="$(jq -er '.id | select(type == "number" and . > 0 and . <= 9007199254740991)' <<<"$match")" - run_url="$(jq -er '.html_url | select(type == "string" and startswith("https://github.com/"))' <<<"$match")" artifact_listing_url="repos/$GITHUB_REPOSITORY/actions/runs/$run_id/artifacts?per_page=100" if ! artifacts="$(gh api "$artifact_listing_url")"; then - echo "::error title=SDK package artifact unavailable::Could not inspect the reviewed SDK archive from $run_url. Rerun the SDK package workflow for this latest PR commit." + echo "::error title=SDK package artifact unavailable::Could not inspect the reviewed SDK archive from $run_url. After GitHub Actions access returns, rerun the failed openshell-sdk-package job in CI / Pull Request." exit 1 fi expected_artifact="openshell-sdk-$HEAD_SHA" - if ! jq -e --arg name "$expected_artifact" ' + if jq -e --arg name "$expected_artifact" ' [.artifacts[]? | select(.name == $name and .expired == false)] | length == 1 ' <<<"$artifacts" >/dev/null; then - echo "::error title=SDK package artifact unavailable::The reviewed SDK archive from $run_url is missing or expired. Rerun the SDK package workflow for this latest PR commit." - exit 1 + echo "run_id=$run_id" >> "$GITHUB_OUTPUT" + exit 0 fi - echo "run_id=$run_id" >> "$GITHUB_OUTPUT" - exit 0 + done < <(jq -c '.[]' <<<"$matches") + if [ "$pending_run" = "false" ]; then + echo "::error title=SDK package artifact unavailable::No unexpired reviewed SDK archive is available. Last checked $newest_completed_url. Rerun Security / Package OpenShell SDK for PR for this latest PR commit. Then rerun the failed openshell-sdk-package job in CI / Pull Request." + exit 1 fi fi sleep 5 diff --git a/test/automation/pull-requests/pr-workflow-contract.test.ts b/test/automation/pull-requests/pr-workflow-contract.test.ts index 45d78465237..cbc285f605b 100644 --- a/test/automation/pull-requests/pr-workflow-contract.test.ts +++ b/test/automation/pull-requests/pr-workflow-contract.test.ts @@ -157,6 +157,94 @@ function runWorkflowShellStep( }; } +type SdkPackageLocatorFixture = Readonly<{ + artifactFailureRunId?: number; + artifactsByRunId?: Readonly>; + runs: readonly unknown[]; + step: WorkflowStep; +}>; + +function runSdkPackageLocator(fixture: SdkPackageLocatorFixture): Readonly<{ + githubOutput: string; + result: ReturnType; +}> { + const tempRoot = mkdtempSync(join(tmpdir(), "nemoclaw-sdk-package-locator-")); + try { + const trustedRoot = join(tempRoot, ".trusted-sdk-package-decision"); + const inspectorDirectory = join(trustedRoot, "scripts/checks"); + const workflowDirectory = join(trustedRoot, ".github/workflows"); + const fakeBin = join(tempRoot, "bin"); + mkdirSync(inspectorDirectory, { recursive: true }); + mkdirSync(workflowDirectory, { recursive: true }); + mkdirSync(fakeBin); + writeFileSync( + join(inspectorDirectory, "prepare-ci-npm-install.mts"), + 'process.stdout.write(JSON.stringify({ required: true, artifactName: "reviewed-sdk.tgz" }));\n', + ); + writeFileSync(join(workflowDirectory, "openshell-sdk-package-pr.yaml"), "name: test\n"); + writeFileSync( + join(fakeBin, "gh"), + [ + "#!/usr/bin/env node", + 'const request = process.argv.slice(2).join(" ");', + 'if (request.includes("actions/workflows/openshell-sdk-package-pr.yaml/runs")) {', + " process.stdout.write(JSON.stringify({ workflow_runs: JSON.parse(process.env.FAKE_WORKFLOW_RUNS) }));", + " process.exit(0);", + "}", + "const artifactMatch = request.match(/actions\\/runs\\/(\\d+)\\/artifacts/);", + "if (!artifactMatch) process.exit(64);", + "const runId = Number(artifactMatch[1]);", + "if (runId === Number(process.env.FAKE_ARTIFACT_FAILURE_RUN_ID)) process.exit(1);", + "const listings = JSON.parse(process.env.FAKE_ARTIFACTS_BY_RUN_ID);", + "process.stdout.write(JSON.stringify(listings[String(runId)] ?? { artifacts: [] }));", + ].join("\n"), + { mode: 0o755 }, + ); + const outputPath = join(tempRoot, "github-output"); + const result = runWorkflowShellStep( + fixture.step, + { + BASE_SHA: "base-sha", + FAKE_ARTIFACTS_BY_RUN_ID: JSON.stringify(fixture.artifactsByRunId ?? {}), + FAKE_ARTIFACT_FAILURE_RUN_ID: String(fixture.artifactFailureRunId ?? 0), + FAKE_WORKFLOW_RUNS: JSON.stringify(fixture.runs), + GH_TOKEN: "test-token", + GITHUB_OUTPUT: outputPath, + GITHUB_REPOSITORY: "NVIDIA/NemoClaw", + GITHUB_WORKSPACE: tempRoot, + HEAD_REPOSITORY: "NVIDIA/NemoClaw", + HEAD_SHA: "head-sha", + PATH: `${fakeBin}:${process.env.PATH ?? ""}`, + PR_NUMBER: "10368", + }, + tempRoot, + ); + return { + githubOutput: existsSync(outputPath) ? readFileSync(outputPath, "utf8") : "", + result, + }; + } finally { + rmSync(tempRoot, { recursive: true, force: true }); + } +} + +function sdkPackageWorkflowRun( + id: number, + status: string, + conclusion: string | null, + createdAt: string, +): Readonly> { + return { + conclusion, + created_at: createdAt, + event: "pull_request_target", + html_url: `https://github.com/NVIDIA/NemoClaw/actions/runs/${id}`, + id, + pull_requests: [{ base: { sha: "base-sha" }, head: { sha: "head-sha" }, number: 10368 }], + status, + }; +} + function workflowJob( id: unknown, name: unknown, @@ -576,67 +664,63 @@ describe("pull request and main workflow contracts", () => { }); it("explains how to recover when the exact SDK package artifact expired", () => { - const tempRoot = mkdtempSync(join(tmpdir(), "nemoclaw-sdk-package-expired-")); - try { - const trustedRoot = join(tempRoot, ".trusted-sdk-package-decision"); - const inspectorDirectory = join(trustedRoot, "scripts/checks"); - const workflowDirectory = join(trustedRoot, ".github/workflows"); - const fakeBin = join(tempRoot, "bin"); - mkdirSync(inspectorDirectory, { recursive: true }); - mkdirSync(workflowDirectory, { recursive: true }); - mkdirSync(fakeBin); - writeFileSync( - join(inspectorDirectory, "prepare-ci-npm-install.mts"), - 'process.stdout.write(JSON.stringify({ required: true, artifactName: "reviewed-sdk.tgz" }));\n', - ); - writeFileSync(join(workflowDirectory, "openshell-sdk-package-pr.yaml"), "name: test\n"); - writeFileSync( - join(fakeBin, "gh"), - [ - "#!/usr/bin/env node", - 'const request = process.argv.slice(2).join(" ");', - 'if (request.includes("actions/workflows/openshell-sdk-package-pr.yaml/runs")) {', - ' process.stdout.write(JSON.stringify({ workflow_runs: [{ id: 321, event: "pull_request_target", status: "completed", conclusion: "success", created_at: "2026-08-27T00:00:00Z", html_url: "https://github.com/NVIDIA/NemoClaw/actions/runs/321", pull_requests: [{ number: 10368, head: { sha: "head-sha" }, base: { sha: "base-sha" } }] }] }));', - '} else if (request.includes("actions/runs/321/artifacts")) {', - ' process.stdout.write(JSON.stringify({ artifacts: [{ name: "openshell-sdk-head-sha", expired: true }] }));', - "} else {", - " process.exit(64);", - "}", - ].join("\n"), - { mode: 0o755 }, - ); - const outputPath = join(tempRoot, "github-output"); - const locate = requiredWorkflowStep( + const { githubOutput, result } = runSdkPackageLocator({ + artifactsByRunId: { + "321": { artifacts: [{ expired: true, name: "openshell-sdk-head-sha" }] }, + }, + runs: [sdkPackageWorkflowRun(321, "completed", "success", "2026-08-27T00:00:00Z")], + step: requiredWorkflowStep( prWorkflow.jobs["openshell-sdk-package"], "Locate exact base-controlled SDK package run", - ); + ), + }); - const result = runWorkflowShellStep( - locate, - { - BASE_SHA: "base-sha", - GH_TOKEN: "test-token", - GITHUB_OUTPUT: outputPath, - GITHUB_REPOSITORY: "NVIDIA/NemoClaw", - GITHUB_WORKSPACE: tempRoot, - HEAD_REPOSITORY: "NVIDIA/NemoClaw", - HEAD_SHA: "head-sha", - PATH: `${fakeBin}:${process.env.PATH ?? ""}`, - PR_NUMBER: "10368", - }, - tempRoot, - ); + expect(result.status).toBe(1); + expect(result.stdout).toContain("reviewed SDK archive"); + expect(result.stdout).toContain("https://github.com/NVIDIA/NemoClaw/actions/runs/321"); + expect(result.stdout).toContain("Rerun Security / Package OpenShell SDK for PR"); + expect(result.stdout).toContain( + "Then rerun the failed openshell-sdk-package job in CI / Pull Request", + ); + expect(githubOutput).not.toContain("run_id="); + }); - expect(result.status).toBe(1); - expect(result.stdout).toContain("reviewed SDK archive"); - expect(result.stdout).toContain("https://github.com/NVIDIA/NemoClaw/actions/runs/321"); - expect(result.stdout).toContain("Rerun the SDK package workflow"); - expect(existsSync(outputPath) ? readFileSync(outputPath, "utf8") : "").not.toContain( - "run_id=", - ); - } finally { - rmSync(tempRoot, { recursive: true, force: true }); - } + it("uses an older exact SDK package run after a newer run is cancelled", () => { + const { githubOutput, result } = runSdkPackageLocator({ + artifactsByRunId: { + "320": { artifacts: [{ expired: false, name: "openshell-sdk-head-sha" }] }, + }, + runs: [ + sdkPackageWorkflowRun(321, "completed", "cancelled", "2026-08-27T01:00:00Z"), + sdkPackageWorkflowRun(320, "completed", "success", "2026-08-27T00:00:00Z"), + ], + step: requiredWorkflowStep( + prWorkflow.jobs["openshell-sdk-package"], + "Locate exact base-controlled SDK package run", + ), + }); + + expect(result).toMatchObject({ status: 0, stderr: "" }); + expect(githubOutput).toContain("run_id=320\n"); + }); + + it("explains how to retry an SDK artifact-listing failure", () => { + const { githubOutput, result } = runSdkPackageLocator({ + artifactFailureRunId: 321, + runs: [sdkPackageWorkflowRun(321, "completed", "success", "2026-08-27T00:00:00Z")], + step: requiredWorkflowStep( + prWorkflow.jobs["openshell-sdk-package"], + "Locate exact base-controlled SDK package run", + ), + }); + + expect(result.status).toBe(1); + expect(result.stdout).toContain("After GitHub Actions access returns"); + expect(result.stdout).toContain( + "rerun the failed openshell-sdk-package job in CI / Pull Request", + ); + expect(result.stdout).not.toContain("Rerun Security / Package OpenShell SDK for PR"); + expect(githubOutput).not.toContain("run_id="); }); // source-shape-contract: security -- Every PR dependency consumer must receive the verified archive without package access diff --git a/test/install/reviewed-npm-archive.test.ts b/test/install/reviewed-npm-archive.test.ts index 1447923d9a4..4d328266bf2 100644 --- a/test/install/reviewed-npm-archive.test.ts +++ b/test/install/reviewed-npm-archive.test.ts @@ -19,11 +19,8 @@ import { const INTEGRITY = `sha512-${"a".repeat(88)}`; const PACKAGE_SPEC = "@example/reviewed@1.2.3"; const TARBALL_URL = "https://registry.npmjs.org/@example/reviewed/-/reviewed-1.2.3.tgz"; -const WECHAT_LOCK = path.join( - import.meta.dirname, - "../..", - "agents/openclaw/wechat-runtime/package-lock.json", -); +const CACHE_PACKAGE_SPEC = "@example/cache-one@1.0.0"; +const CACHE_PACKAGE_TWO_SPEC = "cache-two@2.0.0"; const roots: string[] = []; function request(): ReviewedNpmArchiveRequest { @@ -43,9 +40,29 @@ function cacheRequest(): ReviewedNpmCacheRequest { roots.push(tempDirectory); const cacheDirectory = path.join(tempDirectory, "cache"); fs.mkdirSync(cacheDirectory); + const lockfilePath = path.join(tempDirectory, "package-lock.json"); + fs.writeFileSync( + lockfilePath, + `${JSON.stringify({ + lockfileVersion: 3, + packages: { + "": {}, + "node_modules/@example/cache-one": { + integrity: INTEGRITY, + resolved: "https://registry.npmjs.org/@example/cache-one/-/cache-one-1.0.0.tgz", + version: "1.0.0", + }, + "node_modules/cache-two": { + integrity: INTEGRITY, + resolved: "https://registry.npmjs.org/cache-two/-/cache-two-2.0.0.tgz", + version: "2.0.0", + }, + }, + })}\n`, + ); return { cacheDirectory, - lockfilePath: WECHAT_LOCK, + lockfilePath, registryOrigin: "https://registry.npmjs.org/", tempDirectory, }; @@ -203,12 +220,11 @@ describe("reviewed npm archive", () => { const calls: Array<{ args: readonly string[]; request: ReviewedNpmArchiveRequest }> = []; const reviewed = cacheRequest(); expect(verifyReviewedNpmCache(reviewed, cachedArchiveRunner(calls))).toEqual([ - "@tencent-weixin/openclaw-weixin@2.4.3", - "qrcode-terminal@0.12.0", - "zod@4.4.3", + CACHE_PACKAGE_SPEC, + CACHE_PACKAGE_TWO_SPEC, ]); - expect(calls.filter(({ args }) => args[0] === "pack")).toHaveLength(3); + expect(calls.filter(({ args }) => args[0] === "pack")).toHaveLength(2); calls.forEach(({ request: archiveRequest }) => { expect(archiveRequest.env).toMatchObject({ NPM_CONFIG_CACHE: reviewed.cacheDirectory, @@ -332,12 +348,12 @@ describe("reviewed npm archive", () => { it.each([ { expected: "downloaded tarball integrity mismatch", - mutation: { integrity: "sha512-drift", packageSpec: "qrcode-terminal@0.12.0" }, + mutation: { integrity: "sha512-drift", packageSpec: CACHE_PACKAGE_TWO_SPEC }, name: "packed SRI drift", }, { expected: "reported unsafe archive filename", - mutation: { filename: "../../qrcode-terminal.tgz", packageSpec: "qrcode-terminal@0.12.0" }, + mutation: { filename: "../../cache-two.tgz", packageSpec: CACHE_PACKAGE_TWO_SPEC }, name: "an unsafe packed filename", }, ])("rejects $name in the final cache", ({ expected, mutation }) => { From 63d2599c30203d4e8cdb8bc4e1e5c9dc9eea080f Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Thu, 27 Aug 2026 14:43:56 -0700 Subject: [PATCH 18/27] fix(ci): report package failures without test timeouts Signed-off-by: Apurv Kumaria --- ci/source-architecture-budget.json | 2 +- scripts/checks/prepare-ci-npm-install.mts | 21 +++++++++++++- src/lib/actions/sandbox/destroy-flow.test.ts | 5 ++-- test/helpers/destroy-flow-test-harness.ts | 6 ++-- .../repository/prepare-ci-npm-install.test.ts | 28 +++++++++++++++++++ 5 files changed, 55 insertions(+), 7 deletions(-) diff --git a/ci/source-architecture-budget.json b/ci/source-architecture-budget.json index e553a1ab060..3057920e231 100644 --- a/ci/source-architecture-budget.json +++ b/ci/source-architecture-budget.json @@ -25,7 +25,7 @@ "src/lib/messaging/channels/index.ts": 25, "src/lib/onboard/gateway-binding.ts": 52, "src/lib/runner.ts": 86, - "src/lib/security/redact.ts": 54, + "src/lib/security/redact.ts": 55, "src/lib/state/onboard-session.ts": 36, "src/lib/state/registry.ts": 101, "src/lib/state/state-root.ts": 21, diff --git a/scripts/checks/prepare-ci-npm-install.mts b/scripts/checks/prepare-ci-npm-install.mts index e39b60de792..d881d644732 100755 --- a/scripts/checks/prepare-ci-npm-install.mts +++ b/scripts/checks/prepare-ci-npm-install.mts @@ -9,6 +9,7 @@ import { tmpdir } from "node:os"; import { dirname, isAbsolute, join, resolve } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; +import { redactFullWithUrls } from "../../src/lib/security/redact.ts"; import { parseAuditConfig } from "../audit-reviewed-npm-graph.mts"; import { readReviewedNpmArchiveFile, @@ -17,6 +18,7 @@ import { const TRUSTED_REPOSITORY_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); const MAXIMUM_ARCHIVE_BYTES = 32 * 1024 * 1024; +const MAXIMUM_NPM_DIAGNOSTIC_CHARACTERS = 512; type PreparationRequest = Readonly<{ artifactDirectory?: string; @@ -35,6 +37,19 @@ type NpmCacheStageRequest = Readonly<{ type NpmCacheStager = (request: NpmCacheStageRequest) => void; +function npmCacheFailureDiagnostic( + stderr: string, + request: NpmCacheStageRequest, + stagingRoot: string, +): string { + return redactFullWithUrls(stderr) + .replaceAll(stagingRoot, "") + .replaceAll(request.cacheDirectory, "") + .replace(/[\u0000-\u001f\u007f]+/gu, " ") + .trim() + .slice(0, MAXIMUM_NPM_DIAGNOSTIC_CHARACTERS); +} + export type ReviewedSourceRegistryPackage = Readonly<{ artifactName: string; integrity: string; @@ -82,7 +97,11 @@ function stageReviewedArchiveWithNpm(request: NpmCacheStageRequest): void { ); if (result.error) throw result.error; if (result.status !== 0) { - throw new Error("npm could not stage the reviewed OpenShell SDK archive"); + const diagnostic = npmCacheFailureDiagnostic(result.stderr, request, stagingRoot); + const detail = diagnostic ? `: ${diagnostic}` : ""; + throw new Error( + `npm could not stage the reviewed OpenShell SDK archive (exit ${String(result.status ?? "unavailable")})${detail}`, + ); } } finally { rmSync(stagingRoot, { force: true, recursive: true }); diff --git a/src/lib/actions/sandbox/destroy-flow.test.ts b/src/lib/actions/sandbox/destroy-flow.test.ts index 386e2a42ef4..122f51202ae 100644 --- a/src/lib/actions/sandbox/destroy-flow.test.ts +++ b/src/lib/actions/sandbox/destroy-flow.test.ts @@ -27,6 +27,7 @@ import { import { createDestroyHarness, resetDestroyModuleCache, + resolveDestroySourcePath, traceDestroyBoundaryCalls, } from "../../../../test/helpers/destroy-flow-test-harness"; import { serializedLlamaCppHostLocalInferenceReceipt } from "../../../../test/helpers/host-local-inference-receipt"; @@ -82,9 +83,7 @@ describe("destroySandbox flow", () => { }); it("loads the destroy flow from TypeScript source (#10106)", () => { - const harness = createDestroyHarness(); - - expect(harness.destroySourcePath).toBe(path.join(import.meta.dirname, "destroy.ts")); + expect(resolveDestroySourcePath()).toBe(path.join(import.meta.dirname, "destroy.ts")); }); it("selects the sandbox gateway, deletes live resources, cleans host state, and removes registry state", async () => { diff --git a/test/helpers/destroy-flow-test-harness.ts b/test/helpers/destroy-flow-test-harness.ts index a07230ab537..8dca140747d 100644 --- a/test/helpers/destroy-flow-test-harness.ts +++ b/test/helpers/destroy-flow-test-harness.ts @@ -23,7 +23,6 @@ export type DestroyHarness = { captureOpenshellSpy: MockInstance; compareAndSwapSessionSpy: MockInstance; destroySandbox: DestroySandbox; - destroySourcePath: string; dockerCaptureSpy: MockInstance; dockerRunSpy: MockInstance; errorSpy: MockInstance; @@ -147,6 +146,10 @@ export function resetDestroyModuleCache(): void { delete require.cache[requireSource.resolve(destroyModulePath)]; } +export function resolveDestroySourcePath(): string { + return requireSource.resolve(destroyModulePath); +} + export function traceDestroyBoundaryCalls( harness: Pick, trace: string[], @@ -594,7 +597,6 @@ export function createDestroyHarness(options: DestroyHarnessOptions = {}): Destr dockerCaptureSpy, dockerRunSpy, destroySandbox: requireSource(destroyModulePath).destroySandbox, - destroySourcePath: requireSource.resolve(destroyModulePath), errorSpy, events, executeSandboxDestroySpy, diff --git a/test/repository/prepare-ci-npm-install.test.ts b/test/repository/prepare-ci-npm-install.test.ts index bd7e6c863e1..fc641ac5c81 100644 --- a/test/repository/prepare-ci-npm-install.test.ts +++ b/test/repository/prepare-ci-npm-install.test.ts @@ -4,6 +4,7 @@ import { execFileSync } from "node:child_process"; import { createHash } from "node:crypto"; import { + chmodSync, mkdtempSync, mkdirSync, readFileSync, @@ -176,10 +177,37 @@ function request(source: ReturnType) { afterEach(() => { vi.restoreAllMocks(); + vi.unstubAllEnvs(); for (const root of temporaryRoots.splice(0)) rmSync(root, { force: true, recursive: true }); }); describe("trusted OpenShell SDK archive preparation", () => { + it("reports a bounded redacted npm cache-stage failure", async () => { + const source = fixture(); + const executableDirectory = join(source.root, "bin"); + const npmPath = join(executableDirectory, "npm"); + const longDetail = "x".repeat(700); + mkdirSync(executableDirectory); + writeFileSync( + npmPath, + `#!/bin/sh\nprintf '%s\\n' 'NPM_TOKEN=private-diagnostic-value ${longDetail}' >&2\nexit 23\n`, + ); + chmodSync(npmPath, 0o700); + vi.stubEnv("PATH", `${executableDirectory}:${process.env.PATH ?? ""}`); + + const failure = await seedReviewedSourceRegistryArtifact(request(source)).catch( + (error: unknown) => error, + ); + + expect(failure).toBeInstanceOf(Error); + const message = (failure as Error).message; + expect(message).toContain("npm could not stage the reviewed OpenShell SDK archive (exit 23)"); + expect(message).toContain("NPM_TOKEN="); + expect(message).not.toContain("private-diagnostic-value"); + expect(message).not.toContain(longDetail); + expect(message.length).toBeLessThan(650); + }); + it("requires the reviewed archive when the root lock uses the SDK", async () => { const source = installFixture("root"); const stage = cacheStageMock(); From 38fe78027b4e35f2e552b619d35539dfca3ff372 Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Thu, 27 Aug 2026 15:03:13 -0700 Subject: [PATCH 19/27] fix(ci): cancel stale SDK package runs Signed-off-by: Apurv Kumaria --- .github/workflows/openshell-sdk-package-pr.yaml | 2 +- test/automation/pull-requests/pr-workflow-contract.test.ts | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/openshell-sdk-package-pr.yaml b/.github/workflows/openshell-sdk-package-pr.yaml index ce20087eb79..d2430bc91db 100644 --- a/.github/workflows/openshell-sdk-package-pr.yaml +++ b/.github/workflows/openshell-sdk-package-pr.yaml @@ -15,7 +15,7 @@ permissions: contents: read concurrency: - group: openshell-sdk-package-${{ github.event.pull_request.number }}-${{ github.event.pull_request.head.sha }} + group: openshell-sdk-package-${{ github.event.pull_request.number }} cancel-in-progress: true jobs: diff --git a/test/automation/pull-requests/pr-workflow-contract.test.ts b/test/automation/pull-requests/pr-workflow-contract.test.ts index cbc285f605b..b2df8e007e6 100644 --- a/test/automation/pull-requests/pr-workflow-contract.test.ts +++ b/test/automation/pull-requests/pr-workflow-contract.test.ts @@ -744,6 +744,10 @@ describe("pull request and main workflow contracts", () => { expect(sdkPackageWorkflow.on).toEqual({ pull_request_target: { types: ["opened", "synchronize", "reopened", "edited"] }, }); + expect(sdkPackageWorkflow.concurrency).toEqual({ + group: "openshell-sdk-package-${{ github.event.pull_request.number }}", + "cancel-in-progress": true, + }); expect(sdkPackageWorkflow.permissions).toEqual({ contents: "read" }); expect(sdkPackageJob.permissions).toEqual({ contents: "read", packages: "read" }); expect(sdkPackageJob.if).toBe( From 1e2153a235eb96d623aecd06d341a8894278fe6b Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Thu, 27 Aug 2026 15:11:18 -0700 Subject: [PATCH 20/27] fix(ci): keep package diagnostics dependency-free Signed-off-by: Apurv Kumaria --- ci/source-architecture-budget.json | 2 +- scripts/checks/prepare-ci-npm-install.mts | 25 ++++++++++++++++-- src/lib/actions/sandbox/destroy-flow.test.ts | 26 +++++++++++-------- .../repository/prepare-ci-npm-install.test.ts | 6 ++++- 4 files changed, 44 insertions(+), 15 deletions(-) diff --git a/ci/source-architecture-budget.json b/ci/source-architecture-budget.json index 3057920e231..e553a1ab060 100644 --- a/ci/source-architecture-budget.json +++ b/ci/source-architecture-budget.json @@ -25,7 +25,7 @@ "src/lib/messaging/channels/index.ts": 25, "src/lib/onboard/gateway-binding.ts": 52, "src/lib/runner.ts": 86, - "src/lib/security/redact.ts": 55, + "src/lib/security/redact.ts": 54, "src/lib/state/onboard-session.ts": 36, "src/lib/state/registry.ts": 101, "src/lib/state/state-root.ts": 21, diff --git a/scripts/checks/prepare-ci-npm-install.mts b/scripts/checks/prepare-ci-npm-install.mts index d881d644732..369857dfbe9 100755 --- a/scripts/checks/prepare-ci-npm-install.mts +++ b/scripts/checks/prepare-ci-npm-install.mts @@ -9,7 +9,6 @@ import { tmpdir } from "node:os"; import { dirname, isAbsolute, join, resolve } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; -import { redactFullWithUrls } from "../../src/lib/security/redact.ts"; import { parseAuditConfig } from "../audit-reviewed-npm-graph.mts"; import { readReviewedNpmArchiveFile, @@ -18,7 +17,20 @@ import { const TRUSTED_REPOSITORY_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); const MAXIMUM_ARCHIVE_BYTES = 32 * 1024 * 1024; +const MAXIMUM_NPM_DIAGNOSTIC_INPUT_CHARACTERS = 4096; const MAXIMUM_NPM_DIAGNOSTIC_CHARACTERS = 512; +const NPM_DIAGNOSTIC_URL_PATTERN = /[a-z][a-z0-9+.-]*:\/\/[^\s'"]+/giu; +const NPM_DIAGNOSTIC_AUTH_HEADER_PATTERN = + /(\b(?:authorization|proxy-authorization|cookie|set-cookie)[ \t]*[:=])[^\r\n]*/giu; +const NPM_DIAGNOSTIC_CREDENTIAL_ASSIGNMENT_PATTERN = + /((?:^|[^A-Za-z0-9])(?:[A-Za-z0-9._-]*(?:auth|credential|key|pass|passwd|password|secret|token)[A-Za-z0-9._-]*)[ \t]*(?:=|:)[ \t]*)(?:"(?:\\.|[^"\\\r\n])*"|'(?:\\.|[^'\\\r\n])*'|[^\s]+)/giu; +const NPM_DIAGNOSTIC_PRIVATE_KEY_PATTERN = + /-----BEGIN (?:[A-Z0-9]+ )?PRIVATE KEY-----[\s\S]*?-----END (?:[A-Z0-9]+ )?PRIVATE KEY-----/gu; +const NPM_DIAGNOSTIC_TOKEN_PATTERN = + /\b(?:github_pat_|ghp_|glpat-|gsk_|hf_|nvcf-|nvapi-|pypi-|sk-(?:ant-|proj-)?|tvly-|xapp-|xox[bpas]-)[A-Za-z0-9_-]{8,}/giu; +const NPM_DIAGNOSTIC_JWT_PATTERN = + /\beyJ[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]{2,}\.[A-Za-z0-9_-]{10,}\b/gu; +const NPM_DIAGNOSTIC_OPAQUE_VALUE_PATTERN = /\b[A-Za-z0-9_+/=-]{32,}\b/gu; type PreparationRequest = Readonly<{ artifactDirectory?: string; @@ -42,7 +54,16 @@ function npmCacheFailureDiagnostic( request: NpmCacheStageRequest, stagingRoot: string, ): string { - return redactFullWithUrls(stderr) + return stderr + .slice(0, MAXIMUM_NPM_DIAGNOSTIC_INPUT_CHARACTERS) + .replace(NPM_DIAGNOSTIC_PRIVATE_KEY_PATTERN, "") + .replace(NPM_DIAGNOSTIC_URL_PATTERN, "") + .replace(NPM_DIAGNOSTIC_AUTH_HEADER_PATTERN, "$1 ") + .replace(NPM_DIAGNOSTIC_CREDENTIAL_ASSIGNMENT_PATTERN, "$1") + .replace(/\bBearer[ \t]+\S+/giu, "Bearer ") + .replace(NPM_DIAGNOSTIC_TOKEN_PATTERN, "") + .replace(NPM_DIAGNOSTIC_JWT_PATTERN, "") + .replace(NPM_DIAGNOSTIC_OPAQUE_VALUE_PATTERN, "") .replaceAll(stagingRoot, "") .replaceAll(request.cacheDirectory, "") .replace(/[\u0000-\u001f\u007f]+/gu, " ") diff --git a/src/lib/actions/sandbox/destroy-flow.test.ts b/src/lib/actions/sandbox/destroy-flow.test.ts index 122f51202ae..40933c766e1 100644 --- a/src/lib/actions/sandbox/destroy-flow.test.ts +++ b/src/lib/actions/sandbox/destroy-flow.test.ts @@ -86,19 +86,23 @@ describe("destroySandbox flow", () => { expect(resolveDestroySourcePath()).toBe(path.join(import.meta.dirname, "destroy.ts")); }); - it("selects the sandbox gateway, deletes live resources, cleans host state, and removes registry state", async () => { - const harness = createDestroyHarness(); + it( + "selects the sandbox gateway, deletes live resources, cleans host state, and removes registry state", + { timeout: 30_000 }, + async () => { + const harness = createDestroyHarness(); - await expect( - harness.destroySandbox("alpha", { yes: true, cleanupGateway: true }), - ).resolves.toBeUndefined(); + await expect( + harness.destroySandbox("alpha", { yes: true, cleanupGateway: true }), + ).resolves.toBeUndefined(); - expectSuccessfulLiveDestroy(harness, exitSpy); - expect(harness.retirePortableLifecycleReceiptSpy).toHaveBeenCalledWith("alpha"); - expect(harness.removeSandboxSpy.mock.invocationCallOrder[0]).toBeLessThan( - harness.retirePortableLifecycleReceiptSpy.mock.invocationCallOrder[0], - ); - }); + expectSuccessfulLiveDestroy(harness, exitSpy); + expect(harness.retirePortableLifecycleReceiptSpy).toHaveBeenCalledWith("alpha"); + expect(harness.removeSandboxSpy.mock.invocationCallOrder[0]).toBeLessThan( + harness.retirePortableLifecycleReceiptSpy.mock.invocationCallOrder[0], + ); + }, + ); it.each([ ["--yes", false], diff --git a/test/repository/prepare-ci-npm-install.test.ts b/test/repository/prepare-ci-npm-install.test.ts index fc641ac5c81..cb5f76b89ea 100644 --- a/test/repository/prepare-ci-npm-install.test.ts +++ b/test/repository/prepare-ci-npm-install.test.ts @@ -190,7 +190,7 @@ describe("trusted OpenShell SDK archive preparation", () => { mkdirSync(executableDirectory); writeFileSync( npmPath, - `#!/bin/sh\nprintf '%s\\n' 'NPM_TOKEN=private-diagnostic-value ${longDetail}' >&2\nexit 23\n`, + `#!/bin/sh\nprintf '%s\\n' 'NPM_TOKEN=private-diagnostic-value https://user:private-password@example.test/path?token=private-query Authorization: Bearer private-bearer-value ${longDetail}' >&2\nexit 23\n`, ); chmodSync(npmPath, 0o700); vi.stubEnv("PATH", `${executableDirectory}:${process.env.PATH ?? ""}`); @@ -203,7 +203,11 @@ describe("trusted OpenShell SDK archive preparation", () => { const message = (failure as Error).message; expect(message).toContain("npm could not stage the reviewed OpenShell SDK archive (exit 23)"); expect(message).toContain("NPM_TOKEN="); + expect(message).toContain(""); expect(message).not.toContain("private-diagnostic-value"); + expect(message).not.toContain("private-bearer-value"); + expect(message).not.toContain("private-password"); + expect(message).not.toContain("private-query"); expect(message).not.toContain(longDetail); expect(message.length).toBeLessThan(650); }); From a6a97c6ebeae82a8426929dd7a3ce4835c354138 Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Thu, 27 Aug 2026 15:51:02 -0700 Subject: [PATCH 21/27] fix(ci): report SDK workflow lookup failures Signed-off-by: Apurv Kumaria --- .github/workflows/pr.yaml | 10 ++++-- .../pr-workflow-contract.test.ts | 32 ++++++++++++++++++- 2 files changed, 38 insertions(+), 4 deletions(-) diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index 86d607deaea..c07a9274b98 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -232,8 +232,12 @@ jobs: printf 'artifact_name=%s\n' "$artifact_name" >> "$GITHUB_OUTPUT" echo "required=true" >> "$GITHUB_OUTPUT" for attempt in $(seq 1 96); do - runs="$(gh api \ - "repos/$GITHUB_REPOSITORY/actions/workflows/openshell-sdk-package-pr.yaml/runs?event=pull_request_target&per_page=100")" + if ! runs="$(gh api \ + "repos/$GITHUB_REPOSITORY/actions/workflows/openshell-sdk-package-pr.yaml/runs?event=pull_request_target&per_page=100" \ + 2>/dev/null)"; then + echo "::error title=SDK package workflow unavailable::Could not inspect reviewed SDK package workflow runs. After GitHub Actions access returns, rerun the failed openshell-sdk-package job in CI / Pull Request." + exit 1 + fi matches="$(jq -cer \ --arg base "$BASE_SHA" \ --arg head "$HEAD_SHA" \ @@ -263,7 +267,7 @@ jobs: fi run_id="$(jq -er '.id | select(type == "number" and . > 0 and . <= 9007199254740991)' <<<"$match")" artifact_listing_url="repos/$GITHUB_REPOSITORY/actions/runs/$run_id/artifacts?per_page=100" - if ! artifacts="$(gh api "$artifact_listing_url")"; then + if ! artifacts="$(gh api "$artifact_listing_url" 2>/dev/null)"; then echo "::error title=SDK package artifact unavailable::Could not inspect the reviewed SDK archive from $run_url. After GitHub Actions access returns, rerun the failed openshell-sdk-package job in CI / Pull Request." exit 1 fi diff --git a/test/automation/pull-requests/pr-workflow-contract.test.ts b/test/automation/pull-requests/pr-workflow-contract.test.ts index b2df8e007e6..35efc084d2a 100644 --- a/test/automation/pull-requests/pr-workflow-contract.test.ts +++ b/test/automation/pull-requests/pr-workflow-contract.test.ts @@ -162,6 +162,7 @@ type SdkPackageLocatorFixture = Readonly<{ artifactsByRunId?: Readonly>; runs: readonly unknown[]; step: WorkflowStep; + workflowRunFailure?: boolean; }>; function runSdkPackageLocator(fixture: SdkPackageLocatorFixture): Readonly<{ @@ -188,13 +189,20 @@ function runSdkPackageLocator(fixture: SdkPackageLocatorFixture): Readonly<{ "#!/usr/bin/env node", 'const request = process.argv.slice(2).join(" ");', 'if (request.includes("actions/workflows/openshell-sdk-package-pr.yaml/runs")) {', + ' if (process.env.FAKE_WORKFLOW_RUN_FAILURE === "true") {', + ' process.stderr.write("untrusted API failure detail\\n");', + " process.exit(1);", + " }", " process.stdout.write(JSON.stringify({ workflow_runs: JSON.parse(process.env.FAKE_WORKFLOW_RUNS) }));", " process.exit(0);", "}", "const artifactMatch = request.match(/actions\\/runs\\/(\\d+)\\/artifacts/);", "if (!artifactMatch) process.exit(64);", "const runId = Number(artifactMatch[1]);", - "if (runId === Number(process.env.FAKE_ARTIFACT_FAILURE_RUN_ID)) process.exit(1);", + "if (runId === Number(process.env.FAKE_ARTIFACT_FAILURE_RUN_ID)) {", + ' process.stderr.write("untrusted artifact API failure detail\\n");', + " process.exit(1);", + "}", "const listings = JSON.parse(process.env.FAKE_ARTIFACTS_BY_RUN_ID);", "process.stdout.write(JSON.stringify(listings[String(runId)] ?? { artifacts: [] }));", ].join("\n"), @@ -208,6 +216,7 @@ function runSdkPackageLocator(fixture: SdkPackageLocatorFixture): Readonly<{ FAKE_ARTIFACTS_BY_RUN_ID: JSON.stringify(fixture.artifactsByRunId ?? {}), FAKE_ARTIFACT_FAILURE_RUN_ID: String(fixture.artifactFailureRunId ?? 0), FAKE_WORKFLOW_RUNS: JSON.stringify(fixture.runs), + FAKE_WORKFLOW_RUN_FAILURE: String(fixture.workflowRunFailure ?? false), GH_TOKEN: "test-token", GITHUB_OUTPUT: outputPath, GITHUB_REPOSITORY: "NVIDIA/NemoClaw", @@ -720,6 +729,27 @@ describe("pull request and main workflow contracts", () => { "rerun the failed openshell-sdk-package job in CI / Pull Request", ); expect(result.stdout).not.toContain("Rerun Security / Package OpenShell SDK for PR"); + expect(result.stderr).not.toContain("untrusted artifact API failure detail"); + expect(githubOutput).not.toContain("run_id="); + }); + + it("explains how to retry an SDK workflow-run-listing failure", () => { + const { githubOutput, result } = runSdkPackageLocator({ + runs: [], + step: requiredWorkflowStep( + prWorkflow.jobs["openshell-sdk-package"], + "Locate exact base-controlled SDK package run", + ), + workflowRunFailure: true, + }); + + expect(result.status).toBe(1); + expect(result.stdout).toContain("Could not inspect reviewed SDK package workflow runs"); + expect(result.stdout).toContain("After GitHub Actions access returns"); + expect(result.stdout).toContain( + "rerun the failed openshell-sdk-package job in CI / Pull Request", + ); + expect(result.stderr).not.toContain("untrusted API failure detail"); expect(githubOutput).not.toContain("run_id="); }); From 2d35ca24726b3e4f480beaee41b5686497979dc4 Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Thu, 27 Aug 2026 15:59:48 -0700 Subject: [PATCH 22/27] fix(ci): explain SDK package wait recovery Signed-off-by: Apurv Kumaria --- .github/workflows/pr.yaml | 12 +++++++-- src/lib/actions/sandbox/destroy-flow.test.ts | 5 ---- .../pr-workflow-contract.test.ts | 27 ++++++++++++++++++- test/helpers/destroy-flow-test-harness.ts | 4 --- 4 files changed, 36 insertions(+), 12 deletions(-) diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index c07a9274b98..93a59f5410e 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -231,7 +231,9 @@ jobs: artifact_name="$(jq -er '.artifactName | select(type == "string" and test("^[a-z0-9][a-z0-9._-]*\\.tgz$"))' <<<"$decision")" printf 'artifact_name=%s\n' "$artifact_name" >> "$GITHUB_OUTPUT" echo "required=true" >> "$GITHUB_OUTPUT" - for attempt in $(seq 1 96); do + newest_matching_run_url="" + newest_matching_run_status="" + for attempt in $(seq 1 84); do if ! runs="$(gh api \ "repos/$GITHUB_REPOSITORY/actions/workflows/openshell-sdk-package-pr.yaml/runs?event=pull_request_target&per_page=100" \ 2>/dev/null)"; then @@ -249,6 +251,8 @@ jobs: sort_by(.created_at) | reverse ' <<<"$runs" 2>/dev/null || true)" if jq -e 'length > 0' <<<"$matches" >/dev/null 2>&1; then + newest_matching_run_url="$(jq -er '.[0].html_url | select(type == "string" and startswith("https://github.com/"))' <<<"$matches")" + newest_matching_run_status="$(jq -er '.[0].status | select(type == "string" and test("^[a-z_]+$"))' <<<"$matches")" pending_run=false newest_completed_url="" while IFS= read -r match; do @@ -286,7 +290,11 @@ jobs: fi sleep 5 done - echo "::error title=SDK package workflow timed out::No successful exact-head package run was available." + latest_run_detail="No matching run was found." + if [ -n "$newest_matching_run_url" ]; then + latest_run_detail="Last matching run: $newest_matching_run_url ($newest_matching_run_status)." + fi + echo "::error title=SDK package workflow timed out::No successful reviewed SDK package run for this latest PR commit was available within seven minutes. $latest_run_detail Rerun Security / Package OpenShell SDK for PR for this latest PR commit. Then rerun the failed openshell-sdk-package job in CI / Pull Request." exit 1 - name: Download exact base-controlled SDK archive diff --git a/src/lib/actions/sandbox/destroy-flow.test.ts b/src/lib/actions/sandbox/destroy-flow.test.ts index 40933c766e1..1ba9f6168f4 100644 --- a/src/lib/actions/sandbox/destroy-flow.test.ts +++ b/src/lib/actions/sandbox/destroy-flow.test.ts @@ -27,7 +27,6 @@ import { import { createDestroyHarness, resetDestroyModuleCache, - resolveDestroySourcePath, traceDestroyBoundaryCalls, } from "../../../../test/helpers/destroy-flow-test-harness"; import { serializedLlamaCppHostLocalInferenceReceipt } from "../../../../test/helpers/host-local-inference-receipt"; @@ -82,10 +81,6 @@ describe("destroySandbox flow", () => { expectStrictSandboxPresenceClassification(); }); - it("loads the destroy flow from TypeScript source (#10106)", () => { - expect(resolveDestroySourcePath()).toBe(path.join(import.meta.dirname, "destroy.ts")); - }); - it( "selects the sandbox gateway, deletes live resources, cleans host state, and removes registry state", { timeout: 30_000 }, diff --git a/test/automation/pull-requests/pr-workflow-contract.test.ts b/test/automation/pull-requests/pr-workflow-contract.test.ts index 35efc084d2a..bd870da7bd5 100644 --- a/test/automation/pull-requests/pr-workflow-contract.test.ts +++ b/test/automation/pull-requests/pr-workflow-contract.test.ts @@ -183,6 +183,8 @@ function runSdkPackageLocator(fixture: SdkPackageLocatorFixture): Readonly<{ 'process.stdout.write(JSON.stringify({ required: true, artifactName: "reviewed-sdk.tgz" }));\n', ); writeFileSync(join(workflowDirectory, "openshell-sdk-package-pr.yaml"), "name: test\n"); + writeFileSync(join(fakeBin, "seq"), "#!/bin/sh\nprintf '1\\n'\n", { mode: 0o755 }); + writeFileSync(join(fakeBin, "sleep"), "#!/bin/sh\nexit 0\n", { mode: 0o755 }); writeFileSync( join(fakeBin, "gh"), [ @@ -548,7 +550,7 @@ describe("pull request and main workflow contracts", () => { ); expect(locate.run).toContain("requires two valid public-registry npm lockfiles"); expect(locate.run).toContain("actions/workflows/openshell-sdk-package-pr.yaml/runs"); - expect(locate.run).toContain("for attempt in $(seq 1 96)"); + expect(locate.run).toContain("for attempt in $(seq 1 84)"); expect(locate.run).toContain("sleep 5"); expect(locate.run).toContain(".head.sha == $head and .base.sha == $base"); expect(locate.run).toContain("required=false"); @@ -753,6 +755,29 @@ describe("pull request and main workflow contracts", () => { expect(githubOutput).not.toContain("run_id="); }); + it("explains how to recover when the SDK package wait expires", () => { + const { githubOutput, result } = runSdkPackageLocator({ + runs: [sdkPackageWorkflowRun(321, "in_progress", null, "2026-08-27T00:00:00Z")], + step: requiredWorkflowStep( + prWorkflow.jobs["openshell-sdk-package"], + "Locate exact base-controlled SDK package run", + ), + }); + + expect(result.status).toBe(1); + expect(result.stdout).toContain("this latest PR commit"); + expect(result.stdout).toContain("within seven minutes"); + expect(result.stdout).toContain( + "Last matching run: https://github.com/NVIDIA/NemoClaw/actions/runs/321 (in_progress)", + ); + expect(result.stdout).toContain("Rerun Security / Package OpenShell SDK for PR"); + expect(result.stdout).toContain( + "Then rerun the failed openshell-sdk-package job in CI / Pull Request", + ); + expect(result.stdout).not.toContain("exact-head"); + expect(githubOutput).not.toContain("run_id="); + }); + // source-shape-contract: security -- Every PR dependency consumer must receive the verified archive without package access it.each(dependencyInstallJobs)("passes the verified SDK archive to %s", (jobName) => { const job = prWorkflow.jobs[jobName]; diff --git a/test/helpers/destroy-flow-test-harness.ts b/test/helpers/destroy-flow-test-harness.ts index 8dca140747d..f390d1be22b 100644 --- a/test/helpers/destroy-flow-test-harness.ts +++ b/test/helpers/destroy-flow-test-harness.ts @@ -146,10 +146,6 @@ export function resetDestroyModuleCache(): void { delete require.cache[requireSource.resolve(destroyModulePath)]; } -export function resolveDestroySourcePath(): string { - return requireSource.resolve(destroyModulePath); -} - export function traceDestroyBoundaryCalls( harness: Pick, trace: string[], From 637d5ea24095ec3549b0cf8d9a46754914433ed6 Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Thu, 27 Aug 2026 16:12:29 -0700 Subject: [PATCH 23/27] fix(ci): isolate SDK package metadata runs Signed-off-by: Apurv Kumaria --- .github/workflows/openshell-sdk-package-pr.yaml | 2 +- test/automation/pull-requests/pr-workflow-contract.test.ts | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/openshell-sdk-package-pr.yaml b/.github/workflows/openshell-sdk-package-pr.yaml index d2430bc91db..ba8d5f2a719 100644 --- a/.github/workflows/openshell-sdk-package-pr.yaml +++ b/.github/workflows/openshell-sdk-package-pr.yaml @@ -15,7 +15,7 @@ permissions: contents: read concurrency: - group: openshell-sdk-package-${{ github.event.pull_request.number }} + group: openshell-sdk-package-${{ github.event.pull_request.number }}-${{ github.event.action != 'edited' || github.event.changes.base != null }} cancel-in-progress: true jobs: diff --git a/test/automation/pull-requests/pr-workflow-contract.test.ts b/test/automation/pull-requests/pr-workflow-contract.test.ts index bd870da7bd5..998183829b8 100644 --- a/test/automation/pull-requests/pr-workflow-contract.test.ts +++ b/test/automation/pull-requests/pr-workflow-contract.test.ts @@ -800,7 +800,8 @@ describe("pull request and main workflow contracts", () => { pull_request_target: { types: ["opened", "synchronize", "reopened", "edited"] }, }); expect(sdkPackageWorkflow.concurrency).toEqual({ - group: "openshell-sdk-package-${{ github.event.pull_request.number }}", + group: + "openshell-sdk-package-${{ github.event.pull_request.number }}-${{ github.event.action != 'edited' || github.event.changes.base != null }}", "cancel-in-progress": true, }); expect(sdkPackageWorkflow.permissions).toEqual({ contents: "read" }); From 08d60ddfb1a32404f84f447ed6523b90c8747eb0 Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Thu, 27 Aug 2026 18:16:56 -0700 Subject: [PATCH 24/27] fix(e2e): build changed OpenClaw base image Signed-off-by: Apurv Kumaria --- .github/workflows/e2e.yaml | 1 + test/e2e/README.md | 2 ++ ...penclaw-plugin-runtime-exdev-workflow-boundary.test.ts | 2 ++ .../openclaw-plugin-runtime-exdev-workflow-boundary.mts | 8 ++++++++ 4 files changed, 13 insertions(+) diff --git a/.github/workflows/e2e.yaml b/.github/workflows/e2e.yaml index f8c306d17fc..c58f3eb15ac 100644 --- a/.github/workflows/e2e.yaml +++ b/.github/workflows/e2e.yaml @@ -5663,6 +5663,7 @@ jobs: NEMOCLAW_RUN_LIVE_E2E: "1" NEMOCLAW_NON_INTERACTIVE: "1" NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE: "1" + NEMOCLAW_SANDBOX_BASE_LOCAL_BUILD: "1" NEMOCLAW_SANDBOX_NAME: "e2e-oc-exdev" OPENSHELL_GATEWAY: "nemoclaw" steps: diff --git a/test/e2e/README.md b/test/e2e/README.md index 0aede4e6770..2af7081c222 100644 --- a/test/e2e/README.md +++ b/test/e2e/README.md @@ -609,6 +609,8 @@ The current-checkout fixture locally prebuilds its repository-controlled v1 and v2 Dockerfiles with BuildKit, then hands only those local image references to OpenShell. User-supplied `--from` Dockerfiles retain the gateway-builder trust boundary and are never host-prebuilt by this fixture. +When a PR changes a base-image input, the job also builds that base image from +the candidate after it removes Docker Hub credentials. The runtime target for `openclaw-plugin-runtime-exdev` is 16–17 minutes. Push-run timing for the reduced lifecycle has not yet been measured. diff --git a/test/e2e/support/openclaw-plugin-runtime-exdev-workflow-boundary.test.ts b/test/e2e/support/openclaw-plugin-runtime-exdev-workflow-boundary.test.ts index b4937e3e021..aec879bc1ae 100644 --- a/test/e2e/support/openclaw-plugin-runtime-exdev-workflow-boundary.test.ts +++ b/test/e2e/support/openclaw-plugin-runtime-exdev-workflow-boundary.test.ts @@ -38,6 +38,7 @@ describe("OpenClaw plugin runtime EXDEV workflow boundary", () => { ...job.env, E2E_ARTIFACT_DIR: "/tmp/openclaw-plugin-runtime-exdev", E2E_DEFAULT_ENABLED: "0", + NEMOCLAW_SANDBOX_BASE_LOCAL_BUILD: "0", NVIDIA_INFERENCE_API_KEY: "${{ secrets.NVIDIA_INFERENCE_API_KEY }}", }; @@ -82,6 +83,7 @@ describe("OpenClaw plugin runtime EXDEV workflow boundary", () => { "openclaw-plugin-runtime-exdev must hold only contents: read", "openclaw-plugin-runtime-exdev must set E2E_ARTIFACT_DIR=${{ github.workspace }}/e2e-artifacts/live/openclaw-plugin-runtime-exdev", "openclaw-plugin-runtime-exdev must remain enabled for scheduled and empty manual runs", + "openclaw-plugin-runtime-exdev must set NEMOCLAW_SANDBOX_BASE_LOCAL_BUILD=1 for changed base-image inputs", "openclaw-plugin-runtime-exdev must not expose NVIDIA_INFERENCE_API_KEY at job scope", "openclaw-plugin-runtime-exdev action 'actions/checkout@v6' must pin a full SHA", "openclaw-plugin-runtime-exdev checkout must disable persisted credentials", diff --git a/tools/e2e/openclaw-plugin-runtime-exdev-workflow-boundary.mts b/tools/e2e/openclaw-plugin-runtime-exdev-workflow-boundary.mts index 4647734d975..8b99073daf5 100644 --- a/tools/e2e/openclaw-plugin-runtime-exdev-workflow-boundary.mts +++ b/tools/e2e/openclaw-plugin-runtime-exdev-workflow-boundary.mts @@ -157,6 +157,14 @@ export function validateOpenClawPluginRuntimeExdevWorkflow( for (const [name, value] of Object.entries(expectedEnv)) { if (env[name] !== value) errors.push(`${jobName} must set ${name}=${value}`); } + if ( + jobName === "openclaw-plugin-runtime-exdev" && + env.NEMOCLAW_SANDBOX_BASE_LOCAL_BUILD !== "1" + ) { + errors.push( + `${jobName} must set NEMOCLAW_SANDBOX_BASE_LOCAL_BUILD=1 for changed base-image inputs`, + ); + } if (Object.hasOwn(env, "E2E_DEFAULT_ENABLED")) { errors.push(`${jobName} must remain enabled for scheduled and empty manual runs`); } From 6ba1f67ee4dc0ac8ca621b998b43b8e91c79dc7c Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Thu, 27 Aug 2026 18:22:01 -0700 Subject: [PATCH 25/27] fix(e2e): exercise candidate base image Signed-off-by: Apurv Kumaria --- .github/workflows/e2e.yaml | 1 - test/e2e/README.md | 4 ++-- ...w-plugin-runtime-exdev-trusted-prebuild.ts | 11 ++++++++++ .../openclaw-plugin-runtime-exdev.test.ts | 11 ++++++---- ...gin-runtime-exdev-trusted-prebuild.test.ts | 20 ++++++++++++++++++- ...in-runtime-exdev-workflow-boundary.test.ts | 2 -- ...plugin-runtime-exdev-workflow-boundary.mts | 8 -------- 7 files changed, 39 insertions(+), 18 deletions(-) diff --git a/.github/workflows/e2e.yaml b/.github/workflows/e2e.yaml index c58f3eb15ac..f8c306d17fc 100644 --- a/.github/workflows/e2e.yaml +++ b/.github/workflows/e2e.yaml @@ -5663,7 +5663,6 @@ jobs: NEMOCLAW_RUN_LIVE_E2E: "1" NEMOCLAW_NON_INTERACTIVE: "1" NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE: "1" - NEMOCLAW_SANDBOX_BASE_LOCAL_BUILD: "1" NEMOCLAW_SANDBOX_NAME: "e2e-oc-exdev" OPENSHELL_GATEWAY: "nemoclaw" steps: diff --git a/test/e2e/README.md b/test/e2e/README.md index 2af7081c222..8d2ceb7eca4 100644 --- a/test/e2e/README.md +++ b/test/e2e/README.md @@ -609,8 +609,8 @@ The current-checkout fixture locally prebuilds its repository-controlled v1 and v2 Dockerfiles with BuildKit, then hands only those local image references to OpenShell. User-supplied `--from` Dockerfiles retain the gateway-builder trust boundary and are never host-prebuilt by this fixture. -When a PR changes a base-image input, the job also builds that base image from -the candidate after it removes Docker Hub credentials. +When a PR changes a base-image input, the current-checkout fixture enables that +base-image build after the workflow removes Docker Hub credentials. The runtime target for `openclaw-plugin-runtime-exdev` is 16–17 minutes. Push-run timing for the reduced lifecycle has not yet been measured. diff --git a/test/e2e/live/openclaw-plugin-runtime-exdev-trusted-prebuild.ts b/test/e2e/live/openclaw-plugin-runtime-exdev-trusted-prebuild.ts index decec70a4bf..8c5a3fad0f9 100644 --- a/test/e2e/live/openclaw-plugin-runtime-exdev-trusted-prebuild.ts +++ b/test/e2e/live/openclaw-plugin-runtime-exdev-trusted-prebuild.ts @@ -36,6 +36,17 @@ export type OpenShellTrustedImageWrapper = OpenShellDriverConfigTestWrapper & { selectImage(imageRef: string): void; }; +export function withEnabledLocalBaseImageBuild(operation: () => T): T { + const previous = process.env.NEMOCLAW_SANDBOX_BASE_LOCAL_BUILD; + process.env.NEMOCLAW_SANDBOX_BASE_LOCAL_BUILD = "1"; + try { + return operation(); + } finally { + if (previous === undefined) delete process.env.NEMOCLAW_SANDBOX_BASE_LOCAL_BUILD; + else process.env.NEMOCLAW_SANDBOX_BASE_LOCAL_BUILD = previous; + } +} + export function trustedExdevImageRef(tag: string): string { const imageRef = `${LOCAL_SANDBOX_IMAGE_REPO}:${tag}`; assert.match(imageRef, TRUSTED_EXDEV_IMAGE_REF_PATTERN); diff --git a/test/e2e/live/openclaw-plugin-runtime-exdev.test.ts b/test/e2e/live/openclaw-plugin-runtime-exdev.test.ts index a488c955774..253c95e4ce9 100644 --- a/test/e2e/live/openclaw-plugin-runtime-exdev.test.ts +++ b/test/e2e/live/openclaw-plugin-runtime-exdev.test.ts @@ -47,6 +47,7 @@ import { DELEGATED_CAPABILITY_COMMENT_PREFIX, registerTrustedPluginFixtureImageCleanup, trustedExdevImageRef, + withEnabledLocalBaseImageBuild, } from "./openclaw-plugin-runtime-exdev-trusted-prebuild.ts"; import { createOpenShellDriverConfigTestWrapper, @@ -1309,10 +1310,12 @@ test( }); progress.phase("build and onboard plugin v1"); - const baseImageResolution = pullAndResolveBaseImageDigest({ - forceRefresh: true, - requireOpenshellSandboxAbi: true, - }); + const baseImageResolution = withEnabledLocalBaseImageBuild(() => + pullAndResolveBaseImageDigest({ + forceRefresh: true, + requireOpenshellSandboxAbi: true, + }), + ); assert( baseImageResolution, "current CLI must resolve an OpenShell-compatible sandbox base image", diff --git a/test/e2e/support/openclaw-plugin-runtime-exdev-trusted-prebuild.test.ts b/test/e2e/support/openclaw-plugin-runtime-exdev-trusted-prebuild.test.ts index 58de5287c53..df41345180e 100644 --- a/test/e2e/support/openclaw-plugin-runtime-exdev-trusted-prebuild.test.ts +++ b/test/e2e/support/openclaw-plugin-runtime-exdev-trusted-prebuild.test.ts @@ -1,7 +1,7 @@ // 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 { afterEach, describe, expect, it, vi } from "vitest"; import { CleanupRegistry } from "../fixtures/cleanup.ts"; import type { ShellProbeResult } from "../fixtures/shell-probe.ts"; @@ -9,10 +9,28 @@ import { acceptTrustedPluginFixturePrebuild, registerTrustedPluginFixtureImageCleanup, trustedExdevImageRef, + withEnabledLocalBaseImageBuild, } from "../live/openclaw-plugin-runtime-exdev-trusted-prebuild.ts"; const IMAGE_ID = `sha256:${"a".repeat(64)}`; +afterEach(() => vi.unstubAllEnvs()); + +it("limits the local base-image build setting to one operation", () => { + vi.stubEnv("NEMOCLAW_SANDBOX_BASE_LOCAL_BUILD", "0"); + + expect( + withEnabledLocalBaseImageBuild(() => process.env.NEMOCLAW_SANDBOX_BASE_LOCAL_BUILD), + ).toBe("1"); + expect(process.env.NEMOCLAW_SANDBOX_BASE_LOCAL_BUILD).toBe("0"); + expect(() => + withEnabledLocalBaseImageBuild(() => { + throw new Error("base-image build failed"); + }), + ).toThrow("base-image build failed"); + expect(process.env.NEMOCLAW_SANDBOX_BASE_LOCAL_BUILD).toBe("0"); +}); + function commandResult(exitCode = 0, stderr = ""): ShellProbeResult { return { artifacts: { result: "result.json", stderr: "stderr.txt", stdout: "stdout.txt" }, diff --git a/test/e2e/support/openclaw-plugin-runtime-exdev-workflow-boundary.test.ts b/test/e2e/support/openclaw-plugin-runtime-exdev-workflow-boundary.test.ts index aec879bc1ae..b4937e3e021 100644 --- a/test/e2e/support/openclaw-plugin-runtime-exdev-workflow-boundary.test.ts +++ b/test/e2e/support/openclaw-plugin-runtime-exdev-workflow-boundary.test.ts @@ -38,7 +38,6 @@ describe("OpenClaw plugin runtime EXDEV workflow boundary", () => { ...job.env, E2E_ARTIFACT_DIR: "/tmp/openclaw-plugin-runtime-exdev", E2E_DEFAULT_ENABLED: "0", - NEMOCLAW_SANDBOX_BASE_LOCAL_BUILD: "0", NVIDIA_INFERENCE_API_KEY: "${{ secrets.NVIDIA_INFERENCE_API_KEY }}", }; @@ -83,7 +82,6 @@ describe("OpenClaw plugin runtime EXDEV workflow boundary", () => { "openclaw-plugin-runtime-exdev must hold only contents: read", "openclaw-plugin-runtime-exdev must set E2E_ARTIFACT_DIR=${{ github.workspace }}/e2e-artifacts/live/openclaw-plugin-runtime-exdev", "openclaw-plugin-runtime-exdev must remain enabled for scheduled and empty manual runs", - "openclaw-plugin-runtime-exdev must set NEMOCLAW_SANDBOX_BASE_LOCAL_BUILD=1 for changed base-image inputs", "openclaw-plugin-runtime-exdev must not expose NVIDIA_INFERENCE_API_KEY at job scope", "openclaw-plugin-runtime-exdev action 'actions/checkout@v6' must pin a full SHA", "openclaw-plugin-runtime-exdev checkout must disable persisted credentials", diff --git a/tools/e2e/openclaw-plugin-runtime-exdev-workflow-boundary.mts b/tools/e2e/openclaw-plugin-runtime-exdev-workflow-boundary.mts index 8b99073daf5..4647734d975 100644 --- a/tools/e2e/openclaw-plugin-runtime-exdev-workflow-boundary.mts +++ b/tools/e2e/openclaw-plugin-runtime-exdev-workflow-boundary.mts @@ -157,14 +157,6 @@ export function validateOpenClawPluginRuntimeExdevWorkflow( for (const [name, value] of Object.entries(expectedEnv)) { if (env[name] !== value) errors.push(`${jobName} must set ${name}=${value}`); } - if ( - jobName === "openclaw-plugin-runtime-exdev" && - env.NEMOCLAW_SANDBOX_BASE_LOCAL_BUILD !== "1" - ) { - errors.push( - `${jobName} must set NEMOCLAW_SANDBOX_BASE_LOCAL_BUILD=1 for changed base-image inputs`, - ); - } if (Object.hasOwn(env, "E2E_DEFAULT_ENABLED")) { errors.push(`${jobName} must remain enabled for scheduled and empty manual runs`); } From 34cbb71aebcfa71a404c7f1831323b14f2ff9bb5 Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Thu, 27 Aug 2026 20:01:17 -0700 Subject: [PATCH 26/27] test(e2e): check external gateway SDK health Signed-off-by: Apurv Kumaria --- .github/workflows/e2e.yaml | 126 ++++++++ test/e2e/README.md | 13 + .../live/external-gateway-health-helpers.ts | 220 +++++++++++++ test/e2e/live/external-gateway-health.test.ts | 23 ++ .../dockerhub-auth-workflow-boundary.test.ts | 1 + ...l-gateway-health-workflow-boundary.test.ts | 69 ++++ test/e2e/support/workflow-plan.test.ts | 1 + tools/e2e/check-semantic-phases.mts | 4 + tools/e2e/cli-artifact-workflow-boundary.mts | 4 +- ...ernal-gateway-health-workflow-boundary.mts | 296 ++++++++++++++++++ tools/e2e/operations-workflow-boundary.mts | 6 + ...upload-e2e-artifacts-workflow-boundary.mts | 22 ++ tools/e2e/workflow-boundary.mts | 12 +- 13 files changed, 795 insertions(+), 2 deletions(-) create mode 100644 test/e2e/live/external-gateway-health-helpers.ts create mode 100644 test/e2e/live/external-gateway-health.test.ts create mode 100644 test/e2e/support/external-gateway-health-workflow-boundary.test.ts create mode 100644 tools/e2e/external-gateway-health-workflow-boundary.mts diff --git a/.github/workflows/e2e.yaml b/.github/workflows/e2e.yaml index f8c306d17fc..22f408932ab 100644 --- a/.github/workflows/e2e.yaml +++ b/.github/workflows/e2e.yaml @@ -95,6 +95,66 @@ env: NEMOCLAW_E2E_SHARD: default jobs: + package-openshell-sdk: + if: ${{ github.event_name == 'workflow_dispatch' && contains(format(',{0},', inputs.jobs), ',external-gateway-health,') }} + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + packages: read + outputs: + artifact_name: ${{ steps.identity.outputs.artifact_name }} + steps: + - name: Check out trusted OpenShell SDK package verifier + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.workflow_sha }} + persist-credentials: false + sparse-checkout: | + ci/reviewed-npm-audit.json + scripts/audit-reviewed-npm-graph.mts + scripts/checks/package-openshell-sdk-for-pr.mts + scripts/lib/openclaw-npm-remediation.mts + scripts/lib/reviewed-npm-archive.mts + scripts/lib/reviewed-npm-audit.mts + sparse-checkout-cone-mode: false + + - name: Set up Node for reviewed package download + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: "22" + registry-url: https://npm.pkg.github.com + scope: "@nvidia" + + - id: package + name: Download and verify exact OpenShell SDK package + env: + NEMOCLAW_OPEN_SHELL_SDK_OUTPUT_DIRECTORY: ${{ runner.temp }}/openshell-sdk + NODE_AUTH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + artifact_path="$(node --experimental-strip-types scripts/checks/package-openshell-sdk-for-pr.mts)" + test -n "$artifact_path" + printf 'artifact_path=%s\n' "$artifact_path" >> "$GITHUB_OUTPUT" + + - id: identity + name: Record reviewed OpenShell SDK artifact identity + env: + RUN_ATTEMPT: ${{ github.run_attempt }} + RUN_ID: ${{ github.run_id }} + run: | + set -euo pipefail + artifact_name="openshell-sdk-e2e-${RUN_ID}-${RUN_ATTEMPT}" + printf 'artifact_name=%s\n' "$artifact_name" >> "$GITHUB_OUTPUT" + + - name: Upload reviewed OpenShell SDK archive + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: ${{ steps.identity.outputs.artifact_name }} + path: ${{ steps.package.outputs.artifact_path }} + if-no-files-found: error + retention-days: 1 + base-image-publication: runs-on: ubuntu-latest timeout-minutes: 55 @@ -3263,6 +3323,71 @@ jobs: shell: bash run: bash .github/scripts/docker-auth-cleanup.sh + external-gateway-health: + needs: [generate-matrix, package-openshell-sdk] + if: ${{ contains(fromJSON(needs.generate-matrix.outputs.selected_jobs), 'external-gateway-health') }} + runs-on: ubuntu-latest + timeout-minutes: 15 + env: + E2E_JOB: "1" + E2E_DEFAULT_ENABLED: "0" + E2E_TARGET_ID: "external-gateway-health" + E2E_AGENT_RUNTIME: "none" + E2E_OBSERVABLE_OUTCOME: "The reviewed SDK observes exact public gateway health over explicit HTTPS and CA" + E2E_ENVIRONMENT_OR_INFERENCE_ENDPOINT: "Ubuntu host with OpenShell 0.0.106; no inference endpoint" + E2E_ARTIFACT_DIR: ${{ github.workspace }}/e2e-artifacts/live/external-gateway-health + NEMOCLAW_RUN_LIVE_E2E: "1" + NEMOCLAW_NON_INTERACTIVE: "1" + NEMOCLAW_OPENSHELL_PIN_VERSION: "0.0.106" + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: ${{ inputs.checkout_repository || github.repository }} + ref: ${{ inputs.checkout_sha || github.sha }} + fetch-depth: 0 + persist-credentials: false + + - name: Prepare E2E workspace + uses: NVIDIA/NemoClaw/.github/actions/prepare-e2e@f6304bc25fc35bfaa441c8c2fbfee38f72805a75 + with: + build-cli: "false" + + - name: Restore exact-commit CLI artifact + uses: NVIDIA/NemoClaw/.github/actions/restore-e2e-cli-artifact@c246409193a31133cab10c8a3589001cc0d59eb3 + with: + provenance-json: ${{ needs.generate-matrix.outputs.cli_artifact_provenance }} + + - name: Download reviewed OpenShell SDK archive + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: ${{ needs.package-openshell-sdk.outputs.artifact_name }} + path: ${{ runner.temp }}/openshell-sdk + + - name: Install reviewed OpenShell SDK archive without package credentials + run: | + set -euo pipefail + mapfile -t archives < <(find "$RUNNER_TEMP/openshell-sdk" -maxdepth 1 -type f -name '*.tgz' -print) + test "${#archives[@]}" -eq 1 + env -u NODE_AUTH_TOKEN -u GITHUB_TOKEN \ + npm install --no-save --package-lock=false --ignore-scripts "${archives[0]}" + + - name: Install OpenShell CLI + run: env -u NODE_AUTH_TOKEN -u GITHUB_TOKEN bash scripts/install-openshell.sh + + - name: Run external gateway health live test + run: | + set -euo pipefail + export PATH="$HOME/.local/bin:$HOME/.npm-global/bin:$PATH" + npx tsx tools/e2e/live-vitest-invocation.mts run \ + --test-path test/e2e/live/external-gateway-health.test.ts + + - name: Upload external gateway health artifacts + if: always() + uses: NVIDIA/NemoClaw/.github/actions/upload-e2e-artifacts@7768e15eb90d3ee2d33432f481dfe8747e4f6d57 + with: + name: e2e-external-gateway-health + path: e2e-artifacts/live/external-gateway-health/ + mcp-bridge: needs: [base-image-publication, generate-matrix] if: ${{ contains(fromJSON(needs.generate-matrix.outputs.selected_jobs), 'mcp-bridge') }} @@ -5737,6 +5862,7 @@ jobs: catalogue-github-read, catalogue-brave-nvidia-inference, openshell-gateway-auth-contract, + external-gateway-health, mcp-bridge, openshell-credential-generation-window, openshell-dev-artifact, diff --git a/test/e2e/README.md b/test/e2e/README.md index fa1b440b453..6228a43c926 100644 --- a/test/e2e/README.md +++ b/test/e2e/README.md @@ -266,6 +266,19 @@ discovery command locally to inspect the generated test matrix: npx tsx tools/e2e/credential-free-tests.mts ``` +### External gateway health + +`external-gateway-health` is an explicit-only retained job for the external +OpenShell target work in issue #9872. The trusted workflow downloads and +verifies the exact OpenShell SDK archive with package-read permission. The +candidate job receives the archive but no package credential. It calls a local +OpenShell 0.0.106 gateway over HTTPS with an explicit CA. The target does not +read an authentication file or make an authenticated gateway call. + +Use `jobs=external-gateway-health` for the manual pull request E2E run. The +target records the expected release, reported release, public health status, +and transport. It also stops the gateway and removes its temporary state. + ## Catalogue Targets `tools/e2e/target-catalogue.mts` declares live E2E targets that share one execution shape. diff --git a/test/e2e/live/external-gateway-health-helpers.ts b/test/e2e/live/external-gateway-health-helpers.ts new file mode 100644 index 00000000000..28f74769eeb --- /dev/null +++ b/test/e2e/live/external-gateway-health-helpers.ts @@ -0,0 +1,220 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { type ChildProcess, spawnSync } from "node:child_process"; +import fs from "node:fs"; +import net from "node:net"; +import os from "node:os"; +import path from "node:path"; + +import { ensureDockerDriverGatewayLocalTlsBundle } from "../../../dist/lib/onboard/docker-driver-gateway-local-tls"; +import type { ArtifactSink } from "../fixtures/artifacts.ts"; +import type { CleanupRegistry } from "../fixtures/cleanup.ts"; +import { expect } from "../fixtures/e2e-test.ts"; +import { OPENSHELL_V0106_QUALIFICATION } from "../fixtures/openshell-v0106-qualification.ts"; +import { spawnObservedChild } from "../fixtures/observed-child-process.ts"; +import type { TestProgress } from "../fixtures/progress.ts"; + +export const EXTERNAL_GATEWAY_HEALTH_TIMEOUT_MS = 3 * 60_000; +const HEALTH_TIMEOUT_MS = 5_000; + +type OpenShellHealthClient = Readonly<{ + raw: Readonly<{ + health( + request: Record, + options: Readonly<{ signal: AbortSignal }>, + ): Promise; + }>; +}>; + +type OpenShellSdkModule = Readonly<{ + OpenShellClient: Readonly<{ + connect(options: Readonly<{ gateway: string; caCert: Buffer }>): Promise; + }>; +}>; + +type ScenarioFixtures = Readonly<{ + artifacts: ArtifactSink; + cleanup: CleanupRegistry; + progress: TestProgress; + skip: (message?: string) => void; +}>; + +function resolveGatewayBin(): string | null { + for (const candidate of [ + process.env.OPENSHELL_GATEWAY_BIN, + path.join(os.homedir(), ".local", "bin", "openshell-gateway"), + "/usr/local/bin/openshell-gateway", + "/usr/bin/openshell-gateway", + ]) { + if (candidate && fs.existsSync(candidate)) return candidate; + } + const result = spawnSync("sh", ["-c", "command -v openshell-gateway"], { + encoding: "utf8", + killSignal: "SIGKILL", + stdio: ["ignore", "pipe", "pipe"], + timeout: 5_000, + }); + return result.status === 0 && result.stdout.trim() ? result.stdout.trim() : null; +} + +function pickPort(): Promise { + return new Promise((resolve, reject) => { + const server = net.createServer(); + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (!address || typeof address === "string") { + server.close(() => reject(new Error("failed to allocate a TCP port"))); + return; + } + server.close((error) => (error ? reject(error) : resolve(address.port))); + }); + }); +} + +function delay(milliseconds: number): Promise { + return new Promise((resolve) => setTimeout(resolve, milliseconds)); +} + +async function stopGateway(gateway: ChildProcess): Promise { + if (gateway.exitCode !== null) return; + gateway.kill("SIGTERM"); + for (let attempt = 0; attempt < 20; attempt += 1) { + if (gateway.exitCode !== null) return; + await delay(100); + } + gateway.kill("SIGKILL"); +} + +async function loadSdk(): Promise { + const packageName: string = "@nvidia/openshell-sdk"; + const loaded = (await import(packageName)) as Partial; + if (!loaded.OpenShellClient || typeof loaded.OpenShellClient.connect !== "function") { + throw new Error("the reviewed OpenShell SDK client export is unavailable"); + } + return loaded as OpenShellSdkModule; +} + +async function waitForPublicHealth(options: { + caCert: Buffer; + endpoint: string; + gateway: ChildProcess; +}): Promise> { + const deadline = Date.now() + 60_000; + const sdk = await loadSdk(); + const client = await sdk.OpenShellClient.connect({ + gateway: options.endpoint, + caCert: options.caCert, + }); + while (Date.now() < deadline) { + if (options.gateway.exitCode !== null) { + throw new Error("OpenShell gateway exited before the public health check completed"); + } + try { + const result = await client.raw.health( + {}, + { signal: AbortSignal.timeout(HEALTH_TIMEOUT_MS) }, + ); + if (typeof result === "object" && result !== null && !Array.isArray(result)) { + const health = result as Record; + if (health.version) return { status: health.status, version: health.version }; + } + } catch { + // The gateway can refuse connections until its listener is ready. + } + await delay(250); + } + throw new Error("OpenShell gateway public health did not become available"); +} + +export async function runExternalGatewayHealthScenario({ + artifacts, + cleanup, + progress, + skip, +}: ScenarioFixtures): Promise { + const gatewayBin = resolveGatewayBin(); + if (!gatewayBin) skip("openshell-gateway 0.0.106 is required"); + + progress.phase("confirm the exact OpenShell gateway and SDK prerequisites"); + const version = spawnSync(gatewayBin!, ["--version"], { + encoding: "utf8", + killSignal: "SIGKILL", + stdio: ["ignore", "pipe", "pipe"], + timeout: 5_000, + }); + expect(version.status, `${version.stdout}\n${version.stderr}`).toBe(0); + expect(`${version.stdout}\n${version.stderr}`).toContain(OPENSHELL_V0106_QUALIFICATION.version); + + const port = await pickPort(); + const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-external-health-")); + cleanup.add("remove external gateway health state", () => + fs.rmSync(stateDir, { recursive: true, force: true }), + ); + const tls = ensureDockerDriverGatewayLocalTlsBundle({ gatewayBin: gatewayBin!, stateDir }); + const configPath = path.join(stateDir, "gateway.toml"); + fs.writeFileSync( + configPath, + [ + "[openshell]", + "version = 1", + "", + "[openshell.gateway]", + `bind_address = "127.0.0.1:${String(port)}"`, + "compute_drivers = []", + "disable_tls = false", + "", + "[openshell.gateway.tls]", + `cert_path = ${JSON.stringify(tls.serverCertPath)}`, + `key_path = ${JSON.stringify(tls.serverKeyPath)}`, + "require_client_auth = false", + "", + "[openshell.gateway.auth]", + "allow_unauthenticated_users = true", + "", + ].join("\n"), + { mode: 0o600 }, + ); + + progress.phase("launch a TLS gateway without client-certificate authentication"); + let gatewayOutput = ""; + const gateway = spawnObservedChild(gatewayBin!, [], { + activityLabel: "command: external-gateway-health", + progress, + spawn: { + env: { + ...process.env, + OPENSHELL_DB_URL: `sqlite:${path.join(stateDir, "openshell.db")}`, + OPENSHELL_GATEWAY_CONFIG: configPath, + }, + stdio: ["ignore", "pipe", "pipe"], + }, + }); + gateway.stdout?.on("data", (chunk: Buffer) => { + gatewayOutput += chunk.toString("utf8"); + }); + gateway.stderr?.on("data", (chunk: Buffer) => { + gatewayOutput += chunk.toString("utf8"); + }); + cleanup.add("stop external gateway health gateway", () => stopGateway(gateway)); + + try { + progress.phase("observe public health through the reviewed SDK"); + const health = await waitForPublicHealth({ + caCert: fs.readFileSync(tls.caPath), + endpoint: `https://127.0.0.1:${String(port)}`, + gateway, + }); + expect(health.version).toBe(OPENSHELL_V0106_QUALIFICATION.version); + expect(health.status).toBe(1); + await artifacts.writeJson("external-gateway-health.json", { + expectedRelease: OPENSHELL_V0106_QUALIFICATION.version, + reportedRelease: health.version, + status: "healthy", + transport: "https-explicit-ca", + }); + } finally { + await artifacts.writeText("external-gateway.log", gatewayOutput); + } +} diff --git a/test/e2e/live/external-gateway-health.test.ts b/test/e2e/live/external-gateway-health.test.ts new file mode 100644 index 00000000000..6196b15a9c3 --- /dev/null +++ b/test/e2e/live/external-gateway-health.test.ts @@ -0,0 +1,23 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { test } from "../fixtures/e2e-test.ts"; +import { + EXTERNAL_GATEWAY_HEALTH_TIMEOUT_MS, + runExternalGatewayHealthScenario, +} from "./external-gateway-health-helpers.ts"; + +test( + "OpenShell public health accepts the reviewed SDK over explicit HTTPS and CA (#9872)", + { + timeout: EXTERNAL_GATEWAY_HEALTH_TIMEOUT_MS, + meta: { + e2ePhases: [ + "confirm the exact OpenShell gateway and SDK prerequisites", + "launch a TLS gateway without client-certificate authentication", + "observe public health through the reviewed SDK", + ], + }, + }, + runExternalGatewayHealthScenario, +); diff --git a/test/e2e/support/dockerhub-auth-workflow-boundary.test.ts b/test/e2e/support/dockerhub-auth-workflow-boundary.test.ts index 8dafbad1f32..56547940fac 100644 --- a/test/e2e/support/dockerhub-auth-workflow-boundary.test.ts +++ b/test/e2e/support/dockerhub-auth-workflow-boundary.test.ts @@ -19,6 +19,7 @@ import { readWorkflow } from "../../helpers/e2e-workflow-contract"; import { testTimeout } from "../../helpers/timeouts"; const NO_IMAGE_E2E_JOBS = [ + "external-gateway-health", "staging-brev-launchable", "staging-brev-launchable-identity", "shared-e2e", diff --git a/test/e2e/support/external-gateway-health-workflow-boundary.test.ts b/test/e2e/support/external-gateway-health-workflow-boundary.test.ts new file mode 100644 index 00000000000..0c7a2aa680f --- /dev/null +++ b/test/e2e/support/external-gateway-health-workflow-boundary.test.ts @@ -0,0 +1,69 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; + +import { + readExternalGatewayHealthWorkflow, + validateExternalGatewayHealthWorkflow, + validateExternalGatewayHealthWorkflowBoundary, +} from "../../../tools/e2e/external-gateway-health-workflow-boundary.mts"; + +describe("external gateway health workflow boundary", () => { + it("accepts the checked-in trusted package and live-test contract", () => { + expect(validateExternalGatewayHealthWorkflowBoundary()).toEqual([]); + }); + + it("rejects package credentials or untrusted candidate execution in the package job", () => { + const workflow = readExternalGatewayHealthWorkflow(); + const job = workflow.jobs["package-openshell-sdk"]; + job.if = "${{ always() }}"; + job.permissions = { contents: "write", packages: "write" }; + const checkout = job.steps!.find((step) => step.uses?.startsWith("actions/checkout@"))!; + checkout.with!.ref = "${{ inputs.checkout_sha }}"; + const download = job.steps!.find( + (step) => step.name === "Download and verify exact OpenShell SDK package", + )!; + download.env!.NODE_AUTH_TOKEN = "${{ secrets.PACKAGE_TOKEN }}"; + + expect(validateExternalGatewayHealthWorkflow(workflow)).toEqual( + expect.arrayContaining([ + "package-openshell-sdk must run only for the explicit external health selector", + "package-openshell-sdk must retain its bounded package-read trust boundary", + "package-openshell-sdk must execute only the trusted sparse package verifier checkout", + "package-openshell-sdk must scope its package credential to the reviewed downloader", + ]), + ); + }); + + it("rejects credential exposure and candidate or artifact substitution in the live job", () => { + const workflow = readExternalGatewayHealthWorkflow(); + const job = workflow.jobs["external-gateway-health"]; + job.needs = "generate-matrix"; + job.env = { ...job.env, GITHUB_TOKEN: "${{ github.token }}" }; + const checkout = job.steps!.find((step) => step.uses?.startsWith("actions/checkout@"))!; + checkout.with!.ref = "main"; + const download = job.steps!.find( + (step) => step.name === "Download reviewed OpenShell SDK archive", + )!; + download.with!.name = "unreviewed-sdk"; + const install = job.steps!.find( + (step) => step.name === "Install reviewed OpenShell SDK archive without package credentials", + )!; + install.run = "npm install @nvidia/openshell-sdk@latest"; + const run = job.steps!.find((step) => step.name === "Run external gateway health live test")!; + run.env = { NODE_AUTH_TOKEN: "${{ secrets.PACKAGE_TOKEN }}" }; + + expect(validateExternalGatewayHealthWorkflow(workflow)).toEqual( + expect.arrayContaining([ + "external-gateway-health must wait for the candidate CLI and reviewed SDK archive", + "external-gateway-health must not expose GITHUB_TOKEN at job scope", + "external-gateway-health must use the exact candidate checkout without persisted credentials", + "external-gateway-health must download only this run's reviewed SDK archive", + "external-gateway-health SDK install must retain: env -u NODE_AUTH_TOKEN -u GITHUB_TOKEN", + 'external-gateway-health SDK install must retain: npm install --no-save --package-lock=false --ignore-scripts "${archives[0]}"', + "external-gateway-health must run only the credential-free external health test", + ]), + ); + }); +}); diff --git a/test/e2e/support/workflow-plan.test.ts b/test/e2e/support/workflow-plan.test.ts index 19da31a1e3f..a1185a77f4a 100644 --- a/test/e2e/support/workflow-plan.test.ts +++ b/test/e2e/support/workflow-plan.test.ts @@ -112,6 +112,7 @@ describe("E2E workflow plan", () => { expect(plan.hermesSelected).toBe(true); expect(plan.explicitOnlyJobs).toEqual([ "staging-brev-launchable-identity", + "external-gateway-health", "llama-cpp-dgx-spark-qualification", ]); expect(releaseRequiredWorkflowJobs()).toContain("live"); diff --git a/tools/e2e/check-semantic-phases.mts b/tools/e2e/check-semantic-phases.mts index 4198fc1e209..0c292e731d5 100644 --- a/tools/e2e/check-semantic-phases.mts +++ b/tools/e2e/check-semantic-phases.mts @@ -378,6 +378,10 @@ const OBSERVED_CHILD_PROGRESS_POLICIES = new Map; + id?: string; + if?: string; + name?: string; + run?: string; + uses?: string; + with?: Record; +}; + +type WorkflowJob = { + env?: Record; + if?: string; + needs?: string | string[]; + outputs?: Record; + permissions?: Record; + "runs-on"?: string; + steps?: WorkflowStep[]; + "timeout-minutes"?: number; +}; + +export type ExternalGatewayHealthWorkflow = { + jobs: Record; +}; + +export function readExternalGatewayHealthWorkflow( + workflowPath = DEFAULT_WORKFLOW_PATH, +): ExternalGatewayHealthWorkflow { + return YAML.parse(readFileSync(workflowPath, "utf8")) as ExternalGatewayHealthWorkflow; +} + +function findStep(job: WorkflowJob, name: string): WorkflowStep { + return job.steps?.find((step) => step.name === name) ?? {}; +} + +function sameMembers(value: string | string[] | undefined, expected: readonly string[]): boolean { + const actual = Array.isArray(value) ? value : value ? [value] : []; + return JSON.stringify([...actual].sort()) === JSON.stringify([...expected].sort()); +} + +function requireOrder( + errors: string[], + steps: WorkflowStep[], + beforeName: string, + afterName: string, +): void { + const before = steps.findIndex((step) => step.name === beforeName); + const after = steps.findIndex((step) => step.name === afterName); + if (before < 0 || after < 0 || before >= after) { + errors.push(`${HEALTH_JOB} step '${beforeName}' must precede '${afterName}'`); + } +} + +function validatePackageJob(errors: string[], job: WorkflowJob): void { + if ( + job.if !== + "${{ github.event_name == 'workflow_dispatch' && contains(format(',{0},', inputs.jobs), ',external-gateway-health,') }}" + ) { + errors.push(`${PACKAGE_JOB} must run only for the explicit external health selector`); + } + if ( + job["runs-on"] !== "ubuntu-latest" || + job["timeout-minutes"] !== 5 || + JSON.stringify(job.permissions) !== JSON.stringify({ contents: "read", packages: "read" }) + ) { + errors.push(`${PACKAGE_JOB} must retain its bounded package-read trust boundary`); + } + if (job.outputs?.artifact_name !== "${{ steps.identity.outputs.artifact_name }}") { + errors.push(`${PACKAGE_JOB} must expose only the recorded artifact name`); + } + + const steps = job.steps ?? []; + const checkout = findStep(job, "Check out trusted OpenShell SDK package verifier"); + const sparse = String(checkout.with?.["sparse-checkout"] ?? "") + .split(/\r?\n/u) + .map((entry) => entry.trim()) + .filter(Boolean) + .sort(); + const expectedSparse = [ + "ci/reviewed-npm-audit.json", + "scripts/audit-reviewed-npm-graph.mts", + "scripts/checks/package-openshell-sdk-for-pr.mts", + "scripts/lib/openclaw-npm-remediation.mts", + "scripts/lib/reviewed-npm-archive.mts", + "scripts/lib/reviewed-npm-audit.mts", + ].sort(); + if ( + checkout.uses !== CHECKOUT_ACTION || + checkout.with?.ref !== "${{ github.workflow_sha }}" || + checkout.with?.["persist-credentials"] !== false || + checkout.with?.["sparse-checkout-cone-mode"] !== false || + JSON.stringify(sparse) !== JSON.stringify(expectedSparse) + ) { + errors.push(`${PACKAGE_JOB} must execute only the trusted sparse package verifier checkout`); + } + + const setup = findStep(job, "Set up Node for reviewed package download"); + if ( + setup.uses !== SETUP_NODE_ACTION || + setup.with?.["node-version"] !== "22" || + setup.with?.["registry-url"] !== "https://npm.pkg.github.com" || + setup.with?.scope !== "@nvidia" + ) { + errors.push(`${PACKAGE_JOB} must use the reviewed Node and GitHub Packages setup`); + } + + const download = findStep(job, "Download and verify exact OpenShell SDK package"); + if ( + download.id !== "package" || + download.env?.NEMOCLAW_OPEN_SHELL_SDK_OUTPUT_DIRECTORY !== "${{ runner.temp }}/openshell-sdk" || + download.env?.NODE_AUTH_TOKEN !== "${{ github.token }}" || + !download.run?.includes( + "node --experimental-strip-types scripts/checks/package-openshell-sdk-for-pr.mts", + ) + ) { + errors.push(`${PACKAGE_JOB} must scope its package credential to the reviewed downloader`); + } + + const identity = findStep(job, "Record reviewed OpenShell SDK artifact identity"); + if ( + identity.id !== "identity" || + identity.env?.RUN_ATTEMPT !== "${{ github.run_attempt }}" || + identity.env?.RUN_ID !== "${{ github.run_id }}" || + !identity.run?.includes('artifact_name="openshell-sdk-e2e-${RUN_ID}-${RUN_ATTEMPT}"') + ) { + errors.push(`${PACKAGE_JOB} must bind the artifact name to this workflow attempt`); + } + + const upload = findStep(job, "Upload reviewed OpenShell SDK archive"); + if ( + upload.uses !== UPLOAD_ACTION || + upload.with?.name !== "${{ steps.identity.outputs.artifact_name }}" || + upload.with?.path !== "${{ steps.package.outputs.artifact_path }}" || + upload.with?.["if-no-files-found"] !== "error" || + upload.with?.["retention-days"] !== 1 + ) { + errors.push(`${PACKAGE_JOB} must publish only the exact one-day reviewed archive`); + } + requireOrder(errors, steps, checkout.name ?? "", setup.name ?? ""); + requireOrder(errors, steps, setup.name ?? "", download.name ?? ""); + requireOrder(errors, steps, download.name ?? "", upload.name ?? ""); +} + +function validateHealthJob(errors: string[], job: WorkflowJob): void { + if (!sameMembers(job.needs, ["generate-matrix", PACKAGE_JOB])) { + errors.push(`${HEALTH_JOB} must wait for the candidate CLI and reviewed SDK archive`); + } + if ( + job.if !== + "${{ contains(fromJSON(needs.generate-matrix.outputs.selected_jobs), 'external-gateway-health') }}" + ) { + errors.push(`${HEALTH_JOB} must use the trusted explicit selection and dependency gate`); + } + if (job["runs-on"] !== "ubuntu-latest" || job["timeout-minutes"] !== 15) { + errors.push(`${HEALTH_JOB} must retain its 15 minute Ubuntu resource budget`); + } + const env = job.env ?? {}; + const expectedEnv = { + E2E_AGENT_RUNTIME: "none", + E2E_ARTIFACT_DIR: "${{ github.workspace }}/e2e-artifacts/live/external-gateway-health", + E2E_DEFAULT_ENABLED: "0", + E2E_ENVIRONMENT_OR_INFERENCE_ENDPOINT: + "Ubuntu host with OpenShell 0.0.106; no inference endpoint", + E2E_JOB: "1", + E2E_NON_INTERACTIVE: undefined, + E2E_OBSERVABLE_OUTCOME: + "The reviewed SDK observes exact public gateway health over explicit HTTPS and CA", + E2E_TARGET_ID: HEALTH_JOB, + NEMOCLAW_NON_INTERACTIVE: "1", + NEMOCLAW_OPENSHELL_PIN_VERSION: "0.0.106", + NEMOCLAW_RUN_LIVE_E2E: "1", + }; + for (const [name, value] of Object.entries(expectedEnv)) { + if (value === undefined ? Object.hasOwn(env, name) : env[name] !== value) { + errors.push(`${HEALTH_JOB} must retain ${name}=${String(value)}`); + } + } + for (const name of ["GITHUB_TOKEN", "NODE_AUTH_TOKEN", "NVIDIA_API_KEY"]) { + if (Object.hasOwn(env, name)) errors.push(`${HEALTH_JOB} must not expose ${name} at job scope`); + } + + const steps = job.steps ?? []; + const checkout = steps.find((step) => step.uses?.startsWith("actions/checkout@")) ?? {}; + if ( + checkout.uses !== CHECKOUT_ACTION || + checkout.with?.repository !== "${{ inputs.checkout_repository || github.repository }}" || + checkout.with?.ref !== "${{ inputs.checkout_sha || github.sha }}" || + checkout.with?.["fetch-depth"] !== 0 || + checkout.with?.["persist-credentials"] !== false + ) { + errors.push( + `${HEALTH_JOB} must use the exact candidate checkout without persisted credentials`, + ); + } + const prepare = findStep(job, "Prepare E2E workspace"); + if (prepare.uses !== PREPARE_E2E_ACTION || prepare.with?.["build-cli"] !== "false") { + errors.push(`${HEALTH_JOB} must use the reviewed dependency preparation without rebuilding`); + } + const restore = findStep(job, "Restore exact-commit CLI artifact"); + if ( + !restore.uses?.startsWith("NVIDIA/NemoClaw/.github/actions/restore-e2e-cli-artifact@") || + restore.with?.["provenance-json"] !== + "${{ needs.generate-matrix.outputs.cli_artifact_provenance }}" + ) { + errors.push(`${HEALTH_JOB} must restore the exact candidate CLI artifact`); + } + const download = findStep(job, "Download reviewed OpenShell SDK archive"); + if ( + download.uses !== DOWNLOAD_ACTION || + download.with?.name !== "${{ needs.package-openshell-sdk.outputs.artifact_name }}" || + download.with?.path !== "${{ runner.temp }}/openshell-sdk" + ) { + errors.push(`${HEALTH_JOB} must download only this run's reviewed SDK archive`); + } + const installSdk = findStep( + job, + "Install reviewed OpenShell SDK archive without package credentials", + ); + for (const fragment of [ + "env -u NODE_AUTH_TOKEN -u GITHUB_TOKEN", + 'npm install --no-save --package-lock=false --ignore-scripts "${archives[0]}"', + ]) { + if (!installSdk.run?.includes(fragment)) { + errors.push(`${HEALTH_JOB} SDK install must retain: ${fragment}`); + } + } + const installOpenShell = findStep(job, "Install OpenShell CLI"); + if ( + installOpenShell.run !== + "env -u NODE_AUTH_TOKEN -u GITHUB_TOKEN bash scripts/install-openshell.sh" + ) { + errors.push(`${HEALTH_JOB} OpenShell install must not receive package credentials`); + } + const run = findStep(job, "Run external gateway health live test"); + if ( + !run.run?.includes("tools/e2e/live-vitest-invocation.mts run") || + !run.run?.includes("test/e2e/live/external-gateway-health.test.ts") || + Object.keys(run.env ?? {}).length > 0 || + JSON.stringify(run).includes("secrets.") + ) { + errors.push(`${HEALTH_JOB} must run only the credential-free external health test`); + } + const upload = findStep(job, "Upload external gateway health artifacts"); + if ( + upload.if !== "always()" || + upload.uses !== UPLOAD_E2E_ARTIFACTS_ACTION || + upload.with?.name !== "e2e-external-gateway-health" || + upload.with?.path !== "e2e-artifacts/live/external-gateway-health/" + ) { + errors.push(`${HEALTH_JOB} must always use the reviewed artifact uploader`); + } + requireOrder(errors, steps, prepare.name ?? "", restore.name ?? ""); + requireOrder(errors, steps, restore.name ?? "", download.name ?? ""); + requireOrder(errors, steps, download.name ?? "", installSdk.name ?? ""); + requireOrder(errors, steps, installSdk.name ?? "", run.name ?? ""); + requireOrder(errors, steps, run.name ?? "", upload.name ?? ""); +} + +export function validateExternalGatewayHealthWorkflow( + workflow: ExternalGatewayHealthWorkflow, +): string[] { + const errors: string[] = []; + const packageJob = workflow.jobs[PACKAGE_JOB]; + const healthJob = workflow.jobs[HEALTH_JOB]; + if (!packageJob) errors.push(`workflow is missing ${PACKAGE_JOB}`); + else validatePackageJob(errors, packageJob); + if (!healthJob) errors.push(`workflow is missing ${HEALTH_JOB}`); + else validateHealthJob(errors, healthJob); + return errors; +} + +export function validateExternalGatewayHealthWorkflowBoundary( + workflowPath = DEFAULT_WORKFLOW_PATH, +): string[] { + return validateExternalGatewayHealthWorkflow(readExternalGatewayHealthWorkflow(workflowPath)); +} diff --git a/tools/e2e/operations-workflow-boundary.mts b/tools/e2e/operations-workflow-boundary.mts index 9aa781868aa..fdd00fa79a1 100644 --- a/tools/e2e/operations-workflow-boundary.mts +++ b/tools/e2e/operations-workflow-boundary.mts @@ -16,6 +16,7 @@ const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", ".."); const DEFAULT_WORKFLOW_PATH = join(REPO_ROOT, ".github", "workflows", "e2e.yaml"); const DEFAULT_ADVISOR_PATH = join(REPO_ROOT, ".github", "workflows", "pr-review-advisor.yaml"); const META_JOBS = new Set([ + "package-openshell-sdk", "native-runtime-qualification-podman-toolchain", "native-runtime-qualification-producer-plan", "release-qualification", @@ -494,6 +495,10 @@ function validateManualPrDispatch(errors: string[], workflow: OperationsWorkflow jobName === "base-image-publication" && step.name === "Check out trusted E2E workflow" && step.with?.ref === "${{ github.workflow_sha }}"; + const trustedOpenShellSdkPackageCheckout = + jobName === "package-openshell-sdk" && + step.name === "Check out trusted OpenShell SDK package verifier" && + step.with?.ref === "${{ github.workflow_sha }}"; const trustedManagedImageRuntimeCheckout = jobName === "managed-image-protected-runtime" && step.name === "Checkout trusted protected runtime qualification" && @@ -569,6 +574,7 @@ function validateManualPrDispatch(errors: string[], workflow: OperationsWorkflow trustedRelevantE2eCheckout || trustedLaunchableLaneCheckout || trustedPublicationCheckout || + trustedOpenShellSdkPackageCheckout || trustedManagedImageMultiarchResolverCheckout || trustedManagedImageRuntimeCheckout || trustedLlamaCppPlanCheckout || diff --git a/tools/e2e/upload-e2e-artifacts-workflow-boundary.mts b/tools/e2e/upload-e2e-artifacts-workflow-boundary.mts index 6cefa1075d9..d661e36644c 100644 --- a/tools/e2e/upload-e2e-artifacts-workflow-boundary.mts +++ b/tools/e2e/upload-e2e-artifacts-workflow-boundary.mts @@ -37,6 +37,7 @@ const UPLOAD_E2E_ARTIFACTS_ACTION_PREFIX = "NVIDIA/NemoClaw/.github/actions/uplo const UPLOAD_ARTIFACT_ACTION = "actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a"; const UPLOAD_ARTIFACT_ACTION_PREFIX = "actions/upload-artifact@"; const MANAGED_IMAGE_BUILD_CACHE_PUBLISH_STEP = "Publish exact amd64 protected runtime build cache"; +const OPEN_SHELL_SDK_E2E_PACKAGE_UPLOAD_STEP = "Upload reviewed OpenShell SDK archive"; const MANAGED_IMAGE_BUILD_CACHE_ARTIFACT_NAME = "${{ env.NEMOCLAW_PROTECTED_MANAGED_IMAGE_BUILD_CACHE_ARTIFACT }}"; const MANAGED_IMAGE_BUILD_CACHE_ARTIFACT_PATH = @@ -108,7 +109,27 @@ function isExactNativeRuntimeAggregateUpload(jobName: string, step: WorkflowStep ); } +function isExactOpenShellSdkE2ePackageUpload(jobName: string, step: WorkflowStep): boolean { + const inputs = record(step.with); + return ( + jobName === "package-openshell-sdk" && + step.name === OPEN_SHELL_SDK_E2E_PACKAGE_UPLOAD_STEP && + step.uses === UPLOAD_ARTIFACT_ACTION && + inputs.name === "${{ steps.identity.outputs.artifact_name }}" && + inputs.path === "${{ steps.package.outputs.artifact_path }}" && + inputs["if-no-files-found"] === "error" && + inputs["retention-days"] === 1 + ); +} + const EXPLICIT_UPLOAD_CONTRACTS = new Map([ + [ + "external-gateway-health", + { + name: "e2e-external-gateway-health", + path: "e2e-artifacts/live/external-gateway-health/", + }, + ], [ "generate-matrix", { @@ -462,6 +483,7 @@ export function validateUploadE2eArtifactsInvocations(workflow: WorkflowRecord): uses.startsWith(UPLOAD_ARTIFACT_ACTION_PREFIX) && !isExactCommitCliArtifactUpload && !isExactManagedImageBuildCacheUpload(jobName, step) && + !isExactOpenShellSdkE2ePackageUpload(jobName, step) && !isExactNativeRuntimeAggregateUpload(jobName, step) ) { errors.push(`${jobName} must not invoke actions/upload-artifact directly`); diff --git a/tools/e2e/workflow-boundary.mts b/tools/e2e/workflow-boundary.mts index 8bca7f44ca5..97b6d77c6c8 100644 --- a/tools/e2e/workflow-boundary.mts +++ b/tools/e2e/workflow-boundary.mts @@ -55,6 +55,10 @@ import { validateUploadE2eArtifactsWorkflowBoundary, } from "./upload-e2e-artifacts-workflow-boundary.mts"; import { validateE2eWorkspaceBootstrapBoundary } from "./workspace-bootstrap-workflow-boundary.mts"; +import { + type ExternalGatewayHealthWorkflow, + validateExternalGatewayHealthWorkflow, +} from "./external-gateway-health-workflow-boundary.mts"; const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", ".."); const DEFAULT_E2E_WORKFLOW_PATH = join(REPO_ROOT, ".github", "workflows", "e2e.yaml"); @@ -201,6 +205,7 @@ const PUBLIC_NVIDIA_ENDPOINT_KEY_JOBS = new Set([ "model-router-provider-routed-inference", ]); const NO_IMAGE_E2E_JOBS = new Set([ + "external-gateway-health", "staging-brev-launchable", "staging-brev-launchable-identity", SHARED_E2E_JOB_ID, @@ -1176,7 +1181,9 @@ function validateFreeStandingJobSelector( ): void { const job = asRecord(jobs[jobName]); const expectedNeeds = - jobName === "mcp-bridge-dev" + jobName === "external-gateway-health" + ? ["generate-matrix", "package-openshell-sdk"] + : jobName === "mcp-bridge-dev" ? ["base-image-publication", "generate-matrix", "openshell-dev-artifact"] : [ "mcp-bridge", @@ -2602,6 +2609,9 @@ export function validateE2eWorkflow(workflowValue: unknown): string[] { ...validateOpenShellGatewayAuthContractWorkflow( workflow as unknown as OpenShellGatewayAuthContractWorkflow, ), + ...validateExternalGatewayHealthWorkflow( + workflow as unknown as ExternalGatewayHealthWorkflow, + ), ); errors.push(...validateE2eOperationsWorkflow(workflow as unknown as OperationsWorkflow)); errors.push(...validateStandardProfileWorkflowBoundary(workflow)); From 753c1695bbb46d18022f3b2115a302de62aca5cd Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Thu, 27 Aug 2026 20:21:39 -0700 Subject: [PATCH 27/27] test(e2e): map external health fast coverage Signed-off-by: Apurv Kumaria --- test/e2e/mock-parity.json | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/test/e2e/mock-parity.json b/test/e2e/mock-parity.json index 1f82b65d2ba..b663c52d5e2 100644 --- a/test/e2e/mock-parity.json +++ b/test/e2e/mock-parity.json @@ -208,6 +208,13 @@ "live": "test/e2e/live/openshell-gateway-auth-source-contract.test.ts", "fast": ["test/e2e/support/openshell-gateway-auth-source-contract-helpers.test.ts"] }, + { + "live": "test/e2e/live/external-gateway-health.test.ts", + "fast": [ + "test/e2e/support/external-gateway-health-workflow-boundary.test.ts", + "test/install/reviewed-npm-archive.test.ts" + ] + }, { "live": "test/e2e/live/openshell-credential-generation-window.test.ts", "fast": [