diff --git a/.github/workflows/platform-vitest-main.yaml b/.github/workflows/platform-vitest-main.yaml index 982bf26a462..f07936daf50 100644 --- a/.github/workflows/platform-vitest-main.yaml +++ b/.github/workflows/platform-vitest-main.yaml @@ -4,7 +4,6 @@ name: CI / Platform Compatibility on: - workflow_dispatch: push: branches: - main @@ -20,7 +19,8 @@ permissions: concurrency: group: platform-evidence-${{ github.ref }} - cancel-in-progress: true + queue: max + cancel-in-progress: false jobs: ubuntu-2604-contract: @@ -82,6 +82,9 @@ jobs: macos-vitest: name: macOS compatibility (${{ matrix.shard }}/4) + permissions: + contents: read + packages: read runs-on: macos-26 timeout-minutes: ${{ matrix.timeout_minutes }} strategy: @@ -89,8 +92,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 +122,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 +151,10 @@ jobs: stat --version | head -n 1 awk --version | head -n 1 - - name: Install pinned OpenShell - env: - NEMOCLAW_NON_INTERACTIVE: "1" - run: env -u GH_TOKEN -u GITHUB_TOKEN bash scripts/install-openshell.sh - - name: Install dependencies - run: | - npm ci --ignore-scripts - cd nemoclaw - npm ci --ignore-scripts + env: + NODE_AUTH_TOKEN: ${{ github.token }} + run: bash .github/actions/ci-install-dependencies.sh - name: Build CLI and plugin run: | @@ -166,11 +163,54 @@ 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' }} + permissions: + contents: read + packages: read + 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 +223,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 +236,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 @@ -220,6 +254,9 @@ jobs: wsl-vitest: name: WSL compatibility (${{ matrix.shard }}/4) + permissions: + contents: read + packages: read runs-on: windows-latest timeout-minutes: ${{ matrix.timeout_minutes }} strategy: @@ -238,10 +275,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 +303,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,10 +320,26 @@ 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 + - name: Stop WSL container runtime before non-live tests + shell: powershell + run: | + . "$env:TRUSTED_WSL_HELPER" + $script = @' + set -euo pipefail + service docker stop >/dev/null 2>&1 || true + if docker info >/dev/null 2>&1; then + echo "Docker must remain unavailable during the non-live Vitest suite." >&2 + exit 1 + fi + '@ + Invoke-WslScript -Distro $env:WSL_DISTRO -User root -Script $script + - name: Install Node.js 22 in WSL shell: powershell run: | @@ -309,18 +358,23 @@ jobs: - name: Install dependencies and build in WSL shell: powershell + env: + NODE_AUTH_TOKEN: ${{ github.token }} run: | . "$env:TRUSTED_WSL_HELPER" $testUser = ConvertTo-BashLiteral -Value $env:WSL_TEST_USER $workdir = ConvertTo-BashLiteral -Value $env:WSL_WORKDIR + $nodeAuthToken = ConvertTo-BashLiteral -Value ([string]$env:NODE_AUTH_TOKEN) $script = @" set -euo pipefail id -un | grep -Fxq $testUser cd $workdir - npm ci --ignore-scripts + export NPM_CONFIG_CACHE="`$HOME/.npm" + export NODE_AUTH_TOKEN=$nodeAuthToken + bash .github/actions/ci-install-dependencies.sh + unset NODE_AUTH_TOKEN npm run build:cli cd nemoclaw - npm ci --ignore-scripts npm run build "@ Invoke-WslScript -Distro $env:WSL_DISTRO -User $env:WSL_TEST_USER -Script $script @@ -335,6 +389,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 +427,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 +456,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 +471,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 +493,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/ci/source-shape-test-budget.json b/ci/source-shape-test-budget.json index 205c35546d2..2466888df16 100644 --- a/ci/source-shape-test-budget.json +++ b/ci/source-shape-test-budget.json @@ -290,6 +290,21 @@ "file": "test/runtime/policy/repro-5978-policy-denial-hint.test.ts", "test": "prints only once when the file is sourced twice in one login shell", "category": "compatibility" + }, + { + "file": "test/automation/e2e/platform-vitest-main-workflow.test.ts", + "test": "preserves distinct main-commit evidence in a serialized queue", + "category": "compatibility" + }, + { + "file": "test/automation/e2e/platform-vitest-main-workflow.test.ts", + "test": "grants only the read access needed to install reviewed dependencies", + "category": "security" + }, + { + "file": "test/automation/e2e/platform-vitest-main-workflow.test.ts", + "test": "installs container clients before Vitest but starts Docker only afterward", + "category": "compatibility" } ] } diff --git a/nemoclaw/src/commands/migration-state-restore-transaction.test.ts b/nemoclaw/src/commands/migration-state-restore-transaction.test.ts index ea645a220ba..5bb817348f4 100644 --- a/nemoclaw/src/commands/migration-state-restore-transaction.test.ts +++ b/nemoclaw/src/commands/migration-state-restore-transaction.test.ts @@ -7,6 +7,7 @@ import { mkdtempSync, mkdirSync, readFileSync, + realpathSync, readdirSync, renameSync, rmSync, @@ -53,7 +54,7 @@ import { restoreSnapshotToHost } from "./migration-state.js"; const temporaryRoots: string[] = []; function makeHome(): string { - const home = mkdtempSync(path.join(tmpdir(), "nemoclaw-restore-transaction-")); + const home = realpathSync(mkdtempSync(path.join(tmpdir(), "nemoclaw-restore-transaction-"))); temporaryRoots.push(home); return home; } diff --git a/nemoclaw/src/commands/migration-state-security.test.ts b/nemoclaw/src/commands/migration-state-security.test.ts index c5a775679f6..5f6d70564fb 100644 --- a/nemoclaw/src/commands/migration-state-security.test.ts +++ b/nemoclaw/src/commands/migration-state-security.test.ts @@ -8,6 +8,7 @@ import { mkdirSync, mkdtempSync, openSync, + realpathSync, readdirSync, readFileSync, rmSync, @@ -36,7 +37,7 @@ import { makeSnapshotManifest } from "./migration-state-test-fixtures.js"; const roots: string[] = []; function makeHome(): string { - const home = mkdtempSync(path.join(tmpdir(), "nemoclaw-migration-state-security-")); + const home = realpathSync(mkdtempSync(path.join(tmpdir(), "nemoclaw-migration-state-security-"))); roots.push(home); return home; } diff --git a/src/lib/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..63a999ccfde 100644 --- a/src/lib/actions/sandbox/snapshot/backup-authority-script.test.ts +++ b/src/lib/actions/sandbox/snapshot/backup-authority-script.test.ts @@ -19,6 +19,7 @@ const MAX_CONFIG_BYTES = 16 * 1024 * 1024; const HERMES_DIRECTORY_MAX_BYTES = 256 * 1024 * 1024; const PROTOCOL_PREFIX = "nemoclaw-openclaw-config-capture:"; const fixtureRoots: string[] = []; +const linuxIt = process.platform === "linux" ? it : it.skip; interface CaptureResult { readonly status: number | null; @@ -164,7 +165,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. + linuxIt("uses SQLite backup with a valid database", () => { const directory = fixtureDirectory(); const database = path.join(directory, "state.db"); expect( @@ -227,7 +229,8 @@ describe("Hermes privileged state capture scripts", () => { expect(captured.stdout).toEqual(Buffer.alloc(0)); }); - it("rejects a SQLite file replaced during capture without returning bytes", () => { + // The shipped sandbox probe opens SQLite through Linux /proc/self/fd. + linuxIt("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"); 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/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/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/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..43301a13da4 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 = [ @@ -252,11 +253,12 @@ describe("planRegisteredExtraProviders diagnostics", () => { ].join("\n"); const started = performance.now(); + expect(reportsExactProviderNotFound(adversarial, "redos-provider", LIMIT)).toBe(false); + expect(performance.now() - started).toBeLessThan(100); expect( await reconcile(["redos-provider"], { "redos-provider": { status: 1, stderr: adversarial }, }), ).toEqual(["redos-provider"]); - 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..ae54bcf50d4 100644 --- a/src/lib/onboard/fatal-runtime-preflight.test.ts +++ b/src/lib/onboard/fatal-runtime-preflight.test.ts @@ -618,6 +618,7 @@ describe("runFatalOnboardRuntimePreflight", () => { it("disables the container-backed WSL GPU prover during host admission", () => { const detect = vi.fn((_deps?: DetectGpuDeps): GpuDetection | null => null); + const collectN1xWslProduct = vi.fn(() => undefined); runFatalOnboardRuntimePreflight( {}, @@ -626,9 +627,11 @@ describe("runFatalOnboardRuntimePreflight", () => { deferEffectfulChecks: true, assessHost: wslDockerDesktopHost, detectGpu: detect, + collectN1xWslProduct, }, ); + expect(collectN1xWslProduct).toHaveBeenCalledOnce(); expect(detect).toHaveBeenCalledOnce(); expect(detect).toHaveBeenCalledWith( expect.objectContaining({ proveArm64ContainerGpu: null, n1xWslProduct: null }), 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/src/lib/sandbox/create-stream-process-group.test.ts b/src/lib/sandbox/create-stream-process-group.test.ts index c60736682e1..7830932134f 100644 --- a/src/lib/sandbox/create-stream-process-group.test.ts +++ b/src/lib/sandbox/create-stream-process-group.test.ts @@ -46,6 +46,7 @@ function createChildScript(): { markerPath: string; script: string } { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-create-group-")); startedDirs.push(dir); const markerPath = path.join(dir, "pids.json"); + const pendingMarkerPath = path.join(dir, "pids.pending.json"); const script = ` const fs = require("node:fs"); const { spawn } = require("node:child_process"); @@ -53,9 +54,10 @@ const grandchild = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"] stdio: "ignore", }); fs.writeFileSync( - ${JSON.stringify(markerPath)}, + ${JSON.stringify(pendingMarkerPath)}, JSON.stringify({ child: process.pid, grandchild: grandchild.pid }), ); +fs.renameSync(${JSON.stringify(pendingMarkerPath)}, ${JSON.stringify(markerPath)}); setInterval(() => {}, 1000); `; return { markerPath, script }; diff --git a/test/README.md b/test/README.md index ff8624efb2c..4a60af891b2 100644 --- a/test/README.md +++ b/test/README.md @@ -87,6 +87,9 @@ 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 workflow runs only after pushes to `main`; candidate-controlled and manually dispatched code +does not receive its package credential. WSL installs `gnu-coreutils` for fixtures that require GNU +utility behavior, keeps Ubuntu's default utilities intact, and stops Docker before non-live tests. 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..6b72bfc9b30 100644 --- a/test/automation/e2e/platform-vitest-main-workflow.test.ts +++ b/test/automation/e2e/platform-vitest-main-workflow.test.ts @@ -14,7 +14,13 @@ import { const WORKFLOW_PATH = ".github/workflows/platform-vitest-main.yaml"; const WSL_HELPER_PATH = "tools/wsl/ci-helper.ps1"; const MACOS_REQUIREMENTS_PATH = "ci/platform-vitest-macos-requirements.lock"; -const workflow = readYaml(WORKFLOW_PATH); +const workflow = readYaml< + Workflow & { + concurrency: { group: string; queue: "max"; "cancel-in-progress": boolean }; + on: { push: { branches: string[]; "paths-ignore": string[] } }; + permissions: Record; + } +>(WORKFLOW_PATH); const wslHelperSource = readRepoText(WSL_HELPER_PATH); function job(name: string): WorkflowJob { @@ -30,43 +36,187 @@ function step(jobName: string, name: string): WorkflowStep { } describe("platform evidence workflow", () => { + // source-shape-contract: compatibility -- Exact concurrency settings preserve distinct main commit evidence without overlapping platform runs + it("preserves distinct main-commit evidence in a serialized queue", () => { + expect(workflow.concurrency).toEqual({ + group: "platform-evidence-${{ github.ref }}", + queue: "max", + "cancel-in-progress": false, + }); + }); + + // source-shape-contract: security -- Main only execution and exact workflow permissions keep package credentials out of candidate controlled runs + it("grants only the read access needed to install reviewed dependencies", () => { + expect(Object.keys(workflow.on)).toEqual(["push"]); + expect(workflow.on.push.branches).toEqual(["main"]); + expect(workflow.permissions).toEqual({ contents: "read" }); + expect( + Object.entries(workflow.jobs) + .filter(([, workflowJob]) => workflowJob.permissions !== undefined) + .map(([name, workflowJob]) => [name, workflowJob.permissions]), + ).toEqual([ + ["macos-vitest", { contents: "read", packages: "read" }], + ["macos-live-e2e", { contents: "read", packages: "read" }], + ["wsl-vitest", { contents: "read", packages: "read" }], + ]); + expect(JSON.stringify(workflow)).not.toContain("sdk-artifact-run-id"); + expect(JSON.stringify(workflow)).not.toContain("actions/download-artifact"); + }); + it("marks the container checkout safe before generating build identity", () => { const run = step("ubuntu-2604-contract", "Build CLI").run ?? ""; expect(run).toContain('git config --global --add safe.directory "$GITHUB_WORKSPACE"'); 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 }}", + }); + }); + + 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"); + }); + + // source-shape-contract: compatibility -- Exact WSL workflow ordering stops the runtime for hermetic tests before main only startup + 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 stopped = + step("wsl-vitest", "Stop WSL container runtime before non-live tests").run ?? ""; + const runtime = step("wsl-vitest", "Start the WSL container runtime").run ?? ""; + const installIndex = steps.findIndex((entry) => entry.name === "Install Ubuntu dependencies"); + const stoppedIndex = steps.findIndex( + (entry) => entry.name === "Stop WSL container runtime before non-live tests", + ); + 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("'gcc'"); + expect(install).toContain("'libc6-dev'"); + expect(install).toContain("'podman'"); + expect(install).toContain("'procps'"); + expect(install).toContain("'iproute2'"); + expect(install).toContain("'zip'"); + expect(install).toContain("'gnu-coreutils'"); + expect(install).not.toContain("service docker start"); + expect(stopped).toContain("service docker stop"); + expect(stopped).toContain("if docker info >/dev/null 2>&1; then"); + expect(stopped).toContain("exit 1"); + 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(step("wsl-vitest", "Resolve workspace paths for WSL").run).toContain( + "-WorkdirPrefix '/home/nemoclaw-ci/nemoclaw-wsl-vitest'", + ); + expect(step("wsl-vitest", "Start the WSL container runtime").if).toBe( + "${{ matrix.shard == 1 && github.ref == 'refs/heads/main' }}", + ); + expect(installIndex).toBeGreaterThanOrEqual(0); + expect(stoppedIndex).toBeGreaterThanOrEqual(0); + expect(runtimeIndex).toBeGreaterThanOrEqual(0); + expect(suiteIndex).toBeGreaterThanOrEqual(0); + expect(detectionIndex).toBeGreaterThanOrEqual(0); + expect(liveIndex).toBeGreaterThanOrEqual(0); + expect(stoppedIndex).toBeGreaterThan(installIndex); + expect(stoppedIndex).toBeLessThan(suiteIndex); + 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"'); + }); + + const liveOnlyEnvironment = { + NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE: "1", + NEMOCLAW_NON_INTERACTIVE: "1", + NEMOCLAW_RECREATE_SANDBOX: "1", + NEMOCLAW_SANDBOX_NAME: "e2e-wsl", + }; + + it.each(Object.entries(liveOnlyEnvironment))( + "scopes WSL %s to the credentialed live step", + (key, value) => { + const wsl = job("wsl-vitest"); + const live = step("wsl-vitest", "Run WSL live E2E"); + expect(wsl.env).not.toHaveProperty(key); + const leakedSteps = (wsl.steps ?? []) + .filter((entry) => entry.name !== "Run WSL live E2E") + .filter((entry) => Object.hasOwn(entry.env ?? {}, key) || entry.run?.includes(key)); + expect(leakedSteps).toEqual([]); + expect(live.env).toHaveProperty(key, value); + }, + ); + + it.each([ + { jobName: "macos-vitest", stepName: "Install dependencies" }, + { jobName: "macos-live-e2e", stepName: "Install dependencies" }, + { jobName: "wsl-vitest", stepName: "Install dependencies and build in WSL" }, + ])( + "limits the package credential to trusted $jobName dependency installation", + ({ jobName, stepName }) => { + const install = step(jobName, stepName); + expect(install.env).toEqual({ NODE_AUTH_TOKEN: "${{ github.token }}" }); + expect(install.run).toContain(".github/actions/ci-install-dependencies.sh"); + }, + ); - expect(firstShard?.timeout_minutes).toBe(150); + it("removes the package credential before the WSL build", () => { + const install = step("wsl-vitest", "Install dependencies and build in WSL"); + expect(install.run).toContain("unset NODE_AUTH_TOKEN"); }); }); diff --git a/test/automation/e2e/wsl-ci-helper.test.ts b/test/automation/e2e/wsl-ci-helper.test.ts index 6220141f2cf..c013489de7c 100644 --- a/test/automation/e2e/wsl-ci-helper.test.ts +++ b/test/automation/e2e/wsl-ci-helper.test.ts @@ -107,6 +107,9 @@ Get-WslCheckoutSyncScript -Checkout "/mnt/d/agent work/repo's" -Workdir "/tmp/ne expect(result.stdout).toContain( "chown -R 'nemoclaw-ci:nemoclaw-ci' '/tmp/nemoclaw-wsl-workdir/123-1'", ); + expect(result.stdout).toContain("chmod -R go-w -- '/tmp/nemoclaw-wsl-workdir/123-1'"); + expect(result.stdout).toContain("chmod 0711 '/tmp/nemoclaw-wsl-workdir'"); + expect(result.stdout).toContain("chmod 0700 '/tmp/nemoclaw-wsl-workdir/123-1'"); }, ); @@ -116,7 +119,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 +135,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 +180,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 +202,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/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..6e10e94558e 100644 --- a/test/e2e-runtime/brev-launchable-e2e.test.ts +++ b/test/e2e-runtime/brev-launchable-e2e.test.ts @@ -990,6 +990,31 @@ describe("focused staging Brev Launchable lane", () => { }); }); + it("reports readiness diagnostics when the initial budget expires while logging", () => { + const { calls, env, state, workDir } = fixture({ + brevExecStatus: 0, + delayWorkspaceSshLog: true, + sshAliasQueryStatus: 42, + sshProbeStatus: 0, + sshReadyAfter: Number.MAX_SAFE_INTEGER, + }); + const result = run({ ...env, BREV_SSH_TIMEOUT_SECONDS: "1" }); + expect(result.status).not.toBe(0); + const output = emittedOutput(result, workDir); + expect(output).toContain("Readiness Brev refresh last failure: none"); + expect(output).toContain("Readiness SSH alias nclaw-e2e-test-1: unavailable"); + expect(output).toContain("Readiness Brev refresh: not run before the readiness deadline"); + expect(output).toContain("Readiness classification: direct SSH recovered during diagnostics"); + expect(output).not.toContain("Readiness classification: Brev refresh/configuration failure"); + const commands = fs.readFileSync(calls, "utf8"); + expect(commands).not.toContain("timeout 1s brev refresh"); + expect(commands).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", + }); + }); + it("reports unavailable when the SSH alias diagnostic times out", () => { const { env, state, workDir } = fixture({ sshReadyAfter: Number.MAX_SAFE_INTEGER, diff --git a/test/e2e/README.md b/test/e2e/README.md index cf76d49ce41..6ba48028464 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 job and WSL shard 1 run live E2E 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,11 @@ 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 a 150-minute budget, 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 independent macOS job and WSL shard 1 run focused live E2E 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. @@ -1660,7 +1659,8 @@ for the recorded PR number, selected repository, selected commit SHA, base commi workflow SHA. A changed PR source repository, head commit SHA, or base commit SHA invalidates a head-to-base comparison. -The platform-evidence workflow runs on configured pushes to `main` and supports manual dispatch for branch diagnosis. +The platform-evidence workflow runs only on configured pushes to `main`. +It serializes runs for the same ref, retains the pending queue, and does not cancel an older commit when a newer `main` push arrives. The experimental portable-profile workflow can run for pull requests, matching `main` pushes, and manual dispatch. Its `portable-launch` job runs only when `github.ref` is `refs/heads/main`. The `portable-launch` job's exercise step exposes the long-lived repository `NVIDIA_INFERENCE_API_KEY` to the checked-out source through its environment. diff --git a/test/e2e/docs/README.md b/test/e2e/docs/README.md index a2be60a26dc..2424dec503d 100644 --- a/test/e2e/docs/README.md +++ b/test/e2e/docs/README.md @@ -411,11 +411,12 @@ 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. + Runs for the same ref are serialized and retained instead of being canceled by a newer push, preserving distinct-commit evidence on `main`. + 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. + WSL stops Docker before non-live Vitest and starts it afterward only for the main-only live path. + 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/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/lifecycle-user-service.test.ts b/test/e2e/support/lifecycle-user-service.test.ts index c3a63fcac6e..de90da63e9f 100644 --- a/test/e2e/support/lifecycle-user-service.test.ts +++ b/test/e2e/support/lifecycle-user-service.test.ts @@ -21,6 +21,7 @@ const installer = fileURLToPath(new URL("../../../scripts/install.sh", import.me const upstreamServiceShow = "--user show openshell-gateway.service --property=FragmentPath --property=ExecStart"; const stoppedServicePrefix = "NEMOCLAW_E2E_STOPPED_GATEWAY_USER_SERVICE="; +const linuxDescribe = process.platform === "linux" ? describe : describe.skip; function runStopScript(installerPath: string, env: NodeJS.ProcessEnv) { return spawnSync( @@ -177,8 +178,7 @@ function writeMacServiceStubs( servicePath, }; } - -describe("reboot lifecycle OpenShell gateway user-service fixture", () => { +linuxDescribe("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-"), @@ -213,7 +213,7 @@ describe("reboot lifecycle OpenShell gateway user-service fixture", () => { env.NEMOCLAW_INSTALLER_STAGED = installerCleanupSentinel; const staged = execFileSync( "bash", - ["-lc", buildOpenShellGatewayUserServiceStageScript(), "stage-service", installer], + ["-c", buildOpenShellGatewayUserServiceStageScript(), "stage-service", installer], { encoding: "utf8", env, killSignal: "SIGKILL", timeout: 30_000 }, ); @@ -222,7 +222,7 @@ describe("reboot lifecycle OpenShell gateway user-service fixture", () => { expect(fs.readFileSync(unit, "utf8")).toContain(`ExecStart=${bin}/openshell-gateway`); expect(fs.statSync(unit).mode & 0o777).toBe(0o600); - execFileSync("sh", ["-lc", buildOpenShellGatewayUserServiceRemovalScript()], { + execFileSync("sh", ["-c", buildOpenShellGatewayUserServiceRemovalScript()], { env, killSignal: "SIGKILL", timeout: 30_000, @@ -260,7 +260,7 @@ describe("reboot lifecycle OpenShell gateway user-service fixture", () => { }); const output = execFileSync( "bash", - ["-lc", buildOpenShellGatewayUserServiceStageScript(), "stage-service", installer], + ["-c", buildOpenShellGatewayUserServiceStageScript(), "stage-service", installer], { encoding: "utf8", env, killSignal: "SIGKILL", timeout: 30_000 }, ); @@ -305,7 +305,7 @@ describe("reboot lifecycle OpenShell gateway user-service fixture", () => { expect(() => execFileSync( "bash", - ["-lc", buildOpenShellGatewayUserServiceStageScript(), "stage-service", installer], + ["-c", buildOpenShellGatewayUserServiceStageScript(), "stage-service", installer], { env, killSignal: "SIGKILL", stdio: "pipe", timeout: 30_000 }, ), ).toThrow(); @@ -344,7 +344,7 @@ describe("reboot lifecycle OpenShell gateway user-service fixture", () => { expect(() => execFileSync( "bash", - ["-lc", buildOpenShellGatewayUserServiceStageScript(), "stage-service", installer], + ["-c", buildOpenShellGatewayUserServiceStageScript(), "stage-service", installer], { env, killSignal: "SIGKILL", stdio: "pipe", timeout: 30_000 }, ), ).toThrow(); 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..20f7f816e56 100644 --- a/test/helpers/brev-launchable-e2e-fixture.ts +++ b/test/helpers/brev-launchable-e2e-fixture.ts @@ -11,6 +11,7 @@ const SCRIPT = path.join(REPO_ROOT, "tools", "e2e", "brev-launchable-e2e.sh"); const REAL_CUT = spawnSync("which", ["cut"], { encoding: "utf8" }).stdout.trim(); const REAL_PYTHON3 = spawnSync("which", ["python3"], { encoding: "utf8" }).stdout.trim(); const REAL_STAT = spawnSync("which", ["stat"], { encoding: "utf8" }).stdout.trim(); +const REAL_TEE = spawnSync("which", ["tee"], { encoding: "utf8" }).stdout.trim(); export const candidateSha = "a".repeat(40); const roots: string[] = []; @@ -45,6 +46,7 @@ export function fixture( createAppearsAfterRefresh?: number; createStatus?: number; deleteFails?: boolean; + delayWorkspaceSshLog?: boolean; e2eDiagnosticTimesOut?: boolean; e2eFails?: boolean; gatewayChildJournal?: string; @@ -131,6 +133,19 @@ exec "$@" path.join(bin, "sleep"), '#!/usr/bin/env bash\nprintf "sleep %s\\n" "$*" >> "$FAKE_CALLS"\n', ); + if (options.delayWorkspaceSshLog) { + executable( + path.join(bin, "tee"), + `#!/usr/bin/env bash +set -euo pipefail +IFS= read -r line || true +if [[ "$line" == "Waiting up to "*" seconds for workspace SSH access" ]]; then + /bin/sleep 1 +fi +printf '%s\n' "$line" | ${JSON.stringify(REAL_TEE)} "$@" +`, + ); + } executable( path.join(bin, "sudo"), `#!/usr/bin/env bash 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/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-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-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..d7b78dec27a 100755 --- a/tools/e2e/brev-launchable-e2e.sh +++ b/tools/e2e/brev-launchable-e2e.sh @@ -353,9 +353,16 @@ run_connectivity_diagnostics() { local exec_error exec_status ssh_error ssh_status local workspace_alias local deadline=$((SECONDS + timeout_seconds)) + local refresh_failed=0 log "Readiness diagnostics budget: up to $timeout_seconds seconds" + if [ "$refresh_status" = "not-run" ]; then + log "Readiness Brev refresh: not run before the readiness deadline" + elif [ "$refresh_status" -ne 0 ]; then + refresh_failed=1 + fi + ssh_alias_status "$deadline" "$INSTANCE_NAME" workspace_alias log "Readiness SSH alias $INSTANCE_NAME: $workspace_alias" @@ -369,7 +376,7 @@ run_connectivity_diagnostics() { if [ "$exec_status" = "not-run" ] || [ "$ssh_status" = "not-run" ]; then log "Readiness classification: incomplete diagnostics; inspect available bounded probe results" - elif [ "$refresh_status" -ne 0 ]; then + elif [ "$refresh_failed" -eq 1 ]; then log "Readiness classification: Brev refresh/configuration failure" elif [ "$exec_status" -eq 0 ] && [ "$ssh_status" -ne 0 ]; then log "Readiness classification: Brev execution works but direct SSH fails" @@ -387,18 +394,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="not-run" 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..5dd1fb5479a 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,9 @@ function Get-WslCheckoutSyncScript { "git -C $workdirLiteral reset --hard HEAD" "git -C $workdirLiteral clean -ffdx" $ownerCommand + "chmod -R go-w -- $workdirLiteral" + "chmod 0711 $workdirRootLiteral" + "chmod 0700 $workdirLiteral" "git -C $workdirLiteral status --short" "echo 'WSL ext4 workspace is ready'" ) -join "`n"