From c55480c4f12b65ba6c4f9c92c0e109224d72adea Mon Sep 17 00:00:00 2001 From: Rebecca Sliter <571084+rsliter@users.noreply.github.com> Date: Tue, 8 Sep 2026 08:07:48 -0700 Subject: [PATCH 01/25] fix(ci): isolate platform compatibility tests Signed-off-by: Rebecca Sliter <571084+rsliter@users.noreply.github.com> --- .github/workflows/platform-vitest-main.yaml | 39 +++++++----- .../onboard/docker-driver-gateway-env.test.ts | 5 ++ .../e2e/platform-vitest-main-workflow.test.ts | 40 +++++++++++++ .../analyze-pr-value-stream.test.ts | 59 ++++++++++++------- 4 files changed, 107 insertions(+), 36 deletions(-) diff --git a/.github/workflows/platform-vitest-main.yaml b/.github/workflows/platform-vitest-main.yaml index 7a00d21c510..96241572248 100644 --- a/.github/workflows/platform-vitest-main.yaml +++ b/.github/workflows/platform-vitest-main.yaml @@ -17,6 +17,7 @@ on: permissions: contents: read + packages: read concurrency: group: platform-evidence-${{ github.ref }} @@ -147,16 +148,10 @@ jobs: stat --version | head -n 1 awk --version | head -n 1 - - name: Install pinned OpenShell - env: - NEMOCLAW_NON_INTERACTIVE: "1" - run: env -u GH_TOKEN -u GITHUB_TOKEN bash scripts/install-openshell.sh - - name: Install dependencies - run: | - npm ci --ignore-scripts - cd nemoclaw - npm ci --ignore-scripts + env: + NODE_AUTH_TOKEN: ${{ github.token }} + run: bash .github/actions/ci-install-dependencies.sh - name: Build CLI and plugin run: | @@ -167,6 +162,12 @@ jobs: - name: Run full Vitest suite on macOS run: npx vitest run --testTimeout 60000 --shard="${{ matrix.shard }}/4" + - name: Install pinned OpenShell for macOS E2E + if: ${{ matrix.shard == 1 }} + env: + NEMOCLAW_NON_INTERACTIVE: "1" + run: env -u GH_TOKEN -u GITHUB_TOKEN bash scripts/install-openshell.sh + - id: macos_docker name: Detect Docker availability for macOS E2E if: ${{ matrix.shard == 1 }} @@ -239,10 +240,6 @@ jobs: WSL_DISTRO: Ubuntu WSL_TEST_USER: nemoclaw-ci TRUSTED_WSL_HELPER: ${{ github.workspace }}\trusted-wsl-ci\tools\wsl\ci-helper.ps1 - NEMOCLAW_NON_INTERACTIVE: "1" - NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE: "1" - NEMOCLAW_RECREATE_SANDBOX: "1" - NEMOCLAW_SANDBOX_NAME: "e2e-wsl" steps: - name: Force LF line endings for checkout shell: powershell @@ -288,7 +285,8 @@ jobs: . "$env:TRUSTED_WSL_HELPER" $packages = @( 'bash', 'ca-certificates', 'curl', 'git', 'jq', 'lsb-release', 'make', - 'python3', 'python3-pip', 'python3-venv', 'ripgrep', 'rsync', 'tar', 'unzip', 'xz-utils' + 'docker.io', 'iproute2', 'procps', 'python3', 'python3-pip', 'python3-venv', + 'ripgrep', 'rsync', 'tar', 'unzip', 'xz-utils' ) Install-WslUbuntuDependencies -Distro $env:WSL_DISTRO -Packages $packages -TestUser $env:WSL_TEST_USER @@ -310,18 +308,23 @@ jobs: - name: Install dependencies and build in WSL shell: powershell + env: + NODE_AUTH_TOKEN: ${{ github.token }} run: | . "$env:TRUSTED_WSL_HELPER" $testUser = ConvertTo-BashLiteral -Value $env:WSL_TEST_USER $workdir = ConvertTo-BashLiteral -Value $env:WSL_WORKDIR + $nodeAuthToken = ConvertTo-BashLiteral -Value ([string]$env:NODE_AUTH_TOKEN) $script = @" set -euo pipefail id -un | grep -Fxq $testUser cd $workdir - npm ci --ignore-scripts + export NODE_AUTH_TOKEN=$nodeAuthToken + export GITHUB_EVENT_NAME=push + bash .github/actions/ci-install-dependencies.sh + unset NODE_AUTH_TOKEN npm run build:cli cd nemoclaw - npm ci --ignore-scripts npm run build "@ Invoke-WslScript -Distro $env:WSL_DISTRO -User $env:WSL_TEST_USER -Script $script @@ -401,6 +404,10 @@ jobs: env: GITHUB_TOKEN: ${{ github.token }} NVIDIA_INFERENCE_API_KEY: ${{ secrets.NVIDIA_INFERENCE_API_KEY }} + NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE: "1" + NEMOCLAW_NON_INTERACTIVE: "1" + NEMOCLAW_RECREATE_SANDBOX: "1" + NEMOCLAW_SANDBOX_NAME: "e2e-wsl" run: | . "$env:TRUSTED_WSL_HELPER" $workdir = ConvertTo-BashLiteral -Value $env:WSL_WORKDIR diff --git a/src/lib/onboard/docker-driver-gateway-env.test.ts b/src/lib/onboard/docker-driver-gateway-env.test.ts index a7f87001cb9..f0248e8880b 100644 --- a/src/lib/onboard/docker-driver-gateway-env.test.ts +++ b/src/lib/onboard/docker-driver-gateway-env.test.ts @@ -16,6 +16,7 @@ import { writeDockerGatewayDebEnvOverride, } from "./docker-driver-gateway-env"; import { PORTABLE_HOST_GATEWAY_IP } from "./experimental/portable-profile"; +import { prepareNativePodmanGatewayHostRuntime } from "./runtime-provider/podman-runtime-surfaces"; function homeEnv(home: string, xdgConfigHome = ""): NodeJS.ProcessEnv { return { HOME: home, XDG_CONFIG_HOME: xdgConfigHome } as NodeJS.ProcessEnv; @@ -183,6 +184,10 @@ describe("buildDockerDriverGatewayEnv", () => { const env = buildDockerDriverGatewayEnv({ platform: "linux", stateDir, + gatewayHostRuntime: prepareNativePodmanGatewayHostRuntime({ + environment: process.env, + platform: "linux", + }), getDockerSupervisorImage: () => "supervisor:test", resolveSandboxBin: () => "/usr/bin/openshell-sandbox", }); diff --git a/test/automation/e2e/platform-vitest-main-workflow.test.ts b/test/automation/e2e/platform-vitest-main-workflow.test.ts index af34e3416ae..9a370d6769e 100644 --- a/test/automation/e2e/platform-vitest-main-workflow.test.ts +++ b/test/automation/e2e/platform-vitest-main-workflow.test.ts @@ -57,4 +57,44 @@ describe("platform evidence workflow", () => { NVIDIA_INFERENCE_API_KEY: "${{ secrets.NVIDIA_INFERENCE_API_KEY }}", }); }); + + it("keeps real macOS OpenShell state out of the non-live suite", () => { + const steps = job("macos-vitest").steps ?? []; + const nonLiveIndex = steps.findIndex( + (entry) => entry.name === "Run full Vitest suite on macOS", + ); + const installIndex = steps.findIndex( + (entry) => entry.name === "Install pinned OpenShell for macOS E2E", + ); + expect(nonLiveIndex).toBeGreaterThanOrEqual(0); + expect(installIndex).toBeGreaterThan(nonLiveIndex); + expect(steps[installIndex]?.if).toContain("matrix.shard == 1"); + }); + + it("scopes WSL live-E2E settings to the credentialed live step", () => { + const wsl = job("wsl-vitest"); + const live = step("wsl-vitest", "Run WSL live E2E"); + const liveOnlyEnvironment = { + NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE: "1", + NEMOCLAW_NON_INTERACTIVE: "1", + NEMOCLAW_RECREATE_SANDBOX: "1", + NEMOCLAW_SANDBOX_NAME: "e2e-wsl", + }; + expect(wsl.env).not.toMatchObject(liveOnlyEnvironment); + expect(live.env).toMatchObject(liveOnlyEnvironment); + }); + + it.each(["macos-vitest", "wsl-vitest"])( + "stages the approved OpenShell SDK for %s dependencies", + (jobName) => { + const install = step( + jobName, + jobName === "macos-vitest" + ? "Install dependencies" + : "Install dependencies and build in WSL", + ); + expect(install.env).toMatchObject({ NODE_AUTH_TOKEN: "${{ github.token }}" }); + expect(install.run).toContain(".github/actions/ci-install-dependencies.sh"); + }, + ); }); diff --git a/test/automation/pull-requests/analyze-pr-value-stream.test.ts b/test/automation/pull-requests/analyze-pr-value-stream.test.ts index 4e85c74158c..696e71763be 100644 --- a/test/automation/pull-requests/analyze-pr-value-stream.test.ts +++ b/test/automation/pull-requests/analyze-pr-value-stream.test.ts @@ -660,32 +660,51 @@ describe("pull request value-stream analysis", () => { await expect(stat(candidate)).rejects.toThrow(); }); - test("reclaims stale publication locks but preserves active locks (#10542)", async () => { - const publicationRoot = await mkdtemp(path.join(tmpdir(), "value-stream-lock-")); + test.runIf(process.platform === "linux")( + "reclaims stale publication locks but preserves active locks (#10542)", + async () => { + const publicationRoot = await mkdtemp(path.join(tmpdir(), "value-stream-lock-")); + temporaryDirectories.push(publicationRoot); + const lock = path.join(publicationRoot, "pr-42.lock"); + await mkdir(lock); + const liveStart = await readFile("/proc/" + process.pid + "/stat", "utf8"); + const liveIdentity = + liveStart + .slice(liveStart.lastIndexOf(")") + 2) + .trim() + .split(/\s+/u)[19] ?? null; + await writeFile( + path.join(lock, "owner.json"), + JSON.stringify({ pid: process.pid, startIdentity: liveIdentity }), + ); + expect(await reclaimStalePublicationLock(lock)).toBe(false); + const stale = new Date(Date.now() - 6 * 60 * 1_000); + await utimes(lock, stale, stale); + expect(await reclaimStalePublicationLock(lock)).toBe(false); + await writeFile(path.join(lock, "owner.json"), JSON.stringify({ pid: process.pid })); + expect(await reclaimStalePublicationLock(lock)).toBe(false); + await writeFile( + path.join(lock, "owner.json"), + JSON.stringify({ pid: process.pid, startIdentity: "reused-owner" }), + ); + expect(await reclaimStalePublicationLock(lock)).toBe(true); + await mkdir(lock); + await utimes(lock, stale, stale); + expect(await reclaimStalePublicationLock(lock)).toBe(true); + await expect(stat(lock)).rejects.toThrow(); + }, + ); + + test("preserves active locks without a Linux start identity", async () => { + const publicationRoot = await mkdtemp(path.join(tmpdir(), "value-stream-portable-lock-")); temporaryDirectories.push(publicationRoot); const lock = path.join(publicationRoot, "pr-42.lock"); await mkdir(lock); - const liveStart = await readFile("/proc/" + process.pid + "/stat", "utf8"); - const liveIdentity = liveStart - .slice(liveStart.lastIndexOf(")") + 2) - .trim() - .split(/\s+/u)[19]; - await writeFile( - path.join(lock, "owner.json"), - JSON.stringify({ pid: process.pid, startIdentity: liveIdentity }), - ); - expect(await reclaimStalePublicationLock(lock)).toBe(false); const stale = new Date(Date.now() - 6 * 60 * 1_000); - await utimes(lock, stale, stale); - expect(await reclaimStalePublicationLock(lock)).toBe(false); await writeFile(path.join(lock, "owner.json"), JSON.stringify({ pid: process.pid })); + await utimes(lock, stale, stale); expect(await reclaimStalePublicationLock(lock)).toBe(false); - await writeFile( - path.join(lock, "owner.json"), - JSON.stringify({ pid: process.pid, startIdentity: "reused-owner" }), - ); - expect(await reclaimStalePublicationLock(lock)).toBe(true); - await mkdir(lock); + await writeFile(path.join(lock, "owner.json"), "{}\n"); await utimes(lock, stale, stale); expect(await reclaimStalePublicationLock(lock)).toBe(true); await expect(stat(lock)).rejects.toThrow(); From e8cc4d98e196bd7e66cec1c5e773ad45b18026b8 Mon Sep 17 00:00:00 2001 From: Rebecca Sliter <571084+rsliter@users.noreply.github.com> Date: Tue, 8 Sep 2026 09:14:47 -0700 Subject: [PATCH 02/25] fix(ci): scope platform package credentials Signed-off-by: Rebecca Sliter <571084+rsliter@users.noreply.github.com> --- .github/workflows/platform-vitest-main.yaml | 20 ++++++++++++++----- .../e2e/platform-vitest-main-workflow.test.ts | 13 ++++++++++-- 2 files changed, 26 insertions(+), 7 deletions(-) diff --git a/.github/workflows/platform-vitest-main.yaml b/.github/workflows/platform-vitest-main.yaml index 96241572248..04ee340181c 100644 --- a/.github/workflows/platform-vitest-main.yaml +++ b/.github/workflows/platform-vitest-main.yaml @@ -150,8 +150,14 @@ jobs: - name: Install dependencies env: - NODE_AUTH_TOKEN: ${{ github.token }} - run: bash .github/actions/ci-install-dependencies.sh + NODE_AUTH_TOKEN: ${{ github.repository == 'NVIDIA/NemoClaw' && github.ref == 'refs/heads/main' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.token || '' }} + run: | + if [[ -n "$NODE_AUTH_TOKEN" ]]; then + bash .github/actions/ci-install-dependencies.sh + else + npm ci --ignore-scripts + npm --prefix nemoclaw ci --ignore-scripts + fi - name: Build CLI and plugin run: | @@ -309,7 +315,7 @@ jobs: - name: Install dependencies and build in WSL shell: powershell env: - NODE_AUTH_TOKEN: ${{ github.token }} + NODE_AUTH_TOKEN: ${{ github.repository == 'NVIDIA/NemoClaw' && github.ref == 'refs/heads/main' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.token || '' }} run: | . "$env:TRUSTED_WSL_HELPER" $testUser = ConvertTo-BashLiteral -Value $env:WSL_TEST_USER @@ -320,8 +326,12 @@ jobs: id -un | grep -Fxq $testUser cd $workdir export NODE_AUTH_TOKEN=$nodeAuthToken - export GITHUB_EVENT_NAME=push - bash .github/actions/ci-install-dependencies.sh + if [ -n "`$NODE_AUTH_TOKEN" ]; then + bash .github/actions/ci-install-dependencies.sh + else + npm ci --ignore-scripts + npm --prefix nemoclaw ci --ignore-scripts + fi unset NODE_AUTH_TOKEN npm run build:cli cd nemoclaw diff --git a/test/automation/e2e/platform-vitest-main-workflow.test.ts b/test/automation/e2e/platform-vitest-main-workflow.test.ts index 9a370d6769e..94f9ef61dd3 100644 --- a/test/automation/e2e/platform-vitest-main-workflow.test.ts +++ b/test/automation/e2e/platform-vitest-main-workflow.test.ts @@ -66,8 +66,10 @@ describe("platform evidence workflow", () => { const installIndex = steps.findIndex( (entry) => entry.name === "Install pinned OpenShell for macOS E2E", ); + const liveIndex = steps.findIndex((entry) => entry.name === "Run macOS live E2E"); expect(nonLiveIndex).toBeGreaterThanOrEqual(0); expect(installIndex).toBeGreaterThan(nonLiveIndex); + expect(liveIndex).toBeGreaterThan(installIndex); expect(steps[installIndex]?.if).toContain("matrix.shard == 1"); }); @@ -80,7 +82,10 @@ describe("platform evidence workflow", () => { NEMOCLAW_RECREATE_SANDBOX: "1", NEMOCLAW_SANDBOX_NAME: "e2e-wsl", }; - expect(wsl.env).not.toMatchObject(liveOnlyEnvironment); + expect(wsl.env).not.toHaveProperty("NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE"); + expect(wsl.env).not.toHaveProperty("NEMOCLAW_NON_INTERACTIVE"); + expect(wsl.env).not.toHaveProperty("NEMOCLAW_RECREATE_SANDBOX"); + expect(wsl.env).not.toHaveProperty("NEMOCLAW_SANDBOX_NAME"); expect(live.env).toMatchObject(liveOnlyEnvironment); }); @@ -93,8 +98,12 @@ describe("platform evidence workflow", () => { ? "Install dependencies" : "Install dependencies and build in WSL", ); - expect(install.env).toMatchObject({ NODE_AUTH_TOKEN: "${{ github.token }}" }); + expect(install.env).toMatchObject({ + NODE_AUTH_TOKEN: + "${{ github.repository == 'NVIDIA/NemoClaw' && github.ref == 'refs/heads/main' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.token || '' }}", + }); expect(install.run).toContain(".github/actions/ci-install-dependencies.sh"); + expect(install.run).toContain("npm ci --ignore-scripts"); }, ); }); From 8682e6184ba65ceb8f71239c5412cfc97bdc3eca Mon Sep 17 00:00:00 2001 From: Rebecca Sliter <571084+rsliter@users.noreply.github.com> Date: Tue, 8 Sep 2026 10:03:01 -0700 Subject: [PATCH 03/25] fix(ci): declare platform test prerequisites --- .github/workflows/platform-vitest-main.yaml | 20 +++++++- src/commands/config/export.test.ts | 46 ++++++++++++------- .../rebuild-custom-image-preflight.test.ts | 2 +- src/lib/config/output.test.ts | 17 ++++++- .../docker-driver-gateway-launch.test.ts | 28 ++++++++--- .../onboard/docker-driver-gateway-launch.ts | 3 ++ .../docker-driver-gateway-local-tls.test.ts | 6 +++ .../docker-driver-gateway-local-tls.ts | 14 ++++-- .../e2e/platform-vitest-main-workflow.test.ts | 31 +++++++++++++ .../pr-review-advisor-specialists.test.ts | 14 ++++-- 10 files changed, 147 insertions(+), 34 deletions(-) diff --git a/.github/workflows/platform-vitest-main.yaml b/.github/workflows/platform-vitest-main.yaml index 04ee340181c..1ea6ef740ac 100644 --- a/.github/workflows/platform-vitest-main.yaml +++ b/.github/workflows/platform-vitest-main.yaml @@ -120,11 +120,12 @@ jobs: - name: Install macOS test dependencies run: | set -euo pipefail - brew install bash coreutils fd gawk ripgrep + brew install bash coreutils docker fd gawk gnu-tar iproute2mac podman ripgrep printf '%s\n' \ "$(brew --prefix bash)/bin" \ "$(brew --prefix coreutils)/libexec/gnubin" \ "$(brew --prefix gawk)/libexec/gnubin" \ + "$(brew --prefix gnu-tar)/libexec/gnubin" \ >>"$GITHUB_PATH" python -m pip install \ --only-binary=:all: \ @@ -291,11 +292,26 @@ jobs: . "$env:TRUSTED_WSL_HELPER" $packages = @( 'bash', 'ca-certificates', 'curl', 'git', 'jq', 'lsb-release', 'make', - 'docker.io', 'iproute2', 'procps', 'python3', 'python3-pip', 'python3-venv', + 'docker.io', 'iproute2', 'podman', 'procps', 'python3', 'python3-pip', 'python3-venv', 'ripgrep', 'rsync', 'tar', 'unzip', 'xz-utils' ) Install-WslUbuntuDependencies -Distro $env:WSL_DISTRO -Packages $packages -TestUser $env:WSL_TEST_USER + - name: Start the WSL container runtime + shell: powershell + run: | + . "$env:TRUSTED_WSL_HELPER" + $testUser = ConvertTo-BashLiteral -Value $env:WSL_TEST_USER + $script = @" + set -euo pipefail + usermod -aG docker $testUser + service docker start + docker info + podman --version + ip -Version + "@ + Invoke-WslScript -Distro $env:WSL_DISTRO -User root -Script $script + - name: Install Node.js 22 in WSL shell: powershell run: | diff --git a/src/commands/config/export.test.ts b/src/commands/config/export.test.ts index 71403d99345..0fa36db25c1 100644 --- a/src/commands/config/export.test.ts +++ b/src/commands/config/export.test.ts @@ -79,22 +79,36 @@ describe("config export command", () => { expect(mocks.observeLiveExportSource).not.toHaveBeenCalled(); }); - it("composes live observation through file publication and JSON result (#10938)", async () => { - await expect( - ConfigExportCommand.run(["alpha", "--output", "/tmp/alpha.yaml", "--json"], process.cwd()), - ).resolves.toMatchObject({ - status: "succeeded", - sourceSandbox: "alpha", - outputPath: "/tmp/alpha.yaml", - documentDigest: "sha256:document", - specDigest: "sha256:spec", - }); - expect(mocks.publishExportFile).toHaveBeenCalledWith( - "/tmp/alpha.yaml", - "kind: NemoClawConfig\n", - false, - ); - }); + it.runIf(process.platform === "linux")( + "composes live observation through file publication and JSON result (#10938)", + async () => { + await expect( + ConfigExportCommand.run(["alpha", "--output", "/tmp/alpha.yaml", "--json"], process.cwd()), + ).resolves.toMatchObject({ + status: "succeeded", + sourceSandbox: "alpha", + outputPath: "/tmp/alpha.yaml", + documentDigest: "sha256:document", + specDigest: "sha256:spec", + }); + expect(mocks.publishExportFile).toHaveBeenCalledWith( + "/tmp/alpha.yaml", + "kind: NemoClawConfig\n", + false, + ); + }, + ); + + it.runIf(process.platform !== "linux")( + "rejects file publication before reading source state (#10938)", + async () => { + await expect( + ConfigExportCommand.run(["alpha", "--output", "/tmp/alpha.yaml", "--json"], process.cwd()), + ).resolves.toBeUndefined(); + expect(mocks.observeLiveExportSource).not.toHaveBeenCalled(); + expect(mocks.publishExportFile).not.toHaveBeenCalled(); + }, + ); it("declares the required output and safe replacement flags (#10938)", () => { expect(ConfigExportCommand.flags).toMatchObject({ diff --git a/src/lib/actions/sandbox/rebuild-custom-image-preflight.test.ts b/src/lib/actions/sandbox/rebuild-custom-image-preflight.test.ts index ead3cdef100..498b56d19f8 100644 --- a/src/lib/actions/sandbox/rebuild-custom-image-preflight.test.ts +++ b/src/lib/actions/sandbox/rebuild-custom-image-preflight.test.ts @@ -345,7 +345,7 @@ describe("preflightRebuildImage", () => { } }); - it.runIf(process.platform !== "win32")( + it.runIf(process.platform === "linux")( "uses the isolated config across the WSL helper and Docker subprocess boundary (#7111)", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-wsl-rebuild-process-")); diff --git a/src/lib/config/output.test.ts b/src/lib/config/output.test.ts index 7cb41c3f02c..27efac8c421 100644 --- a/src/lib/config/output.test.ts +++ b/src/lib/config/output.test.ts @@ -25,7 +25,7 @@ afterEach(() => { for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true }); }); -describe("publishExportFile", () => { +describe.runIf(process.platform === "linux")("publishExportFile", () => { it("publishes all YAML bytes through a mode-0600 regular file (#10938)", () => { const root = temporaryRoot(); const outputPath = path.join(root, "selected.yaml"); @@ -247,3 +247,18 @@ describe("publishExportFile", () => { expect(calls).toEqual(["fsync", "publish", "fsync"]); }); }); + +describe.runIf(process.platform !== "linux")("publishExportFile platform boundary", () => { + it("rejects file publication without Linux retained-directory descriptors (#10938)", () => { + const root = temporaryRoot(); + const outputPath = path.join(root, "selected.yaml"); + + expect(() => publishExportFile(outputPath, "content")).toThrowError( + expect.objectContaining>({ + category: "unsafe-output", + outputPath, + }), + ); + expect(fs.existsSync(outputPath)).toBe(false); + }); +}); diff --git a/src/lib/onboard/docker-driver-gateway-launch.test.ts b/src/lib/onboard/docker-driver-gateway-launch.test.ts index 59b77a4b00c..de1264eae2a 100644 --- a/src/lib/onboard/docker-driver-gateway-launch.test.ts +++ b/src/lib/onboard/docker-driver-gateway-launch.test.ts @@ -23,6 +23,7 @@ import { import * as dockerDriverGatewayLocalTls from "./docker-driver-gateway-local-tls"; import { PORTABLE_HOST_GATEWAY_IP } from "./experimental/portable-profile"; import { gatewayProcessCmdlineMatches } from "./gateway-process-identity"; +import { prepareNativePodmanGatewayHostRuntime } from "./runtime-provider/podman-runtime-surfaces"; function withTempBinaries( fn: (paths: { dir: string; gatewayBin: string; sandboxBin: string }) => T, @@ -158,13 +159,24 @@ describe("docker-driver-gateway-launch", () => { }); it("writes the exact rootless socket only for the Podman driver", () => { - const toml = buildDockerDriverGatewayConfigToml({ - OPENSHELL_DRIVERS: "podman", - OPENSHELL_GRPC_ENDPOINT: `https://${PORTABLE_HOST_GATEWAY_IP}:8080`, - OPENSHELL_DOCKER_NETWORK_NAME: "openshell-docker", - OPENSHELL_DOCKER_SUPERVISOR_IMAGE: "supervisor:test", - OPENSHELL_PODMAN_SOCKET: "/run/user/1001/podman/podman.sock", + const gatewayHostRuntime = prepareNativePodmanGatewayHostRuntime({ + environment: {}, + platform: "linux", + socketPath: "/run/user/1001/podman/podman.sock", }); + const toml = buildDockerDriverGatewayConfigToml( + { + OPENSHELL_DRIVERS: "podman", + OPENSHELL_GRPC_ENDPOINT: `https://${PORTABLE_HOST_GATEWAY_IP}:8080`, + OPENSHELL_DOCKER_NETWORK_NAME: "openshell-docker", + OPENSHELL_DOCKER_SUPERVISOR_IMAGE: "supervisor:test", + OPENSHELL_PODMAN_SOCKET: "/run/user/1001/podman/podman.sock", + }, + undefined, + undefined, + "nemoclaw", + gatewayHostRuntime, + ); expect(toml).toContain("[openshell.drivers.podman]"); expect(toml).toContain('socket_path = "/run/user/1001/podman/podman.sock"'); @@ -397,6 +409,10 @@ describe("docker-driver-gateway-launch", () => { hostGlibcVersion: "2.39", requiredGlibcVersions: ["2.39"], gatewayEnv: { OPENSHELL_DRIVERS: "podman" }, + gatewayHostRuntime: prepareNativePodmanGatewayHostRuntime({ + environment: {}, + platform: "linux", + }), }); expect(launch.mode).toBe("host"); diff --git a/src/lib/onboard/docker-driver-gateway-launch.ts b/src/lib/onboard/docker-driver-gateway-launch.ts index f4a041673d6..ef1b8a2da3f 100644 --- a/src/lib/onboard/docker-driver-gateway-launch.ts +++ b/src/lib/onboard/docker-driver-gateway-launch.ts @@ -25,6 +25,7 @@ import { ensureDockerDriverGatewayLocalTlsBundle, } from "./docker-driver-gateway-local-tls"; import { buildOwnedHostGatewayArgv0 } from "./gateway-process-identity"; +import type { RuntimeProviderGatewayHostRuntime } from "./runtime-provider/contract"; export { compareDottedVersions, @@ -109,6 +110,7 @@ type BuildGatewayLaunchOptions = { hostGlibcVersion?: string | null; requiredGlibcVersions?: string[]; ensureLocalTlsBundle?: boolean; + gatewayHostRuntime?: RuntimeProviderGatewayHostRuntime; // Multi-gateway callers pass the selected name. The hardened config derives // its JWT gateway identity from the already gateway-scoped state directory. gatewayName?: string; @@ -159,6 +161,7 @@ export function buildDockerDriverGatewayLaunch( { allowOpenShell0044PreAuthDatabase: process.env.NEMOCLAW_RESTORE_LATEST_BACKUP_ON_RECREATE === "1", + gatewayRuntime: options.gatewayHostRuntime, }, ); assertDockerDriverGatewayAuthConfigSafe(gatewayEnv); diff --git a/src/lib/onboard/docker-driver-gateway-local-tls.test.ts b/src/lib/onboard/docker-driver-gateway-local-tls.test.ts index b7cf6ddbe16..bbccf90103f 100644 --- a/src/lib/onboard/docker-driver-gateway-local-tls.test.ts +++ b/src/lib/onboard/docker-driver-gateway-local-tls.test.ts @@ -13,6 +13,7 @@ import { getDockerDriverGatewayLocalTlsBundle, } from "./docker-driver-gateway-local-tls"; import { PORTABLE_HOST_GATEWAY_IP } from "./experimental/portable-profile"; +import { prepareNativePodmanGatewayHostRuntime } from "./runtime-provider/podman-runtime-surfaces"; const TEST_CERT_VALID_AT = new Date("2026-06-27T00:00:00.000Z"); const TEST_CERT_SKEW_BOUNDARY_NOT_YET_VALID_AT = new Date("2026-06-26T20:38:47.000Z"); @@ -272,6 +273,11 @@ describe("docker-driver-gateway-local-tls", () => { ensureDockerDriverGatewayLocalTlsBundle({ env: { NEMOCLAW_GATEWAY_RUNTIME: "podman" }, gatewayBin: "/opt/openshell/openshell-gateway", + gatewayHostRuntime: prepareNativePodmanGatewayHostRuntime({ + environment: {}, + platform: "linux", + socketPath: "/run/user/1001/podman/podman.sock", + }), platform: "linux", stateDir, spawnSyncImpl: ((_command: string, args: string[]) => { diff --git a/src/lib/onboard/docker-driver-gateway-local-tls.ts b/src/lib/onboard/docker-driver-gateway-local-tls.ts index ac383017482..97ae613ffd0 100644 --- a/src/lib/onboard/docker-driver-gateway-local-tls.ts +++ b/src/lib/onboard/docker-driver-gateway-local-tls.ts @@ -8,6 +8,7 @@ import path from "node:path"; import { isPortableExperimentalProfile, PORTABLE_HOST_GATEWAY_IP } from "./docker-driver-platform"; import { resolveConfiguredRuntimeProvider } from "./runtime-provider/selection"; +import type { RuntimeProviderGatewayHostRuntime } from "./runtime-provider/contract"; // See docs/security/gateway-authentication-controls.mdx for the public compatibility boundary. export const DOCKER_DRIVER_GATEWAY_LOCAL_TLS_DIR_NAME = "tls"; @@ -29,6 +30,7 @@ export interface EnsureDockerDriverGatewayLocalTlsBundleOptions { env?: NodeJS.ProcessEnv; gatewayBin: string; platform?: NodeJS.Platform; + gatewayHostRuntime?: RuntimeProviderGatewayHostRuntime; spawnSyncImpl?: typeof spawnSync; stateDir: string; } @@ -192,6 +194,7 @@ function normalizeDockerDriverGatewayLocalTlsBundlePermissions( export function ensureDockerDriverGatewayLocalTlsBundle({ env = process.env, gatewayBin, + gatewayHostRuntime, platform = process.platform, spawnSyncImpl = spawnSync, stateDir, @@ -205,10 +208,13 @@ export function ensureDockerDriverGatewayLocalTlsBundle({ if (!provider.gateway.supported) { throw new Error("The selected runtime provider does not support a host-managed gateway."); } - return provider.gateway.prepareHostRuntime({ - environment: env, - platform, - }).requiredServerIpSans; + return ( + gatewayHostRuntime ?? + provider.gateway.prepareHostRuntime({ + environment: env, + platform, + }) + ).requiredServerIpSans; })(); const requiredServerIpSans = [...REQUIRED_SERVER_IP_SANS, ...requiredProviderIpSans]; fs.mkdirSync(stateDir, { recursive: true, mode: 0o700 }); diff --git a/test/automation/e2e/platform-vitest-main-workflow.test.ts b/test/automation/e2e/platform-vitest-main-workflow.test.ts index 94f9ef61dd3..10cc6abf887 100644 --- a/test/automation/e2e/platform-vitest-main-workflow.test.ts +++ b/test/automation/e2e/platform-vitest-main-workflow.test.ts @@ -73,6 +73,37 @@ describe("platform evidence workflow", () => { expect(steps[installIndex]?.if).toContain("matrix.shard == 1"); }); + it.each(["docker", "gnu-tar", "iproute2mac", "podman"])( + "installs the %s host tool resolved by macOS platform fixtures", + (formula) => { + const install = step("macos-vitest", "Install macOS test dependencies").run ?? ""; + expect(install).toContain(formula); + }, + ); + + it("puts GNU tar first on the macOS fixture path", () => { + const install = step("macos-vitest", "Install macOS test dependencies").run ?? ""; + expect(install).toContain('"$(brew --prefix gnu-tar)/libexec/gnubin"'); + }); + + it("starts Docker and qualifies both WSL container clients before the suite", () => { + const steps = job("wsl-vitest").steps ?? []; + const install = step("wsl-vitest", "Install Ubuntu dependencies").run ?? ""; + const runtime = step("wsl-vitest", "Start the WSL container runtime").run ?? ""; + const runtimeIndex = steps.findIndex( + (entry) => entry.name === "Start the WSL container runtime", + ); + const suiteIndex = steps.findIndex((entry) => entry.name === "Run full Vitest suite in WSL"); + expect(install).toContain("'docker.io'"); + expect(install).toContain("'podman'"); + expect(runtime).toContain("service docker start"); + expect(runtime).toContain("docker info"); + expect(runtime).toContain("podman --version"); + expect(runtime).toContain("ip -Version"); + expect(runtimeIndex).toBeGreaterThanOrEqual(0); + expect(suiteIndex).toBeGreaterThan(runtimeIndex); + }); + it("scopes WSL live-E2E settings to the credentialed live step", () => { const wsl = job("wsl-vitest"); const live = step("wsl-vitest", "Run WSL live E2E"); diff --git a/test/automation/pull-requests/pr-review-advisor-specialists.test.ts b/test/automation/pull-requests/pr-review-advisor-specialists.test.ts index 8ef6bb190e7..784d8070419 100644 --- a/test/automation/pull-requests/pr-review-advisor-specialists.test.ts +++ b/test/automation/pull-requests/pr-review-advisor-specialists.test.ts @@ -11,9 +11,15 @@ import { canonicalRepoReadPath } from "../../../tools/advisors/repo-read-only-to import { describe, expect, it, onTestFinished, vi } from "vitest"; import { TERMINOLOGY_TRACE_TOOL } from "../../../tools/pr-review-advisor/terminology.mts"; -import { runSpecialistAdvisor, writeSpecialistSummary } from "../../../tools/pr-review-advisor/run-specialist.mts"; +import { + runSpecialistAdvisor, + writeSpecialistSummary, +} from "../../../tools/pr-review-advisor/run-specialist.mts"; import { writeSpecialistDiff } from "../../../tools/pr-review-advisor/specialist-context.mts"; -import type { RunAdvisorResult, RunReadOnlyAdvisorOptions } from "../../../tools/advisors/session.mts"; +import type { + RunAdvisorResult, + RunReadOnlyAdvisorOptions, +} from "../../../tools/advisors/session.mts"; import { ADVISOR_INTERESTS, ADVISOR_SPECIALISTS, @@ -51,11 +57,11 @@ describe("PR review advisor specialist prompts", () => { it("writes readable diff evidence in the prepared advisor context", async () => { const directory = fs.mkdtempSync(path.join(os.tmpdir(), "specialist-context-")); onTestFinished(() => fs.rmSync(directory, { recursive: true, force: true })); - const expected = path.join(directory, "diff.patch"); + const expected = path.join(fs.realpathSync(directory), "diff.patch"); const file = writeSpecialistDiff(directory, "diff evidence"); - expect(file).toBe(expected); + expect(fs.realpathSync(file)).toBe(expected); await expect(canonicalRepoReadPath(directory, "diff.patch")).resolves.toBe(expected); expect(fs.readFileSync(file, "utf8")).toBe("diff evidence"); expect(fs.statSync(directory).mode & 0o777).toBe(0o700); From b408fb8bd7c4aad9191e2ae9b2caf39961649f4f Mon Sep 17 00:00:00 2001 From: Rebecca Sliter <571084+rsliter@users.noreply.github.com> Date: Tue, 8 Sep 2026 10:26:43 -0700 Subject: [PATCH 04/25] fix(ci): align gateway TLS runtime fixture --- .../onboard/docker-driver-gateway-launch.test.ts | 15 +++++++++++++-- src/lib/onboard/docker-driver-gateway-launch.ts | 1 + 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/lib/onboard/docker-driver-gateway-launch.test.ts b/src/lib/onboard/docker-driver-gateway-launch.test.ts index de1264eae2a..0084fa344db 100644 --- a/src/lib/onboard/docker-driver-gateway-launch.test.ts +++ b/src/lib/onboard/docker-driver-gateway-launch.test.ts @@ -214,14 +214,21 @@ describe("docker-driver-gateway-launch", () => { const selectedEnv: NodeJS.ProcessEnv = { HOME: "/home/tester", PATH: "/usr/bin", + NEMOCLAW_GATEWAY_RUNTIME: "podman", OPENSHELL_GATEWAY: "nemoclaw-8090", OPENSHELL_LOCAL_TLS_DIR: "/recorded/tls", OPENSHELL_WORKSPACE: "default", }; + const gatewayHostRuntime = prepareNativePodmanGatewayHostRuntime({ + environment: selectedEnv, + platform: "linux", + socketPath: "/run/user/1001/podman/podman.sock", + }); const ensureTls = vi .spyOn(dockerDriverGatewayLocalTls, "ensureDockerDriverGatewayLocalTlsBundle") - .mockImplementation(({ env, stateDir }) => { + .mockImplementation(({ env, gatewayHostRuntime: selectedRuntime, stateDir }) => { expect(env).toBe(selectedEnv); + expect(selectedRuntime).toBe(gatewayHostRuntime); return dockerDriverGatewayLocalTls.getDockerDriverGatewayLocalTlsBundle(stateDir); }); @@ -232,7 +239,11 @@ describe("docker-driver-gateway-launch", () => { ensureLocalTlsBundle: true, env: selectedEnv, gatewayBin, - gatewayEnv: { OPENSHELL_DRIVERS: "docker" }, + gatewayEnv: { + OPENSHELL_DRIVERS: "podman", + OPENSHELL_PODMAN_SOCKET: gatewayHostRuntime.socketPath ?? "", + }, + gatewayHostRuntime, hostGlibcVersion: "2.39", platform: "linux", requiredGlibcVersions: ["2.39"], diff --git a/src/lib/onboard/docker-driver-gateway-launch.ts b/src/lib/onboard/docker-driver-gateway-launch.ts index ef1b8a2da3f..e611efb3c5d 100644 --- a/src/lib/onboard/docker-driver-gateway-launch.ts +++ b/src/lib/onboard/docker-driver-gateway-launch.ts @@ -144,6 +144,7 @@ export function buildDockerDriverGatewayLaunch( ensureDockerDriverGatewayLocalTlsBundle({ env: baseEnv, gatewayBin: options.gatewayBin, + gatewayHostRuntime: options.gatewayHostRuntime, stateDir: options.stateDir, }); } From 191a5bbd8afb0fb4f42510c8a14bacadac243df7 Mon Sep 17 00:00:00 2001 From: Rebecca Sliter <571084+rsliter@users.noreply.github.com> Date: Tue, 8 Sep 2026 12:47:14 -0700 Subject: [PATCH 05/25] fix(ci): address platform review findings Signed-off-by: Rebecca Sliter <571084+rsliter@users.noreply.github.com> --- .github/workflows/platform-vitest-main.yaml | 56 ++++++++++++----- src/lib/onboard.ts | 4 +- .../onboard/docker-driver-gateway-runtime.ts | 26 ++++++-- .../onboard/gateway/docker-driver-start.ts | 9 ++- src/lib/onboard/gateway/late-binding.test.ts | 41 +++++++++---- .../e2e/platform-vitest-main-workflow.test.ts | 60 ++++++++----------- 6 files changed, 125 insertions(+), 71 deletions(-) diff --git a/.github/workflows/platform-vitest-main.yaml b/.github/workflows/platform-vitest-main.yaml index 1ea6ef740ac..093d9c63683 100644 --- a/.github/workflows/platform-vitest-main.yaml +++ b/.github/workflows/platform-vitest-main.yaml @@ -120,12 +120,15 @@ jobs: - name: Install macOS test dependencies run: | set -euo pipefail - brew install bash coreutils docker fd gawk gnu-tar iproute2mac podman ripgrep + brew install bash coreutils fd gawk ripgrep + test -x "$(command -v gtar)" + mkdir -p "$RUNNER_TEMP/nemoclaw-bin" + ln -s "$(command -v gtar)" "$RUNNER_TEMP/nemoclaw-bin/tar" printf '%s\n' \ + "$RUNNER_TEMP/nemoclaw-bin" \ "$(brew --prefix bash)/bin" \ "$(brew --prefix coreutils)/libexec/gnubin" \ "$(brew --prefix gawk)/libexec/gnubin" \ - "$(brew --prefix gnu-tar)/libexec/gnubin" \ >>"$GITHUB_PATH" python -m pip install \ --only-binary=:all: \ @@ -169,15 +172,42 @@ jobs: - name: Run full Vitest suite on macOS run: npx vitest run --testTimeout 60000 --shard="${{ matrix.shard }}/4" - - name: Install pinned OpenShell for macOS E2E - if: ${{ matrix.shard == 1 }} + macos-live-e2e: + name: macOS live E2E + if: ${{ github.ref == 'refs/heads/main' }} + needs: macos-vitest + runs-on: macos-26 + timeout-minutes: 60 + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Setup Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: "22" + cache: npm + + - name: Install dependencies + env: + NODE_AUTH_TOKEN: ${{ github.token }} + run: bash .github/actions/ci-install-dependencies.sh + + - name: Build CLI and plugin + run: | + npm run build:cli + npm --prefix nemoclaw run build + + - name: Install pinned OpenShell env: NEMOCLAW_NON_INTERACTIVE: "1" run: env -u GH_TOKEN -u GITHUB_TOKEN bash scripts/install-openshell.sh - id: macos_docker name: Detect Docker availability for macOS E2E - if: ${{ matrix.shard == 1 }} shell: bash run: | set -euo pipefail @@ -190,7 +220,7 @@ jobs: fi - name: Run macOS live E2E - if: ${{ matrix.shard == 1 && steps.macos_docker.outputs.docker_ok == 'true' && github.ref == 'refs/heads/main' }} + if: ${{ steps.macos_docker.outputs.docker_ok == 'true' }} env: GITHUB_TOKEN: ${{ github.token }} NVIDIA_INFERENCE_API_KEY: ${{ secrets.NVIDIA_INFERENCE_API_KEY }} @@ -205,19 +235,13 @@ jobs: --silent=false --reporter=default - name: Explain skipped macOS live E2E - if: ${{ matrix.shard == 1 && (steps.macos_docker.outputs.docker_ok != 'true' || github.ref != 'refs/heads/main') }} - shell: bash + if: ${{ steps.macos_docker.outputs.docker_ok != 'true' }} run: | - set -euo pipefail - if [[ "$GITHUB_REF" != "refs/heads/main" ]]; then - echo "The macOS live E2E did not run because this workflow does not test main." - else - echo "The macOS live E2E did not run because Docker is unavailable." - fi - echo "The macOS build and Vitest shard still provide platform evidence." + echo "The macOS live E2E did not run because Docker is unavailable." + echo "The macOS Vitest shards still provide platform evidence." - name: Upload macOS E2E logs on failure - if: ${{ matrix.shard == 1 && failure() }} + if: ${{ failure() }} uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: macos-platform-evidence-logs diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index e07c1338632..8facaed43de 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -563,7 +563,6 @@ import type { Session, SessionUpdates } from "./state/onboard-session"; import type { SandboxEntry } from "./state/registry"; import type { BackupResult } from "./state/sandbox"; import type { ProbeRecovery } from "./validation-recovery"; - const EXPERIMENTAL = process.env.NEMOCLAW_EXPERIMENTAL === "1"; const USE_COLOR = !process.env.NO_COLOR && !!process.stdout.isTTY; const DIM = USE_COLOR ? "\x1b[2m" : ""; @@ -582,6 +581,7 @@ const { getDockerDriverGatewayRuntimeDrift, getDockerDriverGatewayRuntimeDriftFromSnapshot, getDockerDriverGatewayStateDir, + prepareDockerDriverGatewayHostRuntime, getGatewayPortListenerRawScan, isDockerDriverGatewayPortListener, isDockerDriverGatewayProcess, @@ -1387,7 +1387,6 @@ const gatewayRegistration = createGatewayRegistration({ runOpenshell, runQuietOpenshell, }); - const dockerDriverGatewayStart = createDockerDriverGatewayStart({ SUPPORTED_OPENSHELL_FALLBACK_VERSION, checkGatewayPortAvailable, @@ -1404,6 +1403,7 @@ const dockerDriverGatewayStart = createDockerDriverGatewayStart({ getDockerDriverGatewayPortListenerScan, getDockerDriverGatewayRuntimeDrift, getDockerDriverGatewayStateDir, + prepareDockerDriverGatewayHostRuntime, getInstalledOpenshellVersion, isDockerDriverGatewayHttpReady, isDockerDriverGatewayProcessAlive, diff --git a/src/lib/onboard/docker-driver-gateway-runtime.ts b/src/lib/onboard/docker-driver-gateway-runtime.ts index a934f07af52..3fcfc55aa10 100644 --- a/src/lib/onboard/docker-driver-gateway-runtime.ts +++ b/src/lib/onboard/docker-driver-gateway-runtime.ts @@ -36,6 +36,7 @@ import { } from "./gateway-process-identity"; import { resolveOpenshell } from "./openshell-cli"; import type { PortProbeResult } from "./preflight"; +import type { RuntimeProviderGatewayHostRuntime } from "./runtime-provider/contract"; // Keep the listener option type on the established runtime facade while the // implementation remains isolated in docker-driver-gateway-port-listener.ts. @@ -101,7 +102,11 @@ export function createDockerDriverGatewayRuntimeHelpers(deps: DockerDriverGatewa getDockerDriverGatewayEnv( versionOutput?: string | null, platform?: NodeJS.Platform, + gatewayHostRuntime?: RuntimeProviderGatewayHostRuntime, ): Record; + prepareDockerDriverGatewayHostRuntime( + platform?: NodeJS.Platform, + ): RuntimeProviderGatewayHostRuntime; getDockerDriverGatewayPid(): number | null; getDockerDriverGatewayPidFile(): string; getDockerDriverGatewayPortListenerScan( @@ -243,10 +248,9 @@ export function createDockerDriverGatewayRuntimeHelpers(deps: DockerDriverGatewa : `ghcr.io/nvidia/openshell/supervisor:${supportedVersion}`; } - function getDockerDriverGatewayEnv( - versionOutput: string | null = null, + function prepareDockerDriverGatewayHostRuntime( platform: NodeJS.Platform = process.platform, - ): Record { + ): RuntimeProviderGatewayHostRuntime { const dockerHost = process.env.DOCKER_HOST; let podmanSocketPath: string | undefined; if (isPortableExperimentalProfile()) { @@ -258,12 +262,25 @@ export function createDockerDriverGatewayRuntimeHelpers(deps: DockerDriverGatewa } podmanSocketPath = candidate.slice("unix://".length); } + return dockerDriverGatewayEnv.prepareConfiguredGatewayHostRuntime({ + platform, + socketPath: podmanSocketPath, + }); + } + + function getDockerDriverGatewayEnv( + versionOutput: string | null = null, + platform: NodeJS.Platform = process.platform, + gatewayHostRuntime: RuntimeProviderGatewayHostRuntime = prepareDockerDriverGatewayHostRuntime( + platform, + ), + ): Record { const gatewayEnv = dockerDriverGatewayEnv.buildDockerDriverGatewayEnv({ platform, gatewayPort: currentGatewayPort(), stateDir: getDockerDriverGatewayStateDir(), dockerNetworkName: process.env.OPENSHELL_DOCKER_NETWORK_NAME || "openshell-docker", - podmanSocketPath, + gatewayHostRuntime, getDockerSupervisorImage: () => getOpenShellDockerSupervisorImage(versionOutput), resolveSandboxBin: resolveOpenShellSandboxBinary, enableBindMounts: deps.enableBindMounts?.() === true, @@ -593,6 +610,7 @@ export function createDockerDriverGatewayRuntimeHelpers(deps: DockerDriverGatewa getDockerDriverGatewayRuntimeDriftFromSnapshot, getDockerDriverGatewayStateDir, isDockerDriverGatewayPortListener, + prepareDockerDriverGatewayHostRuntime, isDockerDriverGatewayProcess, isDockerDriverGatewayProcessAlive, isDockerDriverGatewayStateInUse, diff --git a/src/lib/onboard/gateway/docker-driver-start.ts b/src/lib/onboard/gateway/docker-driver-start.ts index dc0e798879a..408cad92199 100644 --- a/src/lib/onboard/gateway/docker-driver-start.ts +++ b/src/lib/onboard/gateway/docker-driver-start.ts @@ -42,6 +42,7 @@ export interface DockerDriverGatewayStartDeps { gatewayPort(): number; getDockerDriverGatewayEndpoint: DynamicGatewayHelpers["getDockerDriverGatewayEndpoint"]; getDockerDriverGatewayEnv: GatewayRuntimeHelpers["getDockerDriverGatewayEnv"]; + prepareDockerDriverGatewayHostRuntime: GatewayRuntimeHelpers["prepareDockerDriverGatewayHostRuntime"]; getDockerDriverGatewayPid: GatewayRuntimeHelpers["getDockerDriverGatewayPid"]; getDockerDriverGatewayPortListenerScan: GatewayRuntimeHelpers["getDockerDriverGatewayPortListenerScan"]; getDockerDriverGatewayRuntimeDrift: GatewayRuntimeHelpers["getDockerDriverGatewayRuntimeDrift"]; @@ -137,7 +138,12 @@ export function createDockerDriverGatewayStart( } const gatewayBin = deps.resolveOpenShellGatewayBinary(); const openshellVersionOutput = runCaptureOpenshell(["--version"], { ignoreError: true }); - const gatewayEnv = deps.getDockerDriverGatewayEnv(openshellVersionOutput); + const gatewayHostRuntime = deps.prepareDockerDriverGatewayHostRuntime(); + const gatewayEnv = deps.getDockerDriverGatewayEnv( + openshellVersionOutput, + undefined, + gatewayHostRuntime, + ); const runtimeIdentity = gatewayBin ? dockerDriverGatewayLaunch.buildDockerDriverGatewayRuntimeIdentity({ gatewayBin, @@ -150,6 +156,7 @@ export function createDockerDriverGatewayStart( ), ...(selectedRuntimeEnv ? { env: selectedRuntimeEnv } : {}), ensureLocalTlsBundle: true, + gatewayHostRuntime, }) : null; const gatewayLaunch = runtimeIdentity?.launch ?? null; diff --git a/src/lib/onboard/gateway/late-binding.test.ts b/src/lib/onboard/gateway/late-binding.test.ts index ca99db54b22..5e368375791 100644 --- a/src/lib/onboard/gateway/late-binding.test.ts +++ b/src/lib/onboard/gateway/late-binding.test.ts @@ -12,6 +12,7 @@ import { } from "../docker-driver-gateway-config"; import * as dockerDriverGatewayLaunch from "../docker-driver-gateway-launch"; import * as gatewayBinding from "../gateway-binding"; +import { prepareNativePodmanGatewayHostRuntime } from "../runtime-provider/podman-runtime-surfaces"; import { createDockerDriverGatewayStart } from "./docker-driver-start"; import { createGatewayRecoveryOrchestration } from "./recovery"; import { createGatewayRegistration } from "./registration"; @@ -181,19 +182,32 @@ describe("gateway lifecycle late binding", () => { const dockerDriverGatewayEnv = { startPackageManagedDockerDriverGatewayWithEnvOverride: managedStart, } as unknown as typeof import("../docker-driver-gateway-env"); - const getDockerDriverGatewayEnv = vi.fn(() => { - expect( - gatewayBinding.managedGatewayStateRootOwnershipFailure({ - gatewayName: name, - gatewayPort: port, - stateDir, - }), - ).toBeNull(); - expect( - gatewayStateLifecycleLock.tryAcquireManagedGatewayStateLifecycleLock(stateDir), - ).toBeNull(); - return { OPENSHELL_SERVER_PORT: String(port) }; + const gatewayHostRuntime = prepareNativePodmanGatewayHostRuntime({ + environment: {}, + platform: "linux", + socketPath: "/run/user/1001/podman/podman.sock", }); + const prepareDockerDriverGatewayHostRuntime = vi.fn(() => gatewayHostRuntime); + const getDockerDriverGatewayEnv = vi.fn( + ( + _version?: string | null, + _platform?: NodeJS.Platform, + selectedGatewayHostRuntime?: typeof gatewayHostRuntime, + ) => { + expect(selectedGatewayHostRuntime).toBe(gatewayHostRuntime); + expect( + gatewayBinding.managedGatewayStateRootOwnershipFailure({ + gatewayName: name, + gatewayPort: port, + stateDir, + }), + ).toBeNull(); + expect( + gatewayStateLifecycleLock.tryAcquireManagedGatewayStateLifecycleLock(stateDir), + ).toBeNull(); + return { OPENSHELL_SERVER_PORT: String(port) }; + }, + ); const runCaptureOpenshell = vi.fn((_args: string[], _options?: Record) => ""); const runtimeIdentitySpy = vi .spyOn(dockerDriverGatewayLaunch, "buildDockerDriverGatewayRuntimeIdentity") @@ -238,6 +252,7 @@ describe("gateway lifecycle late binding", () => { isGatewayTcpReady: async () => true, isPidAlive: () => false, logDockerDriverGatewayRestart: vi.fn(), + prepareDockerDriverGatewayHostRuntime, registerDockerDriverGatewayEndpoint: () => true, rememberDockerDriverGatewayPid: vi.fn(), resolveOpenShellGatewayBinary: () => "/opt/openshell/openshell-gateway", @@ -290,6 +305,8 @@ describe("gateway lifecycle late binding", () => { ); const runtimeIdentityOptions = runtimeIdentitySpy.mock.calls[0]?.[0]; const managedOptions = managedStart.mock.calls[0]?.[0]; + expect(prepareDockerDriverGatewayHostRuntime).toHaveBeenCalledOnce(); + expect(runtimeIdentityOptions?.gatewayHostRuntime).toBe(gatewayHostRuntime); expect(runtimeIdentityOptions?.env).toEqual(managedOptions?.env); expect(runtimeIdentityOptions?.env).toEqual( expect.objectContaining({ diff --git a/test/automation/e2e/platform-vitest-main-workflow.test.ts b/test/automation/e2e/platform-vitest-main-workflow.test.ts index 10cc6abf887..e7b5c6e2b8d 100644 --- a/test/automation/e2e/platform-vitest-main-workflow.test.ts +++ b/test/automation/e2e/platform-vitest-main-workflow.test.ts @@ -36,21 +36,10 @@ describe("platform evidence workflow", () => { expect(run).toContain('test "$(git rev-parse --verify HEAD)" = "$GITHUB_SHA"'); expect(run.indexOf("safe.directory")).toBeLessThan(run.indexOf("npm run build:cli")); }); - it.each([ - { - job: "macos-vitest", - step: "Run macOS live E2E", - dockerOutput: "steps.macos_docker.outputs.docker_ok == 'true'", - }, - { - job: "wsl-vitest", - step: "Run WSL live E2E", - dockerOutput: "steps.wsl_docker.outputs.docker_ok == 'true'", - }, - ])("limits credentialed $job E2E to the first main-branch shard", (workflowCase) => { - const live = step(workflowCase.job, workflowCase.step); + it("limits credentialed WSL E2E to the first main-branch shard", () => { + const live = step("wsl-vitest", "Run WSL live E2E"); expect(live.if).toContain("matrix.shard == 1"); - expect(live.if).toContain(workflowCase.dockerOutput); + expect(live.if).toContain("steps.wsl_docker.outputs.docker_ok == 'true'"); expect(live.if).toContain("github.ref == 'refs/heads/main'"); expect(live.env).toMatchObject({ GITHUB_TOKEN: "${{ github.token }}", @@ -58,32 +47,31 @@ describe("platform evidence workflow", () => { }); }); - it("keeps real macOS OpenShell state out of the non-live suite", () => { - const steps = job("macos-vitest").steps ?? []; - const nonLiveIndex = steps.findIndex( - (entry) => entry.name === "Run full Vitest suite on macOS", + it("isolates credentialed macOS E2E from mutable non-live dependencies", () => { + const nonLive = job("macos-vitest"); + const liveJob = job("macos-live-e2e"); + const live = step("macos-live-e2e", "Run macOS live E2E"); + const installOpenShell = step("macos-live-e2e", "Install pinned OpenShell"); + expect(nonLive.steps).not.toEqual( + expect.arrayContaining([expect.objectContaining({ name: "Run macOS live E2E" })]), ); - const installIndex = steps.findIndex( - (entry) => entry.name === "Install pinned OpenShell for macOS E2E", - ); - const liveIndex = steps.findIndex((entry) => entry.name === "Run macOS live E2E"); - expect(nonLiveIndex).toBeGreaterThanOrEqual(0); - expect(installIndex).toBeGreaterThan(nonLiveIndex); - expect(liveIndex).toBeGreaterThan(installIndex); - expect(steps[installIndex]?.if).toContain("matrix.shard == 1"); + expect(liveJob.needs).toBe("macos-vitest"); + expect(liveJob.if).toContain("github.ref == 'refs/heads/main'"); + expect(JSON.stringify(liveJob)).not.toContain("brew install"); + expect(installOpenShell.run).toContain("scripts/install-openshell.sh"); + expect(live.if).toContain("steps.macos_docker.outputs.docker_ok == 'true'"); + expect(live.env).toMatchObject({ + GITHUB_TOKEN: "${{ github.token }}", + NVIDIA_INFERENCE_API_KEY: "${{ secrets.NVIDIA_INFERENCE_API_KEY }}", + }); }); - it.each(["docker", "gnu-tar", "iproute2mac", "podman"])( - "installs the %s host tool resolved by macOS platform fixtures", - (formula) => { - const install = step("macos-vitest", "Install macOS test dependencies").run ?? ""; - expect(install).toContain(formula); - }, - ); - - it("puts GNU tar first on the macOS fixture path", () => { + it("uses the runner's preinstalled GNU tar without adding mutable formulae", () => { const install = step("macos-vitest", "Install macOS test dependencies").run ?? ""; - expect(install).toContain('"$(brew --prefix gnu-tar)/libexec/gnubin"'); + expect(install).toContain('test -x "$(command -v gtar)"'); + expect(install).toContain('ln -s "$(command -v gtar)" "$RUNNER_TEMP/nemoclaw-bin/tar"'); + expect(install).toContain('"$RUNNER_TEMP/nemoclaw-bin"'); + expect(install).not.toMatch(/brew install[^\n]*(?:docker|gnu-tar|iproute2mac|podman)/u); }); it("starts Docker and qualifies both WSL container clients before the suite", () => { From b45a32d6ec6fa2cf6e3e4e98c8b1cbac78cc01e2 Mon Sep 17 00:00:00 2001 From: Rebecca Sliter <571084+rsliter@users.noreply.github.com> Date: Tue, 8 Sep 2026 13:08:49 -0700 Subject: [PATCH 06/25] fix(ci): preserve trusted gateway runtime template Signed-off-by: Rebecca Sliter <571084+rsliter@users.noreply.github.com> --- src/lib/onboard.ts | 2 - .../onboard/docker-driver-gateway-env.test.ts | 17 +++++-- src/lib/onboard/docker-driver-gateway-env.ts | 16 ++++++ .../onboard/docker-driver-gateway-runtime.ts | 26 ++-------- .../onboard/gateway/docker-driver-start.ts | 10 ++-- src/lib/onboard/gateway/late-binding.test.ts | 50 +++++++++---------- 6 files changed, 60 insertions(+), 61 deletions(-) diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 8facaed43de..13591e6620f 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -581,7 +581,6 @@ const { getDockerDriverGatewayRuntimeDrift, getDockerDriverGatewayRuntimeDriftFromSnapshot, getDockerDriverGatewayStateDir, - prepareDockerDriverGatewayHostRuntime, getGatewayPortListenerRawScan, isDockerDriverGatewayPortListener, isDockerDriverGatewayProcess, @@ -1403,7 +1402,6 @@ const dockerDriverGatewayStart = createDockerDriverGatewayStart({ getDockerDriverGatewayPortListenerScan, getDockerDriverGatewayRuntimeDrift, getDockerDriverGatewayStateDir, - prepareDockerDriverGatewayHostRuntime, getInstalledOpenshellVersion, isDockerDriverGatewayHttpReady, isDockerDriverGatewayProcessAlive, diff --git a/src/lib/onboard/docker-driver-gateway-env.test.ts b/src/lib/onboard/docker-driver-gateway-env.test.ts index f0248e8880b..d749e3eb42f 100644 --- a/src/lib/onboard/docker-driver-gateway-env.test.ts +++ b/src/lib/onboard/docker-driver-gateway-env.test.ts @@ -12,6 +12,7 @@ import { writeOpenShell0044PreAuthState } from "../../../test/support/openshell- import { buildDockerDriverGatewayEnv, buildDockerGatewayDebEnvFile, + requirePreparedDockerDriverGatewayHostRuntime, startPackageManagedDockerDriverGatewayWithEnvOverride, writeDockerGatewayDebEnvOverride, } from "./docker-driver-gateway-env"; @@ -181,13 +182,14 @@ describe("buildDockerDriverGatewayEnv", () => { vi.stubEnv("CONTAINERS_CONF", "/tmp/nemoclaw-portable/containers.conf"); const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-native-podman-gateway-")); try { + const gatewayHostRuntime = prepareNativePodmanGatewayHostRuntime({ + environment: process.env, + platform: "linux", + }); const env = buildDockerDriverGatewayEnv({ platform: "linux", stateDir, - gatewayHostRuntime: prepareNativePodmanGatewayHostRuntime({ - environment: process.env, - platform: "linux", - }), + gatewayHostRuntime, getDockerSupervisorImage: () => "supervisor:test", resolveSandboxBin: () => "/usr/bin/openshell-sandbox", }); @@ -200,6 +202,7 @@ describe("buildDockerDriverGatewayEnv", () => { expect(env.OPENSHELL_PODMAN_SOCKET).toMatch(/\/podman\/podman\.sock$/u); expect(env.CONTAINERS_CONF).toBeUndefined(); expect(env.NETAVARK_FW).toBeUndefined(); + expect(requirePreparedDockerDriverGatewayHostRuntime(env)).toBe(gatewayHostRuntime); const toml = fs.readFileSync(env.OPENSHELL_GATEWAY_CONFIG, "utf-8"); expect(toml).toContain('compute_drivers = ["podman"]'); expect(toml).toContain(`socket_path = "${env.OPENSHELL_PODMAN_SOCKET}"`); @@ -210,6 +213,12 @@ describe("buildDockerDriverGatewayEnv", () => { } }); + it("rejects an environment that has no prepared gateway runtime", () => { + expect(() => requirePreparedDockerDriverGatewayHostRuntime({})).toThrow( + "OpenShell gateway environment has no prepared host runtime.", + ); + }); + it.each([ ["a relative path", "run/user/1001/podman/podman.sock"], ["trailing whitespace", "/run/user/1001/podman/podman.sock "], diff --git a/src/lib/onboard/docker-driver-gateway-env.ts b/src/lib/onboard/docker-driver-gateway-env.ts index c43fc2f6303..67516bf0d8c 100644 --- a/src/lib/onboard/docker-driver-gateway-env.ts +++ b/src/lib/onboard/docker-driver-gateway-env.ts @@ -91,6 +91,21 @@ export interface BuildDockerDriverGatewayEnvOptions { enableBindMounts?: boolean; } +const preparedGatewayHostRuntimeByEnv = new WeakMap< + Record, + RuntimeProviderGatewayHostRuntime +>(); + +export function requirePreparedDockerDriverGatewayHostRuntime( + gatewayEnv: Record, +): RuntimeProviderGatewayHostRuntime { + const runtime = preparedGatewayHostRuntimeByEnv.get(gatewayEnv); + if (!runtime) { + throw new Error("OpenShell gateway environment has no prepared host runtime."); + } + return runtime; +} + function preparePortableGatewayHostRuntime( socketPath?: string, platform: NodeJS.Platform = process.platform, @@ -482,6 +497,7 @@ export function buildDockerDriverGatewayEnv({ process.env.NEMOCLAW_RESTORE_LATEST_BACKUP_ON_RECREATE === "1", gatewayRuntime: runtime, }); + preparedGatewayHostRuntimeByEnv.set(env, runtime); return env; } diff --git a/src/lib/onboard/docker-driver-gateway-runtime.ts b/src/lib/onboard/docker-driver-gateway-runtime.ts index 3fcfc55aa10..a934f07af52 100644 --- a/src/lib/onboard/docker-driver-gateway-runtime.ts +++ b/src/lib/onboard/docker-driver-gateway-runtime.ts @@ -36,7 +36,6 @@ import { } from "./gateway-process-identity"; import { resolveOpenshell } from "./openshell-cli"; import type { PortProbeResult } from "./preflight"; -import type { RuntimeProviderGatewayHostRuntime } from "./runtime-provider/contract"; // Keep the listener option type on the established runtime facade while the // implementation remains isolated in docker-driver-gateway-port-listener.ts. @@ -102,11 +101,7 @@ export function createDockerDriverGatewayRuntimeHelpers(deps: DockerDriverGatewa getDockerDriverGatewayEnv( versionOutput?: string | null, platform?: NodeJS.Platform, - gatewayHostRuntime?: RuntimeProviderGatewayHostRuntime, ): Record; - prepareDockerDriverGatewayHostRuntime( - platform?: NodeJS.Platform, - ): RuntimeProviderGatewayHostRuntime; getDockerDriverGatewayPid(): number | null; getDockerDriverGatewayPidFile(): string; getDockerDriverGatewayPortListenerScan( @@ -248,9 +243,10 @@ export function createDockerDriverGatewayRuntimeHelpers(deps: DockerDriverGatewa : `ghcr.io/nvidia/openshell/supervisor:${supportedVersion}`; } - function prepareDockerDriverGatewayHostRuntime( + function getDockerDriverGatewayEnv( + versionOutput: string | null = null, platform: NodeJS.Platform = process.platform, - ): RuntimeProviderGatewayHostRuntime { + ): Record { const dockerHost = process.env.DOCKER_HOST; let podmanSocketPath: string | undefined; if (isPortableExperimentalProfile()) { @@ -262,25 +258,12 @@ export function createDockerDriverGatewayRuntimeHelpers(deps: DockerDriverGatewa } podmanSocketPath = candidate.slice("unix://".length); } - return dockerDriverGatewayEnv.prepareConfiguredGatewayHostRuntime({ - platform, - socketPath: podmanSocketPath, - }); - } - - function getDockerDriverGatewayEnv( - versionOutput: string | null = null, - platform: NodeJS.Platform = process.platform, - gatewayHostRuntime: RuntimeProviderGatewayHostRuntime = prepareDockerDriverGatewayHostRuntime( - platform, - ), - ): Record { const gatewayEnv = dockerDriverGatewayEnv.buildDockerDriverGatewayEnv({ platform, gatewayPort: currentGatewayPort(), stateDir: getDockerDriverGatewayStateDir(), dockerNetworkName: process.env.OPENSHELL_DOCKER_NETWORK_NAME || "openshell-docker", - gatewayHostRuntime, + podmanSocketPath, getDockerSupervisorImage: () => getOpenShellDockerSupervisorImage(versionOutput), resolveSandboxBin: resolveOpenShellSandboxBinary, enableBindMounts: deps.enableBindMounts?.() === true, @@ -610,7 +593,6 @@ export function createDockerDriverGatewayRuntimeHelpers(deps: DockerDriverGatewa getDockerDriverGatewayRuntimeDriftFromSnapshot, getDockerDriverGatewayStateDir, isDockerDriverGatewayPortListener, - prepareDockerDriverGatewayHostRuntime, isDockerDriverGatewayProcess, isDockerDriverGatewayProcessAlive, isDockerDriverGatewayStateInUse, diff --git a/src/lib/onboard/gateway/docker-driver-start.ts b/src/lib/onboard/gateway/docker-driver-start.ts index 408cad92199..2701aa1eb2d 100644 --- a/src/lib/onboard/gateway/docker-driver-start.ts +++ b/src/lib/onboard/gateway/docker-driver-start.ts @@ -42,7 +42,6 @@ export interface DockerDriverGatewayStartDeps { gatewayPort(): number; getDockerDriverGatewayEndpoint: DynamicGatewayHelpers["getDockerDriverGatewayEndpoint"]; getDockerDriverGatewayEnv: GatewayRuntimeHelpers["getDockerDriverGatewayEnv"]; - prepareDockerDriverGatewayHostRuntime: GatewayRuntimeHelpers["prepareDockerDriverGatewayHostRuntime"]; getDockerDriverGatewayPid: GatewayRuntimeHelpers["getDockerDriverGatewayPid"]; getDockerDriverGatewayPortListenerScan: GatewayRuntimeHelpers["getDockerDriverGatewayPortListenerScan"]; getDockerDriverGatewayRuntimeDrift: GatewayRuntimeHelpers["getDockerDriverGatewayRuntimeDrift"]; @@ -138,12 +137,9 @@ export function createDockerDriverGatewayStart( } const gatewayBin = deps.resolveOpenShellGatewayBinary(); const openshellVersionOutput = runCaptureOpenshell(["--version"], { ignoreError: true }); - const gatewayHostRuntime = deps.prepareDockerDriverGatewayHostRuntime(); - const gatewayEnv = deps.getDockerDriverGatewayEnv( - openshellVersionOutput, - undefined, - gatewayHostRuntime, - ); + const gatewayEnv = deps.getDockerDriverGatewayEnv(openshellVersionOutput); + const gatewayHostRuntime = + deps.dockerDriverGatewayEnv.requirePreparedDockerDriverGatewayHostRuntime(gatewayEnv); const runtimeIdentity = gatewayBin ? dockerDriverGatewayLaunch.buildDockerDriverGatewayRuntimeIdentity({ gatewayBin, diff --git a/src/lib/onboard/gateway/late-binding.test.ts b/src/lib/onboard/gateway/late-binding.test.ts index 5e368375791..b333de05913 100644 --- a/src/lib/onboard/gateway/late-binding.test.ts +++ b/src/lib/onboard/gateway/late-binding.test.ts @@ -179,35 +179,34 @@ describe("gateway lifecycle late binding", () => { return true; }, ); - const dockerDriverGatewayEnv = { - startPackageManagedDockerDriverGatewayWithEnvOverride: managedStart, - } as unknown as typeof import("../docker-driver-gateway-env"); const gatewayHostRuntime = prepareNativePodmanGatewayHostRuntime({ environment: {}, platform: "linux", socketPath: "/run/user/1001/podman/podman.sock", }); - const prepareDockerDriverGatewayHostRuntime = vi.fn(() => gatewayHostRuntime); - const getDockerDriverGatewayEnv = vi.fn( - ( - _version?: string | null, - _platform?: NodeJS.Platform, - selectedGatewayHostRuntime?: typeof gatewayHostRuntime, - ) => { - expect(selectedGatewayHostRuntime).toBe(gatewayHostRuntime); - expect( - gatewayBinding.managedGatewayStateRootOwnershipFailure({ - gatewayName: name, - gatewayPort: port, - stateDir, - }), - ).toBeNull(); - expect( - gatewayStateLifecycleLock.tryAcquireManagedGatewayStateLifecycleLock(stateDir), - ).toBeNull(); - return { OPENSHELL_SERVER_PORT: String(port) }; - }, - ); + const gatewayEnv = { OPENSHELL_SERVER_PORT: String(port) }; + const requirePreparedDockerDriverGatewayHostRuntime = vi.fn((selectedGatewayEnv) => { + expect(selectedGatewayEnv).toBe(gatewayEnv); + return gatewayHostRuntime; + }); + const dockerDriverGatewayEnv = { + requirePreparedDockerDriverGatewayHostRuntime, + startPackageManagedDockerDriverGatewayWithEnvOverride: managedStart, + } as unknown as typeof import("../docker-driver-gateway-env"); + const getDockerDriverGatewayEnv = vi.fn(() => { + expect( + gatewayBinding.managedGatewayStateRootOwnershipFailure({ + gatewayName: name, + gatewayPort: port, + stateDir, + }), + ).toBeNull(); + expect( + gatewayStateLifecycleLock.tryAcquireManagedGatewayStateLifecycleLock(stateDir), + ).toBeNull(); + gatewayEnv.OPENSHELL_SERVER_PORT = String(port); + return gatewayEnv; + }); const runCaptureOpenshell = vi.fn((_args: string[], _options?: Record) => ""); const runtimeIdentitySpy = vi .spyOn(dockerDriverGatewayLaunch, "buildDockerDriverGatewayRuntimeIdentity") @@ -252,7 +251,6 @@ describe("gateway lifecycle late binding", () => { isGatewayTcpReady: async () => true, isPidAlive: () => false, logDockerDriverGatewayRestart: vi.fn(), - prepareDockerDriverGatewayHostRuntime, registerDockerDriverGatewayEndpoint: () => true, rememberDockerDriverGatewayPid: vi.fn(), resolveOpenShellGatewayBinary: () => "/opt/openshell/openshell-gateway", @@ -305,7 +303,7 @@ describe("gateway lifecycle late binding", () => { ); const runtimeIdentityOptions = runtimeIdentitySpy.mock.calls[0]?.[0]; const managedOptions = managedStart.mock.calls[0]?.[0]; - expect(prepareDockerDriverGatewayHostRuntime).toHaveBeenCalledOnce(); + expect(requirePreparedDockerDriverGatewayHostRuntime).toHaveBeenCalledOnce(); expect(runtimeIdentityOptions?.gatewayHostRuntime).toBe(gatewayHostRuntime); expect(runtimeIdentityOptions?.env).toEqual(managedOptions?.env); expect(runtimeIdentityOptions?.env).toEqual( From d1ed448959ae3b5cb022b21977cdbf991fbc3937 Mon Sep 17 00:00:00 2001 From: Rebecca Sliter <571084+rsliter@users.noreply.github.com> Date: Tue, 8 Sep 2026 13:30:15 -0700 Subject: [PATCH 07/25] fix(ci): verify runner GNU tar before Homebrew Signed-off-by: Rebecca Sliter <571084+rsliter@users.noreply.github.com> --- .github/workflows/platform-vitest-main.yaml | 2 +- test/automation/e2e/platform-vitest-main-workflow.test.ts | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/platform-vitest-main.yaml b/.github/workflows/platform-vitest-main.yaml index 093d9c63683..580192a189f 100644 --- a/.github/workflows/platform-vitest-main.yaml +++ b/.github/workflows/platform-vitest-main.yaml @@ -120,8 +120,8 @@ jobs: - name: Install macOS test dependencies run: | set -euo pipefail - brew install bash coreutils fd gawk ripgrep test -x "$(command -v gtar)" + brew install bash coreutils fd gawk ripgrep mkdir -p "$RUNNER_TEMP/nemoclaw-bin" ln -s "$(command -v gtar)" "$RUNNER_TEMP/nemoclaw-bin/tar" printf '%s\n' \ diff --git a/test/automation/e2e/platform-vitest-main-workflow.test.ts b/test/automation/e2e/platform-vitest-main-workflow.test.ts index e7b5c6e2b8d..5b9da32319e 100644 --- a/test/automation/e2e/platform-vitest-main-workflow.test.ts +++ b/test/automation/e2e/platform-vitest-main-workflow.test.ts @@ -69,6 +69,9 @@ describe("platform evidence workflow", () => { it("uses the runner's preinstalled GNU tar without adding mutable formulae", () => { const install = step("macos-vitest", "Install macOS test dependencies").run ?? ""; expect(install).toContain('test -x "$(command -v gtar)"'); + expect(install.indexOf('test -x "$(command -v gtar)"')).toBeLessThan( + install.indexOf("brew install"), + ); expect(install).toContain('ln -s "$(command -v gtar)" "$RUNNER_TEMP/nemoclaw-bin/tar"'); expect(install).toContain('"$RUNNER_TEMP/nemoclaw-bin"'); expect(install).not.toMatch(/brew install[^\n]*(?:docker|gnu-tar|iproute2mac|podman)/u); From 169fc3c8eca1f2f1380be18302a313516c913b48 Mon Sep 17 00:00:00 2001 From: Rebecca Sliter <571084+rsliter@users.noreply.github.com> Date: Tue, 8 Sep 2026 13:37:19 -0700 Subject: [PATCH 08/25] fix(onboard): defer prepared runtime lookup Signed-off-by: Rebecca Sliter <571084+rsliter@users.noreply.github.com> --- src/lib/onboard/gateway/docker-driver-start.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/lib/onboard/gateway/docker-driver-start.ts b/src/lib/onboard/gateway/docker-driver-start.ts index 2701aa1eb2d..be6c03a2cda 100644 --- a/src/lib/onboard/gateway/docker-driver-start.ts +++ b/src/lib/onboard/gateway/docker-driver-start.ts @@ -138,8 +138,6 @@ export function createDockerDriverGatewayStart( const gatewayBin = deps.resolveOpenShellGatewayBinary(); const openshellVersionOutput = runCaptureOpenshell(["--version"], { ignoreError: true }); const gatewayEnv = deps.getDockerDriverGatewayEnv(openshellVersionOutput); - const gatewayHostRuntime = - deps.dockerDriverGatewayEnv.requirePreparedDockerDriverGatewayHostRuntime(gatewayEnv); const runtimeIdentity = gatewayBin ? dockerDriverGatewayLaunch.buildDockerDriverGatewayRuntimeIdentity({ gatewayBin, @@ -152,7 +150,8 @@ export function createDockerDriverGatewayStart( ), ...(selectedRuntimeEnv ? { env: selectedRuntimeEnv } : {}), ensureLocalTlsBundle: true, - gatewayHostRuntime, + gatewayHostRuntime: + deps.dockerDriverGatewayEnv.requirePreparedDockerDriverGatewayHostRuntime(gatewayEnv), }) : null; const gatewayLaunch = runtimeIdentity?.launch ?? null; From 281705d873c4759a84f0a65c7232a7dd05aca100 Mon Sep 17 00:00:00 2001 From: Rebecca Sliter <571084+rsliter@users.noreply.github.com> Date: Tue, 8 Sep 2026 15:04:17 -0700 Subject: [PATCH 09/25] fix(ci): isolate WSL platform fixtures --- .github/workflows/platform-vitest-main.yaml | 43 +++++++++++-------- .../e2e/platform-vitest-main-workflow.test.ts | 16 ++++--- test/automation/e2e/wsl-ci-helper.test.ts | 8 ++-- tools/wsl/ci-helper.ps1 | 4 +- 4 files changed, 40 insertions(+), 31 deletions(-) diff --git a/.github/workflows/platform-vitest-main.yaml b/.github/workflows/platform-vitest-main.yaml index 580192a189f..098fef0d055 100644 --- a/.github/workflows/platform-vitest-main.yaml +++ b/.github/workflows/platform-vitest-main.yaml @@ -154,7 +154,7 @@ jobs: - name: Install dependencies env: - NODE_AUTH_TOKEN: ${{ github.repository == 'NVIDIA/NemoClaw' && github.ref == 'refs/heads/main' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.token || '' }} + NODE_AUTH_TOKEN: ${{ github.repository == 'NVIDIA/NemoClaw' && (github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && github.ref == 'refs/heads/main')) && github.token || '' }} run: | if [[ -n "$NODE_AUTH_TOKEN" ]]; then bash .github/actions/ci-install-dependencies.sh @@ -299,7 +299,7 @@ jobs: . "$env:TRUSTED_WSL_HELPER" $null = Set-WslWorkflowPaths ` -Workspace "$env:GITHUB_WORKSPACE\source" ` - -WorkdirPrefix '/tmp/nemoclaw-wsl-vitest' ` + -WorkdirPrefix '/home/nemoclaw-ci/nemoclaw-wsl-vitest' ` -RunId $env:GITHUB_RUN_ID ` -RunAttempt $env:GITHUB_RUN_ATTEMPT ` -EnvironmentFile $env:GITHUB_ENV @@ -316,26 +316,11 @@ jobs: . "$env:TRUSTED_WSL_HELPER" $packages = @( 'bash', 'ca-certificates', 'curl', 'git', 'jq', 'lsb-release', 'make', - 'docker.io', 'iproute2', 'podman', 'procps', 'python3', 'python3-pip', 'python3-venv', + 'procps', 'python3', 'python3-pip', 'python3-venv', 'ripgrep', 'rsync', 'tar', 'unzip', 'xz-utils' ) Install-WslUbuntuDependencies -Distro $env:WSL_DISTRO -Packages $packages -TestUser $env:WSL_TEST_USER - - name: Start the WSL container runtime - shell: powershell - run: | - . "$env:TRUSTED_WSL_HELPER" - $testUser = ConvertTo-BashLiteral -Value $env:WSL_TEST_USER - $script = @" - set -euo pipefail - usermod -aG docker $testUser - service docker start - docker info - podman --version - ip -Version - "@ - Invoke-WslScript -Distro $env:WSL_DISTRO -User root -Script $script - - name: Install Node.js 22 in WSL shell: powershell run: | @@ -355,7 +340,7 @@ jobs: - name: Install dependencies and build in WSL shell: powershell env: - NODE_AUTH_TOKEN: ${{ github.repository == 'NVIDIA/NemoClaw' && github.ref == 'refs/heads/main' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.token || '' }} + NODE_AUTH_TOKEN: ${{ github.repository == 'NVIDIA/NemoClaw' && (github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && github.ref == 'refs/heads/main')) && github.token || '' }} run: | . "$env:TRUSTED_WSL_HELPER" $testUser = ConvertTo-BashLiteral -Value $env:WSL_TEST_USER @@ -426,6 +411,26 @@ jobs: "@ Invoke-WslScript -Distro $env:WSL_DISTRO -User root -Script $script + - name: Install and start the WSL container runtime + if: ${{ matrix.shard == 1 && github.ref == 'refs/heads/main' }} + shell: powershell + run: | + . "$env:TRUSTED_WSL_HELPER" + Install-WslUbuntuDependencies ` + -Distro $env:WSL_DISTRO ` + -Packages @('docker.io', 'iproute2', 'podman') ` + -TestUser $env:WSL_TEST_USER + $testUser = ConvertTo-BashLiteral -Value $env:WSL_TEST_USER + $script = @" + set -euo pipefail + usermod -aG docker $testUser + service docker start + docker info + podman --version + ip -Version + "@ + Invoke-WslScript -Distro $env:WSL_DISTRO -User root -Script $script + - id: wsl_docker name: Detect Docker availability in WSL if: ${{ matrix.shard == 1 }} diff --git a/test/automation/e2e/platform-vitest-main-workflow.test.ts b/test/automation/e2e/platform-vitest-main-workflow.test.ts index 5b9da32319e..df88d26e940 100644 --- a/test/automation/e2e/platform-vitest-main-workflow.test.ts +++ b/test/automation/e2e/platform-vitest-main-workflow.test.ts @@ -77,22 +77,24 @@ describe("platform evidence workflow", () => { expect(install).not.toMatch(/brew install[^\n]*(?:docker|gnu-tar|iproute2mac|podman)/u); }); - it("starts Docker and qualifies both WSL container clients before the suite", () => { + it("keeps container clients out of the non-live WSL suite", () => { const steps = job("wsl-vitest").steps ?? []; const install = step("wsl-vitest", "Install Ubuntu dependencies").run ?? ""; - const runtime = step("wsl-vitest", "Start the WSL container runtime").run ?? ""; + const runtime = step("wsl-vitest", "Install and start the WSL container runtime").run ?? ""; const runtimeIndex = steps.findIndex( - (entry) => entry.name === "Start the WSL container runtime", + (entry) => entry.name === "Install and start the WSL container runtime", ); const suiteIndex = steps.findIndex((entry) => entry.name === "Run full Vitest suite in WSL"); - expect(install).toContain("'docker.io'"); - expect(install).toContain("'podman'"); + expect(install).not.toContain("'docker.io'"); + expect(install).not.toContain("'podman'"); + expect(runtime).toContain("'docker.io'"); + expect(runtime).toContain("'podman'"); expect(runtime).toContain("service docker start"); expect(runtime).toContain("docker info"); expect(runtime).toContain("podman --version"); expect(runtime).toContain("ip -Version"); expect(runtimeIndex).toBeGreaterThanOrEqual(0); - expect(suiteIndex).toBeGreaterThan(runtimeIndex); + expect(runtimeIndex).toBeGreaterThan(suiteIndex); }); it("scopes WSL live-E2E settings to the credentialed live step", () => { @@ -122,7 +124,7 @@ describe("platform evidence workflow", () => { ); expect(install.env).toMatchObject({ NODE_AUTH_TOKEN: - "${{ github.repository == 'NVIDIA/NemoClaw' && github.ref == 'refs/heads/main' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.token || '' }}", + "${{ github.repository == 'NVIDIA/NemoClaw' && (github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && github.ref == 'refs/heads/main')) && github.token || '' }}", }); expect(install.run).toContain(".github/actions/ci-install-dependencies.sh"); expect(install.run).toContain("npm ci --ignore-scripts"); diff --git a/test/automation/e2e/wsl-ci-helper.test.ts b/test/automation/e2e/wsl-ci-helper.test.ts index 6220141f2cf..c349bc8f049 100644 --- a/test/automation/e2e/wsl-ci-helper.test.ts +++ b/test/automation/e2e/wsl-ci-helper.test.ts @@ -116,7 +116,8 @@ Get-WslCheckoutSyncScript -Checkout "/mnt/d/agent work/repo's" -Workdir "/tmp/ne . ${JSON.stringify(WSL_CI_HELPER)} $workdirs = @( '/tmp/nemoclaw-wsl-workdir/123-1', - '/tmp/nemoclaw-wsl-vitest/123-1' + '/tmp/nemoclaw-wsl-vitest/123-1', + '/home/nemoclaw-ci/nemoclaw-wsl-vitest/123-1' ) @( foreach ($workdir in $workdirs) { @@ -131,6 +132,7 @@ $workdirs = @( expect(JSON.parse(result.stdout.trim())).toEqual([ "/tmp/nemoclaw-wsl-workdir/123-1", "/tmp/nemoclaw-wsl-vitest/123-1", + "/home/nemoclaw-ci/nemoclaw-wsl-vitest/123-1", ]); }, ); @@ -175,7 +177,7 @@ $messages | ConvertTo-Json -Compress " ", ]; const message = - "WSL sync workdir must use /tmp/nemoclaw-wsl-workdir or /tmp/nemoclaw-wsl-vitest with one - child. It must not overlap the checkout or contain traversal"; + "WSL sync workdir must use a supported dedicated root with one - child. It must not overlap the checkout or contain traversal"; expect(JSON.parse(result.stdout.trim())).toEqual( unsafeWorkdirs.map((workdir) => `${message}: '${workdir}'.`), ); @@ -197,7 +199,7 @@ try { expect(result.status).toBe(0); expect(result.stderr).toBe(""); expect(result.stdout.trim()).toBe( - "WSL sync workdir must use /tmp/nemoclaw-wsl-workdir or /tmp/nemoclaw-wsl-vitest with one - child. It must not overlap the checkout or contain traversal: '/tmp/nemoclaw-wsl-workdir/123-1'.", + "WSL sync workdir must use a supported dedicated root with one - child. It must not overlap the checkout or contain traversal: '/tmp/nemoclaw-wsl-workdir/123-1'.", ); }, ); diff --git a/tools/wsl/ci-helper.ps1 b/tools/wsl/ci-helper.ps1 index c9ac607b4b8..d3a7302bda5 100644 --- a/tools/wsl/ci-helper.ps1 +++ b/tools/wsl/ci-helper.ps1 @@ -366,7 +366,7 @@ function Get-WslCheckoutSyncScript { $normalizedCheckout = $Checkout.TrimEnd('/') $normalizedWorkdir = $Workdir.TrimEnd('/') - $dedicatedWorkdirPattern = '^/tmp/nemoclaw-wsl-(?:workdir|vitest)/[1-9][0-9]*-[1-9][0-9]*$' + $dedicatedWorkdirPattern = '^/(?:tmp/nemoclaw-wsl-(?:workdir|vitest)|home/nemoclaw-ci/nemoclaw-wsl-vitest)/[1-9][0-9]*-[1-9][0-9]*$' $workdirUsesDedicatedRoot = $normalizedWorkdir -cmatch $dedicatedWorkdirPattern $unsafePathSegment = '(^|/)\.{1,2}(/|$)' $pathsOverlap = $normalizedCheckout -eq $normalizedWorkdir -or @@ -386,7 +386,7 @@ function Get-WslCheckoutSyncScript { $normalizedWorkdir -match $unsafePathSegment -or $pathsOverlap ) { - throw "WSL sync workdir must use /tmp/nemoclaw-wsl-workdir or /tmp/nemoclaw-wsl-vitest with one - child. It must not overlap the checkout or contain traversal: '$Workdir'." + throw "WSL sync workdir must use a supported dedicated root with one - child. It must not overlap the checkout or contain traversal: '$Workdir'." } $workdirRoot = $normalizedWorkdir.Substring(0, $normalizedWorkdir.LastIndexOf('/')) From acc7f1e121829b69f0cf2a31fb37304cefda0d21 Mon Sep 17 00:00:00 2001 From: Rebecca Sliter <571084+rsliter@users.noreply.github.com> Date: Tue, 8 Sep 2026 15:21:43 -0700 Subject: [PATCH 10/25] test(ci): require WSL suite ordering target --- test/automation/e2e/platform-vitest-main-workflow.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/test/automation/e2e/platform-vitest-main-workflow.test.ts b/test/automation/e2e/platform-vitest-main-workflow.test.ts index df88d26e940..f1175a11ee5 100644 --- a/test/automation/e2e/platform-vitest-main-workflow.test.ts +++ b/test/automation/e2e/platform-vitest-main-workflow.test.ts @@ -94,6 +94,7 @@ describe("platform evidence workflow", () => { expect(runtime).toContain("podman --version"); expect(runtime).toContain("ip -Version"); expect(runtimeIndex).toBeGreaterThanOrEqual(0); + expect(suiteIndex).toBeGreaterThanOrEqual(0); expect(runtimeIndex).toBeGreaterThan(suiteIndex); }); From a8ced8453caa6146dd9e101ad8624e803de2545f Mon Sep 17 00:00:00 2001 From: Rebecca Sliter <571084+rsliter@users.noreply.github.com> Date: Tue, 8 Sep 2026 18:04:33 -0700 Subject: [PATCH 11/25] test(ci): harden platform compatibility fixtures --- .github/workflows/platform-vitest-main.yaml | 12 +- ...igration-state-restore-transaction.test.ts | 3 +- .../commands/migration-state-security.test.ts | 3 +- .../llama-cpp/managed-installer.test.ts | 10 +- .../llama-cpp/managed-status.test.ts | 9 +- .../host-gateway-process-target.test.ts | 15 +- src/lib/onboard/host-gateway-process.ts | 4 +- .../docker-operation-authority.test.ts | 8 +- .../e2e/platform-vitest-main-workflow.test.ts | 22 +- test/automation/e2e/wsl-ci-helper.test.ts | 3 + .../pr-review-advisor-local.test.ts | 10 +- test/e2e/fixtures/host-address.ts | 14 +- test/e2e/support/e2e-host-address.test.ts | 1 + .../support/lifecycle-user-service.test.ts | 341 +++++++++--------- .../sandbox-images-workflow-boundary.test.ts | 9 +- test/helpers/rebuild-flow-generic-harness.ts | 6 + test/helpers/rebuild-flow-harness.ts | 3 + .../install-preflight.test.ts | 2 +- ...oard-extra-provider-reconciliation.test.ts | 2 + .../onboard-fresh-create-identity.test.ts | 1 + .../onboard-inference-reconciliation.test.ts | 11 + .../onboard-installer-restore-intent.test.ts | 3 + .../onboard-reservation-recreate.test.ts | 2 + test/onboarding/onboard-sandbox-build.test.ts | 10 + .../onboard-sandbox-recreation.test.ts | 8 + .../migration-state-packaged.test.ts | 7 +- tools/wsl/ci-helper.ps1 | 1 + 27 files changed, 310 insertions(+), 210 deletions(-) diff --git a/.github/workflows/platform-vitest-main.yaml b/.github/workflows/platform-vitest-main.yaml index 098fef0d055..a3f4b380276 100644 --- a/.github/workflows/platform-vitest-main.yaml +++ b/.github/workflows/platform-vitest-main.yaml @@ -122,10 +122,7 @@ jobs: set -euo pipefail test -x "$(command -v gtar)" brew install bash coreutils fd gawk ripgrep - mkdir -p "$RUNNER_TEMP/nemoclaw-bin" - ln -s "$(command -v gtar)" "$RUNNER_TEMP/nemoclaw-bin/tar" printf '%s\n' \ - "$RUNNER_TEMP/nemoclaw-bin" \ "$(brew --prefix bash)/bin" \ "$(brew --prefix coreutils)/libexec/gnubin" \ "$(brew --prefix gawk)/libexec/gnubin" \ @@ -316,8 +313,9 @@ jobs: . "$env:TRUSTED_WSL_HELPER" $packages = @( 'bash', 'ca-certificates', 'curl', 'git', 'jq', 'lsb-release', 'make', + 'docker.io', 'gcc', 'iproute2', 'podman', 'procps', 'python3', 'python3-pip', 'python3-venv', - 'ripgrep', 'rsync', 'tar', 'unzip', 'xz-utils' + 'ripgrep', 'rsync', 'tar', 'unzip', 'xz-utils', 'zip' ) Install-WslUbuntuDependencies -Distro $env:WSL_DISTRO -Packages $packages -TestUser $env:WSL_TEST_USER @@ -411,15 +409,11 @@ jobs: "@ Invoke-WslScript -Distro $env:WSL_DISTRO -User root -Script $script - - name: Install and start the WSL container runtime + - name: Start the WSL container runtime if: ${{ matrix.shard == 1 && github.ref == 'refs/heads/main' }} shell: powershell run: | . "$env:TRUSTED_WSL_HELPER" - Install-WslUbuntuDependencies ` - -Distro $env:WSL_DISTRO ` - -Packages @('docker.io', 'iproute2', 'podman') ` - -TestUser $env:WSL_TEST_USER $testUser = ConvertTo-BashLiteral -Value $env:WSL_TEST_USER $script = @" set -euo pipefail diff --git a/nemoclaw/src/commands/migration-state-restore-transaction.test.ts b/nemoclaw/src/commands/migration-state-restore-transaction.test.ts index ea645a220ba..5bb817348f4 100644 --- a/nemoclaw/src/commands/migration-state-restore-transaction.test.ts +++ b/nemoclaw/src/commands/migration-state-restore-transaction.test.ts @@ -7,6 +7,7 @@ import { mkdtempSync, mkdirSync, readFileSync, + realpathSync, readdirSync, renameSync, rmSync, @@ -53,7 +54,7 @@ import { restoreSnapshotToHost } from "./migration-state.js"; const temporaryRoots: string[] = []; function makeHome(): string { - const home = mkdtempSync(path.join(tmpdir(), "nemoclaw-restore-transaction-")); + const home = realpathSync(mkdtempSync(path.join(tmpdir(), "nemoclaw-restore-transaction-"))); temporaryRoots.push(home); return home; } diff --git a/nemoclaw/src/commands/migration-state-security.test.ts b/nemoclaw/src/commands/migration-state-security.test.ts index c5a775679f6..5f6d70564fb 100644 --- a/nemoclaw/src/commands/migration-state-security.test.ts +++ b/nemoclaw/src/commands/migration-state-security.test.ts @@ -8,6 +8,7 @@ import { mkdirSync, mkdtempSync, openSync, + realpathSync, readdirSync, readFileSync, rmSync, @@ -36,7 +37,7 @@ import { makeSnapshotManifest } from "./migration-state-test-fixtures.js"; const roots: string[] = []; function makeHome(): string { - const home = mkdtempSync(path.join(tmpdir(), "nemoclaw-migration-state-security-")); + const home = realpathSync(mkdtempSync(path.join(tmpdir(), "nemoclaw-migration-state-security-"))); roots.push(home); return home; } diff --git a/src/lib/inference/llama-cpp/managed-installer.test.ts b/src/lib/inference/llama-cpp/managed-installer.test.ts index 76b82273cc0..ade37b316ba 100644 --- a/src/lib/inference/llama-cpp/managed-installer.test.ts +++ b/src/lib/inference/llama-cpp/managed-installer.test.ts @@ -5,7 +5,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createInMemoryRuntimeProviderBundle } from "../../../../test/helpers/runtime-provider-bundle"; import type { ContainerEngine } from "../../adapters/container-engine"; import type { PodmanContainerEngine } from "../../adapters/podman"; @@ -62,7 +62,15 @@ const TEST_WORKLOAD_PROFILE = { const temporaryDirectories: string[] = []; +beforeEach(() => { + const executableRoot = temporaryHome(); + fs.writeFileSync(path.join(executableRoot, "docker"), "#!/bin/sh\nexit 0\n", { mode: 0o700 }); + fs.writeFileSync(path.join(executableRoot, "ssh"), "#!/bin/sh\nexit 0\n", { mode: 0o700 }); + vi.stubEnv("PATH", executableRoot); +}); + afterEach(() => { + vi.unstubAllEnvs(); for (const directory of temporaryDirectories.splice(0)) { fs.rmSync(directory, { force: true, recursive: true }); } diff --git a/src/lib/inference/llama-cpp/managed-status.test.ts b/src/lib/inference/llama-cpp/managed-status.test.ts index 0bfc5d8a673..8bbaac903b4 100644 --- a/src/lib/inference/llama-cpp/managed-status.test.ts +++ b/src/lib/inference/llama-cpp/managed-status.test.ts @@ -5,7 +5,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { ContainerEngine } from "../../adapters/container-engine"; import { dockerLlamaCppBindingSha256 as managedLlamaCppBindingSha256 } from "../../onboard/runtime-provider/docker-llama-cpp-operation"; @@ -35,7 +35,14 @@ const GENERIC_PRESET_ID = "llama-cpp.linux-amd64-nvidia.single.nemotron-3-nano-3 const IMAGE = `ghcr.io/nvidia/llama-cpp@sha256:${"4".repeat(64)}`; const temporaryDirectories: string[] = []; +beforeEach(() => { + const executableRoot = temporaryHome(); + fs.writeFileSync(path.join(executableRoot, "docker"), "#!/bin/sh\nexit 0\n", { mode: 0o700 }); + vi.stubEnv("PATH", executableRoot); +}); + afterEach(() => { + vi.unstubAllEnvs(); for (const directory of temporaryDirectories.splice(0)) { fs.rmSync(directory, { force: true, recursive: true }); } diff --git a/src/lib/onboard/host-gateway-process-target.test.ts b/src/lib/onboard/host-gateway-process-target.test.ts index 3ae574366a6..ae3e6b5d03a 100644 --- a/src/lib/onboard/host-gateway-process-target.test.ts +++ b/src/lib/onboard/host-gateway-process-target.test.ts @@ -176,6 +176,19 @@ function stopScopedTarget( [NEMOCLAW_OPENSHELL_SANDBOX_NAMESPACE_ENV]: overrides.namespace ?? gatewayIdForStateDir(stateDir), }), + ...(provider === "podman" + ? { + resolveRuntimeProvider: () => + ({ + gateway: { + supported: true, + prepareHostRuntime: () => ({ + gatewayConfig: { processOwnership: "runtime-marker" }, + }), + }, + }) as never, + } + : {}), warn: vi.fn(), }, { @@ -241,7 +254,7 @@ describe("stopHostGatewayProcesses target filtering", () => { try { const { kill, pidFile, result, run } = stopScopedTarget({ provider: "podman" }); - expect(result.stopped).toEqual([9_999_601]); + expect(result.stopped, result.ownershipFailures?.join("\n")).toEqual([9_999_601]); expect(result.ownershipFailures).toEqual([]); expect(kill).toHaveBeenCalledWith(9_999_601, "SIGTERM"); expect(run.mock.calls.some(([command]) => command === "pgrep")).toBe(false); diff --git a/src/lib/onboard/host-gateway-process.ts b/src/lib/onboard/host-gateway-process.ts index 27d90661386..2a5f6f0fda1 100644 --- a/src/lib/onboard/host-gateway-process.ts +++ b/src/lib/onboard/host-gateway-process.ts @@ -46,6 +46,7 @@ export interface HostGatewayProcessDeps { log?: (message: string) => void; readProcessExecutable?: (pid: number) => string | null; readProcessEnvironment?: (pid: number) => Record | null; + resolveRuntimeProvider?: typeof resolveRegisteredRuntimeProvider; warn?: (message: string) => void; } @@ -138,6 +139,7 @@ function defaultDeps(overrides: Partial = {}): HostGatew log: overrides.log, readProcessExecutable: overrides.readProcessExecutable, readProcessEnvironment: overrides.readProcessEnvironment, + resolveRuntimeProvider: overrides.resolveRuntimeProvider ?? resolveRegisteredRuntimeProvider, warn: overrides.warn, }; } @@ -351,7 +353,7 @@ function scopedGatewayOwnershipFailure( if (Number(pidText?.trim()) !== pid || marker?.pid !== pid) { return "PID file and runtime marker do not identify the same process"; } - const provider = resolveRegisteredRuntimeProvider(marker.driver); + const provider = deps.resolveRuntimeProvider!(marker.driver); if (!provider?.gateway.supported) { return "runtime marker does not identify a registered gateway provider"; } diff --git a/src/lib/onboard/runtime-provider/docker-operation-authority.test.ts b/src/lib/onboard/runtime-provider/docker-operation-authority.test.ts index c4a8e7e01da..59c2cde919e 100644 --- a/src/lib/onboard/runtime-provider/docker-operation-authority.test.ts +++ b/src/lib/onboard/runtime-provider/docker-operation-authority.test.ts @@ -6,7 +6,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createContextCapture as contextCapture, createDriftingContextCapture, @@ -41,6 +41,12 @@ function fakeDockerScript(script: string): string { return root; } +beforeEach(() => { + const executableRoot = fakeDocker("qualified"); + writeFakeExecutable(executableRoot, "ssh", "exit 0"); + vi.stubEnv("PATH", executableRoot); +}); + afterEach(() => { vi.unstubAllEnvs(); for (const root of roots.splice(0)) fs.rmSync(root, { force: true, recursive: true }); diff --git a/test/automation/e2e/platform-vitest-main-workflow.test.ts b/test/automation/e2e/platform-vitest-main-workflow.test.ts index f1175a11ee5..16c1e8a414f 100644 --- a/test/automation/e2e/platform-vitest-main-workflow.test.ts +++ b/test/automation/e2e/platform-vitest-main-workflow.test.ts @@ -66,29 +66,31 @@ describe("platform evidence workflow", () => { }); }); - it("uses the runner's preinstalled GNU tar without adding mutable formulae", () => { + it("verifies GNU tar without replacing the native macOS tar", () => { const install = step("macos-vitest", "Install macOS test dependencies").run ?? ""; expect(install).toContain('test -x "$(command -v gtar)"'); expect(install.indexOf('test -x "$(command -v gtar)"')).toBeLessThan( install.indexOf("brew install"), ); - expect(install).toContain('ln -s "$(command -v gtar)" "$RUNNER_TEMP/nemoclaw-bin/tar"'); - expect(install).toContain('"$RUNNER_TEMP/nemoclaw-bin"'); + expect(install).not.toContain('ln -s "$(command -v gtar)"'); + expect(install).not.toContain('"$RUNNER_TEMP/nemoclaw-bin"'); expect(install).not.toMatch(/brew install[^\n]*(?:docker|gnu-tar|iproute2mac|podman)/u); }); - it("keeps container clients out of the non-live WSL suite", () => { + it("installs container clients before Vitest but starts Docker only afterward", () => { const steps = job("wsl-vitest").steps ?? []; const install = step("wsl-vitest", "Install Ubuntu dependencies").run ?? ""; - const runtime = step("wsl-vitest", "Install and start the WSL container runtime").run ?? ""; + const runtime = step("wsl-vitest", "Start the WSL container runtime").run ?? ""; const runtimeIndex = steps.findIndex( - (entry) => entry.name === "Install and start the WSL container runtime", + (entry) => entry.name === "Start the WSL container runtime", ); const suiteIndex = steps.findIndex((entry) => entry.name === "Run full Vitest suite in WSL"); - expect(install).not.toContain("'docker.io'"); - expect(install).not.toContain("'podman'"); - expect(runtime).toContain("'docker.io'"); - expect(runtime).toContain("'podman'"); + expect(install).toContain("'docker.io'"); + expect(install).toContain("'podman'"); + expect(install).toContain("'iproute2'"); + expect(install).toContain("'zip'"); + expect(install).not.toContain("service docker start"); + expect(runtime).not.toContain("Install-WslUbuntuDependencies"); expect(runtime).toContain("service docker start"); expect(runtime).toContain("docker info"); expect(runtime).toContain("podman --version"); diff --git a/test/automation/e2e/wsl-ci-helper.test.ts b/test/automation/e2e/wsl-ci-helper.test.ts index c349bc8f049..c9307350786 100644 --- a/test/automation/e2e/wsl-ci-helper.test.ts +++ b/test/automation/e2e/wsl-ci-helper.test.ts @@ -107,6 +107,9 @@ Get-WslCheckoutSyncScript -Checkout "/mnt/d/agent work/repo's" -Workdir "/tmp/ne expect(result.stdout).toContain( "chown -R 'nemoclaw-ci:nemoclaw-ci' '/tmp/nemoclaw-wsl-workdir/123-1'", ); + expect(result.stdout).toContain( + "chmod 0755 '/tmp/nemoclaw-wsl-workdir' '/tmp/nemoclaw-wsl-workdir/123-1'", + ); }, ); diff --git a/test/automation/pull-requests/pr-review-advisor-local.test.ts b/test/automation/pull-requests/pr-review-advisor-local.test.ts index 11b1ff97b1c..a98c2a5d4b3 100644 --- a/test/automation/pull-requests/pr-review-advisor-local.test.ts +++ b/test/automation/pull-requests/pr-review-advisor-local.test.ts @@ -24,7 +24,7 @@ const SIGTERM_IGNORING_CHILD_FIXTURE = fileURLToPath( const temporaryDirectories: string[] = []; function temporaryDirectory(): string { - const directory = fs.mkdtempSync(path.join(os.tmpdir(), "local-review-test-")); + const directory = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "local-review-test-"))); temporaryDirectories.push(directory); return directory; } @@ -425,8 +425,12 @@ describe("local PR review advisor", () => { expect(fs.existsSync(path.join(snapshot, "ignored.txt"))).toBe(false); expect(fs.readlinkSync(path.join(snapshot, "tracked-internal-link"))).toBe("committed.txt"); expect(fs.readlinkSync(path.join(snapshot, "tracked-retargeted-link"))).toBe("committed.txt"); - expect(git(snapshot, ["ls-tree", refs.headRef, "tracked-internal-link"])).toContain("120000 blob"); - expect(git(snapshot, ["ls-tree", refs.headRef, "tracked-retargeted-link"])).toContain("120000 blob"); + expect(git(snapshot, ["ls-tree", refs.headRef, "tracked-internal-link"])).toContain( + "120000 blob", + ); + expect(git(snapshot, ["ls-tree", refs.headRef, "tracked-retargeted-link"])).toContain( + "120000 blob", + ); expect(git(snapshot, ["ls-tree", refs.headRef, "untracked-link"])).toContain("120000 blob"); expect(fs.existsSync(path.join(snapshot, "untracked-link"))).toBe(false); expect(git(source, ["status", "--porcelain=v1", "-uall"])).toBe(before); diff --git a/test/e2e/fixtures/host-address.ts b/test/e2e/fixtures/host-address.ts index ccbf10fee37..c23de1cb6ce 100644 --- a/test/e2e/fixtures/host-address.ts +++ b/test/e2e/fixtures/host-address.ts @@ -26,11 +26,12 @@ export interface HostAddressResult { export function configuredRuntimeProviderHostAddress( environment: NodeJS.ProcessEnv = process.env, platform: NodeJS.Platform = process.platform, + prepareHostRuntime = prepareConfiguredGatewayHostRuntime, ): string | null { if (!environment.NEMOCLAW_GATEWAY_RUNTIME || isPortableExperimentalProfile(environment)) { return null; } - return prepareConfiguredGatewayHostRuntime({ environment, platform }).sandboxHostAddress; + return prepareHostRuntime({ environment, platform }).sandboxHostAddress; } export function parseHostAddressProbe( @@ -61,11 +62,18 @@ export async function discoverHostAddress( artifactName = "host-address-for-sandbox", environment: NodeJS.ProcessEnv = process.env, platform: NodeJS.Platform = process.platform, + prepareHostRuntime = prepareConfiguredGatewayHostRuntime, ): Promise { - const runtimeProviderAddress = configuredRuntimeProviderHostAddress(environment, platform); + const runtimeProviderAddress = configuredRuntimeProviderHostAddress( + environment, + platform, + prepareHostRuntime, + ); if (runtimeProviderAddress !== null) { if (isIP(runtimeProviderAddress) !== 4) { - throw new Error(`runtime provider returned invalid sandbox host address: ${runtimeProviderAddress}`); + throw new Error( + `runtime provider returned invalid sandbox host address: ${runtimeProviderAddress}`, + ); } return { source: "runtime-provider", address: runtimeProviderAddress, probe: null }; } diff --git a/test/e2e/support/e2e-host-address.test.ts b/test/e2e/support/e2e-host-address.test.ts index f03cd4e0755..746df690bed 100644 --- a/test/e2e/support/e2e-host-address.test.ts +++ b/test/e2e/support/e2e-host-address.test.ts @@ -61,6 +61,7 @@ describe("host address discovery", () => { OPENSHELL_PODMAN_SOCKET: "/tmp/podman.sock", }, "linux", + () => ({ sandboxHostAddress: "169.254.2.2" }) as never, ), ).resolves.toEqual({ source: "runtime-provider", address: "169.254.2.2", probe: null }); }); diff --git a/test/e2e/support/lifecycle-user-service.test.ts b/test/e2e/support/lifecycle-user-service.test.ts index 17b4d2a6581..5e9df6e80db 100644 --- a/test/e2e/support/lifecycle-user-service.test.ts +++ b/test/e2e/support/lifecycle-user-service.test.ts @@ -18,182 +18,185 @@ import { const installer = fileURLToPath(new URL("../../../scripts/install.sh", import.meta.url)); -describe("reboot lifecycle OpenShell gateway user-service fixture", () => { - it("stages, enables, and removes the repository service without installer cleanup", () => { - const root = fs.mkdtempSync( - path.join(os.tmpdir(), "nemoclaw-installer-lifecycle-stage-service-"), - ); - const home = path.join(root, "home"); - const configHome = path.join(root, "config"); - const bin = path.join(home, ".local", "bin"); - const log = path.join(root, "systemctl.log"); - const unit = path.join(configHome, "systemd", "user", "nemoclaw-openshell-gateway.service"); - const installerCleanupSentinel = path.join(root, "installer-cleanup-sentinel"); - - fs.mkdirSync(bin, { recursive: true }); - fs.writeFileSync(installerCleanupSentinel, "fixture-owned\n"); - fs.writeFileSync(path.join(bin, "openshell-gateway"), "#!/bin/sh\n", { mode: 0o755 }); - fs.writeFileSync( - path.join(bin, "systemctl"), - [ - "#!/bin/sh", - `printf "%s\\n" "$*" >> ${JSON.stringify(log)}`, - 'if [ "$*" = "--user cat openshell-gateway" ]; then exit 1; fi', - "exit 0", - ].join("\n"), - { mode: 0o755 }, - ); - - try { - const env = buildAvailabilityProbeEnv({ - HOME: home, - PATH: `${bin}:${path.dirname(process.execPath)}:/usr/bin:/bin`, - XDG_CONFIG_HOME: configHome, - }); - env.NEMOCLAW_INSTALLER_STAGED = installerCleanupSentinel; - const staged = execFileSync( - "bash", - ["-lc", buildOpenShellGatewayUserServiceStageScript(), "stage-service", installer], - { encoding: "utf8", env, killSignal: "SIGKILL", timeout: 30_000 }, +describe.runIf(process.platform === "linux")( + "reboot lifecycle OpenShell gateway user-service fixture", + () => { + it("stages, enables, and removes the repository service without installer cleanup", () => { + const root = fs.mkdtempSync( + path.join(os.tmpdir(), "nemoclaw-installer-lifecycle-stage-service-"), ); - - expect(staged).toContain("NEMOCLAW_E2E_GATEWAY_USER_SERVICE=staged"); - expect(fs.existsSync(installerCleanupSentinel)).toBe(true); - expect(fs.readFileSync(unit, "utf8")).toContain(`ExecStart=${bin}/openshell-gateway`); - expect(fs.statSync(unit).mode & 0o777).toBe(0o600); - - execFileSync("sh", ["-lc", buildOpenShellGatewayUserServiceRemovalScript()], { - env, - killSignal: "SIGKILL", - timeout: 30_000, - }); - - expect(fs.existsSync(unit)).toBe(false); - expect(fs.readFileSync(log, "utf8").trim().split("\n")).toEqual([ - "--user daemon-reload", - "--user cat openshell-gateway", - "--user enable nemoclaw-openshell-gateway", - "--user stop nemoclaw-openshell-gateway", - "--user disable nemoclaw-openshell-gateway", - "--user daemon-reload", - ]); - } finally { - fs.rmSync(root, { force: true, recursive: true }); - } - }); - - it("uses an existing upstream service without staging a NemoClaw unit", () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-lifecycle-upstream-service-")); - const home = path.join(root, "home"); - const configHome = path.join(root, "config"); - const bin = path.join(root, "bin"); - - fs.mkdirSync(home, { recursive: true }); - fs.mkdirSync(bin, { recursive: true }); - fs.writeFileSync(path.join(bin, "systemctl"), "#!/bin/sh\nexit 0\n", { mode: 0o755 }); - - try { - const env = buildAvailabilityProbeEnv({ - HOME: home, - PATH: `${bin}:/usr/bin:/bin`, - XDG_CONFIG_HOME: configHome, - }); - const output = execFileSync( - "bash", - ["-lc", buildOpenShellGatewayUserServiceStageScript(), "stage-service", installer], - { encoding: "utf8", env, killSignal: "SIGKILL", timeout: 30_000 }, + const home = path.join(root, "home"); + const configHome = path.join(root, "config"); + const bin = path.join(home, ".local", "bin"); + const log = path.join(root, "systemctl.log"); + const unit = path.join(configHome, "systemd", "user", "nemoclaw-openshell-gateway.service"); + const installerCleanupSentinel = path.join(root, "installer-cleanup-sentinel"); + + fs.mkdirSync(bin, { recursive: true }); + fs.writeFileSync(installerCleanupSentinel, "fixture-owned\n"); + fs.writeFileSync(path.join(bin, "openshell-gateway"), "#!/bin/sh\n", { mode: 0o755 }); + fs.writeFileSync( + path.join(bin, "systemctl"), + [ + "#!/bin/sh", + `printf "%s\\n" "$*" >> ${JSON.stringify(log)}`, + 'if [ "$*" = "--user cat openshell-gateway" ]; then exit 1; fi', + "exit 0", + ].join("\n"), + { mode: 0o755 }, ); - expect(output).toContain("NEMOCLAW_E2E_GATEWAY_USER_SERVICE=upstream"); - expect( - fs.existsSync( - path.join(configHome, "systemd", "user", "nemoclaw-openshell-gateway.service"), - ), - ).toBe(false); - } finally { - fs.rmSync(root, { force: true, recursive: true }); - } - }); - - it("removes a staged service when daemon reload fails", () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-lifecycle-stage-failure-")); - const home = path.join(root, "home"); - const configHome = path.join(root, "config"); - const bin = path.join(home, ".local", "bin"); - const unit = path.join(configHome, "systemd", "user", "nemoclaw-openshell-gateway.service"); - - fs.mkdirSync(bin, { recursive: true }); - fs.writeFileSync(path.join(bin, "openshell-gateway"), "#!/bin/sh\n", { mode: 0o755 }); - fs.writeFileSync( - path.join(bin, "systemctl"), - [ - "#!/bin/sh", - 'if [ "$*" = "--user cat openshell-gateway" ]; then exit 1; fi', - 'if [ "$*" = "--user daemon-reload" ]; then exit 1; fi', - "exit 0", - ].join("\n"), - { mode: 0o755 }, - ); - - try { - const env = buildAvailabilityProbeEnv({ - HOME: home, - PATH: `${bin}:${path.dirname(process.execPath)}:/usr/bin:/bin`, - XDG_CONFIG_HOME: configHome, - }); - - expect(() => - execFileSync( + try { + const env = buildAvailabilityProbeEnv({ + HOME: home, + PATH: `${bin}:${path.dirname(process.execPath)}:/usr/bin:/bin`, + XDG_CONFIG_HOME: configHome, + }); + env.NEMOCLAW_INSTALLER_STAGED = installerCleanupSentinel; + const staged = execFileSync( "bash", ["-lc", buildOpenShellGatewayUserServiceStageScript(), "stage-service", installer], - { env, killSignal: "SIGKILL", stdio: "pipe", timeout: 30_000 }, - ), - ).toThrow(); - expect(fs.existsSync(unit)).toBe(false); - } finally { - fs.rmSync(root, { force: true, recursive: true }); - } - }); - - it("refuses to replace a foreign NemoClaw-named service", () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-lifecycle-foreign-service-")); - const home = path.join(root, "home"); - const configHome = path.join(root, "config"); - const bin = path.join(home, ".local", "bin"); - const unitDir = path.join(configHome, "systemd", "user"); - const unit = path.join(unitDir, "nemoclaw-openshell-gateway.service"); - - fs.mkdirSync(home, { recursive: true }); - fs.mkdirSync(bin, { recursive: true }); - fs.mkdirSync(unitDir, { recursive: true }); - fs.writeFileSync(unit, "[Service]\nExecStart=/tmp/foreign\n"); - fs.writeFileSync(path.join(bin, "openshell-gateway"), "#!/bin/sh\n", { mode: 0o755 }); - fs.writeFileSync( - path.join(bin, "systemctl"), - '#!/bin/sh\n[ "$*" = "--user cat openshell-gateway" ] && exit 1\nexit 0\n', - { mode: 0o755 }, - ); - - try { - const env = buildAvailabilityProbeEnv({ - HOME: home, - PATH: `${bin}:/usr/bin:/bin`, - XDG_CONFIG_HOME: configHome, - }); - - expect(() => - execFileSync( + { encoding: "utf8", env, killSignal: "SIGKILL", timeout: 30_000 }, + ); + + expect(staged).toContain("NEMOCLAW_E2E_GATEWAY_USER_SERVICE=staged"); + expect(fs.existsSync(installerCleanupSentinel)).toBe(true); + expect(fs.readFileSync(unit, "utf8")).toContain(`ExecStart=${bin}/openshell-gateway`); + expect(fs.statSync(unit).mode & 0o777).toBe(0o600); + + execFileSync("sh", ["-lc", buildOpenShellGatewayUserServiceRemovalScript()], { + env, + killSignal: "SIGKILL", + timeout: 30_000, + }); + + expect(fs.existsSync(unit)).toBe(false); + expect(fs.readFileSync(log, "utf8").trim().split("\n")).toEqual([ + "--user daemon-reload", + "--user cat openshell-gateway", + "--user enable nemoclaw-openshell-gateway", + "--user stop nemoclaw-openshell-gateway", + "--user disable nemoclaw-openshell-gateway", + "--user daemon-reload", + ]); + } finally { + fs.rmSync(root, { force: true, recursive: true }); + } + }); + + it("uses an existing upstream service without staging a NemoClaw unit", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-lifecycle-upstream-service-")); + const home = path.join(root, "home"); + const configHome = path.join(root, "config"); + const bin = path.join(root, "bin"); + + fs.mkdirSync(home, { recursive: true }); + fs.mkdirSync(bin, { recursive: true }); + fs.writeFileSync(path.join(bin, "systemctl"), "#!/bin/sh\nexit 0\n", { mode: 0o755 }); + + try { + const env = buildAvailabilityProbeEnv({ + HOME: home, + PATH: `${bin}:/usr/bin:/bin`, + XDG_CONFIG_HOME: configHome, + }); + const output = execFileSync( "bash", ["-lc", buildOpenShellGatewayUserServiceStageScript(), "stage-service", installer], - { env, killSignal: "SIGKILL", stdio: "pipe", timeout: 30_000 }, - ), - ).toThrow(); - expect(fs.readFileSync(unit, "utf8")).toBe("[Service]\nExecStart=/tmp/foreign\n"); - } finally { - fs.rmSync(root, { force: true, recursive: true }); - } - }); -}); + { encoding: "utf8", env, killSignal: "SIGKILL", timeout: 30_000 }, + ); + + expect(output).toContain("NEMOCLAW_E2E_GATEWAY_USER_SERVICE=upstream"); + expect( + fs.existsSync( + path.join(configHome, "systemd", "user", "nemoclaw-openshell-gateway.service"), + ), + ).toBe(false); + } finally { + fs.rmSync(root, { force: true, recursive: true }); + } + }); + + it("removes a staged service when daemon reload fails", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-lifecycle-stage-failure-")); + const home = path.join(root, "home"); + const configHome = path.join(root, "config"); + const bin = path.join(home, ".local", "bin"); + const unit = path.join(configHome, "systemd", "user", "nemoclaw-openshell-gateway.service"); + + fs.mkdirSync(bin, { recursive: true }); + fs.writeFileSync(path.join(bin, "openshell-gateway"), "#!/bin/sh\n", { mode: 0o755 }); + fs.writeFileSync( + path.join(bin, "systemctl"), + [ + "#!/bin/sh", + 'if [ "$*" = "--user cat openshell-gateway" ]; then exit 1; fi', + 'if [ "$*" = "--user daemon-reload" ]; then exit 1; fi', + "exit 0", + ].join("\n"), + { mode: 0o755 }, + ); + + try { + const env = buildAvailabilityProbeEnv({ + HOME: home, + PATH: `${bin}:${path.dirname(process.execPath)}:/usr/bin:/bin`, + XDG_CONFIG_HOME: configHome, + }); + + expect(() => + execFileSync( + "bash", + ["-lc", buildOpenShellGatewayUserServiceStageScript(), "stage-service", installer], + { env, killSignal: "SIGKILL", stdio: "pipe", timeout: 30_000 }, + ), + ).toThrow(); + expect(fs.existsSync(unit)).toBe(false); + } finally { + fs.rmSync(root, { force: true, recursive: true }); + } + }); + + it("refuses to replace a foreign NemoClaw-named service", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-lifecycle-foreign-service-")); + const home = path.join(root, "home"); + const configHome = path.join(root, "config"); + const bin = path.join(home, ".local", "bin"); + const unitDir = path.join(configHome, "systemd", "user"); + const unit = path.join(unitDir, "nemoclaw-openshell-gateway.service"); + + fs.mkdirSync(home, { recursive: true }); + fs.mkdirSync(bin, { recursive: true }); + fs.mkdirSync(unitDir, { recursive: true }); + fs.writeFileSync(unit, "[Service]\nExecStart=/tmp/foreign\n"); + fs.writeFileSync(path.join(bin, "openshell-gateway"), "#!/bin/sh\n", { mode: 0o755 }); + fs.writeFileSync( + path.join(bin, "systemctl"), + '#!/bin/sh\n[ "$*" = "--user cat openshell-gateway" ] && exit 1\nexit 0\n', + { mode: 0o755 }, + ); + + try { + const env = buildAvailabilityProbeEnv({ + HOME: home, + PATH: `${bin}:/usr/bin:/bin`, + XDG_CONFIG_HOME: configHome, + }); + + expect(() => + execFileSync( + "bash", + ["-lc", buildOpenShellGatewayUserServiceStageScript(), "stage-service", installer], + { env, killSignal: "SIGKILL", stdio: "pipe", timeout: 30_000 }, + ), + ).toThrow(); + expect(fs.readFileSync(unit, "utf8")).toBe("[Service]\nExecStart=/tmp/foreign\n"); + } finally { + fs.rmSync(root, { force: true, recursive: true }); + } + }); + }, +); describe("managed OpenShell gateway user-service restart", () => { it("selects a marked unit from an absolute custom XDG config root", () => { diff --git a/test/e2e/support/sandbox-images-workflow-boundary.test.ts b/test/e2e/support/sandbox-images-workflow-boundary.test.ts index 3261f92d76b..b0da4f24e8c 100644 --- a/test/e2e/support/sandbox-images-workflow-boundary.test.ts +++ b/test/e2e/support/sandbox-images-workflow-boundary.test.ts @@ -687,10 +687,7 @@ describe("sandbox image workflow boundary", () => { (step) => step.name === "Remove swap after Hermes image export", ); expect(cleanup).toBeDefined(); - cleanup!.run = cleanup!.run!.replace( - 'sudo swapoff "$swap_file"', - 'echo "swap retained"', - ); + cleanup!.run = cleanup!.run!.replace('sudo swapoff "$swap_file"', 'echo "swap retained"'); expect(validateSandboxImagesWorkflow(imageWorkflow, mainWorkflow)).toContain( `${jobName} Hermes export swap cleanup must include sudo swapoff "$swap_file"`, @@ -716,8 +713,8 @@ describe("sandbox image workflow boundary", () => { producer.steps!.push({ ...runtime }); producer.steps!.push({ ...runtimeSteps.find((step) => step.name === "Set up Node")! }); const save = producer.steps!.find((step) => step.name === "Save production image")!; - save.run = save.run! - .replace("set -euo pipefail", "set -eu") + save.run = save + .run!.replace("set -euo pipefail", "set -eu") .replace("docker save nemoclaw-production", "docker save rebuilt-image"); producer.steps!.push({ ...save }); const isolationUpload = producer.steps!.find((step) => step.name === "Upload isolation image")!; diff --git a/test/helpers/rebuild-flow-generic-harness.ts b/test/helpers/rebuild-flow-generic-harness.ts index e58aa89686f..cf7155d045d 100644 --- a/test/helpers/rebuild-flow-generic-harness.ts +++ b/test/helpers/rebuild-flow-generic-harness.ts @@ -37,6 +37,7 @@ import { policies, policyGet, policyState, + portableRetirementAuthority, processRecovery, purgeRebuildModule, type RebuildFlowHarness, @@ -221,6 +222,11 @@ export function createRebuildFlowHarness(overrides: RebuildFlowOverrides = {}): .mockReturnValue( overrides.baseImagePreflight ?? { ok: true, imageRef: null, overrideEnvVar: null }, ); + vi.spyOn(rebuildFlowHelpers, "removeStaleRebuildDockerOrphan").mockReturnValue(undefined); + vi.spyOn(onboardSession, "listRetainedSandboxRecoveryRecords").mockReturnValue([]); + vi.spyOn(portableRetirementAuthority, "withPortableOnboardRetirementBoundary").mockImplementation( + ((_boundary: unknown, operation: () => unknown) => operation()) as never, + ); const imageIdsByRef = new Map([ [agentBaseImageRef, agentBaseImageId], [agentBaseImageId, agentBaseImageId], diff --git a/test/helpers/rebuild-flow-harness.ts b/test/helpers/rebuild-flow-harness.ts index c180db8ca0b..01a33f209c1 100644 --- a/test/helpers/rebuild-flow-harness.ts +++ b/test/helpers/rebuild-flow-harness.ts @@ -56,6 +56,9 @@ export const openshellRuntime = requireDist("../../adapters/openshell/runtime.js export const policies = requireDist("../../policy/index.js"); export const policyState = requireDist("../../adapters/openshell/policy-state.js"); export const policyGet = requireDist("./policy-get.js"); +export const portableRetirementAuthority = requireDist( + "../../onboard/portable-retirement-authority.js", +); export const portableAgentLifecycle = requireDist( "../../onboard/experimental/portable-agent-lifecycle.js", ); diff --git a/test/installer-integration/install-preflight.test.ts b/test/installer-integration/install-preflight.test.ts index 2ec867c1c5d..bbd89682e1c 100644 --- a/test/installer-integration/install-preflight.test.ts +++ b/test/installer-integration/install-preflight.test.ts @@ -506,7 +506,7 @@ exit 89 writeNodeStub(fakeBin); writeDockerOkStub(fakeBin); writeSourceCheckoutNpmStub(fakeBin, { commandLog: true }); - + writeExecutable(path.join(fakeBin, "uname"), "#!/bin/sh\nprintf 'Linux\\n'\n"); writeSourceCheckoutPackages(tmp); fs.mkdirSync(path.join(tmp, "scripts"), { recursive: true }); diff --git a/test/onboarding/onboard-extra-provider-reconciliation.test.ts b/test/onboarding/onboard-extra-provider-reconciliation.test.ts index ebc5460d251..4f349ab19df 100644 --- a/test/onboarding/onboard-extra-provider-reconciliation.test.ts +++ b/test/onboarding/onboard-extra-provider-reconciliation.test.ts @@ -48,6 +48,8 @@ describe("onboard extra-provider reconciliation", () => { const script = String.raw` const registry = require(${registryPath}); const fixtureMocks = require(${onboardScriptMocksPath}); +fixtureMocks.mockStandaloneGatewayTeardownAuthority(); +fixtureMocks.mockManagedStateVolumeOnboardLifecycle(); const forwardService = fixtureMocks.installForwardServiceReachabilityFixture(); registry.addExtraProvider("tavily-search"); registry.addExtraProvider("brave-search"); diff --git a/test/onboarding/onboard-fresh-create-identity.test.ts b/test/onboarding/onboard-fresh-create-identity.test.ts index d9d7ef4bb2e..6e9c3287417 100644 --- a/test/onboarding/onboard-fresh-create-identity.test.ts +++ b/test/onboarding/onboard-fresh-create-identity.test.ts @@ -193,6 +193,7 @@ describe("fresh create identity", () => { const runner = require(${runnerPath}); const fixtureMocks = require(${onboardScriptMocksPath}); fixtureMocks.mockStandaloneGatewayTeardownAuthority(); + fixtureMocks.mockManagedStateVolumeOnboardLifecycle(); const _n = (c) => (Array.isArray(c) ? c.join(" ") : String(c)).replace(/'/g, ""); let _deleted = false; const registry = require(${registryPath}); diff --git a/test/onboarding/onboard-inference-reconciliation.test.ts b/test/onboarding/onboard-inference-reconciliation.test.ts index 8adc8f63837..528fbcb2064 100644 --- a/test/onboarding/onboard-inference-reconciliation.test.ts +++ b/test/onboarding/onboard-inference-reconciliation.test.ts @@ -213,10 +213,18 @@ describe("onboard helpers", () => { const bridgeDnsPreflightPath = JSON.stringify( path.join(repoRoot, "src", "lib", "onboard", "bridge-dns-preflight.ts"), ); + const onboardScriptMocksPath = JSON.stringify( + path.join(repoRoot, "test", "helpers", "onboard-script-mocks.cjs"), + ); fs.mkdirSync(fakeBin, { recursive: true }); writeOkOpenshell(fakeBin); fs.writeFileSync(path.join(fakeBin, "brew"), "#!/bin/sh\nexit 1\n", { mode: 0o755 }); + fs.writeFileSync( + path.join(fakeBin, "docker"), + '#!/bin/sh\ncase "$1" in\n info) printf \'%s\\n\' \'{"ServerVersion":"test"}\' ;;\n version) printf \'%s\\n\' \'{"Server":{"Version":"test"}}\' ;;\nesac\n', + { mode: 0o755 }, + ); const script = String.raw` const runner = require(${runnerPath}); @@ -230,6 +238,9 @@ const dockerDriverPlatform = require(${dockerDriverPlatformPath}); const gatewayGpuPassthrough = require(${gatewayGpuPassthroughPath}); const onboardProbes = require(${onboardProbesPath}); const preflight = require(${preflightPath}); +const fixtureMocks = require(${onboardScriptMocksPath}); +fixtureMocks.mockStandaloneGatewayTeardownAuthority(); +fixtureMocks.mockManagedStateVolumeOnboardLifecycle(); preflight.assessHost = () => ({ platform: "linux", isWsl: false, diff --git a/test/onboarding/onboard-installer-restore-intent.test.ts b/test/onboarding/onboard-installer-restore-intent.test.ts index bfe16c066ce..77e2376a617 100644 --- a/test/onboarding/onboard-installer-restore-intent.test.ts +++ b/test/onboarding/onboard-installer-restore-intent.test.ts @@ -62,6 +62,7 @@ describe("createSandbox installer restore intent", () => { const runner = require(${runnerPath}); const fixtureMocks = require(${onboardScriptMocksPath}); fixtureMocks.mockStandaloneGatewayTeardownAuthority(); +fixtureMocks.mockManagedStateVolumeOnboardLifecycle(); const _n = (c) => (Array.isArray(c) ? c.join(" ") : String(c)).replace(/'/g, ""); const registry = require(${registryPath}); const sandboxState = require(${sandboxStatePath}); @@ -332,6 +333,7 @@ const MARKER_SHA = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852 const script = String.raw` const runner = require(${runnerPath}); require(${onboardScriptMocksPath}).mockStandaloneGatewayTeardownAuthority(); +require(${onboardScriptMocksPath}).mockManagedStateVolumeOnboardLifecycle(); const normalize = (command) => (Array.isArray(command) ? command.join(" ") : String(command)).replace(/'/g, ""); const registry = require(${registryPath}); @@ -460,6 +462,7 @@ const { createSandbox } = require(${onboardPath}); const runner = require(${runnerPath}); const fixtureMocks = require(${onboardScriptMocksPath}); fixtureMocks.mockStandaloneGatewayTeardownAuthority(); +fixtureMocks.mockManagedStateVolumeOnboardLifecycle(); const _n = (c) => (Array.isArray(c) ? c.join(" ") : String(c)).replace(/'/g, ""); const registry = require(${registryPath}); const sandboxState = require(${sandboxStatePath}); diff --git a/test/onboarding/onboard-reservation-recreate.test.ts b/test/onboarding/onboard-reservation-recreate.test.ts index 781d40f7098..d320f305d1c 100644 --- a/test/onboarding/onboard-reservation-recreate.test.ts +++ b/test/onboarding/onboard-reservation-recreate.test.ts @@ -85,6 +85,7 @@ describe("onboard sandbox recreate reservation safety", () => { const script = String.raw` const runner = require(${runnerPath}); require(${onboardScriptMocksPath}).mockStandaloneGatewayTeardownAuthority(); +require(${onboardScriptMocksPath}).mockManagedStateVolumeOnboardLifecycle(); const _n = (c) => (Array.isArray(c) ? c.join(" ") : String(c)).replace(/'/g, ""); const registry = require(${registryPath}); const fixtureMocks = require(${onboardScriptMocksPath}); @@ -350,6 +351,7 @@ const childProcess = require("node:child_process"); const runner = require(${runnerPath}); const fixtureMocks = require(${onboardScriptMocksPath}); fixtureMocks.mockStandaloneGatewayTeardownAuthority(); +fixtureMocks.mockManagedStateVolumeOnboardLifecycle(); const registry = require(${registryPath}); const onboardSession = require(${onboardSessionPath}); const recreateJournal = require(${recreateJournalPath}); diff --git a/test/onboarding/onboard-sandbox-build.test.ts b/test/onboarding/onboard-sandbox-build.test.ts index 4d267d92510..d3f30d89b97 100644 --- a/test/onboarding/onboard-sandbox-build.test.ts +++ b/test/onboarding/onboard-sandbox-build.test.ts @@ -51,6 +51,8 @@ describe("onboard helpers", () => { const script = String.raw` const runner = require(${runnerPath}); const fixtureMocks = require(${onboardScriptMocksPath}); +fixtureMocks.mockStandaloneGatewayTeardownAuthority(); +fixtureMocks.mockManagedStateVolumeOnboardLifecycle(); const createdSandbox = fixtureMocks.createCreatedSandboxFixture({ sandboxName: "my-assistant", }); @@ -224,6 +226,8 @@ const os = require("node:os"); const path = require("node:path"); const runner = require(${runnerPath}); const fixtureMocks = require(${onboardScriptMocksPath}); +fixtureMocks.mockStandaloneGatewayTeardownAuthority(); +fixtureMocks.mockManagedStateVolumeOnboardLifecycle(); const createdSandbox = fixtureMocks.createCreatedSandboxFixture({ sandboxName: "hermes-sandbox", }); @@ -451,6 +455,8 @@ const os = require("node:os"); const path = require("node:path"); const runner = require(${runnerPath}); const fixtureMocks = require(${onboardScriptMocksPath}); +fixtureMocks.mockStandaloneGatewayTeardownAuthority(); +fixtureMocks.mockManagedStateVolumeOnboardLifecycle(); const _n = (c) => (Array.isArray(c) ? c.join(" ") : String(c)).replace(/'/g, ""); const registry = require(${registryPath}); const preflight = require(${preflightPath}); @@ -616,6 +622,8 @@ const { createSandbox } = require(${onboardPath}); const script = String.raw` const runner = require(${runnerPath}); const fixtureMocks = require(${onboardScriptMocksPath}); +fixtureMocks.mockStandaloneGatewayTeardownAuthority(); +fixtureMocks.mockManagedStateVolumeOnboardLifecycle(); const _n = (c) => (Array.isArray(c) ? c.join(" ") : String(c)).replace(/'/g, ""); const registry = require(${registryPath}); const preflight = require(${preflightPath}); @@ -729,6 +737,8 @@ const { createSandbox } = require(${onboardPath}); const script = String.raw` const runner = require(${runnerPath}); const fixtureMocks = require(${onboardScriptMocksPath}); +fixtureMocks.mockStandaloneGatewayTeardownAuthority(); +fixtureMocks.mockManagedStateVolumeOnboardLifecycle(); const _n = (c) => (Array.isArray(c) ? c.join(" ") : String(c)).replace(/'/g, ""); const registry = require(${registryPath}); const preflight = require(${preflightPath}); diff --git a/test/onboarding/onboard-sandbox-recreation.test.ts b/test/onboarding/onboard-sandbox-recreation.test.ts index 049c4db0417..599aa63be0a 100644 --- a/test/onboarding/onboard-sandbox-recreation.test.ts +++ b/test/onboarding/onboard-sandbox-recreation.test.ts @@ -44,6 +44,7 @@ describe("onboard helpers", () => { const runner = require(${runnerPath}); const fixtureMocks = require(${onboardScriptMocksPath}); fixtureMocks.mockStandaloneGatewayTeardownAuthority(); + fixtureMocks.mockManagedStateVolumeOnboardLifecycle(); const _n = (c) => (Array.isArray(c) ? c.join(" ") : String(c)).replace(/'/g, ""); const registry = require(${registryPath}); const childProcess = require("node:child_process"); @@ -137,6 +138,7 @@ const { createSandbox } = require(${onboardPath}); const runner = require(${runnerPath}); const fixtureMocks = require(${onboardScriptMocksPath}); fixtureMocks.mockStandaloneGatewayTeardownAuthority(); + fixtureMocks.mockManagedStateVolumeOnboardLifecycle(); const _n = (c) => (Array.isArray(c) ? c.join(" ") : String(c)).replace(/'/g, ""); const registry = require(${registryPath}); const childProcess = require("node:child_process"); @@ -309,6 +311,7 @@ const { createSandbox } = require(${onboardPath}); const runner = require(${runnerPath}); const fixtureMocks = require(${onboardScriptMocksPath}); fixtureMocks.mockStandaloneGatewayTeardownAuthority(); + fixtureMocks.mockManagedStateVolumeOnboardLifecycle(); const _n = (c) => (Array.isArray(c) ? c.join(" ") : String(c)).replace(/'/g, ""); const registry = require(${registryPath}); const sandboxState = require(${sandboxStatePath}); @@ -494,6 +497,7 @@ const { createSandbox } = require(${onboardPath}); const runner = require(${runnerPath}); const fixtureMocks = require(${onboardScriptMocksPath}); fixtureMocks.mockStandaloneGatewayTeardownAuthority(); + fixtureMocks.mockManagedStateVolumeOnboardLifecycle(); const _n = (c) => (Array.isArray(c) ? c.join(" ") : String(c)).replace(/'/g, ""); const registry = require(${registryPath}); const sandboxState = require(${sandboxStatePath}); @@ -641,6 +645,7 @@ const { createSandbox } = require(${onboardPath}); const runner = require(${runnerPath}); const fixtureMocks = require(${onboardScriptMocksPath}); fixtureMocks.mockStandaloneGatewayTeardownAuthority(); + fixtureMocks.mockManagedStateVolumeOnboardLifecycle(); const _n = (c) => (Array.isArray(c) ? c.join(" ") : String(c)).replace(/'/g, ""); const registry = require(${registryPath}); const sandboxState = require(${sandboxStatePath}); @@ -816,6 +821,7 @@ const { createSandbox } = require(${onboardPath}); const runner = require(${runnerPath}); const fixtureMocks = require(${onboardScriptMocksPath}); fixtureMocks.mockStandaloneGatewayTeardownAuthority(); + fixtureMocks.mockManagedStateVolumeOnboardLifecycle(); const _n = (c) => (Array.isArray(c) ? c.join(" ") : String(c)).replace(/'/g, ""); const registry = require(${registryPath}); const credentials = require(${credentialsPath}); @@ -966,6 +972,7 @@ const { createSandbox } = require(${onboardPath}); const runner = require(${runnerPath}); const fixtureMocks = require(${onboardScriptMocksPath}); fixtureMocks.mockStandaloneGatewayTeardownAuthority(); + fixtureMocks.mockManagedStateVolumeOnboardLifecycle(); const _n = (c) => (Array.isArray(c) ? c.join(" ") : String(c)).replace(/'/g, ""); const registry = require(${registryPath}); const credentials = require(${credentialsPath}); @@ -1141,6 +1148,7 @@ const { createSandbox } = require(${onboardPath}); const runner = require(${runnerPath}); const fixtureMocks = require(${onboardScriptMocksPath}); fixtureMocks.mockStandaloneGatewayTeardownAuthority(); + fixtureMocks.mockManagedStateVolumeOnboardLifecycle(); const _n = (c) => (Array.isArray(c) ? c.join(" ") : String(c)).replace(/'/g, ""); const registry = require(${registryPath}); const credentials = require(${credentialsPath}); diff --git a/test/package-contract/migration-state-packaged.test.ts b/test/package-contract/migration-state-packaged.test.ts index 389fed09702..37d2de9a59a 100644 --- a/test/package-contract/migration-state-packaged.test.ts +++ b/test/package-contract/migration-state-packaged.test.ts @@ -20,7 +20,7 @@ afterEach(() => { }); test("packaged migration converts and restores external OpenClaw state", async () => { - const home = fs.mkdtempSync(path.join(os.tmpdir(), "migration-home-")); + const home = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "migration-home-"))); homes.push(home); const state = path.join(home, "external-state"); const config = path.join(home, "external-config", "openclaw.json"); @@ -106,7 +106,10 @@ test("packaged migration converts and restores external OpenClaw state", async ( fs.writeFileSync(config, "{}"); expect(restoreSnapshotToHost(bundle!.snapshotDir, logger)).toBe(true); expect(fs.readFileSync(path.join(state, "state-marker"), "utf8")).toBe("before"); - expect(fs.readFileSync(path.join(workspace, "workspace-marker"), "utf8")).toBe("before"); + expect( + fs.readFileSync(path.join(workspace, "workspace-marker"), "utf8"), + messages.join("\n"), + ).toBe("before"); expect(fs.readFileSync(path.join(agentDir, "agent-marker"), "utf8")).toBe("before"); const restored = JSON.parse(fs.readFileSync(config, "utf8")); expect(restored.agents.defaults.workspace).toBe(workspace); diff --git a/tools/wsl/ci-helper.ps1 b/tools/wsl/ci-helper.ps1 index d3a7302bda5..57f477d0f4e 100644 --- a/tools/wsl/ci-helper.ps1 +++ b/tools/wsl/ci-helper.ps1 @@ -424,6 +424,7 @@ function Get-WslCheckoutSyncScript { "git -C $workdirLiteral reset --hard HEAD" "git -C $workdirLiteral clean -ffdx" $ownerCommand + "chmod 0755 $workdirRootLiteral $workdirLiteral" "git -C $workdirLiteral status --short" "echo 'WSL ext4 workspace is ready'" ) -join "`n" From 08dfe7ec3f2daebfb1cd4f41df6b9ce471ecacb0 Mon Sep 17 00:00:00 2001 From: Rebecca Sliter <571084+rsliter@users.noreply.github.com> Date: Tue, 8 Sep 2026 18:49:15 -0700 Subject: [PATCH 12/25] fix(ci): restrict package token to main --- .github/workflows/platform-vitest-main.yaml | 4 ++-- test/automation/e2e/platform-vitest-main-workflow.test.ts | 2 +- test/onboarding/onboard-inference-reconciliation.test.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/platform-vitest-main.yaml b/.github/workflows/platform-vitest-main.yaml index a3f4b380276..6b7fb75f283 100644 --- a/.github/workflows/platform-vitest-main.yaml +++ b/.github/workflows/platform-vitest-main.yaml @@ -151,7 +151,7 @@ jobs: - name: Install dependencies env: - NODE_AUTH_TOKEN: ${{ github.repository == 'NVIDIA/NemoClaw' && (github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && github.ref == 'refs/heads/main')) && github.token || '' }} + NODE_AUTH_TOKEN: ${{ github.repository == 'NVIDIA/NemoClaw' && github.ref == 'refs/heads/main' && github.token || '' }} run: | if [[ -n "$NODE_AUTH_TOKEN" ]]; then bash .github/actions/ci-install-dependencies.sh @@ -338,7 +338,7 @@ jobs: - name: Install dependencies and build in WSL shell: powershell env: - NODE_AUTH_TOKEN: ${{ github.repository == 'NVIDIA/NemoClaw' && (github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && github.ref == 'refs/heads/main')) && github.token || '' }} + NODE_AUTH_TOKEN: ${{ github.repository == 'NVIDIA/NemoClaw' && github.ref == 'refs/heads/main' && github.token || '' }} run: | . "$env:TRUSTED_WSL_HELPER" $testUser = ConvertTo-BashLiteral -Value $env:WSL_TEST_USER diff --git a/test/automation/e2e/platform-vitest-main-workflow.test.ts b/test/automation/e2e/platform-vitest-main-workflow.test.ts index 16c1e8a414f..7eb9974220f 100644 --- a/test/automation/e2e/platform-vitest-main-workflow.test.ts +++ b/test/automation/e2e/platform-vitest-main-workflow.test.ts @@ -127,7 +127,7 @@ describe("platform evidence workflow", () => { ); expect(install.env).toMatchObject({ NODE_AUTH_TOKEN: - "${{ github.repository == 'NVIDIA/NemoClaw' && (github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && github.ref == 'refs/heads/main')) && github.token || '' }}", + "${{ github.repository == 'NVIDIA/NemoClaw' && github.ref == 'refs/heads/main' && github.token || '' }}", }); expect(install.run).toContain(".github/actions/ci-install-dependencies.sh"); expect(install.run).toContain("npm ci --ignore-scripts"); diff --git a/test/onboarding/onboard-inference-reconciliation.test.ts b/test/onboarding/onboard-inference-reconciliation.test.ts index 528fbcb2064..6aa3308035e 100644 --- a/test/onboarding/onboard-inference-reconciliation.test.ts +++ b/test/onboarding/onboard-inference-reconciliation.test.ts @@ -222,7 +222,7 @@ describe("onboard helpers", () => { fs.writeFileSync(path.join(fakeBin, "brew"), "#!/bin/sh\nexit 1\n", { mode: 0o755 }); fs.writeFileSync( path.join(fakeBin, "docker"), - '#!/bin/sh\ncase "$1" in\n info) printf \'%s\\n\' \'{"ServerVersion":"test"}\' ;;\n version) printf \'%s\\n\' \'{"Server":{"Version":"test"}}\' ;;\nesac\n', + '#!/bin/sh\ncase "$1" in\n info) printf \'%s\\n\' \'{"ServerVersion":"test"}\' ;;\n version) printf \'%s\\n\' \'{"Server":{"Version":"test"}}\' ;;\n *) printf \'unexpected docker command: %s\\n\' "$*" >&2; exit 1 ;;\nesac\n', { mode: 0o755 }, ); From cd0c40b0ac4161ea8217d190f97fae75da9ed6ee Mon Sep 17 00:00:00 2001 From: Rebecca Sliter <571084+rsliter@users.noreply.github.com> Date: Tue, 8 Sep 2026 19:36:26 -0700 Subject: [PATCH 13/25] fix(ci): stabilize platform compatibility suites --- .github/workflows/platform-vitest-main.yaml | 11 +++++++++-- .../src/security/snapshot-sanitizer-failure.test.ts | 6 ++++-- .../sandbox/rebuild-custom-image-preflight.test.ts | 1 + src/lib/onboard/dashboard.ts | 4 ++++ src/lib/onboard/fatal-runtime-preflight.test.ts | 2 +- src/lib/onboard/gateway/automatic-port-completion.ts | 2 +- .../onboard/portable-resume-lock-boundary.test.ts | 1 + test/automation/classify-ci-failure.test.ts | 3 ++- .../e2e/platform-vitest-main-workflow.test.ts | 12 ++++++++++++ test/automation/e2e/wsl-ci-helper.test.ts | 3 +++ .../performance/analyze-ci-performance.test.ts | 2 +- .../credentials/rebuild-credential-preflight.test.ts | 4 ++-- .../support/portable-profile-systemctl-shim.test.ts | 6 +++--- test/generation/portable-cpu-delegation-docs.test.ts | 2 +- test/helpers/rebuild-flow-generic-harness.ts | 9 ++++++--- test/helpers/rebuild-flow-test-support.ts | 1 + test/install/install-cdi-repair.test.ts | 6 +++++- .../install-station-dgx-os.test.ts | 2 ++ .../install-station-host-preparation.test.ts | 2 ++ test/state/snapshot-backup-audit-hardlinks.test.ts | 2 +- tools/wsl/ci-helper.ps1 | 1 + 21 files changed, 63 insertions(+), 19 deletions(-) diff --git a/.github/workflows/platform-vitest-main.yaml b/.github/workflows/platform-vitest-main.yaml index 6b7fb75f283..f5c6134dc8b 100644 --- a/.github/workflows/platform-vitest-main.yaml +++ b/.github/workflows/platform-vitest-main.yaml @@ -167,7 +167,12 @@ jobs: npm run build - name: Run full Vitest suite on macOS - run: npx vitest run --testTimeout 60000 --shard="${{ matrix.shard }}/4" + run: | + set -euo pipefail + mkdir -p "$RUNNER_TEMP/nemoclaw-vitest-bin" + ln -s "$(command -v gtar)" "$RUNNER_TEMP/nemoclaw-vitest-bin/tar" + PATH="$RUNNER_TEMP/nemoclaw-vitest-bin:$PATH" \ + npx vitest run --testTimeout 60000 --shard="${{ matrix.shard }}/4" macos-live-e2e: name: macOS live E2E @@ -313,7 +318,7 @@ jobs: . "$env:TRUSTED_WSL_HELPER" $packages = @( 'bash', 'ca-certificates', 'curl', 'git', 'jq', 'lsb-release', 'make', - 'docker.io', 'gcc', 'iproute2', 'podman', + 'docker.io', 'gcc', 'iproute2', 'libc6-dev', 'podman', 'procps', 'python3', 'python3-pip', 'python3-venv', 'ripgrep', 'rsync', 'tar', 'unzip', 'xz-utils', 'zip' ) @@ -348,6 +353,7 @@ jobs: set -euo pipefail id -un | grep -Fxq $testUser cd $workdir + export NPM_CONFIG_CACHE="`$HOME/.npm" export NODE_AUTH_TOKEN=$nodeAuthToken if [ -n "`$NODE_AUTH_TOKEN" ]; then bash .github/actions/ci-install-dependencies.sh @@ -372,6 +378,7 @@ jobs: set -euo pipefail id -un | grep -Fxq $testUser cd $workdir + export NPM_CONFIG_CACHE="`$HOME/.npm" export NEMOCLAW_EXEC_TIMEOUT=60000 export NEMOCLAW_TEST_TIMEOUT=60000 npx vitest run --testTimeout 60000 --shard='${{ matrix.shard }}/4' diff --git a/nemoclaw/src/security/snapshot-sanitizer-failure.test.ts b/nemoclaw/src/security/snapshot-sanitizer-failure.test.ts index 82a43143007..17f5e8288b0 100644 --- a/nemoclaw/src/security/snapshot-sanitizer-failure.test.ts +++ b/nemoclaw/src/security/snapshot-sanitizer-failure.test.ts @@ -209,7 +209,9 @@ describe("migration snapshot sanitizer fallbacks", () => { }, ); - it.runIf(process.platform !== "win32")( + // These adversarial races depend on Linux scheduler and inode timing. The + // portable descriptor behavior remains covered above on every host. + it.runIf(process.platform === "linux")( "rejects a persistent hard link created while sanitized config is installed", () => { const rootPath = makeRoot(); @@ -240,7 +242,7 @@ describe("migration snapshot sanitizer fallbacks", () => { }, ); - it.runIf(process.platform !== "win32")( + it.runIf(process.platform === "linux")( "rejects a transient hard-link mutation while sanitized config is installed", () => { const rootPath = makeRoot(); diff --git a/src/lib/actions/sandbox/rebuild-custom-image-preflight.test.ts b/src/lib/actions/sandbox/rebuild-custom-image-preflight.test.ts index 498b56d19f8..0baaf821c7e 100644 --- a/src/lib/actions/sandbox/rebuild-custom-image-preflight.test.ts +++ b/src/lib/actions/sandbox/rebuild-custom-image-preflight.test.ts @@ -611,6 +611,7 @@ describe("finalizePreparedRebuildImageMessagingPlan", () => { removeImage, env: { DOCKER_CONFIG: dockerConfig, WSL_DISTRO_NAME: "Ubuntu" }, credentialHelperResponds: () => false, + dockerContextIsDefault: () => true, isWslHost: true, }, ), diff --git a/src/lib/onboard/dashboard.ts b/src/lib/onboard/dashboard.ts index e29213aa714..d057fc830e1 100644 --- a/src/lib/onboard/dashboard.ts +++ b/src/lib/onboard/dashboard.ts @@ -294,6 +294,8 @@ export function createOnboardDashboardHelpers(deps: OnboardDashboardDeps): Onboa options: Parameters[1] = {}, ): string { return dashboardAccess.getDashboardForwardPort(chatUiUrl, { + env: deps.env, + isWsl: deps.isWsl(), ...options, runCapture: options.runCapture || runCapture, }); @@ -304,6 +306,8 @@ export function createOnboardDashboardHelpers(deps: OnboardDashboardDeps): Onboa options: Parameters[1] = {}, ): string { return dashboardAccess.getDashboardForwardTarget(chatUiUrl, { + env: deps.env, + isWsl: deps.isWsl(), ...options, runCapture: options.runCapture || runCapture, }); diff --git a/src/lib/onboard/fatal-runtime-preflight.test.ts b/src/lib/onboard/fatal-runtime-preflight.test.ts index 6ffa2d9c986..07263f9c03b 100644 --- a/src/lib/onboard/fatal-runtime-preflight.test.ts +++ b/src/lib/onboard/fatal-runtime-preflight.test.ts @@ -631,7 +631,7 @@ describe("runFatalOnboardRuntimePreflight", () => { expect(detect).toHaveBeenCalledOnce(); expect(detect).toHaveBeenCalledWith( - expect.objectContaining({ proveArm64ContainerGpu: null, n1xWslProduct: null }), + expect.objectContaining({ proveArm64ContainerGpu: null }), ); }); diff --git a/src/lib/onboard/gateway/automatic-port-completion.ts b/src/lib/onboard/gateway/automatic-port-completion.ts index 6dc830b7537..691406eaf90 100644 --- a/src/lib/onboard/gateway/automatic-port-completion.ts +++ b/src/lib/onboard/gateway/automatic-port-completion.ts @@ -42,7 +42,7 @@ export function completeAutomaticGatewayPortAfterOnboard( ...configuredPorts, }, maxBuffer: 64 * 1024, - timeout: 5_000, + timeout: 15_000, }); if (result.error || result.status !== 0 || result.signal) throw new Error(COMPLETION_ERROR); return true; diff --git a/src/lib/onboard/portable-resume-lock-boundary.test.ts b/src/lib/onboard/portable-resume-lock-boundary.test.ts index de18b913dfd..3ef9b69e6d7 100644 --- a/src/lib/onboard/portable-resume-lock-boundary.test.ts +++ b/src/lib/onboard/portable-resume-lock-boundary.test.ts @@ -537,6 +537,7 @@ describe("portable resume command lock boundary", () => { let innerObserved = false; let innerError = ""; const harness = harnessModule.createRebuildFlowHarness({ + useRealPortableRetirementBoundary: true, onboard: async (_session, options) => { const lockPath = retirement.portableHostFencePath(tempHome); const outerInode = fs.lstatSync(lockPath, { bigint: true }).ino; diff --git a/test/automation/classify-ci-failure.test.ts b/test/automation/classify-ci-failure.test.ts index 5dcbe0cbbe7..2a998311ce1 100644 --- a/test/automation/classify-ci-failure.test.ts +++ b/test/automation/classify-ci-failure.test.ts @@ -7,6 +7,7 @@ import { mkdirSync, mkdtempSync, readFileSync, + realpathSync, readdirSync, rmSync, statSync, @@ -178,7 +179,7 @@ function importedClassifierArgs(env: NodeJS.ProcessEnv, extra: string[]): string `import { classifyCiFailureWithRuntimeForTest } from ${JSON.stringify(new URL("file://" + script).href)};`, `const input = ${JSON.stringify(input)};`, "const environment = { ...process.env };", - "const executables = { bash: '/usr/bin/bash', dd: process.env.TEST_DD || '/usr/bin/dd', gh: process.env.TEST_GH, stat: '/usr/bin/stat', tail: '/usr/bin/tail', wc: process.env.TEST_WC || '/usr/bin/wc' };", + `const executables = { bash: ${JSON.stringify(realpathSync("/usr/bin/bash"))}, dd: process.env.TEST_DD || ${JSON.stringify(realpathSync("/usr/bin/dd"))}, gh: process.env.TEST_GH, stat: ${JSON.stringify(realpathSync("/usr/bin/stat"))}, tail: ${JSON.stringify(realpathSync("/usr/bin/tail"))}, wc: process.env.TEST_WC || ${JSON.stringify(realpathSync("/usr/bin/wc"))} };`, "const timeouts = { metadataMs: process.env.TEST_METADATA_TIMEOUT_MS ? Number(process.env.TEST_METADATA_TIMEOUT_MS) : undefined, logMs: process.env.TEST_LOG_TIMEOUT_MS ? Number(process.env.TEST_LOG_TIMEOUT_MS) : undefined, artifactMs: process.env.TEST_ARTIFACT_TIMEOUT_MS ? Number(process.env.TEST_ARTIFACT_TIMEOUT_MS) : undefined };", "void classifyCiFailureWithRuntimeForTest(input, { executables, environment, timeouts }).then((value) => console.log(JSON.stringify(value, null, 2))).catch((error) => { console.error(error instanceof Error ? error.message : String(error)); process.exitCode = 1; });", ].join("\n"), diff --git a/test/automation/e2e/platform-vitest-main-workflow.test.ts b/test/automation/e2e/platform-vitest-main-workflow.test.ts index 7eb9974220f..a6f193430f1 100644 --- a/test/automation/e2e/platform-vitest-main-workflow.test.ts +++ b/test/automation/e2e/platform-vitest-main-workflow.test.ts @@ -68,6 +68,7 @@ describe("platform evidence workflow", () => { it("verifies GNU tar without replacing the native macOS tar", () => { const install = step("macos-vitest", "Install macOS test dependencies").run ?? ""; + const vitest = step("macos-vitest", "Run full Vitest suite on macOS").run ?? ""; expect(install).toContain('test -x "$(command -v gtar)"'); expect(install.indexOf('test -x "$(command -v gtar)"')).toBeLessThan( install.indexOf("brew install"), @@ -75,6 +76,9 @@ describe("platform evidence workflow", () => { expect(install).not.toContain('ln -s "$(command -v gtar)"'); expect(install).not.toContain('"$RUNNER_TEMP/nemoclaw-bin"'); expect(install).not.toMatch(/brew install[^\n]*(?:docker|gnu-tar|iproute2mac|podman)/u); + expect(vitest).toContain('ln -s "$(command -v gtar)" "$RUNNER_TEMP/nemoclaw-vitest-bin/tar"'); + expect(vitest).toContain('PATH="$RUNNER_TEMP/nemoclaw-vitest-bin:$PATH"'); + expect(vitest).not.toContain("GITHUB_PATH"); }); it("installs container clients before Vitest but starts Docker only afterward", () => { @@ -86,6 +90,7 @@ describe("platform evidence workflow", () => { ); const suiteIndex = steps.findIndex((entry) => entry.name === "Run full Vitest suite in WSL"); expect(install).toContain("'docker.io'"); + expect(install).toContain("'libc6-dev'"); expect(install).toContain("'podman'"); expect(install).toContain("'iproute2'"); expect(install).toContain("'zip'"); @@ -100,6 +105,13 @@ describe("platform evidence workflow", () => { expect(runtimeIndex).toBeGreaterThan(suiteIndex); }); + it("uses one native WSL npm cache for installation and package-contract tests", () => { + const install = step("wsl-vitest", "Install dependencies and build in WSL").run ?? ""; + const vitest = step("wsl-vitest", "Run full Vitest suite in WSL").run ?? ""; + expect(install).toContain('export NPM_CONFIG_CACHE="`$HOME/.npm"'); + expect(vitest).toContain('export NPM_CONFIG_CACHE="`$HOME/.npm"'); + }); + it("scopes WSL live-E2E settings to the credentialed live step", () => { const wsl = job("wsl-vitest"); const live = step("wsl-vitest", "Run WSL live E2E"); diff --git a/test/automation/e2e/wsl-ci-helper.test.ts b/test/automation/e2e/wsl-ci-helper.test.ts index c9307350786..d5e4ac7c6fd 100644 --- a/test/automation/e2e/wsl-ci-helper.test.ts +++ b/test/automation/e2e/wsl-ci-helper.test.ts @@ -107,6 +107,9 @@ Get-WslCheckoutSyncScript -Checkout "/mnt/d/agent work/repo's" -Workdir "/tmp/ne expect(result.stdout).toContain( "chown -R 'nemoclaw-ci:nemoclaw-ci' '/tmp/nemoclaw-wsl-workdir/123-1'", ); + expect(result.stdout).toContain( + "chmod -R go-w -- '/tmp/nemoclaw-wsl-workdir/123-1'", + ); expect(result.stdout).toContain( "chmod 0755 '/tmp/nemoclaw-wsl-workdir' '/tmp/nemoclaw-wsl-workdir/123-1'", ); diff --git a/test/automation/performance/analyze-ci-performance.test.ts b/test/automation/performance/analyze-ci-performance.test.ts index 6461fa93723..9e4bc9e57ff 100644 --- a/test/automation/performance/analyze-ci-performance.test.ts +++ b/test/automation/performance/analyze-ci-performance.test.ts @@ -97,7 +97,7 @@ describe("CI performance analysis", () => { await writeFile(trustedListing, JSON.stringify(trustedRuns)); const bin = await installMockGh( directory, - `const fs=require("node:fs"); const args=process.argv.slice(2); if(args[0]==="run"){process.stdout.write(fs.readFileSync(process.env.TRUSTED));}else if(args.includes("--jq")){process.stdout.write(fs.readFileSync(process.env.LISTING));}else{const endpoint=args.join(" "); const id=endpoint.split("/artifacts/")[1].split("/zip")[0];process.stdout.write(fs.readFileSync(process.env.ARCHIVES+"/"+id+".zip"));}`, + `const fs=require("node:fs"); const args=process.argv.slice(2); if(args[0]==="run"){process.stdout.write(fs.readFileSync(process.env.TRUSTED));}else if(args.includes("--jq")){process.stdout.write(fs.readFileSync(process.env.LISTING));}else{const endpoint=args.join(" "); const id=endpoint.split("/artifacts/")[1].split("/zip")[0];fs.writeSync(1,fs.readFileSync(process.env.ARCHIVES+"/"+id+".zip"));}`, ); const result = await runAnalyzer( `${skillRoot}/scripts/analyze-recent-cli-timings.mts`, diff --git a/test/credentials/rebuild-credential-preflight.test.ts b/test/credentials/rebuild-credential-preflight.test.ts index 20c177b3486..00ac8c3199d 100644 --- a/test/credentials/rebuild-credential-preflight.test.ts +++ b/test/credentials/rebuild-credential-preflight.test.ts @@ -19,9 +19,9 @@ import { execTimeout, testTimeoutOptions } from "../helpers/timeouts"; const REPO_ROOT = path.join(import.meta.dirname, "../.."); const NODE_BIN = path.dirname(process.execPath); +const IS_WSL = os.release().toLowerCase().includes("microsoft"); const DOCKER_OPERATING_SYSTEM = - ({ darwin: "Docker Desktop" } as Partial>)[process.platform] ?? - "Docker Engine"; + process.platform === "darwin" || IS_WSL ? "Docker Desktop" : "Docker Engine"; const tmpFixtures: string[] = []; const gatewayProcesses: ReturnType[] = []; diff --git a/test/e2e/support/portable-profile-systemctl-shim.test.ts b/test/e2e/support/portable-profile-systemctl-shim.test.ts index 13537bdb72c..e28ba348003 100644 --- a/test/e2e/support/portable-profile-systemctl-shim.test.ts +++ b/test/e2e/support/portable-profile-systemctl-shim.test.ts @@ -352,8 +352,8 @@ async function waitForServiceStatus(scope: FixtureScope, expected: number): Prom }); } -async function waitForPath(filePath: string): Promise { - await vi.waitFor(() => expect(fs.existsSync(filePath)).toBe(true), { +async function waitForPath(filePath: string, exists = true): Promise { + await vi.waitFor(() => expect(fs.existsSync(filePath)).toBe(exists), { interval: 50, timeout: 5_000, }); @@ -1141,7 +1141,7 @@ describe("portable profile systemctl fixture", () => { const processRecord = readFixtureProcessRecord(failureRecord); expect(pidIsActive(processRecord.pid)).toBe(false); expect(fs.existsSync(servicePidFile)).toBe(false); - expect(fs.existsSync(backendSocketPath)).toBe(false); + await waitForPath(backendSocketPath, false); } finally { await cleanFixture(scope); } diff --git a/test/generation/portable-cpu-delegation-docs.test.ts b/test/generation/portable-cpu-delegation-docs.test.ts index 4c96917cf2c..276eead013f 100644 --- a/test/generation/portable-cpu-delegation-docs.test.ts +++ b/test/generation/portable-cpu-delegation-docs.test.ts @@ -942,7 +942,7 @@ describe("portable CPU delegation documentation (#9188)", () => { const fixture = makeCommandFixture(); const result = runDocumentedCommand(fixture, { SUDO_SCENARIO: "concurrent" }); expect(result.status).not.toBe(0); - expect(result.stderr).toContain("File exists"); + expect(result.stderr).toMatch(/(?:File|Already) exists/u); expect(result.stderr).toContain( `CPU controller drop-in creation failed: ${fixture.delegationDropIn}`, ); diff --git a/test/helpers/rebuild-flow-generic-harness.ts b/test/helpers/rebuild-flow-generic-harness.ts index cf7155d045d..73cd7317231 100644 --- a/test/helpers/rebuild-flow-generic-harness.ts +++ b/test/helpers/rebuild-flow-generic-harness.ts @@ -224,9 +224,12 @@ export function createRebuildFlowHarness(overrides: RebuildFlowOverrides = {}): ); vi.spyOn(rebuildFlowHelpers, "removeStaleRebuildDockerOrphan").mockReturnValue(undefined); vi.spyOn(onboardSession, "listRetainedSandboxRecoveryRecords").mockReturnValue([]); - vi.spyOn(portableRetirementAuthority, "withPortableOnboardRetirementBoundary").mockImplementation( - ((_boundary: unknown, operation: () => unknown) => operation()) as never, - ); + if (!overrides.useRealPortableRetirementBoundary) { + vi.spyOn( + portableRetirementAuthority, + "withPortableOnboardRetirementBoundary", + ).mockImplementation(((_boundary: unknown, operation: () => unknown) => operation()) as never); + } const imageIdsByRef = new Map([ [agentBaseImageRef, agentBaseImageId], [agentBaseImageId, agentBaseImageId], diff --git a/test/helpers/rebuild-flow-test-support.ts b/test/helpers/rebuild-flow-test-support.ts index 88986235e92..42057a3c0f6 100644 --- a/test/helpers/rebuild-flow-test-support.ts +++ b/test/helpers/rebuild-flow-test-support.ts @@ -35,6 +35,7 @@ export type RebuildFlowSession = Record & { steps: Record; }; export type RebuildFlowOverrides = { + useRealPortableRetirementBoundary?: boolean; agentName?: string; sessionAgentName?: string | null; entryUpdatesAfterVersionCheck?: Record; diff --git a/test/install/install-cdi-repair.test.ts b/test/install/install-cdi-repair.test.ts index 8e41cad1bf9..d3eec9cafe0 100644 --- a/test/install/install-cdi-repair.test.ts +++ b/test/install/install-cdi-repair.test.ts @@ -285,7 +285,11 @@ run_installer_host_preflight }; const result = terminal === "none" - ? spawnSync("bash", ["-c", snippet], { cwd: tmp, encoding: "utf-8", env }) + ? spawnSync(process.platform === "linux" ? "setsid" : "bash", [ + ...(process.platform === "linux" ? ["bash"] : []), + "-c", + snippet, + ], { cwd: tmp, encoding: "utf-8", env }) : runInstallerSnippetWithTty(snippet, terminal === "tty" ? "tty" : "pipe", { cwd: tmp, env, diff --git a/test/installer-integration/install-station-dgx-os.test.ts b/test/installer-integration/install-station-dgx-os.test.ts index f0dadd6eca6..7e1583279dc 100644 --- a/test/installer-integration/install-station-dgx-os.test.ts +++ b/test/installer-integration/install-station-dgx-os.test.ts @@ -1054,6 +1054,7 @@ station_sudo_local_default_docker() { *) printf 'UNEXPECTED_DOCKER %s\n' "$*"; return 1 ;; esac } +query_host_docker() { DOCKER_QUERY_OUTPUT=""; return 0; } sudo() { case "$*" in 'nvidia-ctk cdi list') printf 'nvidia.com/gpu=all\n' ;; @@ -1106,6 +1107,7 @@ station_sudo_local_default_docker() { *) printf 'UNEXPECTED_DOCKER %s\n' "$*"; return 1 ;; esac } +query_host_docker() { DOCKER_QUERY_OUTPUT=""; return 0; } require_docker_mutation_quiescence() { printf 'WORKLOAD_GATE_OK %s\n' "$1"; } sudo() { case "$*" in diff --git a/test/installer-integration/install-station-host-preparation.test.ts b/test/installer-integration/install-station-host-preparation.test.ts index 0ee6096fe14..15dbeb4e202 100644 --- a/test/installer-integration/install-station-host-preparation.test.ts +++ b/test/installer-integration/install-station-host-preparation.test.ts @@ -327,6 +327,7 @@ installed_version() { if [[ "$1" == "dkms" ]]; then printf '3.0.11-1ubuntu13'; f install_packages() { printf 'INSTALL_PACKAGES\n'; } ensure_docker_group() { printf 'ENSURE_DOCKER_GROUP\n'; } require_docker_restart_quiescence() { printf 'RECHECK_RESTART_QUIESCENCE\n'; } +query_host_docker() { return 2; } write_install_boot_marker() { printf 'WRITE_BOOT_MARKER\n'; } sudo() { printf 'SUDO %s\n' "$*"; } run_apply @@ -481,6 +482,7 @@ check_capacity() { :; } check_network() { :; } check_failed_units() { :; } capture_docker_container_baseline() { printf 'DOCKER_BASELINE_CAPTURED\n'; } +query_host_docker() { return 2; } check_dgx_os_runtime_commands() { :; } ps() { printf '%s 999 1 python python -m vllm serve model\n' "$EUID"; } ss() { :; } diff --git a/test/state/snapshot-backup-audit-hardlinks.test.ts b/test/state/snapshot-backup-audit-hardlinks.test.ts index b43ca9ca2db..07021b73d0e 100644 --- a/test/state/snapshot-backup-audit-hardlinks.test.ts +++ b/test/state/snapshot-backup-audit-hardlinks.test.ts @@ -230,7 +230,7 @@ describe("pre-backup audit record framing", () => { expect(backup.success).toBe(false); }); - it("keeps accepting a hard-link entry with an empty link target", () => { + hardDereferenceTest("keeps accepting a hard-link entry with an empty link target", () => { const backup = backupWithAuditOutput( encodePreBackupAuditEntries([ ["f", "/sandbox/.openclaw/workspace/lazy-packages/edge_tts/__init__.py", ""], diff --git a/tools/wsl/ci-helper.ps1 b/tools/wsl/ci-helper.ps1 index 57f477d0f4e..a08f244ec29 100644 --- a/tools/wsl/ci-helper.ps1 +++ b/tools/wsl/ci-helper.ps1 @@ -424,6 +424,7 @@ function Get-WslCheckoutSyncScript { "git -C $workdirLiteral reset --hard HEAD" "git -C $workdirLiteral clean -ffdx" $ownerCommand + "chmod -R go-w -- $workdirLiteral" "chmod 0755 $workdirRootLiteral $workdirLiteral" "git -C $workdirLiteral status --short" "echo 'WSL ext4 workspace is ready'" From be0dc840e0b503d098ca51759f43a4e535ce3b67 Mon Sep 17 00:00:00 2001 From: Rebecca Sliter <571084+rsliter@users.noreply.github.com> Date: Tue, 8 Sep 2026 19:53:26 -0700 Subject: [PATCH 14/25] test(ci): isolate Station Docker inventory fixtures --- .../install-station-host-preparation.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/installer-integration/install-station-host-preparation.test.ts b/test/installer-integration/install-station-host-preparation.test.ts index 15dbeb4e202..73ea92a520a 100644 --- a/test/installer-integration/install-station-host-preparation.test.ts +++ b/test/installer-integration/install-station-host-preparation.test.ts @@ -327,7 +327,7 @@ installed_version() { if [[ "$1" == "dkms" ]]; then printf '3.0.11-1ubuntu13'; f install_packages() { printf 'INSTALL_PACKAGES\n'; } ensure_docker_group() { printf 'ENSURE_DOCKER_GROUP\n'; } require_docker_restart_quiescence() { printf 'RECHECK_RESTART_QUIESCENCE\n'; } -query_host_docker() { return 2; } +query_host_docker() { DOCKER_QUERY_OUTPUT=""; return 0; } write_install_boot_marker() { printf 'WRITE_BOOT_MARKER\n'; } sudo() { printf 'SUDO %s\n' "$*"; } run_apply @@ -482,7 +482,7 @@ check_capacity() { :; } check_network() { :; } check_failed_units() { :; } capture_docker_container_baseline() { printf 'DOCKER_BASELINE_CAPTURED\n'; } -query_host_docker() { return 2; } +query_host_docker() { DOCKER_QUERY_OUTPUT=""; return 0; } check_dgx_os_runtime_commands() { :; } ps() { printf '%s 999 1 python python -m vllm serve model\n' "$EUID"; } ss() { :; } From 585a61b5653759bcd70e9532e0b7ad3e06a4ff3e Mon Sep 17 00:00:00 2001 From: Rebecca Sliter <571084+rsliter@users.noreply.github.com> Date: Tue, 8 Sep 2026 21:01:38 -0700 Subject: [PATCH 15/25] ci: decouple macOS live compatibility evidence --- .github/workflows/platform-vitest-main.yaml | 3 +-- test/README.md | 7 ++++--- test/automation/e2e/platform-vitest-main-workflow.test.ts | 4 ++-- test/e2e/README.md | 6 +++--- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/.github/workflows/platform-vitest-main.yaml b/.github/workflows/platform-vitest-main.yaml index f5c6134dc8b..c7ef5c26966 100644 --- a/.github/workflows/platform-vitest-main.yaml +++ b/.github/workflows/platform-vitest-main.yaml @@ -90,7 +90,7 @@ jobs: matrix: include: - shard: 1 - timeout_minutes: 60 + timeout_minutes: 30 - shard: 2 timeout_minutes: 30 - shard: 3 @@ -177,7 +177,6 @@ jobs: macos-live-e2e: name: macOS live E2E if: ${{ github.ref == 'refs/heads/main' }} - needs: macos-vitest runs-on: macos-26 timeout-minutes: 60 steps: diff --git a/test/README.md b/test/README.md index ff8624efb2c..6564b8cdbf2 100644 --- a/test/README.md +++ b/test/README.md @@ -87,6 +87,7 @@ in a final suffix such as `(#1234)`. Some tests require GNU command-line tools that macOS does not provide. The `macos-vitest` job in [`.github/workflows/platform-vitest-main.yaml`](../.github/workflows/platform-vitest-main.yaml) owns -the authoritative package list. Install those tools and put their GNU binaries first on `PATH` -before running the suite on macOS. This job runs on pushes to `main` and manual dispatches, not on -pull requests. +the authoritative package list. The hosted runner must already provide `gtar`; the workflow verifies +that prerequisite before Homebrew installs the other tools. It then puts the installed GNU binaries +first on `PATH` and exposes `gtar` as `tar` only to the Vitest process through a private shim directory. +This job runs on pushes to `main` and manual dispatches, not on pull requests. diff --git a/test/automation/e2e/platform-vitest-main-workflow.test.ts b/test/automation/e2e/platform-vitest-main-workflow.test.ts index a6f193430f1..58dcabc4991 100644 --- a/test/automation/e2e/platform-vitest-main-workflow.test.ts +++ b/test/automation/e2e/platform-vitest-main-workflow.test.ts @@ -47,7 +47,7 @@ describe("platform evidence workflow", () => { }); }); - it("isolates credentialed macOS E2E from mutable non-live dependencies", () => { + it("keeps credentialed macOS E2E independent from non-live shard failures", () => { const nonLive = job("macos-vitest"); const liveJob = job("macos-live-e2e"); const live = step("macos-live-e2e", "Run macOS live E2E"); @@ -55,7 +55,7 @@ describe("platform evidence workflow", () => { expect(nonLive.steps).not.toEqual( expect.arrayContaining([expect.objectContaining({ name: "Run macOS live E2E" })]), ); - expect(liveJob.needs).toBe("macos-vitest"); + expect(liveJob.needs).toBeUndefined(); expect(liveJob.if).toContain("github.ref == 'refs/heads/main'"); expect(JSON.stringify(liveJob)).not.toContain("brew install"); expect(installOpenShell.run).toContain("scripts/install-openshell.sh"); diff --git a/test/e2e/README.md b/test/e2e/README.md index e95c6b3c2af..13119401084 100644 --- a/test/e2e/README.md +++ b/test/e2e/README.md @@ -28,7 +28,7 @@ before those targets run; local runners must provide it themselves. It does not run onboarding or inference and does not satisfy release qualification. - `.github/workflows/platform-vitest-main.yaml` publishes `CI / Platform Compatibility` for Ubuntu 26.04, macOS, and WSL. - On shard 1, its macOS and WSL live E2E run only when the workflow tests `main` and Docker is available. + Its independent macOS live E2E job and first-shard WSL live E2E run only when the workflow tests `main` and Docker is available. This workflow does not publish or satisfy `Release qualification`. - `.github/workflows/portable-profile-e2e.yaml` publishes experimental portable-profile evidence. - `.github/workflows/podman-cpu-proof.yaml` publishes PR-only experimental runtime evidence. @@ -222,10 +222,10 @@ boundaries are the behavior under test. `.github/workflows/platform-vitest-main.yaml` publishes the `CI / Platform Compatibility` workflow. It runs the Ubuntu 26.04 compatibility contracts and the full Vitest suite in four shards on macOS and WSL. The matrix disables `fail-fast`. -The first macOS shard has a 60-minute budget for live E2E; the other shards have 30 minutes. +Each macOS Vitest shard has a 30-minute budget. The independent macOS live E2E job has 60 minutes. The first WSL shard has a 180-minute budget for root-required contracts and live E2E; the other shards have 90 minutes. -On shard 1, the workflow runs focused macOS and WSL live E2E only when the run tests `main` and Docker is available. +The workflow runs focused macOS live E2E in an independent job and WSL live E2E on shard 1 only when the run tests `main` and Docker is available. Otherwise, the workflow records the skip and retains the platform contract evidence. Therefore, the workflow is platform evidence, not `Release qualification`. Only a full manual `.github/workflows/e2e.yaml` run can publish the release check. From 5dc23144c38089b18b6fbb40fc05840aee23e70e Mon Sep 17 00:00:00 2001 From: Rebecca Sliter <571084+rsliter@users.noreply.github.com> Date: Tue, 8 Sep 2026 21:20:14 -0700 Subject: [PATCH 16/25] docs(ci): align platform E2E ownership --- test/e2e/docs/README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/e2e/docs/README.md b/test/e2e/docs/README.md index 73083a156ca..0efd4df33c1 100644 --- a/test/e2e/docs/README.md +++ b/test/e2e/docs/README.md @@ -351,10 +351,10 @@ test/e2e/ to upload its evidence artifact. - `.github/workflows/platform-vitest-main.yaml` publishes `CI / Platform Compatibility`. It runs the Ubuntu 26.04 compatibility contracts and four full-suite Vitest shards on each of macOS and WSL. - Each macOS shard installs the pinned OpenShell formula. - Shard 1 has a 60-minute budget for live E2E; the other shards have 30 minutes. + Each macOS Vitest shard has a 30-minute budget. + The independent `macos-live-e2e` job installs pinned OpenShell and has a 60-minute budget. WSL shard 1 has a 180-minute budget for root-required contracts and live E2E; the other shards have 90 minutes. - On shard 1, the workflow runs focused macOS and WSL live E2E only when the run tests `main` and Docker is available. + The independent macOS job and WSL shard 1 run focused live E2E only when the run tests `main` and Docker is available. Otherwise, those live tests skip and the platform contracts remain as evidence. This conditional result is platform evidence, not `Release qualification`. The live steps give candidate test code the job-scoped `GITHUB_TOKEN` and repository `NVIDIA_INFERENCE_API_KEY`. From 5ec51d75c84322e7e2911a3e3b4423a366158496 Mon Sep 17 00:00:00 2001 From: Rebecca Sliter <571084+rsliter@users.noreply.github.com> Date: Tue, 8 Sep 2026 21:43:24 -0700 Subject: [PATCH 17/25] test(ci): prove platform runtime handoffs --- src/lib/onboard/gateway/late-binding.test.ts | 29 ++++++++++++------- .../e2e/platform-vitest-main-workflow.test.ts | 8 +++++ 2 files changed, 26 insertions(+), 11 deletions(-) diff --git a/src/lib/onboard/gateway/late-binding.test.ts b/src/lib/onboard/gateway/late-binding.test.ts index 0b5b46becb2..18328925fc5 100644 --- a/src/lib/onboard/gateway/late-binding.test.ts +++ b/src/lib/onboard/gateway/late-binding.test.ts @@ -10,9 +10,10 @@ import { ensureDockerDriverGatewayJwtBundle, gatewayIdForStateDir, } from "../docker-driver-gateway-config"; +import * as dockerDriverGatewayEnvModule from "../docker-driver-gateway-env"; import * as dockerDriverGatewayLaunch from "../docker-driver-gateway-launch"; import * as gatewayBinding from "../gateway-binding"; -import { prepareNativePodmanGatewayHostRuntime } from "../runtime-provider/podman-runtime-surfaces"; +import { createDockerRuntimeProviderBundle } from "../runtime-provider/docker"; import { createDockerDriverGatewayStart, resolveDockerDriverGatewayRuntimeMarkerEndpoint, @@ -200,20 +201,15 @@ describe("gateway lifecycle late binding", () => { return true; }, ); - const gatewayHostRuntime = prepareNativePodmanGatewayHostRuntime({ + const gatewayHostRuntime = createDockerRuntimeProviderBundle().gateway.prepareHostRuntime({ environment: {}, platform: "linux", - socketPath: "/run/user/1001/podman/podman.sock", - }); - const gatewayEnv = { OPENSHELL_SERVER_PORT: String(port) }; - const requirePreparedDockerDriverGatewayHostRuntime = vi.fn((selectedGatewayEnv) => { - expect(selectedGatewayEnv).toBe(gatewayEnv); - return gatewayHostRuntime; }); const dockerDriverGatewayEnv = { - requirePreparedDockerDriverGatewayHostRuntime, + ...dockerDriverGatewayEnvModule, startPackageManagedDockerDriverGatewayWithEnvOverride: managedStart, } as unknown as typeof import("../docker-driver-gateway-env"); + let gatewayEnv: Record | undefined; const getDockerDriverGatewayEnv = vi.fn(() => { expect( gatewayBinding.managedGatewayStateRootOwnershipFailure({ @@ -225,7 +221,15 @@ describe("gateway lifecycle late binding", () => { expect( gatewayStateLifecycleLock.tryAcquireManagedGatewayStateLifecycleLock(stateDir), ).toBeNull(); - gatewayEnv.OPENSHELL_SERVER_PORT = String(port); + gatewayEnv = dockerDriverGatewayEnvModule.buildDockerDriverGatewayEnv({ + platform: "linux", + gatewayPort: port, + stateDir, + dockerNetworkName: "openshell-docker", + gatewayHostRuntime, + getDockerSupervisorImage: () => "supervisor:test", + resolveSandboxBin: () => null, + }); return gatewayEnv; }); const runCaptureOpenshell = vi.fn((_args: string[], _options?: Record) => ""); @@ -296,6 +300,7 @@ describe("gateway lifecycle late binding", () => { "/usr/bin/openshell-sandbox", jwtBundle, gatewayIdForStateDir(stateDir), + gatewayHostRuntime, ), { mode: 0o600 }, ); @@ -324,7 +329,9 @@ describe("gateway lifecycle late binding", () => { ); const runtimeIdentityOptions = runtimeIdentitySpy.mock.calls[0]?.[0]; const managedOptions = managedStart.mock.calls[0]?.[0]; - expect(requirePreparedDockerDriverGatewayHostRuntime).toHaveBeenCalledOnce(); + expect( + dockerDriverGatewayEnvModule.requirePreparedDockerDriverGatewayHostRuntime(gatewayEnv!), + ).toBe(gatewayHostRuntime); expect(runtimeIdentityOptions?.gatewayHostRuntime).toBe(gatewayHostRuntime); expect(runtimeIdentityOptions?.env).toEqual(managedOptions?.env); expect(runtimeIdentityOptions?.env).toEqual( diff --git a/test/automation/e2e/platform-vitest-main-workflow.test.ts b/test/automation/e2e/platform-vitest-main-workflow.test.ts index 58dcabc4991..f771ae933ac 100644 --- a/test/automation/e2e/platform-vitest-main-workflow.test.ts +++ b/test/automation/e2e/platform-vitest-main-workflow.test.ts @@ -89,6 +89,10 @@ describe("platform evidence workflow", () => { (entry) => entry.name === "Start the WSL container runtime", ); const suiteIndex = steps.findIndex((entry) => entry.name === "Run full Vitest suite in WSL"); + const detectionIndex = steps.findIndex( + (entry) => entry.name === "Detect Docker availability in WSL", + ); + const liveIndex = steps.findIndex((entry) => entry.name === "Run WSL live E2E"); expect(install).toContain("'docker.io'"); expect(install).toContain("'libc6-dev'"); expect(install).toContain("'podman'"); @@ -102,7 +106,11 @@ describe("platform evidence workflow", () => { expect(runtime).toContain("ip -Version"); expect(runtimeIndex).toBeGreaterThanOrEqual(0); expect(suiteIndex).toBeGreaterThanOrEqual(0); + expect(detectionIndex).toBeGreaterThanOrEqual(0); + expect(liveIndex).toBeGreaterThanOrEqual(0); expect(runtimeIndex).toBeGreaterThan(suiteIndex); + expect(detectionIndex).toBeGreaterThan(runtimeIndex); + expect(liveIndex).toBeGreaterThan(detectionIndex); }); it("uses one native WSL npm cache for installation and package-contract tests", () => { From ff3b2a24721ec9d6dc375e29e6281d29a6ecf109 Mon Sep 17 00:00:00 2001 From: Rebecca Sliter <571084+rsliter@users.noreply.github.com> Date: Tue, 8 Sep 2026 22:07:47 -0700 Subject: [PATCH 18/25] fix(ci): make runtime handoffs explicit --- .github/workflows/platform-vitest-main.yaml | 4 +-- src/lib/onboard.ts | 3 +- .../onboard/dashboard-web-auth-token.test.ts | 18 +++++++++++- src/lib/onboard/dashboard.ts | 4 +-- .../onboard/docker-driver-gateway-env.test.ts | 16 ++++------ src/lib/onboard/docker-driver-gateway-env.ts | 29 ++++++++----------- .../docker-driver-gateway-runtime.test.ts | 5 ++++ .../onboard/docker-driver-gateway-runtime.ts | 24 +++++++++++---- .../onboard/gateway/docker-driver-start.ts | 8 ++--- src/lib/onboard/gateway/late-binding.test.ts | 16 +++++----- .../e2e/platform-vitest-main-workflow.test.ts | 3 ++ 11 files changed, 78 insertions(+), 52 deletions(-) diff --git a/.github/workflows/platform-vitest-main.yaml b/.github/workflows/platform-vitest-main.yaml index c7ef5c26966..35f79b387be 100644 --- a/.github/workflows/platform-vitest-main.yaml +++ b/.github/workflows/platform-vitest-main.yaml @@ -444,7 +444,7 @@ jobs: echo DOCKER_OK=0 fi '@ - $result = Invoke-WslScript -Distro $env:WSL_DISTRO -Script $script -CaptureOutput + $result = Invoke-WslScript -Distro $env:WSL_DISTRO -User $env:WSL_TEST_USER -Script $script -CaptureOutput if ($result -match 'DOCKER_OK=1') { 'docker_ok=true' | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append Write-Host 'Docker is available in WSL' @@ -481,7 +481,7 @@ jobs: export NEMOCLAW_RUN_LIVE_E2E=1 npx vitest run --project e2e-live test/e2e/live/full-e2e.test.ts --silent=false --reporter=default "@ - Invoke-WslScript -Distro $env:WSL_DISTRO -Script $script + Invoke-WslScript -Distro $env:WSL_DISTRO -User $env:WSL_TEST_USER -Script $script - name: Explain skipped WSL live E2E if: ${{ matrix.shard == 1 && (steps.wsl_docker.outputs.docker_ok != 'true' || github.ref != 'refs/heads/main') }} diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 222bde160e7..55aae285be7 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -574,6 +574,7 @@ const { clearDockerDriverGatewayRuntimeFiles, createGatewayServicePortOwnership, getDockerDriverGatewayEnv, + getDockerDriverGatewayPreparation, getDockerDriverGatewayPid, getDockerDriverGatewayPortListenerPid, getDockerDriverGatewayPortListenerScan, @@ -1397,7 +1398,7 @@ const dockerDriverGatewayStart = createDockerDriverGatewayStart({ gatewayName: () => GATEWAY_NAME, gatewayPort: () => GATEWAY_PORT, getDockerDriverGatewayEndpoint, - getDockerDriverGatewayEnv, + getDockerDriverGatewayPreparation, getDockerDriverGatewayPid, getDockerDriverGatewayPortListenerScan, getDockerDriverGatewayRuntimeDrift, diff --git a/src/lib/onboard/dashboard-web-auth-token.test.ts b/src/lib/onboard/dashboard-web-auth-token.test.ts index 61dfa21e997..00c7b41fff6 100644 --- a/src/lib/onboard/dashboard-web-auth-token.test.ts +++ b/src/lib/onboard/dashboard-web-auth-token.test.ts @@ -7,7 +7,10 @@ import { loadAgent } from "../agent/defs"; import { createOnboardDashboardHelpers } from "./dashboard"; // Minimal no-op deps; only runCaptureOpenshell matters for these tests. -function makeHelpers(runCaptureOpenshell: (args: string[], opts?: unknown) => string | null) { +function makeHelpers( + runCaptureOpenshell: (args: string[], opts?: unknown) => string | null, + overrides: Partial[0]> = {}, +) { return createOnboardDashboardHelpers({ runOpenshell: () => ({ status: 0 }), runCaptureOpenshell, @@ -20,6 +23,7 @@ function makeHelpers(runCaptureOpenshell: (args: string[], opts?: unknown) => st redact: (v: unknown) => String(v), sleep: () => {}, printAgentDashboardUi: () => {}, + ...overrides, }); } @@ -79,3 +83,15 @@ describe("fetchAgentWebAuthTokenFromSandbox", () => { expect(runCaptureOpenshell).not.toHaveBeenCalled(); }); }); + +describe("dashboard forwarding dependency handoff", () => { + it("uses the injected dashboard URL and WSL decision", () => { + const helpers = makeHelpers(() => "172.22.1.1\n", { + env: { CHAT_UI_URL: "http://127.0.0.1:19999" }, + isWsl: () => true, + }); + + expect(helpers.getDashboardForwardPort()).toBe("19999"); + expect(helpers.getDashboardForwardTarget()).toBe("0.0.0.0:19999"); + }); +}); diff --git a/src/lib/onboard/dashboard.ts b/src/lib/onboard/dashboard.ts index d057fc830e1..e31c37cda81 100644 --- a/src/lib/onboard/dashboard.ts +++ b/src/lib/onboard/dashboard.ts @@ -290,7 +290,7 @@ export function createOnboardDashboardHelpers(deps: OnboardDashboardDeps): Onboa } function getDashboardForwardPort( - chatUiUrl = process.env.CHAT_UI_URL || `http://127.0.0.1:${CONTROL_UI_PORT}`, + chatUiUrl = deps.env?.CHAT_UI_URL || process.env.CHAT_UI_URL || `http://127.0.0.1:${CONTROL_UI_PORT}`, options: Parameters[1] = {}, ): string { return dashboardAccess.getDashboardForwardPort(chatUiUrl, { @@ -302,7 +302,7 @@ export function createOnboardDashboardHelpers(deps: OnboardDashboardDeps): Onboa } function getDashboardForwardTarget( - chatUiUrl = process.env.CHAT_UI_URL || `http://127.0.0.1:${CONTROL_UI_PORT}`, + chatUiUrl = deps.env?.CHAT_UI_URL || process.env.CHAT_UI_URL || `http://127.0.0.1:${CONTROL_UI_PORT}`, options: Parameters[1] = {}, ): string { return dashboardAccess.getDashboardForwardTarget(chatUiUrl, { diff --git a/src/lib/onboard/docker-driver-gateway-env.test.ts b/src/lib/onboard/docker-driver-gateway-env.test.ts index d749e3eb42f..ca6668272db 100644 --- a/src/lib/onboard/docker-driver-gateway-env.test.ts +++ b/src/lib/onboard/docker-driver-gateway-env.test.ts @@ -12,7 +12,7 @@ import { writeOpenShell0044PreAuthState } from "../../../test/support/openshell- import { buildDockerDriverGatewayEnv, buildDockerGatewayDebEnvFile, - requirePreparedDockerDriverGatewayHostRuntime, + prepareDockerDriverGatewayEnv, startPackageManagedDockerDriverGatewayWithEnvOverride, writeDockerGatewayDebEnvOverride, } from "./docker-driver-gateway-env"; @@ -108,12 +108,13 @@ describe("buildDockerDriverGatewayEnv", () => { try { writeOpenShell0044PreAuthState(stateDir); - const env = buildDockerDriverGatewayEnv({ + const preparation = prepareDockerDriverGatewayEnv({ platform: "linux", stateDir, getDockerSupervisorImage: () => "supervisor:test", resolveSandboxBin: () => "/usr/bin/openshell-sandbox", }); + const env = preparation.gatewayEnv; expect(fs.existsSync(env.OPENSHELL_GATEWAY_CONFIG)).toBe(true); expect(fs.readFileSync(path.join(stateDir, "openshell.db"), "utf-8")).toBe("legacy-database"); @@ -186,13 +187,14 @@ describe("buildDockerDriverGatewayEnv", () => { environment: process.env, platform: "linux", }); - const env = buildDockerDriverGatewayEnv({ + const preparation = prepareDockerDriverGatewayEnv({ platform: "linux", stateDir, gatewayHostRuntime, getDockerSupervisorImage: () => "supervisor:test", resolveSandboxBin: () => "/usr/bin/openshell-sandbox", }); + const env = preparation.gatewayEnv; expect(env).toMatchObject({ OPENSHELL_DRIVERS: "podman", OPENSHELL_BIND_ADDRESS: "0.0.0.0", @@ -202,7 +204,7 @@ describe("buildDockerDriverGatewayEnv", () => { expect(env.OPENSHELL_PODMAN_SOCKET).toMatch(/\/podman\/podman\.sock$/u); expect(env.CONTAINERS_CONF).toBeUndefined(); expect(env.NETAVARK_FW).toBeUndefined(); - expect(requirePreparedDockerDriverGatewayHostRuntime(env)).toBe(gatewayHostRuntime); + expect(preparation.gatewayHostRuntime).toBe(gatewayHostRuntime); const toml = fs.readFileSync(env.OPENSHELL_GATEWAY_CONFIG, "utf-8"); expect(toml).toContain('compute_drivers = ["podman"]'); expect(toml).toContain(`socket_path = "${env.OPENSHELL_PODMAN_SOCKET}"`); @@ -213,12 +215,6 @@ describe("buildDockerDriverGatewayEnv", () => { } }); - it("rejects an environment that has no prepared gateway runtime", () => { - expect(() => requirePreparedDockerDriverGatewayHostRuntime({})).toThrow( - "OpenShell gateway environment has no prepared host runtime.", - ); - }); - it.each([ ["a relative path", "run/user/1001/podman/podman.sock"], ["trailing whitespace", "/run/user/1001/podman/podman.sock "], diff --git a/src/lib/onboard/docker-driver-gateway-env.ts b/src/lib/onboard/docker-driver-gateway-env.ts index 2f5a6cfe7fe..cd6baf38106 100644 --- a/src/lib/onboard/docker-driver-gateway-env.ts +++ b/src/lib/onboard/docker-driver-gateway-env.ts @@ -91,19 +91,9 @@ export interface BuildDockerDriverGatewayEnvOptions { enableBindMounts?: boolean; } -const preparedGatewayHostRuntimeByEnv = new WeakMap< - Record, - RuntimeProviderGatewayHostRuntime ->(); - -export function requirePreparedDockerDriverGatewayHostRuntime( - gatewayEnv: Record, -): RuntimeProviderGatewayHostRuntime { - const runtime = preparedGatewayHostRuntimeByEnv.get(gatewayEnv); - if (!runtime) { - throw new Error("OpenShell gateway environment has no prepared host runtime."); - } - return runtime; +export interface DockerDriverGatewayPreparation { + gatewayEnv: Record; + gatewayHostRuntime: RuntimeProviderGatewayHostRuntime; } function preparePortableGatewayHostRuntime( @@ -438,7 +428,7 @@ export function warnIfGatewayWildcardBindAddress(): void { ); } -export function buildDockerDriverGatewayEnv({ +export function prepareDockerDriverGatewayEnv({ platform = process.platform, architecture = process.arch, gatewayPort = GATEWAY_PORT, @@ -449,7 +439,7 @@ export function buildDockerDriverGatewayEnv({ getDockerSupervisorImage, resolveSandboxBin, enableBindMounts = false, -}: BuildDockerDriverGatewayEnvOptions): Record { +}: BuildDockerDriverGatewayEnvOptions): DockerDriverGatewayPreparation { const portable = isPortableExperimentalProfile(); const runtime = gatewayHostRuntime ?? @@ -506,8 +496,13 @@ export function buildDockerDriverGatewayEnv({ process.env.NEMOCLAW_RESTORE_LATEST_BACKUP_ON_RECREATE === "1", gatewayRuntime: runtime, }); - preparedGatewayHostRuntimeByEnv.set(env, runtime); - return env; + return { gatewayEnv: env, gatewayHostRuntime: runtime }; +} + +export function buildDockerDriverGatewayEnv( + options: BuildDockerDriverGatewayEnvOptions, +): Record { + return prepareDockerDriverGatewayEnv(options).gatewayEnv; } export function buildDockerGatewayDebEnvFile( diff --git a/src/lib/onboard/docker-driver-gateway-runtime.test.ts b/src/lib/onboard/docker-driver-gateway-runtime.test.ts index 53f766efa42..8b07dd6ed8c 100644 --- a/src/lib/onboard/docker-driver-gateway-runtime.test.ts +++ b/src/lib/onboard/docker-driver-gateway-runtime.test.ts @@ -363,6 +363,11 @@ describe("docker-driver gateway runtime helpers", () => { ...options, architecture: "arm64", }), + prepareDockerDriverGatewayEnv: (options) => + dockerDriverGatewayEnv.prepareDockerDriverGatewayEnv({ + ...options, + architecture: "arm64", + }), }), runCapture: vi.fn((args) => processOutput.get(args.join(" ")) ?? ""), }); diff --git a/src/lib/onboard/docker-driver-gateway-runtime.ts b/src/lib/onboard/docker-driver-gateway-runtime.ts index a934f07af52..b66bf860171 100644 --- a/src/lib/onboard/docker-driver-gateway-runtime.ts +++ b/src/lib/onboard/docker-driver-gateway-runtime.ts @@ -102,6 +102,10 @@ export function createDockerDriverGatewayRuntimeHelpers(deps: DockerDriverGatewa versionOutput?: string | null, platform?: NodeJS.Platform, ): Record; + getDockerDriverGatewayPreparation( + versionOutput?: string | null, + platform?: NodeJS.Platform, + ): import("./docker-driver-gateway-env").DockerDriverGatewayPreparation; getDockerDriverGatewayPid(): number | null; getDockerDriverGatewayPidFile(): string; getDockerDriverGatewayPortListenerScan( @@ -243,10 +247,10 @@ export function createDockerDriverGatewayRuntimeHelpers(deps: DockerDriverGatewa : `ghcr.io/nvidia/openshell/supervisor:${supportedVersion}`; } - function getDockerDriverGatewayEnv( + function getDockerDriverGatewayPreparation( versionOutput: string | null = null, platform: NodeJS.Platform = process.platform, - ): Record { + ): import("./docker-driver-gateway-env").DockerDriverGatewayPreparation { const dockerHost = process.env.DOCKER_HOST; let podmanSocketPath: string | undefined; if (isPortableExperimentalProfile()) { @@ -258,7 +262,7 @@ export function createDockerDriverGatewayRuntimeHelpers(deps: DockerDriverGatewa } podmanSocketPath = candidate.slice("unix://".length); } - const gatewayEnv = dockerDriverGatewayEnv.buildDockerDriverGatewayEnv({ + const preparation = dockerDriverGatewayEnv.prepareDockerDriverGatewayEnv({ platform, gatewayPort: currentGatewayPort(), stateDir: getDockerDriverGatewayStateDir(), @@ -268,10 +272,17 @@ export function createDockerDriverGatewayRuntimeHelpers(deps: DockerDriverGatewa resolveSandboxBin: resolveOpenShellSandboxBinary, enableBindMounts: deps.enableBindMounts?.() === true, }); - if (gatewayEnv.OPENSHELL_LOCAL_TLS_DIR) { - process.env.OPENSHELL_LOCAL_TLS_DIR = gatewayEnv.OPENSHELL_LOCAL_TLS_DIR; + if (preparation.gatewayEnv.OPENSHELL_LOCAL_TLS_DIR) { + process.env.OPENSHELL_LOCAL_TLS_DIR = preparation.gatewayEnv.OPENSHELL_LOCAL_TLS_DIR; } - return gatewayEnv; + return preparation; + } + + function getDockerDriverGatewayEnv( + versionOutput: string | null = null, + platform: NodeJS.Platform = process.platform, + ): Record { + return getDockerDriverGatewayPreparation(versionOutput, platform).gatewayEnv; } function isPidAlive(pid: number): boolean { @@ -582,6 +593,7 @@ export function createDockerDriverGatewayRuntimeHelpers(deps: DockerDriverGatewa clearDockerDriverGatewayRuntimeFiles, createGatewayServicePortOwnership, getDockerDriverGatewayEnv, + getDockerDriverGatewayPreparation, getDockerDriverGatewayPid, getDockerDriverGatewayPidFile, getDockerDriverGatewayPortListenerScan, diff --git a/src/lib/onboard/gateway/docker-driver-start.ts b/src/lib/onboard/gateway/docker-driver-start.ts index 1b75828f16a..20744405bb2 100644 --- a/src/lib/onboard/gateway/docker-driver-start.ts +++ b/src/lib/onboard/gateway/docker-driver-start.ts @@ -41,7 +41,7 @@ export interface DockerDriverGatewayStartDeps { gatewayName(): string; gatewayPort(): number; getDockerDriverGatewayEndpoint: DynamicGatewayHelpers["getDockerDriverGatewayEndpoint"]; - getDockerDriverGatewayEnv: GatewayRuntimeHelpers["getDockerDriverGatewayEnv"]; + getDockerDriverGatewayPreparation: GatewayRuntimeHelpers["getDockerDriverGatewayPreparation"]; getDockerDriverGatewayPid: GatewayRuntimeHelpers["getDockerDriverGatewayPid"]; getDockerDriverGatewayPortListenerScan: GatewayRuntimeHelpers["getDockerDriverGatewayPortListenerScan"]; getDockerDriverGatewayRuntimeDrift: GatewayRuntimeHelpers["getDockerDriverGatewayRuntimeDrift"]; @@ -144,7 +144,8 @@ export function createDockerDriverGatewayStart( } const gatewayBin = deps.resolveOpenShellGatewayBinary(); const openshellVersionOutput = runCaptureOpenshell(["--version"], { ignoreError: true }); - const gatewayEnv = deps.getDockerDriverGatewayEnv(openshellVersionOutput); + const { gatewayEnv, gatewayHostRuntime } = + deps.getDockerDriverGatewayPreparation(openshellVersionOutput); const runtimeIdentity = gatewayBin ? dockerDriverGatewayLaunch.buildDockerDriverGatewayRuntimeIdentity({ gatewayBin, @@ -157,8 +158,7 @@ export function createDockerDriverGatewayStart( ), ...(selectedRuntimeEnv ? { env: selectedRuntimeEnv } : {}), ensureLocalTlsBundle: true, - gatewayHostRuntime: - deps.dockerDriverGatewayEnv.requirePreparedDockerDriverGatewayHostRuntime(gatewayEnv), + gatewayHostRuntime, }) : null; const gatewayLaunch = runtimeIdentity?.launch ?? null; diff --git a/src/lib/onboard/gateway/late-binding.test.ts b/src/lib/onboard/gateway/late-binding.test.ts index 18328925fc5..942956da21b 100644 --- a/src/lib/onboard/gateway/late-binding.test.ts +++ b/src/lib/onboard/gateway/late-binding.test.ts @@ -210,7 +210,7 @@ describe("gateway lifecycle late binding", () => { startPackageManagedDockerDriverGatewayWithEnvOverride: managedStart, } as unknown as typeof import("../docker-driver-gateway-env"); let gatewayEnv: Record | undefined; - const getDockerDriverGatewayEnv = vi.fn(() => { + const getDockerDriverGatewayPreparation = vi.fn(() => { expect( gatewayBinding.managedGatewayStateRootOwnershipFailure({ gatewayName: name, @@ -221,7 +221,7 @@ describe("gateway lifecycle late binding", () => { expect( gatewayStateLifecycleLock.tryAcquireManagedGatewayStateLifecycleLock(stateDir), ).toBeNull(); - gatewayEnv = dockerDriverGatewayEnvModule.buildDockerDriverGatewayEnv({ + const preparation = dockerDriverGatewayEnvModule.prepareDockerDriverGatewayEnv({ platform: "linux", gatewayPort: port, stateDir, @@ -230,7 +230,8 @@ describe("gateway lifecycle late binding", () => { getDockerSupervisorImage: () => "supervisor:test", resolveSandboxBin: () => null, }); - return gatewayEnv; + gatewayEnv = { ...preparation.gatewayEnv }; + return { gatewayEnv, gatewayHostRuntime: preparation.gatewayHostRuntime }; }); const runCaptureOpenshell = vi.fn((_args: string[], _options?: Record) => ""); const runtimeIdentitySpy = vi @@ -259,7 +260,7 @@ describe("gateway lifecycle late binding", () => { gatewayName: () => name, gatewayPort: () => port, getDockerDriverGatewayEndpoint: () => "https://127.0.0.1", - getDockerDriverGatewayEnv, + getDockerDriverGatewayPreparation, getDockerDriverGatewayPid: () => null, getDockerDriverGatewayPortListenerScan: () => ({ complete: true, @@ -329,9 +330,6 @@ describe("gateway lifecycle late binding", () => { ); const runtimeIdentityOptions = runtimeIdentitySpy.mock.calls[0]?.[0]; const managedOptions = managedStart.mock.calls[0]?.[0]; - expect( - dockerDriverGatewayEnvModule.requirePreparedDockerDriverGatewayHostRuntime(gatewayEnv!), - ).toBe(gatewayHostRuntime); expect(runtimeIdentityOptions?.gatewayHostRuntime).toBe(gatewayHostRuntime); expect(runtimeIdentityOptions?.env).toEqual(managedOptions?.env); expect(runtimeIdentityOptions?.env).toEqual( @@ -385,7 +383,7 @@ describe("gateway lifecycle late binding", () => { await expect(start.startDockerDriverGateway()).rejects.toThrow(/refusing to adopt/); expect(fs.readFileSync(path.join(unsafeStateDir, "keep.txt"), "utf8")).toBe("keep\n"); - expect(getDockerDriverGatewayEnv).toHaveBeenCalledTimes(1); + expect(getDockerDriverGatewayPreparation).toHaveBeenCalledTimes(1); expect(managedStart).toHaveBeenCalledTimes(1); const writableParent = path.join(root, "writable-parent"); @@ -403,7 +401,7 @@ describe("gateway lifecycle late binding", () => { ), ).toBe(false); expect(fs.existsSync(writableStateDir)).toBe(false); - expect(getDockerDriverGatewayEnv).toHaveBeenCalledTimes(1); + expect(getDockerDriverGatewayPreparation).toHaveBeenCalledTimes(1); expect(managedStart).toHaveBeenCalledTimes(1); } finally { runtimeIdentitySpy.mockRestore(); diff --git a/test/automation/e2e/platform-vitest-main-workflow.test.ts b/test/automation/e2e/platform-vitest-main-workflow.test.ts index f771ae933ac..1ddd695f845 100644 --- a/test/automation/e2e/platform-vitest-main-workflow.test.ts +++ b/test/automation/e2e/platform-vitest-main-workflow.test.ts @@ -38,6 +38,7 @@ describe("platform evidence workflow", () => { }); it("limits credentialed WSL E2E to the first main-branch shard", () => { const live = step("wsl-vitest", "Run WSL live E2E"); + const detection = step("wsl-vitest", "Detect Docker availability in WSL"); expect(live.if).toContain("matrix.shard == 1"); expect(live.if).toContain("steps.wsl_docker.outputs.docker_ok == 'true'"); expect(live.if).toContain("github.ref == 'refs/heads/main'"); @@ -45,6 +46,8 @@ describe("platform evidence workflow", () => { GITHUB_TOKEN: "${{ github.token }}", NVIDIA_INFERENCE_API_KEY: "${{ secrets.NVIDIA_INFERENCE_API_KEY }}", }); + expect(detection.run).toContain("-User $env:WSL_TEST_USER"); + expect(live.run).toContain("-User $env:WSL_TEST_USER"); }); it("keeps credentialed macOS E2E independent from non-live shard failures", () => { From d84c45a19b4ba56cde279ed2b047bb4464f832bb Mon Sep 17 00:00:00 2001 From: Rebecca Sliter <571084+rsliter@users.noreply.github.com> Date: Tue, 8 Sep 2026 22:26:34 -0700 Subject: [PATCH 19/25] test(ci): update gateway preparation fixture --- test/onboarding/onboard-gateway-prelaunch-cutover.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/test/onboarding/onboard-gateway-prelaunch-cutover.test.ts b/test/onboarding/onboard-gateway-prelaunch-cutover.test.ts index 22cd6830df9..406cf09698b 100644 --- a/test/onboarding/onboard-gateway-prelaunch-cutover.test.ts +++ b/test/onboarding/onboard-gateway-prelaunch-cutover.test.ts @@ -165,6 +165,10 @@ Module._load = function(request, parent, isMain) { validatePortOwner: () => {}, }), getDockerDriverGatewayEnv: () => ({}), + getDockerDriverGatewayPreparation: () => ({ + gatewayEnv: {}, + gatewayHostRuntime: {}, + }), getDockerDriverGatewayPid: () => null, getDockerDriverGatewayPortListenerScan: (portCheck) => ({ complete: true, From 032e3cc12b57998ea00b9c12355657ea4f35e503 Mon Sep 17 00:00:00 2001 From: Rebecca Sliter <571084+rsliter@users.noreply.github.com> Date: Thu, 10 Sep 2026 14:47:47 -0700 Subject: [PATCH 20/25] test(platform): use writable home for component fixtures --- src/lib/onboard/external-component/index.test.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/lib/onboard/external-component/index.test.ts b/src/lib/onboard/external-component/index.test.ts index a8032ca7c57..4de6c77d764 100644 --- a/src/lib/onboard/external-component/index.test.ts +++ b/src/lib/onboard/external-component/index.test.ts @@ -3,6 +3,7 @@ import fs from "node:fs"; import net from "node:net"; +import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; @@ -65,8 +66,8 @@ async function closeServer(server: net.Server): Promise { } async function preparedFixture() { - const ancestors = path - .dirname(process.cwd()) + const fixtureParent = fs.realpathSync(os.homedir()); + const ancestors = fixtureParent .split(path.sep) .map((_part, index, parts) => parts.slice(0, index + 1).join(path.sep) || path.sep); for (const ancestor of ancestors) { @@ -78,7 +79,7 @@ async function preparedFixture() { `External component fixtures require a protected ancestor: ${ancestor}`, ).toBe(true); } - const root = fs.mkdtempSync(path.join(path.dirname(process.cwd()), "nc-component-test-")); + const root = fs.mkdtempSync(path.join(fixtureParent, "nc-component-test-")); roots.push(root); fs.chmodSync(root, 0o700); const homeDirectory = path.join(root, "home"); From aea0cfe77a382441245469c28262e63a01cd4bf3 Mon Sep 17 00:00:00 2001 From: Rebecca Sliter <571084+rsliter@users.noreply.github.com> Date: Thu, 10 Sep 2026 15:45:57 -0700 Subject: [PATCH 21/25] test(platform): isolate preflight and model host observations --- .../preflight-docker-authority-conflict.test.ts | 14 ++++++++++++++ .../onboard/setup-nim-flow-serving-profile.test.ts | 4 ++++ 2 files changed, 18 insertions(+) diff --git a/src/lib/onboard/preflight-docker-authority-conflict.test.ts b/src/lib/onboard/preflight-docker-authority-conflict.test.ts index 4dad90501c3..39d888e92cb 100644 --- a/src/lib/onboard/preflight-docker-authority-conflict.test.ts +++ b/src/lib/onboard/preflight-docker-authority-conflict.test.ts @@ -45,6 +45,8 @@ describe("assessHost Docker authority conflict (#10622)", () => { const observe = vi.fn(() => CONFLICT); const assessment = assessHost({ platform: "linux", + release: "6.8.0-generic", + procVersion: "Linux version 6.8.0-generic", env: {}, dockerInfoOutput: "", commandExistsImpl, @@ -68,6 +70,8 @@ describe("assessHost Docker authority conflict (#10622)", () => { const observe = vi.fn(() => CONFLICT); const assessment = assessHost({ platform: "linux", + release: "6.8.0-generic", + procVersion: "Linux version 6.8.0-generic", env: { DOCKER_HOST: "unix:///var/run/docker.sock" }, dockerInfoOutput: "", commandExistsImpl, @@ -86,6 +90,8 @@ describe("assessHost Docker authority conflict (#10622)", () => { const observe = vi.fn(() => CONFLICT); const assessment = assessHost({ platform: "linux", + release: "6.8.0-generic", + procVersion: "Linux version 6.8.0-generic", env: {}, dockerInfoOutput: REACHABLE_DOCKER_INFO, dockerVersionOutput: "", @@ -103,6 +109,8 @@ describe("assessHost Docker authority conflict (#10622)", () => { const observe = vi.fn(() => null); const assessment = assessHost({ platform: "linux", + release: "6.8.0-generic", + procVersion: "Linux version 6.8.0-generic", env: {}, dockerInfoOutput: "", commandExistsImpl, @@ -121,6 +129,8 @@ describe("assessHost Docker authority conflict (#10622)", () => { const observe = vi.spyOn(platform, "observeDockerAuthorityConflict").mockReturnValue(CONFLICT); const assessment = assessHost({ platform: "linux", + release: "6.8.0-generic", + procVersion: "Linux version 6.8.0-generic", env: {}, dockerInfoOutput: "", commandExistsImpl, @@ -138,6 +148,8 @@ describe("assessHost Docker authority conflict (#10622)", () => { const observe = vi.fn(() => CONFLICT); const assessment = assessHost({ platform: "linux", + release: "6.8.0-generic", + procVersion: "Linux version 6.8.0-generic", env: {}, commandExistsImpl, runCaptureImpl, @@ -157,6 +169,8 @@ describe("assessHost Docker authority conflict (#10622)", () => { const observe = vi.fn(() => CONFLICT); const assessment = assessHost({ platform: "linux", + release: "6.8.0-generic", + procVersion: "Linux version 6.8.0-generic", env: {}, dockerInfoOutput: "", commandExistsImpl: (name: string) => name === "systemctl", diff --git a/src/lib/onboard/setup-nim-flow-serving-profile.test.ts b/src/lib/onboard/setup-nim-flow-serving-profile.test.ts index 9b473d00405..c14d03d05c5 100644 --- a/src/lib/onboard/setup-nim-flow-serving-profile.test.ts +++ b/src/lib/onboard/setup-nim-flow-serving-profile.test.ts @@ -51,6 +51,10 @@ async function selectAgainstRunningVllm( isNonInteractive: () => true, getNonInteractiveProvider: () => "install-vllm", detectInferenceProviderHostState: () => runningVllmHostState(), + discoverManagedLlamaCppSelections: () => ({ + choices: [], + resolution: { kind: "rejected", reason: "No llama.cpp profile in this vLLM fixture" }, + }), handleVllmSelection, resolveRequestedServingProfileModel, selectVllmModelFromEnv, From 27eaf059f184458607cd28e3856c5cbc2b736855 Mon Sep 17 00:00:00 2001 From: Rebecca Sliter <571084+rsliter@users.noreply.github.com> Date: Thu, 10 Sep 2026 16:40:44 -0700 Subject: [PATCH 22/25] test(platform): await backend process reaping --- test/e2e/support/portable-profile-systemctl-shim.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/e2e/support/portable-profile-systemctl-shim.test.ts b/test/e2e/support/portable-profile-systemctl-shim.test.ts index 46173fec4a5..d432dd2d2fe 100644 --- a/test/e2e/support/portable-profile-systemctl-shim.test.ts +++ b/test/e2e/support/portable-profile-systemctl-shim.test.ts @@ -1139,7 +1139,7 @@ describe("portable profile systemctl fixture", () => { expect(await activateThroughSocket(scope.socketPath)).toBe(""); await waitForPath(failureRecord); const processRecord = readFixtureProcessRecord(failureRecord); - expect(pidIsActive(processRecord.pid)).toBe(false); + await vi.waitFor(() => expect(pidIsActive(processRecord.pid)).toBe(false)); expect(fs.existsSync(servicePidFile)).toBe(false); await waitForPath(backendSocketPath, false); } finally { From f5e7133eee3ef802e2526cd2aeedbb84b1b7530b Mon Sep 17 00:00:00 2001 From: Rebecca Sliter <571084+rsliter@users.noreply.github.com> Date: Thu, 10 Sep 2026 19:10:01 -0700 Subject: [PATCH 23/25] test(platform): wait for descendant readiness before deadline --- .../openshell/sandbox-command-cli.test.ts | 41 ++++++++++++------- 1 file changed, 27 insertions(+), 14 deletions(-) diff --git a/src/lib/adapters/openshell/sandbox-command-cli.test.ts b/src/lib/adapters/openshell/sandbox-command-cli.test.ts index 438ce741113..3dd76d49261 100644 --- a/src/lib/adapters/openshell/sandbox-command-cli.test.ts +++ b/src/lib/adapters/openshell/sandbox-command-cli.test.ts @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { EventEmitter } from "node:events"; +import { EventEmitter, once } from "node:events"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; @@ -368,35 +368,48 @@ describe("CLI OpenShell sandbox command executor", () => { const pidPath = path.join(directory, "descendant.pid"); const survivorPath = path.join(directory, "descendant-survived"); let descendantPid: number | undefined; + fs.writeFileSync(pidPath, ""); + const watcher = fs.watch(pidPath); + const realSetTimeout = setTimeout; + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); try { const descendantScript = [ "const fs = require('node:fs');", "process.on('SIGTERM', () => {});", - `setTimeout(() => fs.writeFileSync(${JSON.stringify(survivorPath)}, 'alive'), 500);`, + `setInterval(() => fs.writeFileSync(${JSON.stringify(survivorPath)}, 'alive'), 250);`, + `fs.writeFileSync(${JSON.stringify(`${pidPath}.tmp`)}, String(process.pid));`, + `fs.renameSync(${JSON.stringify(`${pidPath}.tmp`)}, ${JSON.stringify(pidPath)});`, "setInterval(() => {}, 1000);", ].join(""); const parentScript = [ "const { spawn } = require('node:child_process');", - "const fs = require('node:fs');", - `const child = spawn(process.execPath, ['-e', ${JSON.stringify(descendantScript)}], { stdio: 'ignore' });`, - `fs.writeFileSync(${JSON.stringify(pidPath)}, String(child.pid));`, "process.on('SIGTERM', () => process.exit(0));", + `spawn(process.execPath, ['-e', ${JSON.stringify(descendantScript)}], { stdio: 'ignore' });`, "setInterval(() => {}, 1000);", ].join(""); - const result = await runCliOpenShellBufferedCommand( - process.execPath, - ["-e", parentScript], - { - timeoutMilliseconds: 250, - }, - ); + const readiness = once(watcher, "change", { signal: AbortSignal.timeout(1000) }); + let result: Awaited> | undefined; + void runCliOpenShellBufferedCommand(process.execPath, ["-e", parentScript], { + timeoutMilliseconds: 250, + }).then((completed) => { + result = completed; + }); + // Keep the deadline clock stopped until the descendant can handle SIGTERM. + await readiness; descendantPid = Number(fs.readFileSync(pidPath, "utf8")); - expect(result).toMatchObject({ status: null, signal: "SIGTERM", timedOut: true }); - await new Promise((resolve) => setTimeout(resolve, 400)); + await vi.advanceTimersByTimeAsync(250); + await expect + .poll(() => result) + .toMatchObject({ status: null, signal: "SIGTERM", timedOut: true }); + fs.rmSync(survivorPath, { force: true }); + await new Promise((resolve) => realSetTimeout(resolve, 400)); expect(fs.existsSync(survivorPath)).toBe(false); } finally { + watcher.close(); + await vi.runAllTimersAsync(); + vi.useRealTimers(); descendantPid === undefined || killTestProcess(descendantPid); fs.rmSync(directory, { recursive: true, force: true }); } From d65aa11cf88cc1e9fb18b517a378d5ab33f969ad Mon Sep 17 00:00:00 2001 From: Rebecca Sliter <571084+rsliter@users.noreply.github.com> Date: Fri, 11 Sep 2026 08:05:40 -0700 Subject: [PATCH 24/25] test(platform): isolate inherited fixture host assumptions --- test/automation/pull-requests/analyze-pr-value-stream.test.ts | 2 +- test/inference/ollama/ollama-proxy-export.test.ts | 3 ++- test/repository/validation-fixture.ts | 2 +- test/repository/validation-reuse.test.ts | 2 +- 4 files changed, 5 insertions(+), 4 deletions(-) diff --git a/test/automation/pull-requests/analyze-pr-value-stream.test.ts b/test/automation/pull-requests/analyze-pr-value-stream.test.ts index ddb6d3c1772..d941e3c8109 100644 --- a/test/automation/pull-requests/analyze-pr-value-stream.test.ts +++ b/test/automation/pull-requests/analyze-pr-value-stream.test.ts @@ -106,7 +106,7 @@ else if (args.includes("/actions/runs/12/jobs")) value = {total_count:0,jobs:[]} else if (args.includes("/actions/runs/") && !args.includes("/jobs") && !args.includes("/artifacts")) { const id = Number(args.split("/actions/runs/")[1].split(" ")[0]); value={...run(id),run_attempt:1,html_url:""}; } else if (args.includes("/artifacts?") && scenario === "artifact-failure") { console.error("Authorization: secret-token"); process.exit(1); } else if (args.includes("/artifacts?")) value = {total_count:1,artifacts:[{id:31,name:"cli-blob-report-1",size_in_bytes:Number(process.env.VALUE_STREAM_ARTIFACT_SIZE),expired:false,workflow_run:{id:11,head_sha:sha},workflow_run_id:11,workflow_run_head_sha:sha}]}; -else if (args.includes("/actions/artifacts/31/zip")) { if (scenario === "artifact-cancel") setInterval(() => {}, 1000); else { process.stdout.write(fs.readFileSync(process.env.VALUE_STREAM_ARTIFACT)); process.exit(0); } } +else if (args.includes("/actions/artifacts/31/zip")) { if (scenario === "artifact-cancel") { setInterval(() => {}, 1000); return; } else { process.stdout.write(fs.readFileSync(process.env.VALUE_STREAM_ARTIFACT)); process.exit(0); } } else if (args.includes("/check-runs?")) { const checks=scenario.startsWith("legacy-") ? [] : [{id:1,name:"required-a",status:"completed",conclusion:"success",created_at:"2026-01-01T00:00:35Z",started_at:"2026-01-01T00:00:45Z",completed_at:scenario === "early-check" ? "2026-01-01T00:00:20Z" : "2026-01-01T00:02:30Z",html_url:"",app:{id:scenario === "wrong-app" ? 8 : 7,slug:"actions"}}]; if (scenario === "duplicate-checks" && args.includes("filter=all")) checks.push({...checks[0],id:3,created_at:"2026-01-01T00:00:30Z"}); if (scenario !== "incomplete" && scenario !== "wrong-app" && scenario !== "any-app" && scenario !== "app-status-denied" && scenario !== "early-check" && !scenario.startsWith("legacy-")) checks.push({...checks[0],id:2,name:"required-b",created_at:"2026-01-01T00:00:40Z",completed_at:"2026-01-01T00:02:40Z"}); value=checks; } else if (args.includes("/status?")) { if (scenario === "app-status-denied") { console.error("Commit statuses forbidden"); process.exit(1); } diff --git a/test/inference/ollama/ollama-proxy-export.test.ts b/test/inference/ollama/ollama-proxy-export.test.ts index 428055122e1..d6d8d87838e 100644 --- a/test/inference/ollama/ollama-proxy-export.test.ts +++ b/test/inference/ollama/ollama-proxy-export.test.ts @@ -7,6 +7,7 @@ import os from "node:os"; import path from "node:path"; import { promisify } from "node:util"; import { expect } from "vitest"; +import { isWsl } from "../../../src/lib/platform"; import { test } from "../../helpers/owned-test-resources"; import { closeServer, @@ -18,7 +19,7 @@ import { const execFileAsync = promisify(execFile); -test.skipIf(process.platform !== "linux")( +test.skipIf(process.platform !== "linux" || isWsl())( "observes existing proxy state without migration or credential exposure (#11435)", async () => { const directory = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-proxy-export-")); diff --git a/test/repository/validation-fixture.ts b/test/repository/validation-fixture.ts index 5f1dbea65be..ef4438aab47 100644 --- a/test/repository/validation-fixture.ts +++ b/test/repository/validation-fixture.ts @@ -32,7 +32,7 @@ export function writeFixture(root: string, file: string, contents: string): void } export function validationFixture(): string { - const root = mkdtempSync(path.join(os.tmpdir(), "nemoclaw-validation-test-")); + const root = fs.realpathSync(mkdtempSync(path.join(os.tmpdir(), "nemoclaw-validation-test-"))); fixtureGit(root, "init", "--initial-branch=main"); writeFixture( root, diff --git a/test/repository/validation-reuse.test.ts b/test/repository/validation-reuse.test.ts index f7b71e165d8..c4f8165fd2e 100644 --- a/test/repository/validation-reuse.test.ts +++ b/test/repository/validation-reuse.test.ts @@ -117,7 +117,7 @@ describe("validation reuse", () => { label: "fixture", command: [command, "literal & argument"], env: { - PATH: [directory, process.env.PATH].join(path.delimiter), + PATH: directory, ComSpec: "/must-not-run", }, report: vi.fn(), From b98aececfb11266776ba109d0d809b18f45f5488 Mon Sep 17 00:00:00 2001 From: Rebecca Sliter <571084+rsliter@users.noreply.github.com> Date: Fri, 11 Sep 2026 10:05:51 -0700 Subject: [PATCH 25/25] test(platform): keep component fixtures in the protected user home --- src/lib/onboard/external-component/connections.test.ts | 3 ++- src/lib/onboard/machine/handlers/gateway.test.ts | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/lib/onboard/external-component/connections.test.ts b/src/lib/onboard/external-component/connections.test.ts index 982c01c4552..18c36ac0bf5 100644 --- a/src/lib/onboard/external-component/connections.test.ts +++ b/src/lib/onboard/external-component/connections.test.ts @@ -4,6 +4,7 @@ import fs from "node:fs"; import assert from "node:assert/strict"; import http from "node:http"; +import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import { parse as parseToml } from "smol-toml"; @@ -57,7 +58,7 @@ function declaration(caCertificatePath = "/run/component/ca.pem"): ExternalCompo } function fixture() { - const root = fs.mkdtempSync(path.join(path.dirname(process.cwd()), "nc-connections-")); + const root = fs.mkdtempSync(path.join(fs.realpathSync(os.homedir()), "nc-connections-")); roots.push(root); const ca = path.join(root, "ca.pem"); fs.writeFileSync(ca, PEM, { mode: 0o600 }); diff --git a/src/lib/onboard/machine/handlers/gateway.test.ts b/src/lib/onboard/machine/handlers/gateway.test.ts index dccb9ae3c47..c1488999305 100644 --- a/src/lib/onboard/machine/handlers/gateway.test.ts +++ b/src/lib/onboard/machine/handlers/gateway.test.ts @@ -291,7 +291,7 @@ describe("handleGatewayState", () => { }); it("starts the gateway after component preparation succeeds (#11507)", async () => { - const directory = fs.mkdtempSync(path.join(path.dirname(process.cwd()), "nc-prepare-")); + const directory = fs.mkdtempSync(path.join(fs.realpathSync(os.homedir()), "nc-prepare-")); const socketPath = path.join(directory, "activate.sock"); const component = preparedConnectionComponent(socketPath); const preparation: componentActivation.ExternalComponentGatewayPreparation = {