From 32ad96a3b52acc4d70d61307b37523a53d4e6816 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Mon, 8 Jun 2026 17:39:02 -0700 Subject: [PATCH 1/3] test(e2e): add environment phase fixture --- .../framework-tests/e2e-clients.test.ts | 2 +- .../e2e-phase-environment.test.ts | 181 ++++++++++++++++++ test/e2e-scenario/framework/clients/host.ts | 6 +- test/e2e-scenario/framework/e2e-test.ts | 5 + .../framework/phases/environment.ts | 107 +++++++++++ test/e2e-scenario/framework/phases/index.ts | 9 + 6 files changed, 308 insertions(+), 2 deletions(-) create mode 100644 test/e2e-scenario/framework-tests/e2e-phase-environment.test.ts create mode 100644 test/e2e-scenario/framework/phases/environment.ts create mode 100644 test/e2e-scenario/framework/phases/index.ts diff --git a/test/e2e-scenario/framework-tests/e2e-clients.test.ts b/test/e2e-scenario/framework-tests/e2e-clients.test.ts index 25c893d5f1..4d5c3a72db 100644 --- a/test/e2e-scenario/framework-tests/e2e-clients.test.ts +++ b/test/e2e-scenario/framework-tests/e2e-clients.test.ts @@ -61,7 +61,7 @@ describe("E2E fixture clients", () => { { command: "./bin/nemoclaw.js", args: ["--version"], - options: { artifactName: "nemoclaw-version" }, + options: { artifactName: "nemoclaw-version", inheritEnv: true }, }, ]); }); diff --git a/test/e2e-scenario/framework-tests/e2e-phase-environment.test.ts b/test/e2e-scenario/framework-tests/e2e-phase-environment.test.ts new file mode 100644 index 0000000000..cf41f3983e --- /dev/null +++ b/test/e2e-scenario/framework-tests/e2e-phase-environment.test.ts @@ -0,0 +1,181 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, expectTypeOf, it } from "vitest"; + +import { HostCliClient, type CommandRunner } from "../framework/clients/index.ts"; +import type { E2EScenarioFixtures } from "../framework/e2e-test.ts"; +import { EnvironmentPhaseFixture, type DockerRuntimeReady } from "../framework/phases/index.ts"; +import type { ShellProbeResult, ShellProbeRunOptions, TrustedShellCommand } from "../framework/shell-probe.ts"; +import type { ScenarioEnvironment } from "../scenarios/types.ts"; + +interface RunnerCall { + command: string; + args: string[]; + options?: ShellProbeRunOptions; +} + +function shellResult(exitCode: number, output = ""): ShellProbeResult { + return { + command: [], + exitCode, + signal: null, + timedOut: false, + stdout: output, + stderr: exitCode === 0 ? "" : output, + artifacts: { + stdout: "/tmp/stdout.txt", + stderr: "/tmp/stderr.txt", + result: "/tmp/result.json", + }, + }; +} + +class FakeRunner implements CommandRunner { + readonly calls: RunnerCall[] = []; + private readonly responses: Array = []; + + enqueue(response: ShellProbeResult | Error): void { + this.responses.push(response); + } + + async run(command: TrustedShellCommand, options?: ShellProbeRunOptions): Promise { + this.calls.push({ command: command.command, args: [...command.args], options }); + const response = this.responses.shift() ?? shellResult(0); + if (response instanceof Error) { + throw response; + } + return response; + } +} + +const cloudOpenClawEnvironment: ScenarioEnvironment = { + platform: "ubuntu-local", + install: "repo-current", + runtime: "docker-running", + onboarding: "cloud-openclaw", +}; + +describe("environment phase fixture", () => { + it("asserts the current repo CLI and required Docker runtime", async () => { + const runner = new FakeRunner(); + runner.enqueue(shellResult(0, "nemoclaw v0.0.0\n")); + runner.enqueue(shellResult(0, "Docker is available\n")); + const environment = new EnvironmentPhaseFixture(new HostCliClient(runner, { cliPath: "./bin/nemoclaw.js" })); + + const ready = await environment.assertReady(cloudOpenClawEnvironment); + + expect(ready).toMatchObject({ + platform: "ubuntu-local", + install: "repo-current", + runtime: "docker-running", + onboarding: "cloud-openclaw", + cliPath: "./bin/nemoclaw.js", + docker: { + id: "docker-running", + expectation: "required", + available: true, + } satisfies Partial, + }); + expect(runner.calls).toEqual([ + { + command: "./bin/nemoclaw.js", + args: ["--version"], + options: { artifactName: "nemoclaw-version", inheritEnv: true }, + }, + { + command: "docker", + args: ["info"], + options: { + artifactName: "runtime-docker-info-docker-running", + inheritEnv: true, + timeoutMs: 30_000, + }, + }, + ]); + }); + + it("fails when a required Docker runtime is unavailable", async () => { + const runner = new FakeRunner(); + runner.enqueue(shellResult(0, "nemoclaw v0.0.0\n")); + runner.enqueue(shellResult(1, "Cannot connect to the Docker daemon")); + const environment = new EnvironmentPhaseFixture(new HostCliClient(runner)); + + await expect(environment.assertReady(cloudOpenClawEnvironment)).rejects.toThrow( + /docker runtime docker-running failed: Cannot connect/, + ); + }); + + it("accepts an unavailable Docker runtime for no-Docker negative scenarios", async () => { + const runner = new FakeRunner(); + runner.enqueue(shellResult(0, "nemoclaw v0.0.0\n")); + runner.enqueue(shellResult(1, "docker intentionally unavailable")); + const environment = new EnvironmentPhaseFixture(new HostCliClient(runner)); + + const ready = await environment.assertReady({ + ...cloudOpenClawEnvironment, + runtime: "docker-missing", + onboarding: "cloud-openclaw-no-docker", + }); + + expect(ready.docker).toMatchObject({ + id: "docker-missing", + expectation: "missing", + available: false, + }); + }); + + it("fails if a no-Docker negative scenario unexpectedly has Docker", async () => { + const runner = new FakeRunner(); + runner.enqueue(shellResult(0, "nemoclaw v0.0.0\n")); + runner.enqueue(shellResult(0, "Docker is available\n")); + const environment = new EnvironmentPhaseFixture(new HostCliClient(runner)); + + await expect( + environment.assertReady({ + ...cloudOpenClawEnvironment, + runtime: "docker-missing", + onboarding: "cloud-openclaw-no-docker", + }), + ).rejects.toThrow(/expected Docker to be unavailable/); + }); + + it("records optional Docker as unavailable without failing", async () => { + const runner = new FakeRunner(); + runner.enqueue(shellResult(0, "nemoclaw v0.0.0\n")); + runner.enqueue(new Error("spawn docker ENOENT")); + const environment = new EnvironmentPhaseFixture(new HostCliClient(runner)); + + const ready = await environment.assertReady({ + ...cloudOpenClawEnvironment, + platform: "macos-local", + runtime: "macos-docker-optional", + }); + + expect(ready.docker).toMatchObject({ + id: "macos-docker-optional", + expectation: "optional", + available: false, + probeError: "spawn docker ENOENT", + }); + }); + + it("rejects unsupported install and runtime IDs", async () => { + const runner = new FakeRunner(); + const environment = new EnvironmentPhaseFixture(new HostCliClient(runner)); + + await expect(environment.assertReady({ ...cloudOpenClawEnvironment, install: "tarball" })).rejects.toThrow( + /Unsupported scenario install 'tarball'/, + ); + expect(runner.calls).toEqual([]); + + runner.enqueue(shellResult(0, "nemoclaw v0.0.0\n")); + await expect(environment.assertReady({ ...cloudOpenClawEnvironment, runtime: "podman-running" })).rejects.toThrow( + /Unsupported scenario runtime 'podman-running'/, + ); + }); + + it("exposes the environment phase on the Vitest scenario context", () => { + expectTypeOf().toEqualTypeOf(); + }); +}); diff --git a/test/e2e-scenario/framework/clients/host.ts b/test/e2e-scenario/framework/clients/host.ts index 9e0ee80ebd..8c5ba1dd9a 100644 --- a/test/e2e-scenario/framework/clients/host.ts +++ b/test/e2e-scenario/framework/clients/host.ts @@ -21,6 +21,10 @@ export class HostCliClient { this.cwd = options.cwd; } + get commandPath(): string { + return this.cliPath; + } + command(command: string, args: string[] = [], options: ShellProbeRunOptions = {}): Promise { const merged: ShellProbeRunOptions = { ...options }; if (this.cwd && !merged.cwd) { @@ -44,7 +48,7 @@ export class HostCliClient { } async expectNemoclawAvailable(): Promise { - const result = await this.nemoclaw(["--version"], { artifactName: "nemoclaw-version" }); + const result = await this.nemoclaw(["--version"], { artifactName: "nemoclaw-version", inheritEnv: true }); assertExitZero(result, "nemoclaw --version"); return result; } diff --git a/test/e2e-scenario/framework/e2e-test.ts b/test/e2e-scenario/framework/e2e-test.ts index e44f50c7f2..6d122b16b1 100644 --- a/test/e2e-scenario/framework/e2e-test.ts +++ b/test/e2e-scenario/framework/e2e-test.ts @@ -12,6 +12,7 @@ import { StateClient, } from "./clients/index.ts"; import { assertCleanupPassed, CleanupRegistry } from "./cleanup.ts"; +import { EnvironmentPhaseFixture } from "./phases/index.ts"; import { SecretStore } from "./secrets.ts"; import { ShellProbe } from "./shell-probe.ts"; @@ -25,6 +26,7 @@ export interface E2EScenarioFixtures { sandbox: SandboxClient; provider: ProviderClient; state: StateClient; + environment: EnvironmentPhaseFixture; } export const test = base.extend({ @@ -77,6 +79,9 @@ export const test = base.extend({ state: async ({}, use) => { await use(new StateClient()); }, + environment: async ({ host }, use) => { + await use(new EnvironmentPhaseFixture(host)); + }, }); export { expect }; diff --git a/test/e2e-scenario/framework/phases/environment.ts b/test/e2e-scenario/framework/phases/environment.ts new file mode 100644 index 0000000000..54cad8d6d6 --- /dev/null +++ b/test/e2e-scenario/framework/phases/environment.ts @@ -0,0 +1,107 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { artifactLabel, assertExitZero } from "../clients/command.ts"; +import type { HostCliClient } from "../clients/host.ts"; +import type { ShellProbeResult } from "../shell-probe.ts"; +import type { ScenarioEnvironment } from "../../scenarios/types.ts"; + +const SUPPORTED_INSTALLS = new Set(["repo-current", "launchable"]); + +const DOCKER_RUNTIME_EXPECTATIONS = { + "docker-running": "required", + "gpu-docker-cdi": "required", + "docker-missing": "missing", + "macos-docker-optional": "optional", +} as const; + +export type DockerRuntimeExpectation = + (typeof DOCKER_RUNTIME_EXPECTATIONS)[keyof typeof DOCKER_RUNTIME_EXPECTATIONS]; + +export interface DockerRuntimeReady { + id: string; + expectation: DockerRuntimeExpectation; + available: boolean; + result?: ShellProbeResult; + probeError?: string; +} + +export interface EnvironmentReady extends ScenarioEnvironment { + cliPath: string; + docker: DockerRuntimeReady; +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function supportedRuntime(runtime: string): DockerRuntimeExpectation { + const expectation = DOCKER_RUNTIME_EXPECTATIONS[runtime as keyof typeof DOCKER_RUNTIME_EXPECTATIONS]; + if (!expectation) { + throw new Error(`Unsupported scenario runtime '${runtime}'.`); + } + return expectation; +} + +export class EnvironmentPhaseFixture { + constructor(private readonly host: HostCliClient) {} + + async assertReady(environment: ScenarioEnvironment): Promise { + await this.assertInstallReady(environment.install); + const docker = await this.assertRuntimeReady(environment.runtime); + return { + ...environment, + cliPath: this.host.commandPath, + docker, + }; + } + + private async assertInstallReady(install: string): Promise { + if (!SUPPORTED_INSTALLS.has(install)) { + throw new Error(`Unsupported scenario install '${install}'.`); + } + return this.host.expectNemoclawAvailable(); + } + + private async assertRuntimeReady(runtime: string): Promise { + const expectation = supportedRuntime(runtime); + const result = await this.probeDocker(runtime, expectation); + if (!result.result) { + return result; + } + + if (expectation === "required") { + assertExitZero(result.result, `docker runtime ${runtime}`); + } + if (expectation === "missing" && result.available) { + throw new Error(`docker runtime ${runtime} expected Docker to be unavailable, but 'docker info' succeeded.`); + } + return result; + } + + private async probeDocker(runtime: string, expectation: DockerRuntimeExpectation): Promise { + try { + const result = await this.host.command("docker", ["info"], { + artifactName: `runtime-docker-info-${artifactLabel(runtime)}`, + inheritEnv: true, + timeoutMs: 30_000, + }); + return { + id: runtime, + expectation, + available: result.exitCode === 0, + result, + }; + } catch (error) { + if (expectation === "required") { + throw error; + } + return { + id: runtime, + expectation, + available: false, + probeError: errorMessage(error), + }; + } + } +} diff --git a/test/e2e-scenario/framework/phases/index.ts b/test/e2e-scenario/framework/phases/index.ts new file mode 100644 index 0000000000..b1eba5be54 --- /dev/null +++ b/test/e2e-scenario/framework/phases/index.ts @@ -0,0 +1,9 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +export { + EnvironmentPhaseFixture, + type DockerRuntimeExpectation, + type DockerRuntimeReady, + type EnvironmentReady, +} from "./environment.ts"; From 99399d7995c36508ae4c9354d84bab30060c53bf Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Mon, 8 Jun 2026 19:56:33 -0700 Subject: [PATCH 2/3] test(e2e): scope environment availability probes --- .../framework-tests/e2e-clients.test.ts | 7 +- .../e2e-phase-environment.test.ts | 109 +++++++++++++++++- .../framework/availability-env.ts | 36 ++++++ test/e2e-scenario/framework/clients/host.ts | 6 +- .../framework/phases/environment.ts | 3 +- 5 files changed, 156 insertions(+), 5 deletions(-) create mode 100644 test/e2e-scenario/framework/availability-env.ts diff --git a/test/e2e-scenario/framework-tests/e2e-clients.test.ts b/test/e2e-scenario/framework-tests/e2e-clients.test.ts index 4d5c3a72db..b652c6e102 100644 --- a/test/e2e-scenario/framework-tests/e2e-clients.test.ts +++ b/test/e2e-scenario/framework-tests/e2e-clients.test.ts @@ -61,7 +61,12 @@ describe("E2E fixture clients", () => { { command: "./bin/nemoclaw.js", args: ["--version"], - options: { artifactName: "nemoclaw-version", inheritEnv: true }, + options: { + artifactName: "nemoclaw-version", + env: expect.objectContaining({ + PATH: expect.any(String), + }), + }, }, ]); }); diff --git a/test/e2e-scenario/framework-tests/e2e-phase-environment.test.ts b/test/e2e-scenario/framework-tests/e2e-phase-environment.test.ts index cf41f3983e..3a7167650b 100644 --- a/test/e2e-scenario/framework-tests/e2e-phase-environment.test.ts +++ b/test/e2e-scenario/framework-tests/e2e-phase-environment.test.ts @@ -81,14 +81,21 @@ describe("environment phase fixture", () => { { command: "./bin/nemoclaw.js", args: ["--version"], - options: { artifactName: "nemoclaw-version", inheritEnv: true }, + options: { + artifactName: "nemoclaw-version", + env: expect.objectContaining({ + PATH: expect.any(String), + }), + }, }, { command: "docker", args: ["info"], options: { artifactName: "runtime-docker-info-docker-running", - inheritEnv: true, + env: expect.objectContaining({ + PATH: expect.any(String), + }), timeoutMs: 30_000, }, }, @@ -160,6 +167,104 @@ describe("environment phase fixture", () => { }); }); + it("records optional Docker as available when present", async () => { + const runner = new FakeRunner(); + runner.enqueue(shellResult(0, "nemoclaw v0.0.0\n")); + runner.enqueue(shellResult(0, "Docker is available\n")); + const environment = new EnvironmentPhaseFixture(new HostCliClient(runner)); + + const ready = await environment.assertReady({ + ...cloudOpenClawEnvironment, + platform: "macos-local", + runtime: "macos-docker-optional", + }); + + expect(ready.docker).toMatchObject({ + id: "macos-docker-optional", + expectation: "optional", + available: true, + }); + }); + + it("scopes availability probe env instead of inheriting unrelated secrets", async () => { + const previousSecret = process.env.NVIDIA_API_KEY; + const previousDockerHost = process.env.DOCKER_HOST; + process.env.NVIDIA_API_KEY = "must-not-leak"; + process.env.DOCKER_HOST = "unix:///tmp/e2e-docker.sock"; + try { + const runner = new FakeRunner(); + runner.enqueue(shellResult(0, "nemoclaw v0.0.0\n")); + runner.enqueue(shellResult(0, "Docker is available\n")); + const environment = new EnvironmentPhaseFixture(new HostCliClient(runner)); + + await environment.assertReady(cloudOpenClawEnvironment); + + const cliEnv = runner.calls[0]?.options?.env; + const dockerEnv = runner.calls[1]?.options?.env; + expect(cliEnv).toMatchObject({ DOCKER_HOST: "unix:///tmp/e2e-docker.sock" }); + expect(dockerEnv).toMatchObject({ DOCKER_HOST: "unix:///tmp/e2e-docker.sock" }); + expect(cliEnv).not.toHaveProperty("NVIDIA_API_KEY"); + expect(dockerEnv).not.toHaveProperty("NVIDIA_API_KEY"); + expect(runner.calls[0]?.options?.inheritEnv).toBeUndefined(); + expect(runner.calls[1]?.options?.inheritEnv).toBeUndefined(); + } finally { + if (previousSecret === undefined) { + delete process.env.NVIDIA_API_KEY; + } else { + process.env.NVIDIA_API_KEY = previousSecret; + } + if (previousDockerHost === undefined) { + delete process.env.DOCKER_HOST; + } else { + process.env.DOCKER_HOST = previousDockerHost; + } + } + }); + + it("treats launchable install as current first-layer CLI readiness", async () => { + const runner = new FakeRunner(); + runner.enqueue(shellResult(0, "nemoclaw v0.0.0\n")); + runner.enqueue(shellResult(0, "Docker is available\n")); + const environment = new EnvironmentPhaseFixture(new HostCliClient(runner)); + + const ready = await environment.assertReady({ + ...cloudOpenClawEnvironment, + install: "launchable", + }); + + expect(ready.install).toBe("launchable"); + expect(runner.calls.map((call) => [call.command, call.args])).toEqual([ + ["nemoclaw", ["--version"]], + ["docker", ["info"]], + ]); + }); + + it("treats gpu-docker-cdi as current first-layer Docker daemon readiness", async () => { + const runner = new FakeRunner(); + runner.enqueue(shellResult(0, "nemoclaw v0.0.0\n")); + runner.enqueue(shellResult(0, "Docker is available\n")); + const environment = new EnvironmentPhaseFixture(new HostCliClient(runner)); + + const ready = await environment.assertReady({ + ...cloudOpenClawEnvironment, + runtime: "gpu-docker-cdi", + }); + + expect(ready.docker).toMatchObject({ + id: "gpu-docker-cdi", + expectation: "required", + available: true, + }); + expect(runner.calls[1]).toMatchObject({ + command: "docker", + args: ["info"], + options: { + artifactName: "runtime-docker-info-gpu-docker-cdi", + timeoutMs: 30_000, + }, + }); + }); + it("rejects unsupported install and runtime IDs", async () => { const runner = new FakeRunner(); const environment = new EnvironmentPhaseFixture(new HostCliClient(runner)); diff --git a/test/e2e-scenario/framework/availability-env.ts b/test/e2e-scenario/framework/availability-env.ts new file mode 100644 index 0000000000..f462492c22 --- /dev/null +++ b/test/e2e-scenario/framework/availability-env.ts @@ -0,0 +1,36 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +const AVAILABILITY_PROBE_ENV_KEYS = new Set([ + "PATH", + "HOME", + "USER", + "LOGNAME", + "SHELL", + "TERM", + "LANG", + "LC_ALL", + "LC_CTYPE", + "TZ", + "TMPDIR", + "TMP", + "TEMP", + "CI", + "GITHUB_ACTIONS", + "RUNNER_OS", + "RUNNER_TEMP", + "DOCKER_CONFIG", + "DOCKER_CONTEXT", + "DOCKER_HOST", + "XDG_RUNTIME_DIR", +]); + +export function buildAvailabilityProbeEnv(base: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv { + const env: NodeJS.ProcessEnv = {}; + for (const [key, value] of Object.entries(base)) { + if (value !== undefined && AVAILABILITY_PROBE_ENV_KEYS.has(key)) { + env[key] = value; + } + } + return env; +} diff --git a/test/e2e-scenario/framework/clients/host.ts b/test/e2e-scenario/framework/clients/host.ts index 8c5ba1dd9a..1a0aeee75c 100644 --- a/test/e2e-scenario/framework/clients/host.ts +++ b/test/e2e-scenario/framework/clients/host.ts @@ -1,6 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import { buildAvailabilityProbeEnv } from "../availability-env.ts"; import type { ShellProbeResult, ShellProbeRunOptions } from "../shell-probe.ts"; import { trustedShellCommand } from "../shell-probe.ts"; import { artifactLabel, assertExitZero, type CommandRunner } from "./command.ts"; @@ -48,7 +49,10 @@ export class HostCliClient { } async expectNemoclawAvailable(): Promise { - const result = await this.nemoclaw(["--version"], { artifactName: "nemoclaw-version", inheritEnv: true }); + const result = await this.nemoclaw(["--version"], { + artifactName: "nemoclaw-version", + env: buildAvailabilityProbeEnv(), + }); assertExitZero(result, "nemoclaw --version"); return result; } diff --git a/test/e2e-scenario/framework/phases/environment.ts b/test/e2e-scenario/framework/phases/environment.ts index 54cad8d6d6..8b4b8d7dd7 100644 --- a/test/e2e-scenario/framework/phases/environment.ts +++ b/test/e2e-scenario/framework/phases/environment.ts @@ -1,6 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import { buildAvailabilityProbeEnv } from "../availability-env.ts"; import { artifactLabel, assertExitZero } from "../clients/command.ts"; import type { HostCliClient } from "../clients/host.ts"; import type { ShellProbeResult } from "../shell-probe.ts"; @@ -83,7 +84,7 @@ export class EnvironmentPhaseFixture { try { const result = await this.host.command("docker", ["info"], { artifactName: `runtime-docker-info-${artifactLabel(runtime)}`, - inheritEnv: true, + env: buildAvailabilityProbeEnv(), timeoutMs: 30_000, }); return { From 8fe4353d8f7ef3bc24197e78ac2fe6f79458c080 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Mon, 8 Jun 2026 20:09:54 -0700 Subject: [PATCH 3/3] test(e2e): align availability probe env path --- .../e2e-phase-environment.test.ts | 16 ++++++++ .../framework/availability-env.ts | 40 +++++++------------ .../scenarios/orchestrators/redaction.ts | 16 +++++++- 3 files changed, 45 insertions(+), 27 deletions(-) diff --git a/test/e2e-scenario/framework-tests/e2e-phase-environment.test.ts b/test/e2e-scenario/framework-tests/e2e-phase-environment.test.ts index 3a7167650b..524bad1667 100644 --- a/test/e2e-scenario/framework-tests/e2e-phase-environment.test.ts +++ b/test/e2e-scenario/framework-tests/e2e-phase-environment.test.ts @@ -189,8 +189,12 @@ describe("environment phase fixture", () => { it("scopes availability probe env instead of inheriting unrelated secrets", async () => { const previousSecret = process.env.NVIDIA_API_KEY; const previousDockerHost = process.env.DOCKER_HOST; + const previousHome = process.env.HOME; + const previousPath = process.env.PATH; process.env.NVIDIA_API_KEY = "must-not-leak"; process.env.DOCKER_HOST = "unix:///tmp/e2e-docker.sock"; + process.env.HOME = "/tmp/e2e-home"; + process.env.PATH = "/usr/bin"; try { const runner = new FakeRunner(); runner.enqueue(shellResult(0, "nemoclaw v0.0.0\n")); @@ -203,6 +207,8 @@ describe("environment phase fixture", () => { const dockerEnv = runner.calls[1]?.options?.env; expect(cliEnv).toMatchObject({ DOCKER_HOST: "unix:///tmp/e2e-docker.sock" }); expect(dockerEnv).toMatchObject({ DOCKER_HOST: "unix:///tmp/e2e-docker.sock" }); + expect(cliEnv?.PATH).toBe("/tmp/e2e-home/.local/bin:/usr/bin"); + expect(dockerEnv?.PATH).toBe("/tmp/e2e-home/.local/bin:/usr/bin"); expect(cliEnv).not.toHaveProperty("NVIDIA_API_KEY"); expect(dockerEnv).not.toHaveProperty("NVIDIA_API_KEY"); expect(runner.calls[0]?.options?.inheritEnv).toBeUndefined(); @@ -218,6 +224,16 @@ describe("environment phase fixture", () => { } else { process.env.DOCKER_HOST = previousDockerHost; } + if (previousHome === undefined) { + delete process.env.HOME; + } else { + process.env.HOME = previousHome; + } + if (previousPath === undefined) { + delete process.env.PATH; + } else { + process.env.PATH = previousPath; + } } }); diff --git a/test/e2e-scenario/framework/availability-env.ts b/test/e2e-scenario/framework/availability-env.ts index f462492c22..6b9c20daeb 100644 --- a/test/e2e-scenario/framework/availability-env.ts +++ b/test/e2e-scenario/framework/availability-env.ts @@ -1,36 +1,24 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -const AVAILABILITY_PROBE_ENV_KEYS = new Set([ - "PATH", - "HOME", - "USER", - "LOGNAME", - "SHELL", - "TERM", - "LANG", - "LC_ALL", - "LC_CTYPE", - "TZ", - "TMPDIR", - "TMP", - "TEMP", - "CI", - "GITHUB_ACTIONS", - "RUNNER_OS", - "RUNNER_TEMP", +import { buildChildEnv } from "../scenarios/orchestrators/redaction.ts"; + +const AVAILABILITY_PROBE_EXTRA_ENV_KEYS = [ "DOCKER_CONFIG", "DOCKER_CONTEXT", "DOCKER_HOST", + "DOCKER_TLS_VERIFY", + "DOCKER_CERT_PATH", + "DOCKER_API_VERSION", "XDG_RUNTIME_DIR", -]); +]; export function buildAvailabilityProbeEnv(base: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv { - const env: NodeJS.ProcessEnv = {}; - for (const [key, value] of Object.entries(base)) { - if (value !== undefined && AVAILABILITY_PROBE_ENV_KEYS.has(key)) { - env[key] = value; - } - } - return env; + // Availability probes run outside PhaseOrchestrator, but they need the + // same child-env and PATH policy as scenario steps. Add only Docker + // discovery knobs on top of the shared framework boundary. + return buildChildEnv(base, { + additionalAllowedEnv: AVAILABILITY_PROBE_EXTRA_ENV_KEYS, + frameworkOverlay: {}, + }); } diff --git a/test/e2e-scenario/scenarios/orchestrators/redaction.ts b/test/e2e-scenario/scenarios/orchestrators/redaction.ts index 347eae12bc..6424964230 100644 --- a/test/e2e-scenario/scenarios/orchestrators/redaction.ts +++ b/test/e2e-scenario/scenarios/orchestrators/redaction.ts @@ -135,6 +135,8 @@ export function isValidSecretEnvKey(key: string): boolean { export interface BuildChildEnvOptions { /** Per-action / per-step declared secret-bearing env keys to pass through. */ secretEnv?: readonly string[]; + /** Additional non-secret env keys required by a framework-owned spawn helper. */ + additionalAllowedEnv?: readonly string[]; /** Framework-controlled overlay (E2E_CONTEXT_DIR, E2E_PHASE, E2E_*_ID). */ frameworkOverlay: NodeJS.ProcessEnv; } @@ -144,7 +146,8 @@ export interface BuildChildEnvOptions { * keeping only: * 1. keys in FRAMEWORK_ENV_ALLOWLIST * 2. keys starting with one of FRAMEWORK_ENV_PREFIXES - * 3. keys explicitly declared in `opts.secretEnv` (validated shape) + * 3. non-secret keys explicitly declared in `opts.additionalAllowedEnv` + * 4. keys explicitly declared in `opts.secretEnv` (validated shape) * then layering `opts.frameworkOverlay` on top. * * Throws if a `secretEnv` entry doesn't match the secret-key shape; @@ -167,6 +170,17 @@ export function buildChildEnv( continue; } } + for (const key of opts.additionalAllowedEnv ?? []) { + if (isValidSecretEnvKey(key)) { + throw new Error( + `additionalAllowedEnv entry '${key}' looks secret-bearing; use secretEnv ` + + `so secret passthrough remains explicit.`, + ); + } + if (base[key] !== undefined) { + out[key] = base[key]; + } + } for (const key of opts.secretEnv ?? []) { if (!isValidSecretEnvKey(key)) { throw new Error(