diff --git a/.github/workflows/platform-vitest-main.yaml b/.github/workflows/platform-vitest-main.yaml index 982bf26a462..93536211610 100644 --- a/.github/workflows/platform-vitest-main.yaml +++ b/.github/workflows/platform-vitest-main.yaml @@ -5,6 +5,11 @@ name: CI / Platform Compatibility on: workflow_dispatch: + inputs: + sdk-artifact-run-id: + description: "Completed PR CI run supplying the reviewed SDK (required off main)" + type: string + required: false push: branches: - main @@ -17,6 +22,8 @@ on: permissions: contents: read + actions: read + packages: read concurrency: group: platform-evidence-${{ github.ref }} @@ -89,8 +96,7 @@ jobs: matrix: include: - shard: 1 - # The live E2E has a 70m timeout; every other step shares the remaining job time. - timeout_minutes: 150 + timeout_minutes: 30 - shard: 2 timeout_minutes: 30 - shard: 3 @@ -120,6 +126,7 @@ jobs: - name: Install macOS test dependencies run: | set -euo pipefail + test -x "$(command -v gtar)" brew install bash coreutils fd gawk ripgrep printf '%s\n' \ "$(brew --prefix bash)/bin" \ @@ -148,16 +155,31 @@ 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: Download reviewed SDK for branch validation + if: ${{ github.ref != 'refs/heads/main' }} + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + github-token: ${{ github.token }} + run-id: ${{ inputs.sdk-artifact-run-id }} + name: openshell-sdk-package + path: ${{ github.workspace }}/platform-sdk - name: Install dependencies + env: + NODE_AUTH_TOKEN: ${{ github.repository == 'NVIDIA/NemoClaw' && github.ref == 'refs/heads/main' && github.token || '' }} run: | - npm ci --ignore-scripts - cd nemoclaw - npm ci --ignore-scripts + if [[ -n "$NODE_AUTH_TOKEN" ]]; then + bash .github/actions/ci-install-dependencies.sh + else + mkdir -p "$HOME/.npm" + NEMOCLAW_CI_NPM_PACKAGE_MODE=artifact \ + NEMOCLAW_CI_TARGET_ROOT="$PWD" \ + NEMOCLAW_CI_NPM_CACHE="$HOME/.npm" \ + NEMOCLAW_OPEN_SHELL_SDK_ARTIFACT_DIRECTORY="$GITHUB_WORKSPACE/platform-sdk" \ + node scripts/checks/prepare-ci-npm-install.mts + npm ci --ignore-scripts --prefer-offline + npm --prefix nemoclaw ci --ignore-scripts --prefer-offline + fi - name: Build CLI and plugin run: | @@ -166,11 +188,51 @@ jobs: npm run build - name: Run full Vitest suite on macOS - run: npx vitest run --testTimeout 60000 --shard="${{ matrix.shard }}/4" + env: + NEMOCLAW_EXEC_TIMEOUT: "60000" + NEMOCLAW_TEST_TIMEOUT: "60000" + 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 + if: ${{ github.ref == 'refs/heads/main' }} + runs-on: macos-26 + timeout-minutes: 150 + 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 @@ -183,7 +245,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 }} @@ -196,19 +258,13 @@ jobs: npx vitest run --project e2e-live test/e2e/live/full-e2e.test.ts --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 @@ -238,10 +294,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 @@ -270,7 +322,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 @@ -287,7 +339,9 @@ 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', 'gcc', 'gnu-coreutils', 'iproute2', 'libc6-dev', 'podman', + 'procps', 'python3', 'python3-pip', 'python3-venv', + 'ripgrep', 'rsync', 'tar', 'unzip', 'xz-utils', 'zip' ) Install-WslUbuntuDependencies -Distro $env:WSL_DISTRO -Packages $packages -TestUser $env:WSL_TEST_USER @@ -307,20 +361,46 @@ jobs: -Workdir $env:WSL_WORKDIR ` -Owner $env:WSL_TEST_USER + - name: Download reviewed SDK for branch validation + if: ${{ github.ref != 'refs/heads/main' }} + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + github-token: ${{ github.token }} + run-id: ${{ inputs.sdk-artifact-run-id }} + name: openshell-sdk-package + path: ${{ github.workspace }}/platform-sdk + - name: Install dependencies and build in WSL shell: powershell + env: + 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 $workdir = ConvertTo-BashLiteral -Value $env:WSL_WORKDIR + $nodeAuthToken = ConvertTo-BashLiteral -Value ([string]$env:NODE_AUTH_TOKEN) + $sdkArtifact = ConvertTo-BashLiteral -Value "$env:WSL_CHECKOUT_DIR/../platform-sdk" $script = @" set -euo pipefail id -un | grep -Fxq $testUser cd $workdir - npm ci --ignore-scripts + export NPM_CONFIG_CACHE="`$HOME/.npm" + export NODE_AUTH_TOKEN=$nodeAuthToken + if [ -n "`$NODE_AUTH_TOKEN" ]; then + bash .github/actions/ci-install-dependencies.sh + else + mkdir -p "`$NPM_CONFIG_CACHE" + NEMOCLAW_CI_NPM_PACKAGE_MODE=artifact \ + NEMOCLAW_CI_TARGET_ROOT="`$PWD" \ + NEMOCLAW_CI_NPM_CACHE="`$NPM_CONFIG_CACHE" \ + NEMOCLAW_OPEN_SHELL_SDK_ARTIFACT_DIRECTORY=$sdkArtifact \ + node scripts/checks/prepare-ci-npm-install.mts + npm ci --ignore-scripts --prefer-offline + npm --prefix nemoclaw ci --ignore-scripts --prefer-offline + fi + 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 @@ -335,6 +415,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' @@ -372,6 +453,22 @@ jobs: "@ Invoke-WslScript -Distro $env:WSL_DISTRO -User root -Script $script + - name: Start the WSL container runtime + if: ${{ matrix.shard == 1 && github.ref == 'refs/heads/main' }} + 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 + - id: wsl_docker name: Detect Docker availability in WSL if: ${{ matrix.shard == 1 }} @@ -385,7 +482,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' @@ -400,6 +497,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 @@ -418,7 +519,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/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/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/mcp-bridge-status-resolution.test.ts b/src/lib/actions/sandbox/mcp-bridge-status-resolution.test.ts index c1cdb8333ed..9165d1ddd9d 100644 --- a/src/lib/actions/sandbox/mcp-bridge-status-resolution.test.ts +++ b/src/lib/actions/sandbox/mcp-bridge-status-resolution.test.ts @@ -7,6 +7,7 @@ import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; +import { testTimeoutOptions } from "../../../../test/helpers/timeouts"; const sourceRequireHook = path.resolve("test/helpers/onboard-script-mocks.cjs"); const sourceNodeOptions = [process.env.NODE_OPTIONS, `--require=${sourceRequireHook}`] @@ -212,7 +213,7 @@ ${body} return { status: result.status, stdout: result.stdout }; } -describe("MCP status wire-level credential-resolution probe", { timeout: 15_000 }, () => { +describe("MCP status wire-level credential-resolution probe", testTimeoutOptions(15_000), () => { it("inspects the attachment inventory once for a multi-server status read (#9806)", () => { const home = createTempHome("nemoclaw-mcp-status-attachments-"); const { stdout } = runHarness( 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..0baaf821c7e 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-")); @@ -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/actions/sandbox/snapshot/backup-authority-script.test.ts b/src/lib/actions/sandbox/snapshot/backup-authority-script.test.ts index 56faf5e9cf4..3523606e6da 100644 --- a/src/lib/actions/sandbox/snapshot/backup-authority-script.test.ts +++ b/src/lib/actions/sandbox/snapshot/backup-authority-script.test.ts @@ -164,7 +164,8 @@ describe("Hermes privileged state capture scripts", () => { expect(unsafe.stdout).toEqual(Buffer.alloc(0)); }); - it("uses SQLite backup with a valid database", () => { + // The shipped sandbox probe opens SQLite through Linux /proc/self/fd. + it.skipIf(process.platform !== "linux")("uses SQLite backup with a valid database", () => { const directory = fixtureDirectory(); const database = path.join(directory, "state.db"); expect( @@ -227,37 +228,41 @@ describe("Hermes privileged state capture scripts", () => { expect(captured.stdout).toEqual(Buffer.alloc(0)); }); - it("rejects a SQLite file replaced during capture without returning bytes", () => { - const directory = fixtureDirectory(); - const database = path.join(directory, "state.db"); - const outside = path.join(path.dirname(directory), "outside.db"); - expect( - spawnSync("/usr/bin/python3", [ - "-c", - `import sqlite3; db = sqlite3.connect(${JSON.stringify(database)}); db.execute('create table state (value text)'); db.execute("insert into state values ('saved')"); db.commit()`, - ]).status, - ).toBe(0); - expect( - spawnSync("/usr/bin/python3", [ - "-c", - `import sqlite3; db = sqlite3.connect(${JSON.stringify(outside)}); db.execute('create table state (value text)'); db.execute("insert into state values ('saved')"); db.commit()`, - ]).status, - ).toBe(0); - const script = hermesSqliteMutationHarness( - ` original = os.path.join(base, relative)\n` + - ` os.rename(original, original + ".old")\n` + - ` os.symlink(${JSON.stringify(outside)}, original)`, - ); - - const captured = spawnSync( - "/usr/bin/python3", - ["-I", "-S", "-c", script, directory, "state.db", "sqlite_backup"], - { encoding: null }, - ); - - expect(captured.status).toBe(13); - expect(captured.stdout).toEqual(Buffer.alloc(0)); - }); + // The shipped sandbox probe opens SQLite through Linux /proc/self/fd. + it.skipIf(process.platform !== "linux")( + "rejects a SQLite file replaced during capture without returning bytes", + () => { + const directory = fixtureDirectory(); + const database = path.join(directory, "state.db"); + const outside = path.join(path.dirname(directory), "outside.db"); + expect( + spawnSync("/usr/bin/python3", [ + "-c", + `import sqlite3; db = sqlite3.connect(${JSON.stringify(database)}); db.execute('create table state (value text)'); db.execute("insert into state values ('saved')"); db.commit()`, + ]).status, + ).toBe(0); + expect( + spawnSync("/usr/bin/python3", [ + "-c", + `import sqlite3; db = sqlite3.connect(${JSON.stringify(outside)}); db.execute('create table state (value text)'); db.execute("insert into state values ('saved')"); db.commit()`, + ]).status, + ).toBe(0); + const script = hermesSqliteMutationHarness( + ` original = os.path.join(base, relative)\n` + + ` os.rename(original, original + ".old")\n` + + ` os.symlink(${JSON.stringify(outside)}, original)`, + ); + + const captured = spawnSync( + "/usr/bin/python3", + ["-I", "-S", "-c", script, directory, "state.db", "sqlite_backup"], + { encoding: null }, + ); + + expect(captured.status).toBe(13); + expect(captured.stdout).toEqual(Buffer.alloc(0)); + }, + ); it("rejects an intermediate directory replaced during capture", () => { const directory = fixtureDirectory(); diff --git a/src/lib/adapters/fs/config-export-file.test.ts b/src/lib/adapters/fs/config-export-file.test.ts index 4888c857cda..1abb347444d 100644 --- a/src/lib/adapters/fs/config-export-file.test.ts +++ b/src/lib/adapters/fs/config-export-file.test.ts @@ -31,7 +31,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"); @@ -655,3 +655,15 @@ describe("publishExportFile", () => { expect(calls).toEqual(["fsync", "publish", "fsync"]); }); }); + +describe.runIf(process.platform !== "linux")("publishExportFile platform boundary", () => { + it("rejects publication without creating an output file", () => { + const root = temporaryRoot(); + const outputPath = path.join(root, "selected.yaml"); + expect(publishExportFile(outputPath, "content")).toMatchObject({ + ok: false, + failure: { category: "unsafe-output" }, + }); + expect(fs.readdirSync(root)).toEqual([]); + }); +}); diff --git a/src/lib/adapters/openshell/sandbox-command-cli.test.ts b/src/lib/adapters/openshell/sandbox-command-cli.test.ts index 6c0723e104f..3782d55bf64 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 }); } 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.ts b/src/lib/onboard.ts index 4509f814893..54178415cdc 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -565,7 +565,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" : ""; @@ -577,6 +576,7 @@ const { clearDockerDriverGatewayRuntimeFiles, createGatewayServicePortOwnership, getDockerDriverGatewayEnv, + getDockerDriverGatewayPreparation, getDockerDriverGatewayPid, getDockerDriverGatewayPortListenerPid, getDockerDriverGatewayPortListenerScan, @@ -1385,7 +1385,6 @@ const gatewayRegistration = createGatewayRegistration({ runOpenshell, runQuietOpenshell, }); - const dockerDriverGatewayStart = createDockerDriverGatewayStart({ SUPPORTED_OPENSHELL_FALLBACK_VERSION, checkGatewayPortAvailable, @@ -1397,7 +1396,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 21cb7aed2c6..70c14025a25 100644 --- a/src/lib/onboard/dashboard.ts +++ b/src/lib/onboard/dashboard.ts @@ -289,20 +289,28 @@ 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, { + env: deps.env, + isWsl: deps.isWsl(), ...options, runCapture: options.runCapture || runCapture, }); } 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, { + env: deps.env, + isWsl: deps.isWsl(), ...options, runCapture: options.runCapture || runCapture, }); diff --git a/src/lib/onboard/docker-driver-gateway-env.test.ts b/src/lib/onboard/docker-driver-gateway-env.test.ts index 8c7f2883895..e4011cbd2da 100644 --- a/src/lib/onboard/docker-driver-gateway-env.test.ts +++ b/src/lib/onboard/docker-driver-gateway-env.test.ts @@ -12,12 +12,14 @@ import { writeOpenShell0044PreAuthState } from "../../../test/support/openshell- import { buildDockerDriverGatewayEnv, buildDockerGatewayDebEnvFile, + prepareDockerDriverGatewayEnv, configureDockerDriverGatewayExternalComponent, startPackageManagedDockerDriverGatewayWithEnvOverride, writeDockerGatewayDebEnvOverride, } from "./docker-driver-gateway-env"; import { NEMOCLAW_EXTERNAL_COMPONENT_GATEWAY_IDENTITY_ENV } from "./docker-driver-gateway-config"; 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; @@ -133,12 +135,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"); @@ -207,12 +210,18 @@ 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 env = buildDockerDriverGatewayEnv({ + const gatewayHostRuntime = prepareNativePodmanGatewayHostRuntime({ + environment: process.env, + platform: "linux", + }); + 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", @@ -222,6 +231,7 @@ describe("buildDockerDriverGatewayEnv", () => { expect(env.OPENSHELL_PODMAN_SOCKET).toMatch(/\/podman\/podman\.sock$/u); expect(env.CONTAINERS_CONF).toBeUndefined(); expect(env.NETAVARK_FW).toBeUndefined(); + 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}"`); diff --git a/src/lib/onboard/docker-driver-gateway-env.ts b/src/lib/onboard/docker-driver-gateway-env.ts index bc7c7dd44e6..87a4b5d4677 100644 --- a/src/lib/onboard/docker-driver-gateway-env.ts +++ b/src/lib/onboard/docker-driver-gateway-env.ts @@ -96,6 +96,11 @@ export interface BuildDockerDriverGatewayEnvOptions { enableBindMounts?: boolean; } +export interface DockerDriverGatewayPreparation { + gatewayEnv: Record; + gatewayHostRuntime: RuntimeProviderGatewayHostRuntime; +} + export function configureDockerDriverGatewayExternalComponent( gatewayEnv: Record, externalComponent: ExternalComponentGatewayConfiguration | null, @@ -444,7 +449,7 @@ export function warnIfGatewayWildcardBindAddress(): void { ); } -export function buildDockerDriverGatewayEnv({ +export function prepareDockerDriverGatewayEnv({ platform = process.platform, architecture = process.arch, gatewayPort = GATEWAY_PORT, @@ -455,7 +460,7 @@ export function buildDockerDriverGatewayEnv({ getDockerSupervisorImage, resolveSandboxBin, enableBindMounts = false, -}: BuildDockerDriverGatewayEnvOptions): Record { +}: BuildDockerDriverGatewayEnvOptions): DockerDriverGatewayPreparation { const portable = isPortableExperimentalProfile(); const runtime = gatewayHostRuntime ?? @@ -512,7 +517,13 @@ export function buildDockerDriverGatewayEnv({ process.env.NEMOCLAW_RESTORE_LATEST_BACKUP_ON_RECREATE === "1", gatewayRuntime: 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-launch.test.ts b/src/lib/onboard/docker-driver-gateway-launch.test.ts index 7bfddd5a512..d8ae4c89a59 100644 --- a/src/lib/onboard/docker-driver-gateway-launch.test.ts +++ b/src/lib/onboard/docker-driver-gateway-launch.test.ts @@ -216,14 +216,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); }); @@ -234,7 +241,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 88a2e4561f8..e5b117aa99b 100644 --- a/src/lib/onboard/docker-driver-gateway-launch.ts +++ b/src/lib/onboard/docker-driver-gateway-launch.ts @@ -145,6 +145,7 @@ export function buildDockerDriverGatewayLaunch( ensureDockerDriverGatewayLocalTlsBundle({ env: baseEnv, gatewayBin: options.gatewayBin, + gatewayHostRuntime: options.gatewayHostRuntime, stateDir: options.stateDir, }); } 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 585999b345e..150d41937e5 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.observeHostRuntime({ - environment: env, - platform, - }).requiredServerIpSans; + return ( + gatewayHostRuntime ?? + provider.gateway.observeHostRuntime({ + environment: env, + platform, + }) + ).requiredServerIpSans; })(); const requiredServerIpSans = [...REQUIRED_SERVER_IP_SANS, ...requiredProviderIpSans]; fs.mkdirSync(stateDir, { recursive: true, mode: 0o700 }); diff --git a/src/lib/onboard/docker-driver-gateway-runtime.test.ts b/src/lib/onboard/docker-driver-gateway-runtime.test.ts index 625d51ecd2a..dffa1acbd4c 100644 --- a/src/lib/onboard/docker-driver-gateway-runtime.test.ts +++ b/src/lib/onboard/docker-driver-gateway-runtime.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 * as dockerDriverGatewayEnv from "./docker-driver-gateway-env"; import { gatewayIdForStateDir, @@ -82,7 +82,14 @@ function withTemporaryGatewayState(callback: () => T): T { } describe("docker-driver gateway runtime helpers", () => { + let stateRoot: string; + beforeEach(() => { + stateRoot = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-gateway-runtime-state-")); + vi.stubEnv("NEMOCLAW_OPENSHELL_GATEWAY_STATE_DIR", path.join(stateRoot, "gateway")); + }); afterEach(() => { + vi.unstubAllEnvs(); + fs.rmSync(stateRoot, { recursive: true, force: true }); vi.restoreAllMocks(); }); @@ -388,6 +395,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 7f6e9fa046a..c181a1eda3e 100644 --- a/src/lib/onboard/docker-driver-gateway-runtime.ts +++ b/src/lib/onboard/docker-driver-gateway-runtime.ts @@ -103,6 +103,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( @@ -255,10 +259,10 @@ export function createDockerDriverGatewayRuntimeHelpers(deps: DockerDriverGatewa return qualifiedImage; } - 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()) { @@ -270,7 +274,7 @@ export function createDockerDriverGatewayRuntimeHelpers(deps: DockerDriverGatewa } podmanSocketPath = candidate.slice("unix://".length); } - const gatewayEnv = dockerDriverGatewayEnv.buildDockerDriverGatewayEnv({ + const preparation = dockerDriverGatewayEnv.prepareDockerDriverGatewayEnv({ platform, gatewayPort: currentGatewayPort(), stateDir: getDockerDriverGatewayStateDir(), @@ -280,10 +284,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 { @@ -594,6 +605,7 @@ export function createDockerDriverGatewayRuntimeHelpers(deps: DockerDriverGatewa clearDockerDriverGatewayRuntimeFiles, createGatewayServicePortOwnership, getDockerDriverGatewayEnv, + getDockerDriverGatewayPreparation, getDockerDriverGatewayPid, getDockerDriverGatewayPidFile, getDockerDriverGatewayPortListenerScan, diff --git a/src/lib/onboard/experimental/hermes-authenticated-health.test.ts b/src/lib/onboard/experimental/hermes-authenticated-health.test.ts index b750803b610..aa71f6140fd 100644 --- a/src/lib/onboard/experimental/hermes-authenticated-health.test.ts +++ b/src/lib/onboard/experimental/hermes-authenticated-health.test.ts @@ -3,6 +3,7 @@ import { spawnSync } from "node:child_process"; import { describe, expect, it } from "vitest"; +import { execTimeout } from "../../../../test/helpers/timeouts"; import { hermesPortableContainerInternals } from "./hermes-portable-container"; import { hermesPortableLifecycleInternals } from "./hermes-portable-lifecycle"; @@ -24,7 +25,9 @@ class Health(http.server.BaseHTTPRequestHandler): self.end_headers() def log_message(self, *args): pass -server = http.server.HTTPServer(("127.0.0.1", 0), Health) +# The loopback fixture's server name must not depend on host DNS. +with patch("socket.getfqdn", return_value="localhost"): + server = http.server.HTTPServer(("127.0.0.1", 0), Health) thread = threading.Thread(target=server.serve_forever, daemon=True) thread.start() stdout, stderr = io.StringIO(), io.StringIO() @@ -70,9 +73,9 @@ function runProbe( const result = spawnSync("python3", ["-I", "-c", HARNESS], { input: JSON.stringify({ program, env, status, redirect, successStatus }), encoding: "utf8", - timeout: 5_000, + timeout: execTimeout(5_000), }); - expect(result.error).toBeUndefined(); + expect(result.error, result.stderr).toBeUndefined(); expect(result.status, result.stderr).toBe(0); const output = JSON.parse(result.stdout) as { code: number; diff --git a/src/lib/onboard/experimental/hermes-portable-ollama-inference.test.ts b/src/lib/onboard/experimental/hermes-portable-ollama-inference.test.ts index 67f3876e0cd..ed968129db5 100644 --- a/src/lib/onboard/experimental/hermes-portable-ollama-inference.test.ts +++ b/src/lib/onboard/experimental/hermes-portable-ollama-inference.test.ts @@ -87,7 +87,9 @@ function snapshotExactTestFile(filePath: string) { const named = fs.lstatSync(filePath, { bigint: true }); expect(exactTestFileIdentity(after)).toBe(exactTestFileIdentity(before)); expect(exactTestFileIdentity(named)).toBe(exactTestFileIdentity(after)); - return { contents, metadata: after }; + // Reading may update access time on relatime filesystems without changing the file. + const { atimeNs: _atimeNs, atimeMs: _atimeMs, ...metadata } = after; + return { contents, metadata }; } finally { fs.closeSync(descriptor); } 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/external-component/index.test.ts b/src/lib/onboard/external-component/index.test.ts index cf4809db007..a259d4e5efb 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"); diff --git a/src/lib/onboard/extra-provider-reconciliation-diagnostics.test.ts b/src/lib/onboard/extra-provider-reconciliation-diagnostics.test.ts index 4efe2045d1e..0e8fce8cfb5 100644 --- a/src/lib/onboard/extra-provider-reconciliation-diagnostics.test.ts +++ b/src/lib/onboard/extra-provider-reconciliation-diagnostics.test.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { describe, expect, it, vi } from "vitest"; +import { reportsExactProviderNotFound } from "../adapters/openshell/provider-diagnostic-cli"; import { LIMIT, reconcile } from "./extra-provider-reconciliation.test-fixtures"; const exactWrappedDiagnostic = [ @@ -243,7 +244,7 @@ describe("planRegisteredExtraProviders diagnostics", () => { ).toEqual(["tavily-search"]); }); - it("parses adversarial diagnostics within a bounded budget (#6501)", async () => { + it("parses adversarial diagnostics within a bounded budget (#6501)", () => { const adversarial = [ `${"error: ".repeat(2_000)}provider 'redos-provider' not found`, `Error: provider '${"a".repeat(8_000)}`, @@ -252,11 +253,7 @@ describe("planRegisteredExtraProviders diagnostics", () => { ].join("\n"); const started = performance.now(); - expect( - await reconcile(["redos-provider"], { - "redos-provider": { status: 1, stderr: adversarial }, - }), - ).toEqual(["redos-provider"]); + expect(reportsExactProviderNotFound(adversarial, "redos-provider", LIMIT)).toBe(false); expect(performance.now() - started).toBeLessThan(100); }); }); diff --git a/src/lib/onboard/fatal-runtime-preflight.test.ts b/src/lib/onboard/fatal-runtime-preflight.test.ts index 6ffa2d9c986..60a653a86ad 100644 --- a/src/lib/onboard/fatal-runtime-preflight.test.ts +++ b/src/lib/onboard/fatal-runtime-preflight.test.ts @@ -630,9 +630,7 @@ describe("runFatalOnboardRuntimePreflight", () => { ); expect(detect).toHaveBeenCalledOnce(); - expect(detect).toHaveBeenCalledWith( - expect.objectContaining({ proveArm64ContainerGpu: null, n1xWslProduct: null }), - ); + expect(detect).toHaveBeenCalledWith(expect.objectContaining({ proveArm64ContainerGpu: null })); }); it("rejects known GPU configuration errors before bridge or GPU container probes (#7411)", () => { 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/gateway/docker-driver-start.ts b/src/lib/onboard/gateway/docker-driver-start.ts index e28b65cb574..664e8020f07 100644 --- a/src/lib/onboard/gateway/docker-driver-start.ts +++ b/src/lib/onboard/gateway/docker-driver-start.ts @@ -42,7 +42,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"]; @@ -148,7 +148,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, @@ -161,6 +162,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 0e8253d4e9a..c34a8d0fa6b 100644 --- a/src/lib/onboard/gateway/late-binding.test.ts +++ b/src/lib/onboard/gateway/late-binding.test.ts @@ -10,8 +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 { createDockerRuntimeProviderBundle } from "../runtime-provider/docker"; import { createDockerDriverGatewayStart, resolveDockerDriverGatewayRuntimeMarkerEndpoint, @@ -241,10 +243,16 @@ describe("gateway lifecycle late binding", () => { return true; }, ); + const gatewayHostRuntime = createDockerRuntimeProviderBundle().gateway.prepareHostRuntime({ + environment: {}, + platform: "linux", + }); const dockerDriverGatewayEnv = { + ...dockerDriverGatewayEnvModule, startPackageManagedDockerDriverGatewayWithEnvOverride: managedStart, } as unknown as typeof import("../docker-driver-gateway-env"); - const getDockerDriverGatewayEnv = vi.fn(() => { + let gatewayEnv: Record | undefined; + const getDockerDriverGatewayPreparation = vi.fn(() => { expect( gatewayBinding.managedGatewayStateRootOwnershipFailure({ gatewayName: name, @@ -255,7 +263,17 @@ describe("gateway lifecycle late binding", () => { expect( gatewayStateLifecycleLock.tryAcquireManagedGatewayStateLifecycleLock(stateDir), ).toBeNull(); - return { OPENSHELL_SERVER_PORT: String(port) }; + const preparation = dockerDriverGatewayEnvModule.prepareDockerDriverGatewayEnv({ + platform: "linux", + gatewayPort: port, + stateDir, + dockerNetworkName: "openshell-docker", + gatewayHostRuntime, + getDockerSupervisorImage: () => "supervisor:test", + resolveSandboxBin: () => null, + }); + gatewayEnv = { ...preparation.gatewayEnv }; + return { gatewayEnv, gatewayHostRuntime: preparation.gatewayHostRuntime }; }); const runCaptureOpenshell = vi.fn((_args: string[], _options?: Record) => ""); const runtimeIdentitySpy = vi @@ -284,7 +302,7 @@ describe("gateway lifecycle late binding", () => { gatewayName: () => name, gatewayPort: () => port, getDockerDriverGatewayEndpoint: () => "https://127.0.0.1", - getDockerDriverGatewayEnv, + getDockerDriverGatewayPreparation, getDockerDriverGatewayPid: () => null, getDockerDriverGatewayPortListenerScan: () => ({ complete: true, @@ -325,6 +343,7 @@ describe("gateway lifecycle late binding", () => { "/usr/bin/openshell-sandbox", jwtBundle, gatewayIdForStateDir(stateDir), + gatewayHostRuntime, ), { mode: 0o600 }, ); @@ -353,6 +372,7 @@ describe("gateway lifecycle late binding", () => { ); const runtimeIdentityOptions = runtimeIdentitySpy.mock.calls[0]?.[0]; const managedOptions = managedStart.mock.calls[0]?.[0]; + expect(runtimeIdentityOptions?.gatewayHostRuntime).toBe(gatewayHostRuntime); expect(runtimeIdentityOptions?.env).toEqual(managedOptions?.env); expect(runtimeIdentityOptions?.env).toEqual( expect.objectContaining({ @@ -405,7 +425,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"); @@ -423,7 +443,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/src/lib/onboard/host-gateway-process-target.test.ts b/src/lib/onboard/host-gateway-process-target.test.ts index 3ae574366a6..a3948b0d597 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, + observeHostRuntime: () => ({ + 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 e1c3c1aa848..f115e28a9e4 100644 --- a/src/lib/onboard/host-gateway-process.ts +++ b/src/lib/onboard/host-gateway-process.ts @@ -47,6 +47,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; } @@ -139,6 +140,7 @@ function defaultDeps(overrides: Partial = {}): HostGatew log: overrides.log, readProcessExecutable: overrides.readProcessExecutable, readProcessEnvironment: overrides.readProcessEnvironment, + resolveRuntimeProvider: overrides.resolveRuntimeProvider ?? resolveRegisteredRuntimeProvider, warn: overrides.warn, }; } @@ -371,7 +373,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/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 = { 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/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/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/README.md b/test/README.md index ff8624efb2c..904693c3576 100644 --- a/test/README.md +++ b/test/README.md @@ -87,6 +87,13 @@ 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. + +For manual platform validation off `main`, provide `sdk-artifact-run-id` from a completed +PR CI run for the candidate commit. The workflow downloads its `openshell-sdk-package` +artifact and verifies the reviewed archive before staging it in npm's cache. Branch +validation does not receive the package credential. WSL installs `gnu-coreutils` for +fixtures that require GNU utility behavior; it keeps Ubuntu's default utilities intact. diff --git a/test/automation/classify-ci-failure.test.ts b/test/automation/classify-ci-failure.test.ts index 7b5970e5881..0fc9494934c 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, @@ -26,6 +27,9 @@ const script = resolve( ".agents/skills/nemoclaw-maintainer-classify-ci-failure/scripts/classify-ci-failure.mts", ); const roots: string[] = []; +// Ubuntu 26.04 exposes GNU tools separately from its default Rust coreutils. +const gnuTool = (name: string) => + realpathSync(existsSync(`/usr/bin/gnu${name}`) ? `/usr/bin/gnu${name}` : `/usr/bin/${name}`); const uid = process.getuid?.() ?? "unknown"; const REDACTION_CASES = [ [ @@ -111,7 +115,7 @@ function fixture(log: string, result?: Record, archive?: Buffer `#!${process.execPath}`, "const {spawnSync}=require('node:child_process');", "if(process.env.FAIL_PROBE_DD && process.argv.includes('count=1')) process.exit(7);", - "const result=spawnSync('/usr/bin/dd',process.argv.slice(2),{stdio:'inherit'});", + `const result=spawnSync(${JSON.stringify(gnuTool("dd"))},process.argv.slice(2),{stdio:'inherit'});`, "process.exit(result.status ?? 1);", ].join("\n"), ); @@ -123,7 +127,7 @@ function fixture(log: string, result?: Record, archive?: Buffer `#!${process.execPath}`, "const {spawnSync}=require('node:child_process');", "if(process.env.FAIL_PROBE_WC && process.argv.includes('-c')) process.exit(7);", - "const result=spawnSync('/usr/bin/wc',process.argv.slice(2),{stdio:'inherit'});", + `const result=spawnSync(${JSON.stringify(gnuTool("wc"))},process.argv.slice(2),{stdio:'inherit'});`, "process.exit(result.status ?? 1);", ].join("\n"), ); @@ -176,7 +180,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(gnuTool("dd"))}, gh: process.env.TEST_GH, stat: ${JSON.stringify(gnuTool("stat"))}, tail: ${JSON.stringify(gnuTool("tail"))}, wc: process.env.TEST_WC || ${JSON.stringify(gnuTool("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 63c4dc12a5f..9b251be3d73 100644 --- a/test/automation/e2e/platform-vitest-main-workflow.test.ts +++ b/test/automation/e2e/platform-vitest-main-workflow.test.ts @@ -36,37 +36,135 @@ 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); - expect([ - live.if?.includes("matrix.shard == 1"), - live.if?.includes(workflowCase.dockerOutput), - ]).toEqual([true, true]); + 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'"); expect(live.env).toMatchObject({ 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("reserves macOS cleanup and artifact time beyond the full E2E deadline", () => { - const macosShards = job("macos-vitest").strategy?.matrix?.include as Array<{ - shard: number; - timeout_minutes: number; - }>; - const firstShard = macosShards.find(({ shard }) => shard === 1); + 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"); + const installOpenShell = step("macos-live-e2e", "Install pinned OpenShell"); + expect(nonLive.steps).not.toEqual( + expect.arrayContaining([expect.objectContaining({ name: "Run macOS live E2E" })]), + ); + expect(liveJob.needs).toBeUndefined(); + expect(liveJob["timeout-minutes"]).toBe(150); + expect(liveJob.if).toContain("github.ref == 'refs/heads/main'"); + expect(JSON.stringify(liveJob)).not.toContain("brew install"); + expect(live.env).toHaveProperty("NEMOCLAW_RUN_LIVE_E2E", "1"); + 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 }}", + }); + }); - expect(firstShard?.timeout_minutes).toBe(150); + 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"), + ); + 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", () => { + 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"); + 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'"); + expect(install).toContain("'iproute2'"); + expect(install).toContain("'zip'"); + expect(install).toContain("'gnu-coreutils'"); + 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"); + 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", () => { + 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"); + 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.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); + }); + + 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.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"); + const artifact = step(jobName, "Download reviewed SDK for branch validation"); + expect(artifact.if).toBe("${{ github.ref != 'refs/heads/main' }}"); + expect(artifact.with).toMatchObject({ + name: "openshell-sdk-package", + "run-id": "${{ inputs.sdk-artifact-run-id }}", + }); + expect(install.run).toContain("NEMOCLAW_CI_NPM_PACKAGE_MODE=artifact"); + expect(install.run).toContain("node scripts/checks/prepare-ci-npm-install.mts"); + }, + ); }); diff --git a/test/automation/e2e/wsl-ci-helper.test.ts b/test/automation/e2e/wsl-ci-helper.test.ts index 6220141f2cf..203bdb23ec7 100644 --- a/test/automation/e2e/wsl-ci-helper.test.ts +++ b/test/automation/e2e/wsl-ci-helper.test.ts @@ -107,6 +107,10 @@ 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'", + ); }, ); @@ -116,7 +120,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 +136,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 +181,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 +203,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/test/automation/performance/analyze-ci-performance.test.ts b/test/automation/performance/analyze-ci-performance.test.ts index 76f9bfa40c6..0c09054e1a6 100644 --- a/test/automation/performance/analyze-ci-performance.test.ts +++ b/test/automation/performance/analyze-ci-performance.test.ts @@ -2,7 +2,8 @@ // SPDX-License-Identifier: Apache-2.0 import { execFile } from "node:child_process"; -import { chmod, mkdtemp, rm, stat, writeFile } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import { chmod, mkdtemp, rm, stat, symlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { promisify } from "node:util"; @@ -30,6 +31,12 @@ async function fixtureDirectory(): Promise { async function installMockGh(directory: string, source: string): Promise { const bin = path.join(directory, "bin"); await execFileAsync("mkdir", ["-p", bin]); + const gnuTools = ["dd", "stat", "base64", "wc"] + .map((name) => ({ name, executable: `/usr/bin/gnu${name}` })) + .filter(({ executable }) => existsSync(executable)); + for (const { name, executable } of gnuTools) { + await symlink(executable, path.join(bin, name)); + } const gh = path.join(bin, "gh"); await writeFile(gh, "#!/usr/bin/env node\n" + source); await chmod(gh, 0o700); @@ -97,7 +104,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/automation/pull-requests/analyze-pr-value-stream.test.ts b/test/automation/pull-requests/analyze-pr-value-stream.test.ts index 39a81abb3ee..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); } @@ -639,32 +639,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(); 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 e62eead23b3..b0da80af386 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; } 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 8ada6584cce..8959bb3c355 100644 --- a/test/automation/pull-requests/pr-review-advisor-specialists.test.ts +++ b/test/automation/pull-requests/pr-review-advisor-specialists.test.ts @@ -62,14 +62,12 @@ 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); - await expect(canonicalRepoReadPath(directory, "diff.patch")).resolves.toBe( - fs.realpathSync(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); expect(fs.statSync(file).mode & 0o777).toBe(0o600); diff --git a/test/credentials/rebuild-credential-preflight.test.ts b/test/credentials/rebuild-credential-preflight.test.ts index d48d0a2f24b..06980bb29a2 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-runtime/brev-launchable-e2e.test.ts b/test/e2e-runtime/brev-launchable-e2e.test.ts index 8d609250055..e9ed55e1217 100644 --- a/test/e2e-runtime/brev-launchable-e2e.test.ts +++ b/test/e2e-runtime/brev-launchable-e2e.test.ts @@ -975,8 +975,9 @@ describe("focused staging Brev Launchable lane", () => { }); }); - it("reports unavailable when SSH alias lookup fails", () => { - const { env, state, workDir } = fixture({ + it("reports unavailable when SSH alias lookup fails after readiness expires during logging (#11208)", () => { + const { calls, env, state, workDir } = fixture({ + delaySshReadinessLog: true, sshAliasQueryStatus: 42, sshReadyAfter: Number.MAX_SAFE_INTEGER, }); @@ -984,6 +985,8 @@ describe("focused staging Brev Launchable lane", () => { expect(result.status).not.toBe(0); const output = emittedOutput(result, workDir); expect(output).toContain("Readiness SSH alias nclaw-e2e-test-1: unavailable"); + expect(output).toContain("Readiness classification: workspace shell is unreachable"); + expect(fs.readFileSync(calls, "utf8")).not.toContain("ssh readiness attempt"); expect(fs.existsSync(state)).toBe(false); expect(JSON.parse(fs.readFileSync(path.join(workDir, "cleanup.json"), "utf8"))).toMatchObject({ status: "ABSENT", diff --git a/test/e2e/README.md b/test/e2e/README.md index cf76d49ce41..8fcedd17fe6 100644 --- a/test/e2e/README.md +++ b/test/e2e/README.md @@ -26,7 +26,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. @@ -280,12 +280,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 150-minute job timeout. Its live E2E has a -70-minute timeout, and every other step shares the remaining job time. The -other shards have 30 minutes. +Each macOS Vitest shard has a 30-minute budget. The independent macOS live E2E job has 150 minutes, including its 70-minute live test and cleanup. 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. diff --git a/test/e2e/docs/README.md b/test/e2e/docs/README.md index a2be60a26dc..11fbe457158 100644 --- a/test/e2e/docs/README.md +++ b/test/e2e/docs/README.md @@ -411,11 +411,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 150-minute job timeout. Its live E2E has a 70-minute timeout, and every other step shares the remaining job time. - 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 150-minute budget, including its 70-minute live test and cleanup. 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`. diff --git a/test/e2e/fixtures/host-address.ts b/test/e2e/fixtures/host-address.ts index 19e488d905c..dd3720540e9 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, + observeHostRuntime = observeConfiguredGatewayHostRuntime, ): string | null { if (!environment.NEMOCLAW_GATEWAY_RUNTIME || isPortableExperimentalProfile(environment)) { return null; } - return observeConfiguredGatewayHostRuntime({ environment, platform }).sandboxHostAddress; + return observeHostRuntime({ environment, platform }).sandboxHostAddress; } export function parseHostAddressProbe( @@ -61,8 +62,13 @@ export async function discoverHostAddress( artifactName = "host-address-for-sandbox", environment: NodeJS.ProcessEnv = process.env, platform: NodeJS.Platform = process.platform, + observeHostRuntime = observeConfiguredGatewayHostRuntime, ): Promise { - const runtimeProviderAddress = configuredRuntimeProviderHostAddress(environment, platform); + const runtimeProviderAddress = configuredRuntimeProviderHostAddress( + environment, + platform, + observeHostRuntime, + ); if (runtimeProviderAddress !== null) { if (isIP(runtimeProviderAddress) !== 4) { throw new Error( diff --git a/test/e2e/support/cli-artifact-workflow-boundary.test.ts b/test/e2e/support/cli-artifact-workflow-boundary.test.ts index 42d2dfc4028..4a92d48d7a2 100644 --- a/test/e2e/support/cli-artifact-workflow-boundary.test.ts +++ b/test/e2e/support/cli-artifact-workflow-boundary.test.ts @@ -196,7 +196,7 @@ function writeTraversalArchive(context: ArchiveFixtureContext): void { process.platform === "darwin" ? ["-s", "|^outside.txt$|dist/../outside.txt|"] : ["--transform=s|^outside.txt$|dist/../outside.txt|"]; - execFileSync("tar", [ + execFileSync(process.platform === "darwin" ? "/usr/bin/tar" : "tar", [ "-cf", context.payload, ...transform, 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..1d5dd12dd07 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 }, - ); + ["-c", buildOpenShellGatewayUserServiceStageScript(), "stage-service", installer], + { 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", ["-c", 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", + ["-c", buildOpenShellGatewayUserServiceStageScript(), "stage-service", installer], + { 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}:/usr/bin:/bin`, - XDG_CONFIG_HOME: configHome, - }); + try { + const env = buildAvailabilityProbeEnv({ + HOME: home, + PATH: `${bin}:${path.dirname(process.execPath)}:/usr/bin:/bin`, + XDG_CONFIG_HOME: configHome, + }); + + expect(() => + execFileSync( + "bash", + ["-c", 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 }, + ); - 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 }); - } - }); -}); + try { + const env = buildAvailabilityProbeEnv({ + HOME: home, + PATH: `${bin}:/usr/bin:/bin`, + XDG_CONFIG_HOME: configHome, + }); + + expect(() => + execFileSync( + "bash", + ["-c", 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", () => { @@ -228,7 +231,7 @@ describe("managed OpenShell gateway user-service restart", () => { PATH: `${bin}:/usr/bin:/bin`, XDG_CONFIG_HOME: configHome, }); - execFileSync("sh", ["-lc", buildOpenShellGatewayUserServiceRestartScript()], { + execFileSync("sh", ["-c", buildOpenShellGatewayUserServiceRestartScript()], { env, killSignal: "SIGKILL", timeout: 30_000, diff --git a/test/e2e/support/portable-profile-systemctl-shim.test.ts b/test/e2e/support/portable-profile-systemctl-shim.test.ts index 0cf691ed19e..d432dd2d2fe 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, }); @@ -1139,9 +1139,9 @@ 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); - expect(fs.existsSync(backendSocketPath)).toBe(false); + await waitForPath(backendSocketPath, false); } finally { await cleanFixture(scope); } diff --git a/test/e2e/support/prepare-e2e-workflow-boundary.test.ts b/test/e2e/support/prepare-e2e-workflow-boundary.test.ts index c9224bbb66e..b74230d085c 100644 --- a/test/e2e/support/prepare-e2e-workflow-boundary.test.ts +++ b/test/e2e/support/prepare-e2e-workflow-boundary.test.ts @@ -39,7 +39,7 @@ describe("prepare-e2e workflow boundary", () => { const checkout = workflow.jobs["generate-matrix"].steps!.find( (step) => step.name === "Check out trusted compiled artifact action", )!; - const root = fs.mkdtempSync(path.join(os.tmpdir(), "trusted-build-action-")); + const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "trusted-build-action-"))); try { const included = String(checkout.with!["sparse-checkout"]).trim().split("\n"); fs.cpSync(process.cwd(), root, { 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/brev-launchable-e2e-fixture.ts b/test/helpers/brev-launchable-e2e-fixture.ts index 7d8f940aaa3..b5ec307f491 100644 --- a/test/helpers/brev-launchable-e2e-fixture.ts +++ b/test/helpers/brev-launchable-e2e-fixture.ts @@ -44,6 +44,7 @@ export function fixture( brevExecStatus?: number; createAppearsAfterRefresh?: number; createStatus?: number; + delaySshReadinessLog?: boolean; deleteFails?: boolean; e2eDiagnosticTimesOut?: boolean; e2eFails?: boolean; @@ -90,6 +91,18 @@ export function fixture( fs.mkdirSync(workDir); fs.writeFileSync(timeoutBlock, "block\n"); + if (options.delaySshReadinessLog) { + executable( + path.join(bin, "tee"), + `#!/usr/bin/env bash +set -euo pipefail +input="$(cat)" +if [[ "$input" == 'Waiting up to '* ]]; then /bin/sleep 1; fi +printf '%s\\n' "$input" | /usr/bin/tee "$@" +`, + ); + } + executable( path.join(bin, "timeout"), `#!/usr/bin/env bash diff --git a/test/helpers/prepared-gateway-runtime.ts b/test/helpers/prepared-gateway-runtime.ts deleted file mode 100644 index 53effa49170..00000000000 --- a/test/helpers/prepared-gateway-runtime.ts +++ /dev/null @@ -1,55 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import assert from "node:assert/strict"; - -export function selectPreparedGatewayRuntime(source: string): string { - return [ - [ - " getDockerDriverGatewayPid(): number | null;", - ` getDockerDriverGatewayPreparation( - versionOutput?: string | null, - platform?: NodeJS.Platform, - ): import("./docker-driver-gateway-env").DockerDriverGatewayPreparation; - getDockerDriverGatewayPid(): number | null;`, - ], - [ - ` function getDockerDriverGatewayEnv( - versionOutput: string | null = null, - platform: NodeJS.Platform = process.platform, - ): Record {`, - ` function getDockerDriverGatewayPreparation( - versionOutput: string | null = null, - platform: NodeJS.Platform = process.platform, - ): import("./docker-driver-gateway-env").DockerDriverGatewayPreparation {`, - ], - [ - "const gatewayEnv = dockerDriverGatewayEnv.buildDockerDriverGatewayEnv({", - "const preparation = dockerDriverGatewayEnv.prepareDockerDriverGatewayEnv({", - ], - [ - ` if (gatewayEnv.OPENSHELL_LOCAL_TLS_DIR) { - process.env.OPENSHELL_LOCAL_TLS_DIR = gatewayEnv.OPENSHELL_LOCAL_TLS_DIR; - } - return gatewayEnv;`, - ` if (preparation.gatewayEnv.OPENSHELL_LOCAL_TLS_DIR) { - process.env.OPENSHELL_LOCAL_TLS_DIR = preparation.gatewayEnv.OPENSHELL_LOCAL_TLS_DIR; - } - return preparation; - } - - function getDockerDriverGatewayEnv( - versionOutput: string | null = null, - platform: NodeJS.Platform = process.platform, - ): Record { - return getDockerDriverGatewayPreparation(versionOutput, platform).gatewayEnv;`, - ], - [ - " getDockerDriverGatewayEnv,", - " getDockerDriverGatewayEnv,\n getDockerDriverGatewayPreparation,", - ], - ].reduce((result, [expected, replacement]) => { - assert.ok(result.includes(expected), `preparation fixture must contain ${expected}`); - return result.replaceAll(expected, replacement); - }, source); -} diff --git a/test/helpers/rebuild-flow-generic-harness.ts b/test/helpers/rebuild-flow-generic-harness.ts index 72f3da7e014..9e716df345c 100644 --- a/test/helpers/rebuild-flow-generic-harness.ts +++ b/test/helpers/rebuild-flow-generic-harness.ts @@ -38,6 +38,7 @@ import { policies, policyGet, policyState, + portableRetirementAuthority, processRecovery, purgeRebuildModule, type RebuildFlowHarness, @@ -226,6 +227,14 @@ 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([]); + 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-harness.ts b/test/helpers/rebuild-flow-harness.ts index b6f28104717..964ea29c681 100644 --- a/test/helpers/rebuild-flow-harness.ts +++ b/test/helpers/rebuild-flow-harness.ts @@ -57,6 +57,9 @@ export const providerCommand = requireDist("../../adapters/openshell/provider-co 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/helpers/rebuild-flow-test-support.ts b/test/helpers/rebuild-flow-test-support.ts index 303137f6a26..c5d7ea7e307 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/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/install/install-cdi-repair.test.ts b/test/install/install-cdi-repair.test.ts index 8e41cad1bf9..23eb81ae6e8 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/install/installer-hash-check.test.ts b/test/install/installer-hash-check.test.ts index a94f2cb1aeb..f7763628b40 100644 --- a/test/install/installer-hash-check.test.ts +++ b/test/install/installer-hash-check.test.ts @@ -34,8 +34,6 @@ import { removeV00106OperationalTrust, } from "../helpers/openshell-installer-template"; -import { selectPreparedGatewayRuntime } from "../helpers/prepared-gateway-runtime"; - const REPO_ROOT = path.join(import.meta.dirname, "../.."); const INSTALLER_TEMPLATE = fs.readFileSync( path.join(REPO_ROOT, "scripts/install-openshell.sh"), @@ -983,7 +981,7 @@ describe("installer hash verification", () => { const runtimePath = "src/lib/onboard/docker-driver-gateway-runtime.ts"; const candidatePins = fs.readFileSync(path.join(root, runtimePath), "utf8"); const source = fs.readFileSync(path.join(REPO_ROOT, runtimePath), "utf8"); - const prepared = selectPreparedGatewayRuntime(source).replace( + const prepared = source.replace( /const OPENSHELL_SUPERVISOR_MANIFEST_DIGESTS: Readonly> = \{[\s\S]*?\n\};/, candidatePins.trim(), ); diff --git a/test/install/installer-supervisor-manifest-trust.test.ts b/test/install/installer-supervisor-manifest-trust.test.ts index d4246d12609..411c4f683d7 100644 --- a/test/install/installer-supervisor-manifest-trust.test.ts +++ b/test/install/installer-supervisor-manifest-trust.test.ts @@ -14,8 +14,6 @@ import { V00116_SUPERVISOR_MANIFEST_DIGEST, } from "../helpers/openshell-release-fixtures"; -import { selectPreparedGatewayRuntime } from "../helpers/prepared-gateway-runtime"; - const REPO_ROOT = path.join(import.meta.dirname, "../.."); const PARSER = path.join(REPO_ROOT, "scripts/checks/extract-installer-pins.mts"); const INSTALLER_TEMPLATE = fs.readFileSync( @@ -147,14 +145,14 @@ function runParser(options: RunOptions = {}) { describe("OpenShell supervisor manifest trust", () => { it("accepts the gateway runtime template that prepares the Docker driver environment (#11212)", () => { - const result = runParser({ transformSupervisor: selectPreparedGatewayRuntime }); + const result = runParser(); expect(result.status, result.stderr).toBe(0); }); it("rejects a repository mutation of the gateway-preparation runtime template (#11212)", () => { const result = runParser({ transformSupervisor: (source) => - selectPreparedGatewayRuntime(source).replace( + source.replace( "ghcr.io/nvidia/openshell/supervisor@${manifestDigest}", "registry.invalid/openshell/supervisor@${manifestDigest}", ), diff --git a/test/installer-integration/install-preflight.test.ts b/test/installer-integration/install-preflight.test.ts index d7fd84199aa..31c987c89f3 100644 --- a/test/installer-integration/install-preflight.test.ts +++ b/test/installer-integration/install-preflight.test.ts @@ -28,7 +28,6 @@ import { } from "../helpers/installer-sourced-env"; const INSTALLER = path.join(import.meta.dirname, "../..", "install.sh"); -const CURL_PIPE_INSTALLER = path.join(import.meta.dirname, "../..", "install.sh"); const GITHUB_INSTALL_URL = "git+https://github.com/NVIDIA/NemoClaw.git"; // This installer test owns the fake compiled-tree exemption. const INSTALLER_ONBOARD_MODULE_DIR = path.join("dist", "lib", "onboard"); @@ -505,6 +504,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", "lib"), { recursive: true }); fs.copyFileSync( @@ -2367,7 +2367,7 @@ fi exit 0`, }); - const result = spawnSync("bash", [CURL_PIPE_INSTALLER], { + const result = spawnSync("bash", [INSTALLER], { cwd: tmp, encoding: "utf-8", env: { @@ -2414,7 +2414,7 @@ fi exit 0`, }); - const result = spawnSync("bash", [CURL_PIPE_INSTALLER], { + const result = spawnSync("bash", [INSTALLER], { cwd: tmp, encoding: "utf-8", env: { @@ -2441,7 +2441,7 @@ exit 0`, const repoLike = path.join(tmp, "repo"); fs.mkdirSync(path.join(repoLike, "scripts"), { recursive: true }); const rootInstaller = path.join(repoLike, "install.sh"); - fs.copyFileSync(CURL_PIPE_INSTALLER, rootInstaller); + fs.copyFileSync(INSTALLER, rootInstaller); writeExecutable( path.join(repoLike, "scripts", "install.sh"), `#!/usr/bin/env bash @@ -2492,7 +2492,7 @@ fi exit 0`, ); - const installerInput = fs.readFileSync(CURL_PIPE_INSTALLER, "utf-8"); + const installerInput = fs.readFileSync(INSTALLER, "utf-8"); const result = spawnSync("bash", [], { cwd: tmp, input: installerInput, @@ -2553,7 +2553,7 @@ fi exit 0`, }); - const installerInput = fs.readFileSync(CURL_PIPE_INSTALLER, "utf-8"); + const installerInput = fs.readFileSync(INSTALLER, "utf-8"); const result = spawnSync("bash", [], { cwd: tmp, input: installerInput, @@ -2615,7 +2615,7 @@ fi exit 0`, }); - const installerInput = fs.readFileSync(CURL_PIPE_INSTALLER, "utf-8"); + const installerInput = fs.readFileSync(INSTALLER, "utf-8"); const result = spawnSync("bash", [], { cwd: tmp, input: installerInput, diff --git a/test/installer-integration/install-station-dgx-os.test.ts b/test/installer-integration/install-station-dgx-os.test.ts index befc740564d..ea391e71b1f 100644 --- a/test/installer-integration/install-station-dgx-os.test.ts +++ b/test/installer-integration/install-station-dgx-os.test.ts @@ -1042,6 +1042,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' ;; @@ -1094,6 +1095,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..73ea92a520a 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() { DOCKER_QUERY_OUTPUT=""; return 0; } 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() { DOCKER_QUERY_OUTPUT=""; return 0; } check_dgx_os_runtime_commands() { :; } ps() { printf '%s 999 1 python python -m vllm serve model\n' "$EUID"; } ss() { :; } 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 4b9d5d8c783..8ca51c71855 100644 --- a/test/onboarding/onboard-fresh-create-identity.test.ts +++ b/test/onboarding/onboard-fresh-create-identity.test.ts @@ -254,6 +254,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-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, diff --git a/test/onboarding/onboard-inference-reconciliation.test.ts b/test/onboarding/onboard-inference-reconciliation.test.ts index 72ed6006800..a4398ef6559 100644 --- a/test/onboarding/onboard-inference-reconciliation.test.ts +++ b/test/onboarding/onboard-inference-reconciliation.test.ts @@ -7,7 +7,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { clearPersistedOllamaHostIfUnused, loadPersistedOllamaHost, @@ -53,6 +53,15 @@ const OPENAI_API_PROVIDER_METADATA = [ ].join("\n"); describe("onboard helpers", () => { + let fixtureHome: string; + beforeEach(() => { + fixtureHome = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-inference-reconciliation-")); + vi.stubEnv("HOME", fixtureHome); + }); + afterEach(() => { + vi.unstubAllEnvs(); + fs.rmSync(fixtureHome, { recursive: true, force: true }); + }); it("reuses a registered Hermes Provider without re-collecting host credentials", async () => { await withProcessEnv( { @@ -237,10 +246,18 @@ describe("onboard helpers", () => { const runtimeEffectfulPreflightPath = JSON.stringify( path.join(repoRoot, "src/lib/onboard/machine/runtime-effectful-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"}}\' ;;\n *) printf \'unexpected docker command: %s\\n\' "$*" >&2; exit 1 ;;\nesac\n', + { mode: 0o755 }, + ); const script = String.raw` const runner = require(${runnerPath}); @@ -254,6 +271,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 d9115f7919e..7cdfbe1d915 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}); @@ -368,6 +369,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}); @@ -496,6 +498,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 b4b49592092..1e8ceb2a25d 100644 --- a/test/onboarding/onboard-reservation-recreate.test.ts +++ b/test/onboarding/onboard-reservation-recreate.test.ts @@ -92,6 +92,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}); @@ -409,6 +410,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 b76e3f4298c..bdc051968dd 100644 --- a/test/onboarding/onboard-sandbox-recreation.test.ts +++ b/test/onboarding/onboard-sandbox-recreation.test.ts @@ -49,6 +49,7 @@ describe.concurrent("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"); @@ -145,6 +146,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"); @@ -320,6 +322,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}); @@ -508,6 +511,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}); @@ -658,6 +662,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}); @@ -836,6 +841,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}); @@ -989,6 +995,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}); @@ -1167,6 +1174,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/onboarding/onboard.test.ts b/test/onboarding/onboard.test.ts index 93f4bb905eb..778bf1f1bc1 100644 --- a/test/onboarding/onboard.test.ts +++ b/test/onboarding/onboard.test.ts @@ -749,6 +749,7 @@ childProcess.spawn = (...args) => { return child; }; +require(${JSON.stringify(path.join(repoRoot, "src", "lib", "platform.ts"))}).isWsl = () => false; const { createSandbox } = require(${onboardPath}); (async () => { 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/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(), 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/e2e/brev-launchable-e2e.sh b/tools/e2e/brev-launchable-e2e.sh index 1709f75a034..aba8164d79d 100755 --- a/tools/e2e/brev-launchable-e2e.sh +++ b/tools/e2e/brev-launchable-e2e.sh @@ -387,18 +387,19 @@ wait_for_workspace_ssh() { local deadline=$((SECONDS + timeout_seconds)) local remaining refresh_timeout sleep_seconds ssh_timeout refresh_error ssh_error local attempts=0 - local refresh_status=1 ssh_status=1 + local refresh_status=0 ssh_status=1 local last_refresh_error="" last_refresh_failure_status="" local last_ssh_error="" last_ssh_failure_status="" log "Waiting up to $timeout_seconds seconds for workspace SSH access" remaining=$((deadline - SECONDS)) - [ "$remaining" -gt 0 ] || die "workspace SSH readiness timed out" - refresh_timeout=$((remaining < 60 ? remaining : 60)) - run_bounded_probe "$refresh_timeout" refresh_error refresh_status brev refresh - if [ "$refresh_status" -ne 0 ]; then - last_refresh_error="$refresh_error" - last_refresh_failure_status="$refresh_status" + if [ "$remaining" -gt 0 ]; then + refresh_timeout=$((remaining < 60 ? remaining : 60)) + run_bounded_probe "$refresh_timeout" refresh_error refresh_status brev refresh + if [ "$refresh_status" -ne 0 ]; then + last_refresh_error="$refresh_error" + last_refresh_failure_status="$refresh_status" + fi fi while [ "$SECONDS" -lt "$deadline" ]; do diff --git a/tools/wsl/ci-helper.ps1 b/tools/wsl/ci-helper.ps1 index c9ac607b4b8..a08f244ec29 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('/')) @@ -424,6 +424,8 @@ 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'" ) -join "`n"