diff --git a/.github/workflows/e2e-standard-profile.yaml b/.github/workflows/e2e-standard-profile.yaml index eb520798319..071bdf59b90 100644 --- a/.github/workflows/e2e-standard-profile.yaml +++ b/.github/workflows/e2e-standard-profile.yaml @@ -434,7 +434,9 @@ jobs: 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 -u GH_TOKEN \ - npm install --no-save --package-lock=false --ignore-scripts "${archives[0]}" + npm cache add "${archives[0]}" --offline --ignore-scripts + env -u NODE_AUTH_TOKEN -u GITHUB_TOKEN -u GH_TOKEN \ + npm ci --ignore-scripts --prefer-offline --no-audit --no-fund env -u NODE_AUTH_TOKEN -u GITHUB_TOKEN -u GH_TOKEN \ node --input-type=module -e 'const { OpenShellClient } = await import("@nvidia/openshell-sdk"); if (typeof OpenShellClient?.connect !== "function") throw new Error("OpenShell SDK connection API is unavailable");' diff --git a/.github/workflows/e2e.yaml b/.github/workflows/e2e.yaml index 42a3321149b..676beb6a497 100644 --- a/.github/workflows/e2e.yaml +++ b/.github/workflows/e2e.yaml @@ -3417,8 +3417,12 @@ jobs: 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]}" + env -u NODE_AUTH_TOKEN -u GITHUB_TOKEN -u GH_TOKEN \ + npm cache add "${archives[0]}" --offline --ignore-scripts + env -u NODE_AUTH_TOKEN -u GITHUB_TOKEN -u GH_TOKEN \ + npm ci --ignore-scripts --prefer-offline --no-audit --no-fund + env -u NODE_AUTH_TOKEN -u GITHUB_TOKEN -u GH_TOKEN \ + node --input-type=module -e 'const { OpenShellClient } = await import("@nvidia/openshell-sdk"); if (typeof OpenShellClient?.connect !== "function") throw new Error("OpenShell SDK connection API is unavailable");' - name: Install OpenShell CLI run: env -u NODE_AUTH_TOKEN -u GITHUB_TOKEN bash scripts/install-openshell.sh diff --git a/test/e2e/README.md b/test/e2e/README.md index 6c638c00e67..8e3c008d217 100644 --- a/test/e2e/README.md +++ b/test/e2e/README.md @@ -362,7 +362,9 @@ and transport. It also stops the gateway and removes its temporary state. Every catalogue profile installs the reviewed OpenShell SDK archive before restoring the candidate CLI. The shared package job downloads and verifies the pinned SDK with package-read permission. Catalogue jobs receive the run-scoped archive without package credentials and reject a missing or ambiguous archive. -The install disables package scripts and verifies that the SDK connection API loads before running tests. +Catalogue and external-gateway health jobs add the archive to npm's cache, then reinstall dependencies from the lockfile with package scripts disabled. +This preserves the locked dependency versions and avoids npm resolving a new peer dependency graph during SDK installation. +Both jobs verify that the SDK connection API loads before running tests. This keeps the private optional dependency available for SDK-backed commands such as configuration export. The `network-policy` target also owns live configuration-export evidence for #10938 and PR #11065. diff --git a/test/e2e/support/openshell-sdk-install.test.ts b/test/e2e/support/openshell-sdk-install.test.ts index 24768fe0044..dc6d6b314ba 100644 --- a/test/e2e/support/openshell-sdk-install.test.ts +++ b/test/e2e/support/openshell-sdk-install.test.ts @@ -1,7 +1,8 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { spawnSync } from "node:child_process"; +import { execFileSync, spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; @@ -17,12 +18,78 @@ const installScript = profile.jobs.run.steps.find( (step) => step.name === "Install reviewed OpenShell SDK archive without package credentials", )!.run!; +const externalGateway = YAML.parse(fs.readFileSync(".github/workflows/e2e.yaml", "utf8")) as { + jobs: Record }>; +}; +const externalGatewayInstallScript = externalGateway.jobs["external-gateway-health"]!.steps.find( + (step) => step.name === "Install reviewed OpenShell SDK archive without package credentials", +)!.run!; + +function writePackageArchive( + root: string, + name: string, + dependencies: Record = {}, +) { + const source = path.join(root, name.replaceAll("/", "-")); + fs.mkdirSync(source); + fs.writeFileSync( + path.join(source, "package.json"), + JSON.stringify({ + name, + version: "1.0.0", + type: "module", + exports: "./index.js", + dependencies, + scripts: { + preinstall: "node -e \"require('node:fs').writeFileSync('lifecycle-ran', 'yes')\"", + }, + }), + ); + fs.writeFileSync( + path.join(source, "index.js"), + name === "@nvidia/openshell-sdk" + ? 'import { version } from "fixture-transport"; if (process.env.NODE_AUTH_TOKEN || process.env.GITHUB_TOKEN || process.env.GH_TOKEN) throw new Error("Unexpected credential"); export class OpenShellClient { static connect() { return version; } }' + : 'export const version = "1.0.0";', + ); + const packed = JSON.parse( + execFileSync( + "npm", + ["pack", source, "--pack-destination", root, "--json", "--offline", "--ignore-scripts"], + { + cwd: root, + encoding: "utf8", + env: { + PATH: process.env.PATH, + HOME: root, + NPM_CONFIG_CACHE: path.join(root, "pack-cache"), + }, + timeout: 10_000, + }, + ), + ) as Array<{ filename: string }>; + const archive = path.join(root, packed[0]!.filename); + return { + archive, + lock: { + version: "1.0.0", + hasInstallScript: true, + resolved: `https://registry.example.invalid/${path.basename(archive)}`, + integrity: `sha512-${createHash("sha512").update(fs.readFileSync(archive)).digest("base64")}`, + dependencies, + }, + }; +} + const npmFixture = `#!/usr/bin/env node const fs = require("node:fs"); -fs.writeFileSync(process.env.INSTALL_LOG, JSON.stringify({ +const calls = JSON.parse(fs.readFileSync(process.env.INSTALL_LOG, "utf8")); +calls.push({ args: process.argv.slice(2), auth: [process.env.NODE_AUTH_TOKEN, process.env.GITHUB_TOKEN, process.env.GH_TOKEN], -})); +}); +fs.writeFileSync(process.env.INSTALL_LOG, JSON.stringify(calls)); +if (process.argv[2] === process.env.NPM_FAILURE) process.exit(17); +if (process.argv[2] === "cache") process.exit(0); const directory = "node_modules/@nvidia/openshell-sdk"; fs.mkdirSync(directory, { recursive: true }); fs.writeFileSync(directory + "/package.json", JSON.stringify({ type: "module", exports: "./index.js" })); @@ -30,78 +97,197 @@ fs.writeFileSync(directory + "/index.js", process.env.SDK_SOURCE); `; describe("catalogue OpenShell SDK installation", () => { + it.each([ + { name: "catalogue", script: installScript }, + { name: "external gateway", script: externalGatewayInstallScript }, + ])("installs the SDK and locked dependencies offline for $name", ({ script }) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-sdk-real-npm-")); + try { + const sdk = writePackageArchive(root, "@nvidia/openshell-sdk", { + "fixture-transport": "^1.0.0", + }); + const transport = writePackageArchive(root, "fixture-transport"); + const sibling = writePackageArchive(root, "fixture-sibling"); + const workspace = path.join(root, "workspace"); + fs.mkdirSync(workspace); + const manifest = JSON.stringify({ + name: "sdk-install-fixture", + version: "1.0.0", + dependencies: { "fixture-sibling": "^1.0.0" }, + optionalDependencies: { "@nvidia/openshell-sdk": "1.0.0" }, + scripts: { + preinstall: "node -e \"require('node:fs').writeFileSync('lifecycle-ran', 'yes')\"", + }, + }); + const lock = JSON.stringify({ + name: "sdk-install-fixture", + version: "1.0.0", + lockfileVersion: 3, + requires: true, + packages: { + "": JSON.parse(manifest), + "node_modules/@nvidia/openshell-sdk": { ...sdk.lock, optional: true }, + "node_modules/fixture-transport": { ...transport.lock, optional: true }, + "node_modules/fixture-sibling": sibling.lock, + }, + }); + fs.writeFileSync(path.join(workspace, "package.json"), manifest); + fs.writeFileSync(path.join(workspace, "package-lock.json"), lock); + const env = { + PATH: process.env.PATH, + HOME: root, + NPM_CONFIG_CACHE: path.join(root, "cache"), + NPM_CONFIG_OFFLINE: "true", + NPM_CONFIG_AUDIT: "false", + NPM_CONFIG_FUND: "false", + NPM_CONFIG_UPDATE_NOTIFIER: "false", + RUNNER_TEMP: root, + }; + const runNpm = (args: string[]) => + execFileSync("npm", args, { + cwd: workspace, + encoding: "utf8", + env, + timeout: 10_000, + }); + runNpm(["cache", "add", transport.archive, sibling.archive, "--offline", "--ignore-scripts"]); + runNpm(["ci", "--ignore-scripts"]); + expect(fs.existsSync(path.join(workspace, "node_modules/@nvidia/openshell-sdk"))).toBe(false); + fs.mkdirSync(path.join(root, "openshell-sdk")); + fs.copyFileSync(sdk.archive, path.join(root, "openshell-sdk", "sdk.tgz")); + + const result = spawnSync("bash", ["-c", script], { + cwd: workspace, + encoding: "utf8", + env, + timeout: 20_000, + }); + + expect(result.error).toBeUndefined(); + expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(0); + const observed = execFileSync( + process.execPath, + [ + "--input-type=module", + "-e", + 'import { OpenShellClient } from "@nvidia/openshell-sdk"; import { version } from "fixture-sibling"; console.log(JSON.stringify([OpenShellClient.connect(), version]));', + ], + { cwd: workspace, encoding: "utf8", env }, + ); + expect(JSON.parse(observed)).toEqual(["1.0.0", "1.0.0"]); + expect(fs.existsSync(path.join(workspace, "lifecycle-ran"))).toBe(false); + expect( + fs.existsSync(path.join(workspace, "node_modules/@nvidia/openshell-sdk/lifecycle-ran")), + ).toBe(false); + expect( + fs.existsSync(path.join(workspace, "node_modules/fixture-transport/lifecycle-ran")), + ).toBe(false); + expect( + fs.existsSync(path.join(workspace, "node_modules/fixture-sibling/lifecycle-ran")), + ).toBe(false); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + it.each([ { name: "one reviewed archive", archives: ["sdk.tgz"], sdk: 'if (process.env.NODE_AUTH_TOKEN || process.env.GITHUB_TOKEN || process.env.GH_TOKEN) throw new Error("Unexpected credential"); export class OpenShellClient { static connect() {} }', status: 0, - install: true, + calls: 2, + failure: "", }, - { name: "no archive", archives: [], sdk: "", status: 1, install: false }, + { name: "no archive", archives: [], sdk: "", status: 1, calls: 0, failure: "" }, { name: "ambiguous archives", archives: ["first.tgz", "second.tgz"], sdk: "", status: 1, - install: false, + calls: 0, + failure: "", }, { name: "an SDK without the connection API", archives: ["sdk.tgz"], sdk: "export const OpenShellClient = {};", status: 1, - install: true, + calls: 2, + failure: "", }, - ])("checks $name before running the catalogue target", ({ archives, sdk, status, install }) => { - const directory = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-sdk-install-")); - const archiveDirectory = path.join(directory, "openshell-sdk"); - const bin = path.join(directory, "bin"); - const log = path.join(directory, "install.json"); - try { - fs.mkdirSync(archiveDirectory); - fs.mkdirSync(bin); - fs.writeFileSync(log, "null"); - archives.forEach((archive) => - fs.writeFileSync(path.join(archiveDirectory, archive), "fixture"), - ); - fs.writeFileSync(path.join(bin, "npm"), npmFixture, { mode: 0o755 }); - fs.symlinkSync(process.execPath, path.join(bin, "node")); + { + name: "a cache staging failure", + archives: ["sdk.tgz"], + sdk: "", + status: 17, + calls: 1, + failure: "cache", + }, + { + name: "a dependency install failure", + archives: ["sdk.tgz"], + sdk: "", + status: 17, + calls: 2, + failure: "ci", + }, + ])( + "checks $name before running the catalogue target", + ({ archives, sdk, status, calls, failure }) => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-sdk-install-")); + const archiveDirectory = path.join(directory, "openshell-sdk"); + const bin = path.join(directory, "bin"); + const log = path.join(directory, "install.json"); + try { + fs.mkdirSync(archiveDirectory); + fs.mkdirSync(bin); + fs.writeFileSync(log, "[]"); + archives.forEach((archive) => + fs.writeFileSync(path.join(archiveDirectory, archive), "fixture"), + ); + fs.writeFileSync(path.join(bin, "npm"), npmFixture, { mode: 0o755 }); + fs.symlinkSync(process.execPath, path.join(bin, "node")); - const result = spawnSync("bash", ["-c", installScript], { - cwd: directory, - encoding: "utf8", - timeout: 10_000, - env: { - PATH: `${bin}${path.delimiter}${process.env.PATH ?? ""}`, - RUNNER_TEMP: directory, - INSTALL_LOG: log, - SDK_SOURCE: sdk, - NODE_AUTH_TOKEN: "package-credential-canary", - GITHUB_TOKEN: "github-credential-canary", - GH_TOKEN: "gh-credential-canary", - }, - }); + const result = spawnSync("bash", ["-c", installScript], { + cwd: directory, + encoding: "utf8", + timeout: 10_000, + env: { + PATH: `${bin}${path.delimiter}${process.env.PATH ?? ""}`, + RUNNER_TEMP: directory, + INSTALL_LOG: log, + SDK_SOURCE: sdk, + NPM_FAILURE: failure, + NODE_AUTH_TOKEN: "package-credential-canary", + GITHUB_TOKEN: "github-credential-canary", + GH_TOKEN: "gh-credential-canary", + }, + }); - expect(result.error).toBeUndefined(); - expect(result.signal).toBeNull(); - expect(result.status, result.stderr).toBe(status); - expect(JSON.parse(fs.readFileSync(log, "utf8"))).toEqual( - install - ? { - args: [ - "install", - "--no-save", - "--package-lock=false", - "--ignore-scripts", - path.join(archiveDirectory, archives[0]!), - ], - auth: [null, null, null], - } - : null, - ); - } finally { - fs.rmSync(directory, { recursive: true, force: true }); - } - }); + expect(result.error).toBeUndefined(); + expect(result.signal).toBeNull(); + expect(result.status, result.stderr).toBe(status); + const expectedCalls = [ + { + args: [ + "cache", + "add", + path.join(archiveDirectory, archives[0] ?? ""), + "--offline", + "--ignore-scripts", + ], + auth: [null, null, null], + }, + { + args: ["ci", "--ignore-scripts", "--prefer-offline", "--no-audit", "--no-fund"], + auth: [null, null, null], + }, + ].slice(0, calls); + expect(JSON.parse(fs.readFileSync(log, "utf8"))).toEqual(expectedCalls); + } finally { + fs.rmSync(directory, { recursive: true, force: true }); + } + }, + ); }); diff --git a/test/helpers/vitest-watch-triggers.ts b/test/helpers/vitest-watch-triggers.ts index 06c3465566f..949b03fbfb9 100644 --- a/test/helpers/vitest-watch-triggers.ts +++ b/test/helpers/vitest-watch-triggers.ts @@ -321,11 +321,17 @@ export const vitestWatchTriggerPatterns: VitestWatchTriggerPattern[] = [ }, { pattern: /(?:^|\/)\.github\/workflows\/e2e\.yaml$/, - testsToRun: runTests(...E2E_WORKFLOW_CONTRACTS), + testsToRun: runTests( + ...E2E_WORKFLOW_CONTRACTS, + "test/e2e/support/openshell-sdk-install.test.ts", + ), }, { pattern: /(?:^|\/)\.github\/workflows\/e2e-standard-profile\.yaml$/, - testsToRun: runTests("test/e2e/support/standard-profile-workflow-boundary.test.ts"), + testsToRun: runTests( + "test/e2e/support/standard-profile-workflow-boundary.test.ts", + "test/e2e/support/openshell-sdk-install.test.ts", + ), }, { pattern: /(?:^|\/)\.github\/workflows\/portable-profile-e2e\.yaml$/, diff --git a/test/repository/vitest-watch-triggers.test.ts b/test/repository/vitest-watch-triggers.test.ts index 280a6ced6d0..06b4397cc1e 100644 --- a/test/repository/vitest-watch-triggers.test.ts +++ b/test/repository/vitest-watch-triggers.test.ts @@ -315,9 +315,13 @@ describe("Vitest opaque-input watch triggers", () => { "test/e2e/support/e2e-manifests.test.ts", ]); expect(triggeredBy("test/e2e/manifests/openclaw-nvidia.yml")).toEqual([]); - expect(triggeredBy(".github/workflows/e2e.yaml")).toEqual(E2E_WORKFLOW_CONTRACTS); + expect(triggeredBy(".github/workflows/e2e.yaml")).toEqual([ + ...E2E_WORKFLOW_CONTRACTS, + "test/e2e/support/openshell-sdk-install.test.ts", + ]); expect(triggeredBy(".github/workflows/e2e-standard-profile.yaml")).toEqual([ "test/e2e/support/standard-profile-workflow-boundary.test.ts", + "test/e2e/support/openshell-sdk-install.test.ts", ]); expect(triggeredBy(".github/workflows/portable-profile-e2e.yaml")).toEqual([ "test/e2e/support/portable-profile-rootless-runtime-workflow.test.ts", diff --git a/tools/e2e/standard-profile-workflow-boundary.mts b/tools/e2e/standard-profile-workflow-boundary.mts index 97e2d491b6a..7ddc0f3d567 100644 --- a/tools/e2e/standard-profile-workflow-boundary.mts +++ b/tools/e2e/standard-profile-workflow-boundary.mts @@ -90,7 +90,9 @@ const SDK_INSTALL_SCRIPT = [ "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 -u GH_TOKEN \\", - ' npm install --no-save --package-lock=false --ignore-scripts "${archives[0]}"', + ' npm cache add "${archives[0]}" --offline --ignore-scripts', + "env -u NODE_AUTH_TOKEN -u GITHUB_TOKEN -u GH_TOKEN \\", + " npm ci --ignore-scripts --prefer-offline --no-audit --no-fund", "env -u NODE_AUTH_TOKEN -u GITHUB_TOKEN -u GH_TOKEN \\", ' node --input-type=module -e \'const { OpenShellClient } = await import("@nvidia/openshell-sdk"); if (typeof OpenShellClient?.connect !== "function") throw new Error("OpenShell SDK connection API is unavailable");\'', "",