From 73390f9491c387cb5ce668e8f2e6efdbb790cdee Mon Sep 17 00:00:00 2001 From: Senthil Ravichandran Date: Fri, 7 Aug 2026 13:28:33 -0700 Subject: [PATCH 01/15] fix(cli): target portable cleanup runtime --- .github/workflows/portable-profile-e2e.yaml | 119 +++++++++++++- .../portable-demo-lifecycle.test.ts | 152 +++++++++++++++++- .../experimental/portable-demo-lifecycle.ts | 53 ++++++ src/lib/sandbox/privileged-exec.test.ts | 81 ++++++++++ src/lib/sandbox/privileged-exec.ts | 24 +++ test/e2e/fixtures/availability-env.ts | 1 + test/e2e/live/full-e2e.test.ts | 29 +++- test/e2e/live/launch-agent-turn.ts | 30 +++- test/e2e/mock-parity.json | 1 + test/e2e/support/e2e-workflow.test.ts | 19 ++- test/e2e/support/launch-agent-turn.test.ts | 6 +- 11 files changed, 491 insertions(+), 24 deletions(-) diff --git a/.github/workflows/portable-profile-e2e.yaml b/.github/workflows/portable-profile-e2e.yaml index 488137dd2e1..6cf85bc1949 100644 --- a/.github/workflows/portable-profile-e2e.yaml +++ b/.github/workflows/portable-profile-e2e.yaml @@ -16,8 +16,12 @@ on: - "scripts/install.sh" - "scripts/install-openshell.sh" - "src/lib/onboard/**" + - "src/lib/actions/sandbox/**" - "src/lib/domain/sandbox/image-tag.ts" - - "src/lib/sandbox/build-context.ts" + - "src/lib/sandbox/**" + - "test/e2e/fixtures/availability-env.ts" + - "test/e2e/live/full-e2e.test.ts" + - "test/e2e/live/launch-agent-turn.ts" - "test/e2e/live/portable-profile-gateway-proof.ts" - "test/e2e/live/portable-profile-rootless-linux.test.ts" - "tools/e2e/check-semantic-phases.mts" @@ -93,3 +97,116 @@ jobs: include-hidden-files: false if-no-files-found: ignore retention-days: 14 + + portable-launch: + if: ${{ github.ref == 'refs/heads/main' }} + runs-on: ubuntu-latest + timeout-minutes: 75 + env: + E2E_JOB: "1" + E2E_TARGET_ID: portable-launch + E2E_ARTIFACT_DIR: ${{ github.workspace }}/e2e-artifacts/portable-launch + NEMOCLAW_CLI_BIN: ${{ github.workspace }}/bin/nemoclaw.js + NEMOCLAW_RUN_LIVE_E2E: "1" + NEMOCLAW_E2E_USE_HOSTED_INFERENCE: "1" + NEMOCLAW_NON_INTERACTIVE: "1" + NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE: "1" + NEMOCLAW_EXPERIMENTAL_PROFILE: portable + NEMOCLAW_SANDBOX_NAME: portable-launch + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Prepare E2E workspace + uses: NVIDIA/NemoClaw/.github/actions/prepare-e2e@f6304bc25fc35bfaa441c8c2fbfee38f72805a75 + + - name: Provision restricted rootless Linux runtime + shell: bash + run: | + set -euo pipefail + sudo apt-get update + sudo apt-get install --yes fuse-overlayfs passt podman slirp4netns uidmap + runtime_dir="/run/user/$(id -u)" + sudo install -d -m 700 -o "$(id -u)" -g "$(id -g)" "$runtime_dir" + if ! grep -q "^${USER}:" /etc/subuid; then + sudo usermod --add-subuids 100000-165535 "$USER" + fi + if ! grep -q "^${USER}:" /etc/subgid; then + sudo usermod --add-subgids 100000-165535 "$USER" + fi + + shim_dir="${RUNNER_TEMP}/nemoclaw-portable-bin" + install -d -m 700 "$shim_dir" + cat >"$shim_dir/systemctl" <<'SHIM' + #!/usr/bin/env bash + set -euo pipefail + runtime_dir="${XDG_RUNTIME_DIR:?}" + service_dir="${runtime_dir}/podman" + socket_path="${service_dir}/podman.sock" + pid_file="${runtime_dir}/nemoclaw-podman-service.pid" + log_file="${runtime_dir}/nemoclaw-podman-service.log" + case "$*" in + "--user set-environment "*) exit 0 ;; + "--user try-restart podman.service") + if [[ -f "$pid_file" ]]; then + kill "$(<"$pid_file")" 2>/dev/null || true + rm -f "$pid_file" "$socket_path" + fi + ;; + "--user enable --now podman.socket") + mkdir -p "$service_dir" + nohup podman system service --time=0 "unix://$socket_path" >"$log_file" 2>&1 & + echo $! >"$pid_file" + for _ in $(seq 1 100); do + [[ -S "$socket_path" ]] && exit 0 + sleep 0.1 + done + cat "$log_file" >&2 || true + exit 1 + ;; + *) + echo "unexpected user-service command: $*" >&2 + exit 64 + ;; + esac + SHIM + chmod 700 "$shim_dir/systemctl" + + export PATH="$shim_dir:$PATH" + export XDG_RUNTIME_DIR="$runtime_dir" + systemctl --user enable --now podman.socket + printf '%s\n' "$shim_dir" >>"$GITHUB_PATH" + printf 'XDG_RUNTIME_DIR=%s\n' "$runtime_dir" >>"$GITHUB_ENV" + printf 'DOCKER_HOST=unix://%s/podman/podman.sock\n' "$runtime_dir" >>"$GITHUB_ENV" + podman --version + docker --version + docker --host "unix://$runtime_dir/podman/podman.sock" info + + - name: Exercise a portable launch through chat and permission restoration + env: + NVIDIA_INFERENCE_API_KEY: ${{ secrets.NVIDIA_INFERENCE_API_KEY }} + run: >- + npx tsx tools/e2e/live-vitest-invocation.mts run + --test-path test/e2e/live/full-e2e.test.ts + + - name: Upload portable launch E2E artifacts + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: portable-launch-e2e-artifacts + path: e2e-artifacts/portable-launch/ + include-hidden-files: false + if-no-files-found: ignore + retention-days: 14 + + - name: Clean up portable runtime + if: always() + shell: bash + run: | + podman system reset --force || true + pid_file="${XDG_RUNTIME_DIR}/nemoclaw-podman-service.pid" + if [[ -f "$pid_file" ]]; then + kill "$(<"$pid_file")" 2>/dev/null || true + fi diff --git a/src/lib/onboard/experimental/portable-demo-lifecycle.test.ts b/src/lib/onboard/experimental/portable-demo-lifecycle.test.ts index cd97f4ec0e2..a8cd3ae8711 100644 --- a/src/lib/onboard/experimental/portable-demo-lifecycle.test.ts +++ b/src/lib/onboard/experimental/portable-demo-lifecycle.test.ts @@ -12,6 +12,7 @@ import { installPortableDemoSandboxLifecycle, portableDemoLifecycleInternals, recoverPortableDemoSandboxLifecycle, + resolvePortableDemoPrivilegedExecTarget, } from "./portable-demo-lifecycle"; const CONTAINER_ID = "a".repeat(64); @@ -63,16 +64,24 @@ function createPodman( let sandboxId = options.sandboxId ?? SANDBOX_ID; let managedLabel = "true"; let sandboxNameLabel = "alpha"; + let containerId = CONTAINER_ID; + let containerName = "openshell-sandbox-alpha"; + let matches = [CONTAINER_ID]; + let socketPath = "/run/user/1001/podman/podman.sock"; const podman = vi.fn((args: readonly string[]) => { - switch (args[0]) { + const command = args[0] === "--url" ? args.slice(2) : args; + switch (command[0]) { + case "info": + return { status: 0, stdout: `${socketPath}\n` }; case "ps": - return { status: 0, stdout: `${CONTAINER_ID}\n` }; + return { status: 0, stdout: matches.length > 0 ? `${matches.join("\n")}\n` : "" }; case "inspect": return { status: 0, stdout: JSON.stringify([ { - Id: CONTAINER_ID, + Id: containerId, + Name: containerName, Config: { Labels: { "openshell.managed": managedLabel, @@ -104,6 +113,21 @@ function createPodman( setSandboxNameLabel(value: string) { sandboxNameLabel = value; }, + setContainerId(value: string) { + containerId = value; + }, + setContainerName(value: string) { + containerName = value; + }, + setMatches(value: string[]) { + matches = value; + }, + setRunning(value: boolean) { + running = value; + }, + setSocketPath(value: string) { + socketPath = value; + }, }; } @@ -254,6 +278,128 @@ describe("portable demo sandbox lifecycle", () => { expect(fs.statSync(filePath).mode & 0o777).toBe(0o600); }); + it("resolves the receipt-owned container through the rootless Podman socket (#8584)", () => { + const stateDir = temporaryStateDir(); + const runtime = createPodman(); + installReceipt(stateDir, runtime.podman); + runtime.podman.mockClear(); + + expect( + resolvePortableDemoPrivilegedExecTarget("alpha", { + platform: "linux", + stateDir, + podman: runtime.podman, + }), + ).toEqual({ + containerId: CONTAINER_ID, + dockerHost: "unix:///run/user/1001/podman/podman.sock", + }); + expect(runtime.podman.mock.calls).toEqual([ + [["info", "--format", "{{.Host.RemoteSocket.Path}}"]], + [ + [ + "--url", + "unix:///run/user/1001/podman/podman.sock", + "ps", + "-a", + "--no-trunc", + "--filter", + "label=openshell.managed=true", + "--filter", + "label=openshell.sandbox-name=alpha", + "--format", + "{{.ID}}", + ], + ], + [["--url", "unix:///run/user/1001/podman/podman.sock", "inspect", CONTAINER_ID]], + ]); + }); + + it("refuses missing or duplicate portable containers before privileged exec (#8584)", () => { + for (const matches of [[], [CONTAINER_ID, "b".repeat(64)]]) { + const stateDir = temporaryStateDir(); + const runtime = createPodman(); + installReceipt(stateDir, runtime.podman); + runtime.setMatches(matches); + + expect(() => + resolvePortableDemoPrivilegedExecTarget("alpha", { + platform: "linux", + stateDir, + podman: runtime.podman, + }), + ).toThrow(`found ${matches.length}`); + } + }); + + it("refuses renamed or relabeled portable containers before privileged exec (#8584)", () => { + const stateDir = temporaryStateDir(); + const runtime = createPodman(); + installReceipt(stateDir, runtime.podman); + + runtime.setContainerName("renamed-alpha"); + expect(() => + resolvePortableDemoPrivilegedExecTarget("alpha", { + platform: "linux", + stateDir, + podman: runtime.podman, + }), + ).toThrow("OpenShell identity does not match"); + + runtime.setContainerName("openshell-sandbox-alpha"); + runtime.setSandboxNameLabel("beta"); + expect(() => + resolvePortableDemoPrivilegedExecTarget("alpha", { + platform: "linux", + stateDir, + podman: runtime.podman, + }), + ).toThrow("OpenShell identity does not match"); + }); + + it("refuses a replacement or stopped portable container before privileged exec (#8584)", () => { + const stateDir = temporaryStateDir(); + const runtime = createPodman(); + installReceipt(stateDir, runtime.podman); + + const replacementId = "b".repeat(64); + runtime.setMatches([replacementId]); + runtime.setContainerId(replacementId); + expect(() => + resolvePortableDemoPrivilegedExecTarget("alpha", { + platform: "linux", + stateDir, + podman: runtime.podman, + }), + ).toThrow("recorded container identity changed"); + + runtime.setMatches([CONTAINER_ID]); + runtime.setContainerId(CONTAINER_ID); + runtime.setRunning(false); + expect(() => + resolvePortableDemoPrivilegedExecTarget("alpha", { + platform: "linux", + stateDir, + podman: runtime.podman, + }), + ).toThrow("is not running"); + }); + + it("refuses a non-local portable Podman socket before privileged exec (#8584)", () => { + const stateDir = temporaryStateDir(); + const runtime = createPodman(); + installReceipt(stateDir, runtime.podman); + runtime.setSocketPath("tcp://example.test:1234"); + + expect(() => + resolvePortableDemoPrivilegedExecTarget("alpha", { + platform: "linux", + stateDir, + podman: runtime.podman, + }), + ).toThrow("socket path is invalid"); + }); + it("does not persist proxy credentials from the create-time environment (#8441)", () => { const stateDir = temporaryStateDir(); const { podman } = createPodman(); diff --git a/src/lib/onboard/experimental/portable-demo-lifecycle.ts b/src/lib/onboard/experimental/portable-demo-lifecycle.ts index d9b565fce40..d42a40ac2b2 100644 --- a/src/lib/onboard/experimental/portable-demo-lifecycle.ts +++ b/src/lib/onboard/experimental/portable-demo-lifecycle.ts @@ -32,6 +32,7 @@ const SANDBOX_ID_PATTERN = /^[A-Za-z0-9._:-]{1,256}$/u; const PODMAN_MANAGED_LABEL = "openshell.managed"; const PODMAN_SANDBOX_ID_LABEL = "openshell.sandbox-id"; const PODMAN_SANDBOX_NAME_LABEL = "openshell.sandbox-name"; +const PODMAN_SANDBOX_CONTAINER_PREFIX = "openshell-sandbox-"; const OPENSHELL_RUNTIME_CA_CERT = "/etc/openshell-tls/openshell-ca.pem"; const OPENSHELL_RUNTIME_CA_BUNDLE = "/etc/openshell-tls/ca-bundle.pem"; const CURRENT_RECEIPT_SCHEMA_VERSION = 2; @@ -60,6 +61,11 @@ interface PodmanContainerInspection { running: boolean; } +export interface PortableDemoPrivilegedExecTarget { + readonly containerId: string; + readonly dockerHost: string; +} + export interface PortableDemoLifecycleDeps { platform?: NodeJS.Platform; stateDir?: string; @@ -308,6 +314,7 @@ function inspectPodmanContainer( const sandboxId = labels?.[PODMAN_SANDBOX_ID_LABEL]; if ( inspection.Id !== containerId || + inspection.Name !== `${PODMAN_SANDBOX_CONTAINER_PREFIX}${sandboxName}` || labels?.[PODMAN_MANAGED_LABEL] !== "true" || labels?.[PODMAN_SANDBOX_NAME_LABEL] !== sandboxName || typeof sandboxId !== "string" || @@ -357,6 +364,52 @@ function discoverPodmanContainer( return inspectPodmanContainer(matches[0]!, sandboxName, podman); } +function podmanDockerHost(podman: NonNullable): string { + const result = podman(["info", "--format", "{{.Host.RemoteSocket.Path}}"]); + requireCommand(result, "Resolving the portable Podman socket"); + const socket = String(result.stdout ?? "").trim(); + if (/[\u0000-\u001f\u007f-\u009f]/u.test(socket)) { + throw new Error("The portable Podman socket path is invalid"); + } + const socketPath = socket.startsWith("unix://") ? socket.slice("unix://".length) : socket; + if (!path.posix.isAbsolute(socketPath)) { + throw new Error("The portable Podman socket path is invalid"); + } + return `unix://${socketPath}`; +} + +/** Resolve the receipt-owned portable container for a host-side privileged exec. */ +export function resolvePortableDemoPrivilegedExecTarget( + sandboxName: string, + deps: PortableDemoLifecycleDeps = {}, +): PortableDemoPrivilegedExecTarget | null { + const commandEnv = deps.env ?? process.env; + const receipt = loadReceipt(sandboxName, deps.stateDir ?? defaultStateDir(commandEnv)); + if (!receipt) return null; + if ((deps.platform ?? process.platform) !== "linux") { + throw new Error("Portable demo lifecycle receipt is only valid on Linux"); + } + + const podman = deps.podman ?? ((args) => defaultPodman(args, commandEnv)); + const dockerHost = podmanDockerHost(podman); + const providerPodman = (args: readonly string[]) => podman(["--url", dockerHost, ...args]); + const inspection = discoverPodmanContainer(sandboxName, providerPodman); + if (inspection.containerId !== receipt.containerId) { + throw new Error( + `Portable demo lifecycle refused container '${inspection.containerId}' because the recorded container identity changed`, + ); + } + if (inspection.sandboxId !== receipt.sandboxId) { + throw new Error( + `Portable demo lifecycle refused container '${receipt.containerId}' because its OpenShell sandbox ID changed`, + ); + } + if (!inspection.running) { + throw new Error(`Portable sandbox '${sandboxName}' is not running`); + } + return { containerId: inspection.containerId, dockerHost }; +} + function startupArgv(receipt: PortableDemoLifecycleReceipt): string[] { const port = String(receipt.dashboardPort); // A raw Podman restart can preserve a merged CA bundle from the previous diff --git a/src/lib/sandbox/privileged-exec.test.ts b/src/lib/sandbox/privileged-exec.test.ts index 2598fbd6b48..f36de74435f 100644 --- a/src/lib/sandbox/privileged-exec.test.ts +++ b/src/lib/sandbox/privileged-exec.test.ts @@ -9,6 +9,7 @@ const require = createRequire(import.meta.url); const requireCache: Record = require.cache as any; const helperPath = require.resolve("./privileged-exec"); const dockerRunPath = require.resolve("../adapters/docker/run"); +const portableLifecyclePath = require.resolve("../onboard/experimental/portable-demo-lifecycle"); const registryPath = require.resolve("../state/registry"); const { containerNameMatchesSandbox, selectDirectSandboxContainer } = require(helperPath); @@ -20,11 +21,15 @@ function withPrivilegedExecMocks( sandboxes?: Array<{ name?: string | null }>; defaultSandbox?: string | null; }; + resolvePortableDemoPrivilegedExecTarget?: ( + sandboxName: string, + ) => { containerId: string; dockerHost: string } | null; }, run: (helper: typeof import("./privileged-exec")) => T, ): T { const priorHelper = require.cache[helperPath]; const priorDockerRun = require.cache[dockerRunPath]; + const priorPortableLifecycle = require.cache[portableLifecyclePath]; const priorRegistry = require.cache[registryPath]; delete require.cache[helperPath]; @@ -34,6 +39,15 @@ function withPrivilegedExecMocks( loaded: true, exports: { dockerCapture: deps.dockerCapture }, } as any; + requireCache[portableLifecyclePath] = { + id: portableLifecyclePath, + filename: portableLifecyclePath, + loaded: true, + exports: { + resolvePortableDemoPrivilegedExecTarget: + deps.resolvePortableDemoPrivilegedExecTarget ?? (() => null), + }, + } as any; requireCache[registryPath] = { id: registryPath, filename: registryPath, @@ -53,6 +67,9 @@ function withPrivilegedExecMocks( if (priorDockerRun) requireCache[dockerRunPath] = priorDockerRun; else delete requireCache[dockerRunPath]; + if (priorPortableLifecycle) requireCache[portableLifecyclePath] = priorPortableLifecycle; + else delete requireCache[portableLifecyclePath]; + if (priorRegistry) requireCache[registryPath] = priorRegistry; else delete requireCache[registryPath]; } @@ -126,6 +143,70 @@ describe("privileged sandbox exec routing", () => { ); }); + it("uses the receipt-owned Podman socket when the default Docker daemon has no container (#8584)", () => { + let dockerPsCalls = 0; + withPrivilegedExecMocks( + { + getSandbox: () => ({ name: "alpha", openshellDriver: "docker" }), + listSandboxes: () => ({ sandboxes: [{ name: "alpha" }], defaultSandbox: "alpha" }), + dockerCapture: () => { + dockerPsCalls += 1; + return ""; + }, + resolvePortableDemoPrivilegedExecTarget: () => ({ + containerId: "a".repeat(64), + dockerHost: "unix:///run/user/1001/podman/podman.sock", + }), + }, + ({ privilegedSandboxExecArgv }) => { + expect(privilegedSandboxExecArgv("alpha", ["id"], false, true)).toEqual([ + "--host", + "unix:///run/user/1001/podman/podman.sock", + "exec", + "--env", + "BASH_ENV=", + "--env", + "ENV=", + "--env", + "GCONV_PATH=", + "--env", + "GLIBC_TUNABLES=", + "--env", + "LD_AUDIT=", + "--env", + "LD_LIBRARY_PATH=", + "--env", + "LD_PRELOAD=", + "--env", + "LOCPATH=", + "--env", + "NODE_OPTIONS=", + "--env", + "PERL5OPT=", + "--env", + "PYTHONHOME=", + "--env", + "PYTHONINSPECT=", + "--env", + "PYTHONNOUSERSITE=1", + "--env", + "PYTHONPATH=", + "--env", + "PYTHONSTARTUP=", + "--env", + "PYTHONUSERBASE=", + "--env", + "RUBYOPT=", + "--user", + "root", + "a".repeat(64), + "id", + ]); + }, + ); + expect(dockerPsCalls).toBe(0); + }); + it("bounds direct sandbox container discovery", () => { const discoveryCalls: Array<{ args: readonly string[]; diff --git a/src/lib/sandbox/privileged-exec.ts b/src/lib/sandbox/privileged-exec.ts index c45f59a60a4..9de5591fb94 100644 --- a/src/lib/sandbox/privileged-exec.ts +++ b/src/lib/sandbox/privileged-exec.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { dockerCapture } from "../adapters/docker/run"; +import { resolvePortableDemoPrivilegedExecTarget } from "../onboard/experimental/portable-demo-lifecycle"; import * as registry from "../state/registry"; const OPENSHELL_MANAGED_BY_LABEL = "openshell.ai/managed-by"; @@ -210,6 +211,29 @@ function privilegedSandboxExecArgv( ): string[] { const entry = readSandboxEntry(sandboxName); if (!entry) throw missingRegistryEntryError(sandboxName); + const portableTarget = resolvePortableDemoPrivilegedExecTarget(sandboxName); + if (portableTarget) { + if (expectedContainerId !== undefined && portableTarget.containerId !== expectedContainerId) { + throw new Error( + `OpenShell container identity changed for sandbox '${sandboxName}'; ` + + "refusing privileged execution against a different container.", + ); + } + const sanitizedEnvArgs = sanitizeEnvironment + ? SANITIZED_PRIVILEGED_ENV.flatMap((value) => ["--env", value]) + : []; + return [ + "--host", + portableTarget.dockerHost, + "exec", + ...(stdin ? ["-i"] : []), + ...sanitizedEnvArgs, + "--user", + "root", + portableTarget.containerId, + ...cmd, + ]; + } const driver = normalizeDriver(entry?.openshellDriver); if (driver !== null && driver !== "docker" && driver !== "vm") { throw unsupportedDirectDriverError(sandboxName, driver); diff --git a/test/e2e/fixtures/availability-env.ts b/test/e2e/fixtures/availability-env.ts index a0918446d56..e2ee67dc498 100644 --- a/test/e2e/fixtures/availability-env.ts +++ b/test/e2e/fixtures/availability-env.ts @@ -13,6 +13,7 @@ const AVAILABILITY_PROBE_EXTRA_ENV_KEYS = [ "XDG_CONFIG_HOME", "XDG_RUNTIME_DIR", "NEMOCLAW_OLLAMA_PULL_TIMEOUT", + "NEMOCLAW_EXPERIMENTAL_PROFILE", "NEMOCLAW_TRACE_DIR", ]; diff --git a/test/e2e/live/full-e2e.test.ts b/test/e2e/live/full-e2e.test.ts index e9131f6131e..ee27c543ca4 100644 --- a/test/e2e/live/full-e2e.test.ts +++ b/test/e2e/live/full-e2e.test.ts @@ -37,17 +37,18 @@ import { buildOpenClawFirstTurnLatencyEvidence, extractOpenClawAgentPayloadText, } from "./agent-turn-latency-helpers.ts"; -import { runLaunchAgentTurn } from "./launch-agent-turn.ts"; -import { bindApprovedPrBaseForBaseImageComparison } from "./pr-base-comparison.ts"; import { FULL_E2E_INFERENCE_CAPTURE_LIMIT_BYTES, fullE2eInferenceProbeEvidence, runFullE2eInferenceProbe, } from "./full-e2e-inference-probe.ts"; +import { runLaunchAgentTurn } from "./launch-agent-turn.ts"; +import { bindApprovedPrBaseForBaseImageComparison } from "./pr-base-comparison.ts"; const SANDBOX_NAME = process.env.NEMOCLAW_SANDBOX_NAME ?? "e2e-full"; const SETUP_MODE = process.env.NEMOCLAW_E2E_SETUP_MODE ?? "source-install"; const USE_PREINSTALLED_LAUNCHABLE = SETUP_MODE === "preinstalled-launchable"; +const PORTABLE_PROFILE = process.env.NEMOCLAW_EXPERIMENTAL_PROFILE === "portable"; const LIVE_TIMEOUT_MS = 50 * 60_000; const FIRST_TURN_TIMEOUT_MS = 240_000; const MAX_SILENCE_SECS = 60; @@ -143,11 +144,27 @@ async function runOpenClawLaunchTurnAfterRecovery(input: { artifactName: "phase-4-openclaw-launch-turn", cliCommand: USE_PREINSTALLED_LAUNCHABLE ? "nemoclaw" : process.execPath, ...(!USE_PREINSTALLED_LAUNCHABLE ? { cliEntrypoint: CLI_ENTRYPOINT } : {}), - env: env(), + env: env(PORTABLE_PROFILE ? { DOCKER_HOST: "" } : {}), + exitCommand: "/exit", host: input.host, redactionValues: input.redactionValues, sandboxName: SANDBOX_NAME, }); + + const permissions = await input.sandbox.execShell( + SANDBOX_NAME, + trustedSandboxShellScript( + "test \"$(stat -c '%a %U:%G' /sandbox/.openclaw)\" = '2770 sandbox:sandbox' && " + + "test \"$(stat -c '%a %U:%G' /sandbox/.openclaw/openclaw.json)\" = '660 sandbox:sandbox'", + ), + { + artifactName: "phase-4-openclaw-launch-permissions", + env: env(), + redactionValues: input.redactionValues, + timeoutMs: 30_000, + }, + ); + expect(permissions.exitCode, resultText(permissions)).toBe(0); } async function cleanup(host: HostCliClient, sandbox: SandboxClient): Promise { @@ -357,7 +374,7 @@ test("full e2e: install, onboard, inference, cli operations, and cleanup", { const coldOnboardBudget = USE_PREINSTALLED_LAUNCHABLE ? null : readFullE2eColdPathBudget(); const redactionValues = [hosted.apiKey]; await artifacts.target.declare({ - id: "full-e2e", + id: process.env.E2E_TARGET_ID ?? "full-e2e", sandboxName: SANDBOX_NAME, endpointUrl: hosted.endpointUrl, model: hosted.model, @@ -370,7 +387,9 @@ test("full e2e: install, onboard, inference, cli operations, and cleanup", { "sandbox appears in list/status and has policy/inference configuration", "direct hosted inference and sandbox inference.local both respond", ...(process.platform === "linux" - ? ["a recovered OpenClaw sandbox completes a launch turn through inference.local"] + ? [ + "a recovered OpenClaw sandbox completes a /exit launch turn through inference.local and restores the mutable config permission contract", + ] : []), "nemoclaw logs produces output and cleanup removes registry state", ...(securityPostureEnabled() diff --git a/test/e2e/live/launch-agent-turn.ts b/test/e2e/live/launch-agent-turn.ts index 3638d8a6584..8110567ed17 100644 --- a/test/e2e/live/launch-agent-turn.ts +++ b/test/e2e/live/launch-agent-turn.ts @@ -65,19 +65,33 @@ for _ in {1..180}; do sleep 1 done -# The TUI may close the FIFO after the first interrupt. Ignore SIGPIPE while -# sending interrupts so the driver can record a reply it already observed. -trap '' PIPE -printf '\003' >&3 2>/dev/null || true -sleep 1 -printf '\003' >&3 2>/dev/null || true -trap - PIPE +if [[ -n "$NEMOCLAW_LAUNCH_EXIT_COMMAND" ]]; then + printf '%s\r' "$NEMOCLAW_LAUNCH_EXIT_COMMAND" >&3 +else + # Some TUIs have no exit command. They may close the FIFO after the first + # interrupt, so ignore SIGPIPE while sending the second one. + trap '' PIPE + printf '\003' >&3 2>/dev/null || true + sleep 1 + printf '\003' >&3 2>/dev/null || true + trap - PIPE +fi exec 3>&- if [[ "$reply_seen" != 1 ]]; then echo "launch did not produce the expected agent reply" >&2 exit 1 fi +if wait "$session_pid"; then + launch_status=0 +else + launch_status=$? +fi +session_pid="" +if [[ "$launch_status" != 0 ]]; then + echo "launch exited with status $launch_status" >&2 + exit "$launch_status" +fi printf '%s\n' "NEMOCLAW_LAUNCH_TURN_OK" `; @@ -86,6 +100,7 @@ export interface LaunchAgentTurnOptions { cliCommand: string; cliEntrypoint?: string; env: NodeJS.ProcessEnv; + exitCommand?: string; host: HostCliClient; redactionValues: string[]; sandboxName: string; @@ -103,6 +118,7 @@ export async function runLaunchAgentTurn( ...options.env, NEMOCLAW_LAUNCH_COMMAND: options.cliCommand, NEMOCLAW_LAUNCH_ENTRYPOINT: options.cliEntrypoint ?? "", + NEMOCLAW_LAUNCH_EXIT_COMMAND: options.exitCommand ?? "", NEMOCLAW_LAUNCH_EXPECTED_REPLY: EXPECTED_REPLY, NEMOCLAW_LAUNCH_PROMPT: PROMPT, NEMOCLAW_LAUNCH_SANDBOX: options.sandboxName, diff --git a/test/e2e/mock-parity.json b/test/e2e/mock-parity.json index 1dc57345361..b86375a3f8e 100644 --- a/test/e2e/mock-parity.json +++ b/test/e2e/mock-parity.json @@ -94,6 +94,7 @@ "live": "test/e2e/live/full-e2e.test.ts", "fast": [ "test/e2e/support/full-e2e-inference-probe.test.ts", + "test/e2e/support/launch-agent-turn.test.ts", "test/e2e/support/onboard-performance.test.ts", "test/e2e/support/e2e-cleanup-resources.test.ts", "test/e2e/support/e2e-clients.test.ts" diff --git a/test/e2e/support/e2e-workflow.test.ts b/test/e2e/support/e2e-workflow.test.ts index a8866977c17..6768c8aff4c 100644 --- a/test/e2e/support/e2e-workflow.test.ts +++ b/test/e2e/support/e2e-workflow.test.ts @@ -714,16 +714,18 @@ describe("e2e workflow boundary", () => { jobs: Record; if?: string }>; }; const workflowJobs = new Set(Object.keys(workflow.jobs)); - const portableWorkflow = YAML.parse( - fs.readFileSync( - path.join(process.cwd(), ".github", "workflows", "portable-profile-e2e.yaml"), - "utf8", - ), - ) as { + const portableWorkflowSource = fs.readFileSync( + path.join(process.cwd(), ".github", "workflows", "portable-profile-e2e.yaml"), + "utf8", + ); + const portableWorkflow = YAML.parse(portableWorkflowSource) as { on?: { pull_request?: { paths?: string[] }; push?: { paths?: string[] } }; }; const portableProofInputs = [ "scripts/install-openshell.sh", + "src/lib/sandbox/**", + "test/e2e/live/full-e2e.test.ts", + "test/e2e/live/launch-agent-turn.ts", "test/e2e/live/portable-profile-gateway-proof.ts", "test/e2e/live/portable-profile-rootless-linux.test.ts", "tools/e2e/check-semantic-phases.mts", @@ -732,6 +734,11 @@ describe("e2e workflow boundary", () => { expect(validateFreeStandingWorkflowInventory()).toEqual([]); expect(portableWorkflow.on?.push?.paths).toEqual(expect.arrayContaining(portableProofInputs)); expect(portableWorkflow.on?.push?.paths).toEqual(expect.arrayContaining(portableProofInputs)); + expect(portableWorkflowSource).toContain("github.ref == 'refs/heads/main'"); + expect(portableWorkflowSource).toContain("NEMOCLAW_EXPERIMENTAL_PROFILE: portable"); + expect(portableWorkflowSource).toContain( + "NVIDIA_INFERENCE_API_KEY: ${{ secrets.NVIDIA_INFERENCE_API_KEY }}", + ); expect(inventory.allowedJobs).not.toHaveLength(0); expect(inventory.targetToJob.size).toBeGreaterThan(0); expect(inventory.workflowJobs.every((job) => workflowJobs.has(job))).toBe(true); diff --git a/test/e2e/support/launch-agent-turn.test.ts b/test/e2e/support/launch-agent-turn.test.ts index 959b4f8d083..ef93c4f3218 100644 --- a/test/e2e/support/launch-agent-turn.test.ts +++ b/test/e2e/support/launch-agent-turn.test.ts @@ -10,7 +10,7 @@ import { expect, it } from "vitest"; import { LAUNCH_TURN_SCRIPT } from "../live/launch-agent-turn.ts"; it.runIf(process.platform !== "win32")( - "records a successful Hermes reply when the TUI closes after the first interrupt (#6006)", + "records a successful reply and exit status 0 after the TUI exit command (#8584)", () => { const fixtureRoot = mkdtempSync(join(tmpdir(), "nemoclaw-launch-turn-")); const scriptStub = join(fixtureRoot, "script"); @@ -29,7 +29,8 @@ done : >"$capture" IFS= read -r -d $'\r' _ printf 'PONG\n' | tee "$capture" -IFS= read -r -n 1 _ +IFS= read -r -d $'\r' exit_command +[[ "$exit_command" == "/exit" ]] `, ); writeFileSync(sleepStub, "#!/bin/sh\n/bin/sleep 0.5\n"); @@ -44,6 +45,7 @@ IFS= read -r -n 1 _ ...process.env, NEMOCLAW_LAUNCH_COMMAND: "ignored", NEMOCLAW_LAUNCH_ENTRYPOINT: "", + NEMOCLAW_LAUNCH_EXIT_COMMAND: "/exit", NEMOCLAW_LAUNCH_EXPECTED_REPLY: "PONG", NEMOCLAW_LAUNCH_PROMPT: "prompt", NEMOCLAW_LAUNCH_SANDBOX: "sandbox", From a1b9e6035dfb0f26eacf18f8d16824fb7ca6d198 Mon Sep 17 00:00:00 2001 From: Senthil Ravichandran Date: Fri, 7 Aug 2026 13:41:13 -0700 Subject: [PATCH 02/15] test(cli): satisfy conditional guardrail Signed-off-by: Senthil Ravichandran --- src/lib/sandbox/privileged-exec.test.ts | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/src/lib/sandbox/privileged-exec.test.ts b/src/lib/sandbox/privileged-exec.test.ts index f36de74435f..9aeb6ccbee1 100644 --- a/src/lib/sandbox/privileged-exec.test.ts +++ b/src/lib/sandbox/privileged-exec.test.ts @@ -13,6 +13,11 @@ const portableLifecyclePath = require.resolve("../onboard/experimental/portable- const registryPath = require.resolve("../state/registry"); const { containerNameMatchesSandbox, selectDirectSandboxContainer } = require(helperPath); +function restoreRequireCacheEntry(modulePath: string, priorEntry: unknown): void { + if (priorEntry) requireCache[modulePath] = priorEntry; + else delete requireCache[modulePath]; +} + function withPrivilegedExecMocks( deps: { dockerCapture: (args: readonly string[], options?: { timeout?: number }) => string; @@ -61,17 +66,10 @@ function withPrivilegedExecMocks( try { return run(require(helperPath)); } finally { - if (priorHelper) requireCache[helperPath] = priorHelper; - else delete requireCache[helperPath]; - - if (priorDockerRun) requireCache[dockerRunPath] = priorDockerRun; - else delete requireCache[dockerRunPath]; - - if (priorPortableLifecycle) requireCache[portableLifecyclePath] = priorPortableLifecycle; - else delete requireCache[portableLifecyclePath]; - - if (priorRegistry) requireCache[registryPath] = priorRegistry; - else delete requireCache[registryPath]; + restoreRequireCacheEntry(helperPath, priorHelper); + restoreRequireCacheEntry(dockerRunPath, priorDockerRun); + restoreRequireCacheEntry(portableLifecyclePath, priorPortableLifecycle); + restoreRequireCacheEntry(registryPath, priorRegistry); } } From cea4b237005a46e01ec6be4938d0fbeb1d0c6a3f Mon Sep 17 00:00:00 2001 From: Senthil Ravichandran Date: Fri, 7 Aug 2026 13:55:42 -0700 Subject: [PATCH 03/15] fix(cli): bind portable cleanup to Podman socket authority Signed-off-by: Senthil Ravichandran --- src/lib/adapters/podman/index.ts | 2 +- .../portable-demo-lifecycle.test.ts | 189 ++++++++++++------ .../experimental/portable-demo-lifecycle.ts | 66 +++++- src/lib/sandbox/privileged-exec.test.ts | 7 +- src/lib/sandbox/privileged-exec.ts | 1 + 5 files changed, 189 insertions(+), 76 deletions(-) diff --git a/src/lib/adapters/podman/index.ts b/src/lib/adapters/podman/index.ts index f5122e6088c..044e238e533 100644 --- a/src/lib/adapters/podman/index.ts +++ b/src/lib/adapters/podman/index.ts @@ -65,5 +65,5 @@ export function createPodmanContainerEngine( }); } -export type { PodmanSocketAuthority } from "./socket-authority"; +export type { PodmanSocketAuthority, PodmanSocketAuthorityDeps } from "./socket-authority"; export { assertPodmanSocketAuthority, capturePodmanSocketAuthority } from "./socket-authority"; diff --git a/src/lib/onboard/experimental/portable-demo-lifecycle.test.ts b/src/lib/onboard/experimental/portable-demo-lifecycle.test.ts index a8cd3ae8711..e5503707d42 100644 --- a/src/lib/onboard/experimental/portable-demo-lifecycle.test.ts +++ b/src/lib/onboard/experimental/portable-demo-lifecycle.test.ts @@ -6,10 +6,12 @@ import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; +import type { PodmanSocketAuthorityDeps } from "../../adapters/podman"; import type { SandboxEntry } from "../../state/registry"; import { recordUserLocalOllamaOwnership } from "./ollama-user-local-runtime"; import { installPortableDemoSandboxLifecycle, + type PortableDemoLifecycleDeps, portableDemoLifecycleInternals, recoverPortableDemoSandboxLifecycle, resolvePortableDemoPrivilegedExecTarget, @@ -17,6 +19,7 @@ import { const CONTAINER_ID = "a".repeat(64); const SANDBOX_ID = "sandbox-id-alpha"; +const SOCKET_PATH = "/run/user/1001/podman/podman.sock"; const STARTUP_ARGV = [ "env", "CHAT_UI_URL=http://127.0.0.1:18789", @@ -68,7 +71,7 @@ function createPodman( let containerName = "openshell-sandbox-alpha"; let matches = [CONTAINER_ID]; let socketPath = "/run/user/1001/podman/podman.sock"; - const podman = vi.fn((args: readonly string[]) => { + const podman = vi.fn((args: readonly string[], _env?: NodeJS.ProcessEnv) => { const command = args[0] === "--url" ? args.slice(2) : args; switch (command[0]) { case "info": @@ -131,6 +134,56 @@ function createPodman( }; } +function socketAuthorityDeps( + options: { + directory?: boolean; + directoryMode?: bigint; + socketInode?: () => bigint; + socketMode?: bigint; + socketUid?: bigint; + } = {}, +): PodmanSocketAuthorityDeps { + const directoryInodes = new Map(); + return { + uid: 1001, + lstat: (filePath) => { + const socket = filePath === SOCKET_PATH; + const directoryInode = directoryInodes.get(filePath) ?? BigInt(7000 + directoryInodes.size); + directoryInodes.set(filePath, directoryInode); + return { + dev: 8n, + ino: socket ? (options.socketInode?.() ?? 9001n) : directoryInode, + mode: socket + ? (options.socketMode ?? 0o600n) + : filePath === path.dirname(SOCKET_PATH) + ? (options.directoryMode ?? 0o700n) + : 0o755n, + uid: socket + ? (options.socketUid ?? 1001n) + : filePath.startsWith("/run/user/1001") + ? 1001n + : 0n, + isDirectory: () => !socket && (options.directory ?? true), + isSocket: () => socket, + }; + }, + }; +} + +function resolveTarget( + stateDir: string, + runtime: ReturnType, + overrides: Partial = {}, +) { + return resolvePortableDemoPrivilegedExecTarget("alpha", { + platform: "linux", + stateDir, + podman: runtime.podman, + podmanSocketAuthorityDeps: socketAuthorityDeps(), + ...overrides, + }); +} + function installReceipt(stateDir: string, podman: ReturnType["podman"]): void { installPortableDemoSandboxLifecycle( "alpha", @@ -284,34 +337,31 @@ describe("portable demo sandbox lifecycle", () => { installReceipt(stateDir, runtime.podman); runtime.podman.mockClear(); - expect( - resolvePortableDemoPrivilegedExecTarget("alpha", { - platform: "linux", - stateDir, - podman: runtime.podman, - }), - ).toEqual({ + expect(resolveTarget(stateDir, runtime)).toMatchObject({ containerId: CONTAINER_ID, dockerHost: "unix:///run/user/1001/podman/podman.sock", }); - expect(runtime.podman.mock.calls).toEqual([ - [["info", "--format", "{{.Host.RemoteSocket.Path}}"]], + expect(runtime.podman.mock.calls.map(([args]) => args)).toEqual([ + ["info", "--format", "{{.Host.RemoteSocket.Path}}"], [ - [ - "--url", - "unix:///run/user/1001/podman/podman.sock", - "ps", - "-a", - "--no-trunc", - "--filter", - "label=openshell.managed=true", - "--filter", - "label=openshell.sandbox-name=alpha", - "--format", - "{{.ID}}", - ], + "--url", + "unix:///run/user/1001/podman/podman.sock", + "ps", + "-a", + "--no-trunc", + "--filter", + "label=openshell.managed=true", + "--filter", + "label=openshell.sandbox-name=alpha", + "--format", + "{{.ID}}", ], - [["--url", "unix:///run/user/1001/podman/podman.sock", "inspect", CONTAINER_ID]], + ["--url", "unix:///run/user/1001/podman/podman.sock", "inspect", CONTAINER_ID], + ]); + expect(runtime.podman.mock.calls.map(([, env]) => env)).toEqual([ + expect.not.objectContaining({ CONTAINER_HOST: expect.anything() }), + expect.not.objectContaining({ CONTAINER_HOST: expect.anything() }), + expect.not.objectContaining({ CONTAINER_HOST: expect.anything() }), ]); }); @@ -322,13 +372,7 @@ describe("portable demo sandbox lifecycle", () => { installReceipt(stateDir, runtime.podman); runtime.setMatches(matches); - expect(() => - resolvePortableDemoPrivilegedExecTarget("alpha", { - platform: "linux", - stateDir, - podman: runtime.podman, - }), - ).toThrow(`found ${matches.length}`); + expect(() => resolveTarget(stateDir, runtime)).toThrow(`found ${matches.length}`); } }); @@ -338,23 +382,11 @@ describe("portable demo sandbox lifecycle", () => { installReceipt(stateDir, runtime.podman); runtime.setContainerName("renamed-alpha"); - expect(() => - resolvePortableDemoPrivilegedExecTarget("alpha", { - platform: "linux", - stateDir, - podman: runtime.podman, - }), - ).toThrow("OpenShell identity does not match"); + expect(() => resolveTarget(stateDir, runtime)).toThrow("OpenShell identity does not match"); runtime.setContainerName("openshell-sandbox-alpha"); runtime.setSandboxNameLabel("beta"); - expect(() => - resolvePortableDemoPrivilegedExecTarget("alpha", { - platform: "linux", - stateDir, - podman: runtime.podman, - }), - ).toThrow("OpenShell identity does not match"); + expect(() => resolveTarget(stateDir, runtime)).toThrow("OpenShell identity does not match"); }); it("refuses a replacement or stopped portable container before privileged exec (#8584)", () => { @@ -365,24 +397,12 @@ describe("portable demo sandbox lifecycle", () => { const replacementId = "b".repeat(64); runtime.setMatches([replacementId]); runtime.setContainerId(replacementId); - expect(() => - resolvePortableDemoPrivilegedExecTarget("alpha", { - platform: "linux", - stateDir, - podman: runtime.podman, - }), - ).toThrow("recorded container identity changed"); + expect(() => resolveTarget(stateDir, runtime)).toThrow("recorded container identity changed"); runtime.setMatches([CONTAINER_ID]); runtime.setContainerId(CONTAINER_ID); runtime.setRunning(false); - expect(() => - resolvePortableDemoPrivilegedExecTarget("alpha", { - platform: "linux", - stateDir, - podman: runtime.podman, - }), - ).toThrow("is not running"); + expect(() => resolveTarget(stateDir, runtime)).toThrow("is not running"); }); it("refuses a non-local portable Podman socket before privileged exec (#8584)", () => { @@ -391,13 +411,52 @@ describe("portable demo sandbox lifecycle", () => { installReceipt(stateDir, runtime.podman); runtime.setSocketPath("tcp://example.test:1234"); + expect(() => resolveTarget(stateDir, runtime)).toThrow("socket path is invalid"); + }); + + it.each([ + ["foreign owner", socketAuthorityDeps({ socketUid: 2000n }), "owned by uid 2000"], + ["writable socket", socketAuthorityDeps({ socketMode: 0o660n }), "writable by another"], + ["writable parent", socketAuthorityDeps({ directoryMode: 0o770n }), "writable by another"], + ["symlinked parent", socketAuthorityDeps({ directory: false }), "not a real directory"], + ])("refuses a %s for portable privileged exec (#8584)", (_case, authority, message) => { + const stateDir = temporaryStateDir(); + const runtime = createPodman(); + installReceipt(stateDir, runtime.podman); + expect(() => - resolvePortableDemoPrivilegedExecTarget("alpha", { - platform: "linux", - stateDir, - podman: runtime.podman, - }), - ).toThrow("socket path is invalid"); + resolveTarget(stateDir, runtime, { podmanSocketAuthorityDeps: authority }), + ).toThrow(message); + }); + + it("ignores ambient Podman remote selection for portable privileged exec (#8584)", () => { + const stateDir = temporaryStateDir(); + const runtime = createPodman(); + installReceipt(stateDir, runtime.podman); + runtime.podman.mockClear(); + + resolveTarget(stateDir, runtime, { + env: { + CONTAINER_CONNECTION: "attacker", + CONTAINER_HOST: "tcp://example.test:1234", + CONTAINER_SSHKEY: "/tmp/attacker-key", + }, + }); + + expect(runtime.podman.mock.calls.map(([, env]) => env)).toEqual([{}, {}, {}]); + }); + + it("refuses socket replacement after portable workload inspection (#8584)", () => { + const stateDir = temporaryStateDir(); + const runtime = createPodman(); + installReceipt(stateDir, runtime.podman); + let inode = 9001n; + const target = resolveTarget(stateDir, runtime, { + podmanSocketAuthorityDeps: socketAuthorityDeps({ socketInode: () => inode }), + }); + inode = 9002n; + + expect(() => target?.assertRuntimeAuthority()).toThrow("changed after it was qualified"); }); it("does not persist proxy credentials from the create-time environment (#8441)", () => { diff --git a/src/lib/onboard/experimental/portable-demo-lifecycle.ts b/src/lib/onboard/experimental/portable-demo-lifecycle.ts index d42a40ac2b2..5fbb17ad660 100644 --- a/src/lib/onboard/experimental/portable-demo-lifecycle.ts +++ b/src/lib/onboard/experimental/portable-demo-lifecycle.ts @@ -7,7 +7,14 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import type { ContainerEngineCommandCapture } from "../../adapters/container-engine"; import { openRegularFileNoFollow } from "../../adapters/fs/regular-file"; +import { + assertPodmanSocketAuthority, + capturePodmanSocketAuthority, + createPodmanContainerEngine, + type PodmanSocketAuthorityDeps, +} from "../../adapters/podman"; import { ensureConfigDir } from "../../state/config-io"; import { isPortableExperimentalProfile } from "../docker-driver-platform"; import { @@ -62,6 +69,7 @@ interface PodmanContainerInspection { } export interface PortableDemoPrivilegedExecTarget { + readonly assertRuntimeAuthority: () => void; readonly containerId: string; readonly dockerHost: string; } @@ -71,7 +79,8 @@ export interface PortableDemoLifecycleDeps { stateDir?: string; env?: NodeJS.ProcessEnv; openshellBinary?: string; - podman?: (args: readonly string[]) => CommandResult; + podman?: (args: readonly string[], env?: NodeJS.ProcessEnv) => CommandResult; + podmanSocketAuthorityDeps?: PodmanSocketAuthorityDeps; captureOpenshell?: (args: readonly string[], timeoutMs: number) => CommandResult; launchOpenshell?: (args: readonly string[]) => void; captureHost?: (command: string, args: readonly string[], timeoutMs: number) => CommandResult; @@ -364,8 +373,19 @@ function discoverPodmanContainer( return inspectPodmanContainer(matches[0]!, sandboxName, podman); } -function podmanDockerHost(podman: NonNullable): string { - const result = podman(["info", "--format", "{{.Host.RemoteSocket.Path}}"]); +function localPodmanEnvironment(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + const local = { ...env }; + delete local.CONTAINER_CONNECTION; + delete local.CONTAINER_HOST; + delete local.CONTAINER_SSHKEY; + return local; +} + +function podmanSocketPath( + podman: NonNullable, + env: NodeJS.ProcessEnv, +): string { + const result = podman(["info", "--format", "{{.Host.RemoteSocket.Path}}"], env); requireCommand(result, "Resolving the portable Podman socket"); const socket = String(result.stdout ?? "").trim(); if (/[\u0000-\u001f\u007f-\u009f]/u.test(socket)) { @@ -375,7 +395,22 @@ function podmanDockerHost(podman: NonNullable, + env: NodeJS.ProcessEnv, +): ContainerEngineCommandCapture { + return (_executable, args) => { + const result = podman(args, env); + return { + status: result.status ?? 1, + stdout: String(result.stdout ?? ""), + stderr: String(result.stderr ?? ""), + ...(result.error ? { error: result.error } : {}), + }; + }; } /** Resolve the receipt-owned portable container for a host-side privileged exec. */ @@ -390,9 +425,19 @@ export function resolvePortableDemoPrivilegedExecTarget( throw new Error("Portable demo lifecycle receipt is only valid on Linux"); } - const podman = deps.podman ?? ((args) => defaultPodman(args, commandEnv)); - const dockerHost = podmanDockerHost(podman); - const providerPodman = (args: readonly string[]) => podman(["--url", dockerHost, ...args]); + const podman = deps.podman ?? ((args, env = commandEnv) => defaultPodman(args, env)); + const podmanEnv = localPodmanEnvironment(commandEnv); + const socketAuthority = capturePodmanSocketAuthority( + podmanSocketPath(podman, podmanEnv), + deps.podmanSocketAuthorityDeps, + ); + const provider = createPodmanContainerEngine({ + operation: "sandbox-lifecycle", + socketAuthority, + authorityDeps: deps.podmanSocketAuthorityDeps, + ...(deps.podman ? { capture: podmanCapture(podman, podmanEnv) } : {}), + }); + const providerPodman = (args: readonly string[]) => provider.capture(args, COMMAND_TIMEOUT_MS); const inspection = discoverPodmanContainer(sandboxName, providerPodman); if (inspection.containerId !== receipt.containerId) { throw new Error( @@ -407,7 +452,12 @@ export function resolvePortableDemoPrivilegedExecTarget( if (!inspection.running) { throw new Error(`Portable sandbox '${sandboxName}' is not running`); } - return { containerId: inspection.containerId, dockerHost }; + return { + assertRuntimeAuthority: () => + assertPodmanSocketAuthority(socketAuthority, deps.podmanSocketAuthorityDeps), + containerId: inspection.containerId, + dockerHost: `unix://${socketAuthority.socketPath}`, + }; } function startupArgv(receipt: PortableDemoLifecycleReceipt): string[] { diff --git a/src/lib/sandbox/privileged-exec.test.ts b/src/lib/sandbox/privileged-exec.test.ts index 9aeb6ccbee1..560fa0e4c92 100644 --- a/src/lib/sandbox/privileged-exec.test.ts +++ b/src/lib/sandbox/privileged-exec.test.ts @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { createRequire } from "node:module"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; // The shared source hook preserves the writable CommonJS cache used by these mocks. const require = createRequire(import.meta.url); @@ -28,7 +28,7 @@ function withPrivilegedExecMocks( }; resolvePortableDemoPrivilegedExecTarget?: ( sandboxName: string, - ) => { containerId: string; dockerHost: string } | null; + ) => { assertRuntimeAuthority: () => void; containerId: string; dockerHost: string } | null; }, run: (helper: typeof import("./privileged-exec")) => T, ): T { @@ -143,6 +143,7 @@ describe("privileged sandbox exec routing", () => { it("uses the receipt-owned Podman socket when the default Docker daemon has no container (#8584)", () => { let dockerPsCalls = 0; + const assertRuntimeAuthority = vi.fn(); withPrivilegedExecMocks( { getSandbox: () => ({ name: "alpha", openshellDriver: "docker" }), @@ -152,6 +153,7 @@ describe("privileged sandbox exec routing", () => { return ""; }, resolvePortableDemoPrivilegedExecTarget: () => ({ + assertRuntimeAuthority, containerId: "a".repeat(64), dockerHost: "unix:///run/user/1001/podman/podman.sock", }), @@ -203,6 +205,7 @@ describe("privileged sandbox exec routing", () => { }, ); expect(dockerPsCalls).toBe(0); + expect(assertRuntimeAuthority).toHaveBeenCalledOnce(); }); it("bounds direct sandbox container discovery", () => { diff --git a/src/lib/sandbox/privileged-exec.ts b/src/lib/sandbox/privileged-exec.ts index 9de5591fb94..cdffd777871 100644 --- a/src/lib/sandbox/privileged-exec.ts +++ b/src/lib/sandbox/privileged-exec.ts @@ -222,6 +222,7 @@ function privilegedSandboxExecArgv( const sanitizedEnvArgs = sanitizeEnvironment ? SANITIZED_PRIVILEGED_ENV.flatMap((value) => ["--env", value]) : []; + portableTarget.assertRuntimeAuthority(); return [ "--host", portableTarget.dockerHost, From ede0211fb5faa0bd2e8d766b3483ef0bc44d27ce Mon Sep 17 00:00:00 2001 From: Senthil Ravichandran Date: Fri, 7 Aug 2026 14:17:15 -0700 Subject: [PATCH 04/15] test(e2e): cover failed TUI exit after reply Signed-off-by: Senthil Ravichandran --- test/e2e/support/launch-agent-turn.test.ts | 91 +++++++++++++--------- 1 file changed, 54 insertions(+), 37 deletions(-) diff --git a/test/e2e/support/launch-agent-turn.test.ts b/test/e2e/support/launch-agent-turn.test.ts index ef93c4f3218..e6158932888 100644 --- a/test/e2e/support/launch-agent-turn.test.ts +++ b/test/e2e/support/launch-agent-turn.test.ts @@ -9,18 +9,16 @@ import { join } from "node:path"; import { expect, it } from "vitest"; import { LAUNCH_TURN_SCRIPT } from "../live/launch-agent-turn.ts"; -it.runIf(process.platform !== "win32")( - "records a successful reply and exit status 0 after the TUI exit command (#8584)", - () => { - const fixtureRoot = mkdtempSync(join(tmpdir(), "nemoclaw-launch-turn-")); - const scriptStub = join(fixtureRoot, "script"); - const sleepStub = join(fixtureRoot, "sleep"); - const timeoutStub = join(fixtureRoot, "timeout"); +function runLaunchTurnFixture(exitStatus: number) { + const fixtureRoot = mkdtempSync(join(tmpdir(), "nemoclaw-launch-turn-")); + const scriptStub = join(fixtureRoot, "script"); + const sleepStub = join(fixtureRoot, "sleep"); + const timeoutStub = join(fixtureRoot, "timeout"); - try { - writeFileSync( - scriptStub, - String.raw`#!/usr/bin/env bash + try { + writeFileSync( + scriptStub, + String.raw`#!/usr/bin/env bash set -euo pipefail capture="" for argument in "$@"; do @@ -31,34 +29,53 @@ IFS= read -r -d $'\r' _ printf 'PONG\n' | tee "$capture" IFS= read -r -d $'\r' exit_command [[ "$exit_command" == "/exit" ]] +exit ${exitStatus} `, - ); - writeFileSync(sleepStub, "#!/bin/sh\n/bin/sleep 0.5\n"); - writeFileSync(timeoutStub, '#!/bin/sh\nshift 2\nexec "$@"\n'); - chmodSync(scriptStub, 0o755); - chmodSync(sleepStub, 0o755); - chmodSync(timeoutStub, 0o755); + ); + writeFileSync(sleepStub, "#!/bin/sh\n/bin/sleep 0.5\n"); + writeFileSync(timeoutStub, '#!/bin/sh\nshift 2\nexec "$@"\n'); + chmodSync(scriptStub, 0o755); + chmodSync(sleepStub, 0o755); + chmodSync(timeoutStub, 0o755); + + return spawnSync("bash", ["-c", LAUNCH_TURN_SCRIPT], { + encoding: "utf8", + env: { + ...process.env, + NEMOCLAW_LAUNCH_COMMAND: "ignored", + NEMOCLAW_LAUNCH_ENTRYPOINT: "", + NEMOCLAW_LAUNCH_EXIT_COMMAND: "/exit", + NEMOCLAW_LAUNCH_EXPECTED_REPLY: "PONG", + NEMOCLAW_LAUNCH_PROMPT: "prompt", + NEMOCLAW_LAUNCH_SANDBOX: "sandbox", + PATH: `${fixtureRoot}:${process.env.PATH ?? ""}`, + }, + timeout: 10_000, + }); + } finally { + rmSync(fixtureRoot, { force: true, recursive: true }); + } +} - const result = spawnSync("bash", ["-c", LAUNCH_TURN_SCRIPT], { - encoding: "utf8", - env: { - ...process.env, - NEMOCLAW_LAUNCH_COMMAND: "ignored", - NEMOCLAW_LAUNCH_ENTRYPOINT: "", - NEMOCLAW_LAUNCH_EXIT_COMMAND: "/exit", - NEMOCLAW_LAUNCH_EXPECTED_REPLY: "PONG", - NEMOCLAW_LAUNCH_PROMPT: "prompt", - NEMOCLAW_LAUNCH_SANDBOX: "sandbox", - PATH: `${fixtureRoot}:${process.env.PATH ?? ""}`, - }, - timeout: 10_000, - }); +it.runIf(process.platform !== "win32")( + "records a successful reply and exit status 0 after the TUI exit command (#8584)", + () => { + const result = runLaunchTurnFixture(0); + + expect(result.signal, result.stderr).toBeNull(); + expect(result.status, result.stderr).toBe(0); + expect(result.stdout).toContain("NEMOCLAW_LAUNCH_TURN_OK"); + }, +); + +it.runIf(process.platform !== "win32")( + "reports a nonzero TUI exit after recording a successful reply (#8584)", + () => { + const result = runLaunchTurnFixture(23); - expect(result.signal, result.stderr).toBeNull(); - expect(result.status, result.stderr).toBe(0); - expect(result.stdout).toContain("NEMOCLAW_LAUNCH_TURN_OK"); - } finally { - rmSync(fixtureRoot, { force: true, recursive: true }); - } + expect(result.signal, result.stderr).toBeNull(); + expect(result.status).toBe(23); + expect(result.stderr).toContain("launch exited with status 23"); + expect(result.stdout).not.toContain("NEMOCLAW_LAUNCH_TURN_OK"); }, ); From b1d0e3bad069d5d5553a4afb50709a4f15c9ef73 Mon Sep 17 00:00:00 2001 From: Senthil Ravichandran Date: Fri, 7 Aug 2026 14:43:33 -0700 Subject: [PATCH 05/15] fix(cli): secure standard rootless Podman socket Signed-off-by: Senthil Ravichandran --- .github/workflows/portable-profile-e2e.yaml | 7 ++- src/lib/adapters/podman/index.ts | 6 +- .../adapters/podman/socket-authority.test.ts | 63 ++++++++++++++++++- src/lib/adapters/podman/socket-authority.ts | 47 +++++++++++++- .../portable-demo-lifecycle.test.ts | 14 ++++- .../experimental/portable-demo-lifecycle.ts | 9 +-- .../portable-host-preparation.test.ts | 16 ++++- .../experimental/portable-host-preparation.ts | 7 ++- .../portable-profile-rootless-linux.test.ts | 8 ++- 9 files changed, 158 insertions(+), 19 deletions(-) diff --git a/.github/workflows/portable-profile-e2e.yaml b/.github/workflows/portable-profile-e2e.yaml index 6cf85bc1949..2d7c72ce324 100644 --- a/.github/workflows/portable-profile-e2e.yaml +++ b/.github/workflows/portable-profile-e2e.yaml @@ -156,11 +156,14 @@ jobs: fi ;; "--user enable --now podman.socket") - mkdir -p "$service_dir" + install -d -m 755 "$service_dir" nohup podman system service --time=0 "unix://$socket_path" >"$log_file" 2>&1 & echo $! >"$pid_file" for _ in $(seq 1 100); do - [[ -S "$socket_path" ]] && exit 0 + if [[ -S "$socket_path" ]]; then + chmod 660 "$socket_path" + exit 0 + fi sleep 0.1 done cat "$log_file" >&2 || true diff --git a/src/lib/adapters/podman/index.ts b/src/lib/adapters/podman/index.ts index 044e238e533..bfa4eb7a0be 100644 --- a/src/lib/adapters/podman/index.ts +++ b/src/lib/adapters/podman/index.ts @@ -66,4 +66,8 @@ export function createPodmanContainerEngine( } export type { PodmanSocketAuthority, PodmanSocketAuthorityDeps } from "./socket-authority"; -export { assertPodmanSocketAuthority, capturePodmanSocketAuthority } from "./socket-authority"; +export { + assertPodmanSocketAuthority, + capturePodmanSocketAuthority, + hardenPodmanSocketDirectory, +} from "./socket-authority"; diff --git a/src/lib/adapters/podman/socket-authority.test.ts b/src/lib/adapters/podman/socket-authority.test.ts index 683c323ea0f..6cc2aba32d2 100644 --- a/src/lib/adapters/podman/socket-authority.test.ts +++ b/src/lib/adapters/podman/socket-authority.test.ts @@ -1,9 +1,17 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + import { describe, expect, it, vi } from "vitest"; -import { assertPodmanSocketAuthority, capturePodmanSocketAuthority } from "./socket-authority"; +import { + assertPodmanSocketAuthority, + capturePodmanSocketAuthority, + hardenPodmanSocketDirectory, +} from "./socket-authority"; const SOCKET_PATH = "/run/user/1000/podman/podman.sock"; @@ -85,10 +93,59 @@ describe("Podman socket authority", () => { ).toThrow("writable by another user or group"); }); - it.each([0o660n, 0o666n])("rejects another-user-writable socket mode %s", (mode) => { + it("accepts the rootless systemd socket mode inside a private current-user directory", () => { + const authority = capturePodmanSocketAuthority(SOCKET_PATH, { + lstat: secureLstat({ mode: 0o660n }, { "/run/user/1000/podman": { mode: 0o700n } }), + uid: 1000, + }); + + expect(authority.mode).toBe(String(0o660)); + }); + + it.runIf(process.platform !== "win32")( + "hardens the current-user socket directory without following unsafe parents (#8584)", + () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-podman-socket-")); + try { + const socketDirectory = path.join(root, "podman"); + fs.mkdirSync(socketDirectory); + fs.chmodSync(socketDirectory, 0o755); + const socketPath = path.join(socketDirectory, "podman.sock"); + const uid = process.getuid?.(); + if (uid === undefined) throw new Error("Unix user ID is unavailable."); + + hardenPodmanSocketDirectory(socketPath, uid); + expect(fs.statSync(socketDirectory).mode & 0o777).toBe(0o700); + + fs.chmodSync(socketDirectory, 0o770); + expect(() => hardenPodmanSocketDirectory(socketPath, uid)).toThrow( + "writable by another user or group", + ); + + const targetDirectory = path.join(root, "target"); + const linkedDirectory = path.join(root, "linked"); + fs.mkdirSync(targetDirectory); + fs.symlinkSync(targetDirectory, linkedDirectory, "dir"); + expect(() => + hardenPodmanSocketDirectory(path.join(linkedDirectory, "podman.sock"), uid), + ).toThrow(); + } finally { + fs.rmSync(root, { force: true, recursive: true }); + } + }, + ); + + it("rejects socket modes reachable by another user", () => { + expect(() => + capturePodmanSocketAuthority(SOCKET_PATH, { + lstat: secureLstat({ mode: 0o660n }), + uid: 1000, + }), + ).toThrow("socket authority is writable by another user or group"); + expect(() => capturePodmanSocketAuthority(SOCKET_PATH, { - lstat: secureLstat({ mode }), + lstat: secureLstat({ mode: 0o666n }, { "/run/user/1000/podman": { mode: 0o700n } }), uid: 1000, }), ).toThrow("socket authority is writable by another user or group"); diff --git a/src/lib/adapters/podman/socket-authority.ts b/src/lib/adapters/podman/socket-authority.ts index 64750a7b6d3..47df25f5c11 100644 --- a/src/lib/adapters/podman/socket-authority.ts +++ b/src/lib/adapters/podman/socket-authority.ts @@ -79,6 +79,43 @@ function normalizedSocketPath(socketPath: string): string { return normalized; } +export function hardenPodmanSocketDirectory(socketPath: string, configuredUid?: number): void { + const normalized = normalizedSocketPath(socketPath); + const uid = currentUid(configuredUid); + const directory = path.dirname(normalized); + const descriptor = fs.openSync( + directory, + fs.constants.O_RDONLY | fs.constants.O_DIRECTORY | fs.constants.O_NOFOLLOW, + ); + try { + const before = fs.fstatSync(descriptor, { bigint: true }); + if (!before.isDirectory()) { + throw new Error("Podman socket directory is not a real directory."); + } + if (before.uid !== BigInt(uid)) { + throw new Error( + `Podman socket directory is owned by uid ${before.uid.toString(10)}; expected current uid ${String(uid)}.`, + ); + } + if ((before.mode & 0o022n) !== 0n) { + throw new Error("Podman socket directory is writable by another user or group."); + } + + fs.fchmodSync(descriptor, 0o700); + const after = fs.fstatSync(descriptor, { bigint: true }); + if ( + after.dev !== before.dev || + after.ino !== before.ino || + after.uid !== before.uid || + (after.mode & 0o777n) !== 0o700n + ) { + throw new Error("Podman socket directory changed while it was secured."); + } + } finally { + fs.closeSync(descriptor); + } +} + function captureDirectoryChain( socketPath: string, uid: number, @@ -140,11 +177,17 @@ export function capturePodmanSocketAuthority( ); } const mode = integerValue(stat.mode, "mode"); - if ((mode & 0o022n) !== 0n) { + const directoryChain = captureDirectoryChain(normalized, uid, lstat); + const socketParent = directoryChain[0]; + const parentMode = socketParent ? BigInt(socketParent.mode) : 0o777n; + // The rootless Podman systemd socket defaults to 0660. Group write stays + // inside the current-UID trust boundary when its owner-only parent prevents + // every other non-root user from reaching the socket. + if ((mode & 0o002n) !== 0n || ((mode & 0o020n) !== 0n && (parentMode & 0o077n) !== 0n)) { throw new Error("Podman socket authority is writable by another user or group."); } return Object.freeze({ - directoryChain: captureDirectoryChain(normalized, uid, lstat), + directoryChain, device: integerIdentity(stat.dev, "device"), inode: integerIdentity(stat.ino, "inode"), mode: mode.toString(10), diff --git a/src/lib/onboard/experimental/portable-demo-lifecycle.test.ts b/src/lib/onboard/experimental/portable-demo-lifecycle.test.ts index e5503707d42..d0584380806 100644 --- a/src/lib/onboard/experimental/portable-demo-lifecycle.test.ts +++ b/src/lib/onboard/experimental/portable-demo-lifecycle.test.ts @@ -154,7 +154,7 @@ function socketAuthorityDeps( dev: 8n, ino: socket ? (options.socketInode?.() ?? 9001n) : directoryInode, mode: socket - ? (options.socketMode ?? 0o600n) + ? (options.socketMode ?? 0o660n) : filePath === path.dirname(SOCKET_PATH) ? (options.directoryMode ?? 0o700n) : 0o755n, @@ -180,6 +180,7 @@ function resolveTarget( stateDir, podman: runtime.podman, podmanSocketAuthorityDeps: socketAuthorityDeps(), + hardenSocketDirectory: vi.fn(), ...overrides, }); } @@ -336,11 +337,13 @@ describe("portable demo sandbox lifecycle", () => { const runtime = createPodman(); installReceipt(stateDir, runtime.podman); runtime.podman.mockClear(); + const hardenSocketDirectory = vi.fn(); - expect(resolveTarget(stateDir, runtime)).toMatchObject({ + expect(resolveTarget(stateDir, runtime, { hardenSocketDirectory })).toMatchObject({ containerId: CONTAINER_ID, dockerHost: "unix:///run/user/1001/podman/podman.sock", }); + expect(hardenSocketDirectory).toHaveBeenCalledWith(SOCKET_PATH); expect(runtime.podman.mock.calls.map(([args]) => args)).toEqual([ ["info", "--format", "{{.Host.RemoteSocket.Path}}"], [ @@ -416,7 +419,12 @@ describe("portable demo sandbox lifecycle", () => { it.each([ ["foreign owner", socketAuthorityDeps({ socketUid: 2000n }), "owned by uid 2000"], - ["writable socket", socketAuthorityDeps({ socketMode: 0o660n }), "writable by another"], + ["world-writable socket", socketAuthorityDeps({ socketMode: 0o666n }), "writable by another"], + [ + "group-writable socket outside a private parent", + socketAuthorityDeps({ directoryMode: 0o750n }), + "writable by another", + ], ["writable parent", socketAuthorityDeps({ directoryMode: 0o770n }), "writable by another"], ["symlinked parent", socketAuthorityDeps({ directory: false }), "not a real directory"], ])("refuses a %s for portable privileged exec (#8584)", (_case, authority, message) => { diff --git a/src/lib/onboard/experimental/portable-demo-lifecycle.ts b/src/lib/onboard/experimental/portable-demo-lifecycle.ts index 5fbb17ad660..43f05e79e52 100644 --- a/src/lib/onboard/experimental/portable-demo-lifecycle.ts +++ b/src/lib/onboard/experimental/portable-demo-lifecycle.ts @@ -13,6 +13,7 @@ import { assertPodmanSocketAuthority, capturePodmanSocketAuthority, createPodmanContainerEngine, + hardenPodmanSocketDirectory, type PodmanSocketAuthorityDeps, } from "../../adapters/podman"; import { ensureConfigDir } from "../../state/config-io"; @@ -81,6 +82,7 @@ export interface PortableDemoLifecycleDeps { openshellBinary?: string; podman?: (args: readonly string[], env?: NodeJS.ProcessEnv) => CommandResult; podmanSocketAuthorityDeps?: PodmanSocketAuthorityDeps; + hardenSocketDirectory?: (socketPath: string) => void; captureOpenshell?: (args: readonly string[], timeoutMs: number) => CommandResult; launchOpenshell?: (args: readonly string[]) => void; captureHost?: (command: string, args: readonly string[], timeoutMs: number) => CommandResult; @@ -427,10 +429,9 @@ export function resolvePortableDemoPrivilegedExecTarget( const podman = deps.podman ?? ((args, env = commandEnv) => defaultPodman(args, env)); const podmanEnv = localPodmanEnvironment(commandEnv); - const socketAuthority = capturePodmanSocketAuthority( - podmanSocketPath(podman, podmanEnv), - deps.podmanSocketAuthorityDeps, - ); + const socketPath = podmanSocketPath(podman, podmanEnv); + (deps.hardenSocketDirectory ?? hardenPodmanSocketDirectory)(socketPath); + const socketAuthority = capturePodmanSocketAuthority(socketPath, deps.podmanSocketAuthorityDeps); const provider = createPodmanContainerEngine({ operation: "sandbox-lifecycle", socketAuthority, diff --git a/src/lib/onboard/experimental/portable-host-preparation.test.ts b/src/lib/onboard/experimental/portable-host-preparation.test.ts index 4817c16b435..990e242fa41 100644 --- a/src/lib/onboard/experimental/portable-host-preparation.test.ts +++ b/src/lib/onboard/experimental/portable-host-preparation.test.ts @@ -47,6 +47,7 @@ describe("preparePortableExperimentalHost", () => { .mockReturnValueOnce(result(1)) // inspect: registry not present yet .mockReturnValueOnce(result()); // run const podman = vi.fn(() => result(0, "/run/user/1001/custom/podman.sock\n")); + const hardenSocketDirectory = vi.fn(); const env: NodeJS.ProcessEnv = { NEMOCLAW_EXPERIMENTAL_PROFILE: "portable", }; @@ -58,6 +59,7 @@ describe("preparePortableExperimentalHost", () => { systemctl, podman, docker, + hardenSocketDirectory, }); expect(env).toMatchObject({ @@ -76,6 +78,7 @@ describe("preparePortableExperimentalHost", () => { ["--user", "enable", "--now", "podman.socket"], ]); expect(podman).toHaveBeenCalledWith(["info", "--format", "{{.Host.RemoteSocket.Path}}"], env); + expect(hardenSocketDirectory).toHaveBeenCalledWith("/run/user/1001/custom/podman.sock", 1001); expect(docker.mock.calls[0]?.[0]).toEqual(["--version"]); expect(docker.mock.calls[2]?.[0]).toEqual([ "run", @@ -118,7 +121,15 @@ describe("preparePortableExperimentalHost", () => { preparePortableExperimentalHost( { NEMOCLAW_EXPERIMENTAL_PROFILE: "portable" }, - { platform: "linux", home, uid: 1001, systemctl, podman, docker }, + { + platform: "linux", + home, + uid: 1001, + systemctl, + podman, + docker, + hardenSocketDirectory: vi.fn(), + }, ); const dropIn = path.join( @@ -144,6 +155,7 @@ describe("preparePortableExperimentalHost", () => { systemctl: () => result(), podman: () => result(0, "/run/user/1001/podman/podman.sock"), docker: () => result(0, "unexpected-owner"), + hardenSocketDirectory: vi.fn(), }), ).toThrow(/unmanaged container/); }); @@ -177,6 +189,7 @@ describe("preparePortableExperimentalHost", () => { systemctl: () => result(), podman: () => result(0, "/run/user/1001/podman/podman.sock"), docker, + hardenSocketDirectory: vi.fn(), }, ), ).toThrow(/Inspecting the managed portable registry failed: registry inspection timed out/); @@ -229,6 +242,7 @@ describe("preparePortableExperimentalHost", () => { systemctl: () => result(), podman: () => result(0, "/run/user/1001/podman/podman.sock"), docker, + hardenSocketDirectory: vi.fn(), }; expect(() => preparePortableExperimentalHost(env, deps)).toThrow(/podman-docker/); diff --git a/src/lib/onboard/experimental/portable-host-preparation.ts b/src/lib/onboard/experimental/portable-host-preparation.ts index aa7ef066030..2e2cc836f4e 100644 --- a/src/lib/onboard/experimental/portable-host-preparation.ts +++ b/src/lib/onboard/experimental/portable-host-preparation.ts @@ -7,6 +7,7 @@ import path from "node:path"; import { dockerSpawnSync } from "../../adapters/docker/exec"; import { openRegularFileNoFollow } from "../../adapters/fs/regular-file"; +import { hardenPodmanSocketDirectory } from "../../adapters/podman"; import { ensureConfigDir } from "../../state/config-io"; import { isPortableExperimentalProfile, PORTABLE_LOCAL_REGISTRY } from "../docker-driver-platform"; @@ -37,6 +38,7 @@ export interface PortableHostPreparationDeps { systemctl?: (args: readonly string[], env: NodeJS.ProcessEnv) => SpawnResult; podman?: (args: readonly string[], env: NodeJS.ProcessEnv) => SpawnResult; docker?: (args: readonly string[], env: NodeJS.ProcessEnv) => SpawnResult; + hardenSocketDirectory?: (socketPath: string, uid: number) => void; } function commandDetail(result: SpawnResult): string { @@ -224,9 +226,12 @@ export function preparePortableExperimentalHost( env: childEnv, timeout: HOST_COMMAND_TIMEOUT_MS, })); - env.DOCKER_HOST = resolvePodmanDockerHost( + const dockerHost = resolvePodmanDockerHost( podman(["info", "--format", "{{.Host.RemoteSocket.Path}}"], env), ); + const socketPath = dockerHost.slice("unix://".length); + (deps.hardenSocketDirectory ?? hardenPodmanSocketDirectory)(socketPath, Number(uid)); + env.DOCKER_HOST = dockerHost; const docker = deps.docker ?? diff --git a/test/e2e/live/portable-profile-rootless-linux.test.ts b/test/e2e/live/portable-profile-rootless-linux.test.ts index c4a31928c5a..d6dabecb60f 100644 --- a/test/e2e/live/portable-profile-rootless-linux.test.ts +++ b/test/e2e/live/portable-profile-rootless-linux.test.ts @@ -120,11 +120,14 @@ case "$*" in exit 0 ;; "--user enable --now podman.socket") - mkdir -p "\${service_dir}" + install -d -m 755 "\${service_dir}" nohup podman system service --time=0 "unix://\${socket_path}" >"\${log_file}" 2>&1 & echo $! >"\${pid_file}" for _ in $(seq 1 100); do - [[ -S "\${socket_path}" ]] && exit 0 + if [[ -S "\${socket_path}" ]]; then + chmod 660 "\${socket_path}" + exit 0 + fi sleep 0.1 done cat "\${log_file}" >&2 || true @@ -182,6 +185,7 @@ async function main(progress: TestProgress): Promise { progress.phase("prepare the rootless container runtime"); preparePortableExperimentalHost(process.env); assert.equal(process.env.DOCKER_HOST, `unix://${runtimeDir}/podman/podman.sock`); + assert.equal(fs.statSync(path.join(runtimeDir, "podman")).mode & 0o777, 0o700); assert.match( fs.readFileSync(String(process.env.CONTAINERS_CONF), "utf-8"), /default_rootless_network_cmd = "pasta"/, From e99ada704694bb8e92847a44b5fc1fefc0d1f783 Mon Sep 17 00:00:00 2001 From: Senthil Ravichandran Date: Fri, 7 Aug 2026 14:50:34 -0700 Subject: [PATCH 06/15] test(cli): keep socket authority test linear Signed-off-by: Senthil Ravichandran --- src/lib/adapters/podman/socket-authority.test.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/lib/adapters/podman/socket-authority.test.ts b/src/lib/adapters/podman/socket-authority.test.ts index 6cc2aba32d2..78aec641557 100644 --- a/src/lib/adapters/podman/socket-authority.test.ts +++ b/src/lib/adapters/podman/socket-authority.test.ts @@ -111,8 +111,7 @@ describe("Podman socket authority", () => { fs.mkdirSync(socketDirectory); fs.chmodSync(socketDirectory, 0o755); const socketPath = path.join(socketDirectory, "podman.sock"); - const uid = process.getuid?.(); - if (uid === undefined) throw new Error("Unix user ID is unavailable."); + const uid = process.getuid?.() ?? -1; hardenPodmanSocketDirectory(socketPath, uid); expect(fs.statSync(socketDirectory).mode & 0o777).toBe(0o700); From 6747593aeb00ead2d394a76e6468059dec4d9458 Mon Sep 17 00:00:00 2001 From: Senthil Ravichandran Date: Fri, 7 Aug 2026 14:59:18 -0700 Subject: [PATCH 07/15] test(cli): assert socket hardening order Signed-off-by: Senthil Ravichandran --- .../experimental/portable-demo-lifecycle.test.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/lib/onboard/experimental/portable-demo-lifecycle.test.ts b/src/lib/onboard/experimental/portable-demo-lifecycle.test.ts index d0584380806..fa32e83ec27 100644 --- a/src/lib/onboard/experimental/portable-demo-lifecycle.test.ts +++ b/src/lib/onboard/experimental/portable-demo-lifecycle.test.ts @@ -138,6 +138,7 @@ function socketAuthorityDeps( options: { directory?: boolean; directoryMode?: bigint; + onLstat?: () => void; socketInode?: () => bigint; socketMode?: bigint; socketUid?: bigint; @@ -147,6 +148,7 @@ function socketAuthorityDeps( return { uid: 1001, lstat: (filePath) => { + options.onLstat?.(); const socket = filePath === SOCKET_PATH; const directoryInode = directoryInodes.get(filePath) ?? BigInt(7000 + directoryInodes.size); directoryInodes.set(filePath, directoryInode); @@ -337,13 +339,20 @@ describe("portable demo sandbox lifecycle", () => { const runtime = createPodman(); installReceipt(stateDir, runtime.podman); runtime.podman.mockClear(); - const hardenSocketDirectory = vi.fn(); + const socketEvents: string[] = []; + const hardenSocketDirectory = vi.fn(() => socketEvents.push("harden")); + const podmanSocketAuthorityDeps = socketAuthorityDeps({ + onLstat: () => socketEvents.push("capture"), + }); - expect(resolveTarget(stateDir, runtime, { hardenSocketDirectory })).toMatchObject({ + expect( + resolveTarget(stateDir, runtime, { hardenSocketDirectory, podmanSocketAuthorityDeps }), + ).toMatchObject({ containerId: CONTAINER_ID, dockerHost: "unix:///run/user/1001/podman/podman.sock", }); expect(hardenSocketDirectory).toHaveBeenCalledWith(SOCKET_PATH); + expect(socketEvents.slice(0, 2)).toEqual(["harden", "capture"]); expect(runtime.podman.mock.calls.map(([args]) => args)).toEqual([ ["info", "--format", "{{.Host.RemoteSocket.Path}}"], [ From 926fcf9317a212903d0d29ed9e32a0e12463d3ec Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Fri, 7 Aug 2026 15:31:02 -0700 Subject: [PATCH 08/15] fix(cli): address portable cleanup review findings Signed-off-by: Prekshi Vyas --- src/lib/actions/sandbox/destroy-flow.test.ts | 5 ++ src/lib/actions/sandbox/destroy.ts | 4 ++ src/lib/adapters/podman/index.ts | 8 +++ .../adapters/podman/socket-authority.test.ts | 24 ++++++-- src/lib/adapters/podman/socket-authority.ts | 58 +++++++++++++++++++ .../portable-demo-lifecycle.test.ts | 34 ++++++++--- .../experimental/portable-demo-lifecycle.ts | 22 +++---- .../portable-host-preparation.test.ts | 17 +++++- .../experimental/portable-host-preparation.ts | 4 +- src/lib/sandbox/privileged-exec.test.ts | 7 +++ src/lib/sandbox/privileged-exec.ts | 9 ++- test/e2e/live/full-e2e.test.ts | 5 +- test/e2e/support/e2e-workflow.test.ts | 5 ++ test/helpers/destroy-flow-test-harness.ts | 8 +++ 14 files changed, 178 insertions(+), 32 deletions(-) diff --git a/src/lib/actions/sandbox/destroy-flow.test.ts b/src/lib/actions/sandbox/destroy-flow.test.ts index 74eb373b7a9..f49d155b026 100644 --- a/src/lib/actions/sandbox/destroy-flow.test.ts +++ b/src/lib/actions/sandbox/destroy-flow.test.ts @@ -57,6 +57,10 @@ describe("destroySandbox flow", () => { ).resolves.toBeUndefined(); expectSuccessfulLiveDestroy(harness, exitSpy); + expect(harness.removePortableDemoLifecycleReceiptSpy).toHaveBeenCalledWith("alpha"); + expect(harness.removeSandboxSpy.mock.invocationCallOrder[0]).toBeLessThan( + harness.removePortableDemoLifecycleReceiptSpy.mock.invocationCallOrder[0], + ); }); it("revokes the prior HTTPS-pin route only after confirmed deletion and registry removal", async () => { @@ -106,6 +110,7 @@ describe("destroySandbox flow", () => { await expect(harness.destroySandbox("alpha", { yes: true })).rejects.toThrow("process.exit(7)"); expectFailedDeletePreservesHostState(harness, exitSpy); + expect(harness.removePortableDemoLifecycleReceiptSpy).not.toHaveBeenCalled(); }); it("preserves provider and registry ownership when runtime authority is unknown", async () => { diff --git a/src/lib/actions/sandbox/destroy.ts b/src/lib/actions/sandbox/destroy.ts index 894dd8a77b1..3fc88dda333 100644 --- a/src/lib/actions/sandbox/destroy.ts +++ b/src/lib/actions/sandbox/destroy.ts @@ -22,6 +22,7 @@ import { revokeHttpsPinRuntimeAdapterRoute, } from "../../inference/https-pin-runtime-adapter"; import { cleanupManagedLlamaCppRuntimeForSandbox } from "../../inference/local-model-profile/cleanup"; +import { removePortableDemoSandboxLifecycleReceipt } from "../../onboard/experimental/portable-demo-lifecycle"; import { CURRENT_RUNTIME_PROVIDER_BUNDLES, normalizeRuntimeProviderIdentity, @@ -612,6 +613,9 @@ async function destroySandboxUnlocked( ); process.exit(1); } + if (removed) { + removePortableDemoSandboxLifecycleReceipt(sandboxName); + } if (deleteSucceededOrAlreadyGone && removed && priorHttpsPinRouteId) { await revokeDestroyedSandboxHttpsPinRoute(cleanupGatewayName, priorHttpsPinRouteId); } diff --git a/src/lib/adapters/podman/index.ts b/src/lib/adapters/podman/index.ts index bfa4eb7a0be..66a7902fc5f 100644 --- a/src/lib/adapters/podman/index.ts +++ b/src/lib/adapters/podman/index.ts @@ -26,6 +26,14 @@ export interface PodmanContainerEngineOptions { ) => void; } +export function localPodmanEnvironment(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + const local = { ...env }; + delete local.CONTAINER_CONNECTION; + delete local.CONTAINER_HOST; + delete local.CONTAINER_SSHKEY; + return local; +} + function podmanAuthorityId(authority: PodmanSocketAuthority): string { const canonical = JSON.stringify({ socketPath: authority.socketPath, diff --git a/src/lib/adapters/podman/socket-authority.test.ts b/src/lib/adapters/podman/socket-authority.test.ts index 78aec641557..9a6ddc78107 100644 --- a/src/lib/adapters/podman/socket-authority.test.ts +++ b/src/lib/adapters/podman/socket-authority.test.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import fs from "node:fs"; +import net from "node:net"; import os from "node:os"; import path from "node:path"; @@ -104,14 +105,19 @@ describe("Podman socket authority", () => { it.runIf(process.platform !== "win32")( "hardens the current-user socket directory without following unsafe parents (#8584)", - () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-podman-socket-")); + async () => { + const root = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), "nc-p-")); + const server = net.createServer(); try { - const socketDirectory = path.join(root, "podman"); + const socketDirectory = path.join(root, "p"); fs.mkdirSync(socketDirectory); fs.chmodSync(socketDirectory, 0o755); - const socketPath = path.join(socketDirectory, "podman.sock"); + const socketPath = path.join(socketDirectory, "s"); const uid = process.getuid?.() ?? -1; + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(socketPath, resolve); + }); hardenPodmanSocketDirectory(socketPath, uid); expect(fs.statSync(socketDirectory).mode & 0o777).toBe(0o700); @@ -128,7 +134,17 @@ describe("Podman socket authority", () => { expect(() => hardenPodmanSocketDirectory(path.join(linkedDirectory, "podman.sock"), uid), ).toThrow(); + + const missingSocketDirectory = path.join(root, "missing"); + fs.mkdirSync(missingSocketDirectory, { mode: 0o755 }); + expect(() => + hardenPodmanSocketDirectory(path.join(missingSocketDirectory, "missing.sock"), uid), + ).toThrow(); + expect(fs.statSync(missingSocketDirectory).mode & 0o777).toBe(0o755); } finally { + if (server.listening) { + await new Promise((resolve) => server.close(() => resolve())); + } fs.rmSync(root, { force: true, recursive: true }); } }, diff --git a/src/lib/adapters/podman/socket-authority.ts b/src/lib/adapters/podman/socket-authority.ts index 47df25f5c11..bbd23a8a885 100644 --- a/src/lib/adapters/podman/socket-authority.ts +++ b/src/lib/adapters/podman/socket-authority.ts @@ -82,6 +82,26 @@ function normalizedSocketPath(socketPath: string): string { export function hardenPodmanSocketDirectory(socketPath: string, configuredUid?: number): void { const normalized = normalizedSocketPath(socketPath); const uid = currentUid(configuredUid); + const lstat = (filePath: string): PodmanSocketStat => fs.lstatSync(filePath, { bigint: true }); + const socketBefore = lstat(normalized); + if (!socketBefore.isSocket()) { + throw new Error("Podman socket authority path is not a Unix socket."); + } + const socketOwnerUid = integerIdentity(socketBefore.uid, "owner"); + if (socketOwnerUid !== String(uid)) { + throw new Error( + `Podman socket authority is owned by uid ${socketOwnerUid}; expected current uid ${String(uid)}.`, + ); + } + const socketMode = integerValue(socketBefore.mode, "mode"); + if ((socketMode & 0o002n) !== 0n) { + throw new Error("Podman socket authority is writable by another user or group."); + } + const directoryChainBefore = captureDirectoryChain(normalized, uid, lstat); + const socketParentBefore = directoryChainBefore[0]; + if (!socketParentBefore) { + throw new Error("Podman socket authority has no parent directory."); + } const directory = path.dirname(normalized); const descriptor = fs.openSync( directory, @@ -100,6 +120,14 @@ export function hardenPodmanSocketDirectory(socketPath: string, configuredUid?: if ((before.mode & 0o022n) !== 0n) { throw new Error("Podman socket directory is writable by another user or group."); } + if ( + integerIdentity(before.dev, "directory device") !== socketParentBefore.device || + integerIdentity(before.ino, "directory inode") !== socketParentBefore.inode || + integerIdentity(before.uid, "directory owner") !== socketParentBefore.ownerUid || + integerValue(before.mode, "directory mode").toString(10) !== socketParentBefore.mode + ) { + throw new Error("Podman socket directory changed before it was secured."); + } fs.fchmodSync(descriptor, 0o700); const after = fs.fstatSync(descriptor, { bigint: true }); @@ -114,6 +142,36 @@ export function hardenPodmanSocketDirectory(socketPath: string, configuredUid?: } finally { fs.closeSync(descriptor); } + + const authority = capturePodmanSocketAuthority(normalized, { lstat, uid }); + const socketChanged = + authority.device !== integerIdentity(socketBefore.dev, "device") || + authority.inode !== integerIdentity(socketBefore.ino, "inode") || + authority.mode !== socketMode.toString(10) || + authority.ownerUid !== socketOwnerUid; + const directoryChanged = authority.directoryChain.some((component, index) => { + const before = directoryChainBefore[index]; + const componentMode = BigInt(component.mode); + const beforeMode = before ? BigInt(before.mode) : 0n; + return ( + !before || + component.device !== before.device || + component.inode !== before.inode || + component.ownerUid !== before.ownerUid || + component.path !== before.path || + (index === 0 + ? (componentMode & ~0o777n) !== (beforeMode & ~0o777n) || + (componentMode & 0o777n) !== 0o700n + : component.mode !== before.mode) + ); + }); + if ( + socketChanged || + authority.directoryChain.length !== directoryChainBefore.length || + directoryChanged + ) { + throw new Error("Podman socket authority changed while its directory was secured."); + } } function captureDirectoryChain( diff --git a/src/lib/onboard/experimental/portable-demo-lifecycle.test.ts b/src/lib/onboard/experimental/portable-demo-lifecycle.test.ts index fa32e83ec27..4d21004f073 100644 --- a/src/lib/onboard/experimental/portable-demo-lifecycle.test.ts +++ b/src/lib/onboard/experimental/portable-demo-lifecycle.test.ts @@ -232,25 +232,43 @@ afterEach(() => { }); describe("portable demo sandbox lifecycle", () => { - it("does not inspect Podman unless the portable profile is explicit (#8441)", () => { - const podman = vi.fn(); + it("removes a stale receipt without inspecting Podman for a non-portable replacement (#8584)", () => { + const stateDir = temporaryStateDir(); + const runtime = createPodman(); + installReceipt(stateDir, runtime.podman); + runtime.podman.mockClear(); + const filePath = portableDemoLifecycleInternals.receiptPath("alpha", stateDir); - installPortableDemoSandboxLifecycle("alpha", STARTUP_ARGV, {}, { podman }); + installPortableDemoSandboxLifecycle( + "alpha", + STARTUP_ARGV, + {}, + { + podman: runtime.podman, + stateDir, + }, + ); - expect(podman).not.toHaveBeenCalled(); + expect(fs.existsSync(filePath)).toBe(false); + expect(runtime.podman).not.toHaveBeenCalled(); }); - it("does not install an OpenClaw demo receipt for another startup contract (#8441)", () => { - const podman = vi.fn(); + it("removes a stale receipt for another startup contract (#8584)", () => { + const stateDir = temporaryStateDir(); + const runtime = createPodman(); + installReceipt(stateDir, runtime.podman); + runtime.podman.mockClear(); + const filePath = portableDemoLifecycleInternals.receiptPath("alpha", stateDir); installPortableDemoSandboxLifecycle( "alpha", ["env", "NEMOCLAW_OBSERVABILITY=0", "/usr/local/bin/nemoclaw-start"], { NEMOCLAW_EXPERIMENTAL_PROFILE: "portable" }, - { platform: "linux", podman }, + { platform: "linux", podman: runtime.podman, stateDir }, ); - expect(podman).not.toHaveBeenCalled(); + expect(fs.existsSync(filePath)).toBe(false); + expect(runtime.podman).not.toHaveBeenCalled(); }); it("ignores an installed receipt for another agent (#8441)", () => { diff --git a/src/lib/onboard/experimental/portable-demo-lifecycle.ts b/src/lib/onboard/experimental/portable-demo-lifecycle.ts index 43f05e79e52..1887f65c7fa 100644 --- a/src/lib/onboard/experimental/portable-demo-lifecycle.ts +++ b/src/lib/onboard/experimental/portable-demo-lifecycle.ts @@ -14,6 +14,7 @@ import { capturePodmanSocketAuthority, createPodmanContainerEngine, hardenPodmanSocketDirectory, + localPodmanEnvironment, type PodmanSocketAuthorityDeps, } from "../../adapters/podman"; import { ensureConfigDir } from "../../state/config-io"; @@ -275,6 +276,13 @@ function removeReceipt(sandboxName: string, stateDir: string): void { } } +export function removePortableDemoSandboxLifecycleReceipt( + sandboxName: string, + stateDir = defaultStateDir(process.env), +): void { + removeReceipt(sandboxName, stateDir); +} + function startupEnvValue(startupArgv: readonly string[], name: string): string | null { const prefix = `${name}=`; for (let index = startupArgv.length - 2; index >= 1; index -= 1) { @@ -375,14 +383,6 @@ function discoverPodmanContainer( return inspectPodmanContainer(matches[0]!, sandboxName, podman); } -function localPodmanEnvironment(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { - const local = { ...env }; - delete local.CONTAINER_CONNECTION; - delete local.CONTAINER_HOST; - delete local.CONTAINER_SSHKEY; - return local; -} - function podmanSocketPath( podman: NonNullable, env: NodeJS.ProcessEnv, @@ -709,11 +709,13 @@ export function installPortableDemoSandboxLifecycle( env: NodeJS.ProcessEnv = process.env, deps: PortableDemoLifecycleDeps = {}, ): void { - if (!isPortableExperimentalProfile(env)) return; + const stateDir = deps.stateDir ?? defaultStateDir(env); if ( + !isPortableExperimentalProfile(env) || createdStartupArgv[createdStartupArgv.length - 1] !== "/usr/local/bin/nemoclaw-start" || startupEnvValue(createdStartupArgv, "OPENCLAW_HOME") === null ) { + removeReceipt(sandboxName, stateDir); return; } if ((deps.platform ?? process.platform) !== "linux") { @@ -733,7 +735,7 @@ export function installPortableDemoSandboxLifecycle( podman(["update", "--restart=unless-stopped", inspection.containerId]), `Setting the portable restart policy for sandbox '${sandboxName}'`, ); - writeReceipt(receipt, deps.stateDir ?? defaultStateDir(env)); + writeReceipt(receipt, stateDir); } /** diff --git a/src/lib/onboard/experimental/portable-host-preparation.test.ts b/src/lib/onboard/experimental/portable-host-preparation.test.ts index 990e242fa41..16850917552 100644 --- a/src/lib/onboard/experimental/portable-host-preparation.test.ts +++ b/src/lib/onboard/experimental/portable-host-preparation.test.ts @@ -49,6 +49,9 @@ describe("preparePortableExperimentalHost", () => { const podman = vi.fn(() => result(0, "/run/user/1001/custom/podman.sock\n")); const hardenSocketDirectory = vi.fn(); const env: NodeJS.ProcessEnv = { + CONTAINER_CONNECTION: "remote-test", + CONTAINER_HOST: "ssh://example.test/run/user/1001/podman/podman.sock", + CONTAINER_SSHKEY: "/tmp/remote-test-key", NEMOCLAW_EXPERIMENTAL_PROFILE: "portable", }; @@ -77,7 +80,19 @@ describe("preparePortableExperimentalHost", () => { ["--user", "try-restart", "podman.service"], ["--user", "enable", "--now", "podman.socket"], ]); - expect(podman).toHaveBeenCalledWith(["info", "--format", "{{.Host.RemoteSocket.Path}}"], env); + expect(podman).toHaveBeenCalledWith( + ["info", "--format", "{{.Host.RemoteSocket.Path}}"], + expect.not.objectContaining({ + CONTAINER_CONNECTION: expect.anything(), + CONTAINER_HOST: expect.anything(), + CONTAINER_SSHKEY: expect.anything(), + }), + ); + expect(podman.mock.calls[0]?.[1]).toMatchObject({ + CONTAINERS_CONF: path.join(home, ".config/nemoclaw/portable/containers.conf"), + NETAVARK_FW: "iptables", + NEMOCLAW_EXPERIMENTAL_PROFILE: "portable", + }); expect(hardenSocketDirectory).toHaveBeenCalledWith("/run/user/1001/custom/podman.sock", 1001); expect(docker.mock.calls[0]?.[0]).toEqual(["--version"]); expect(docker.mock.calls[2]?.[0]).toEqual([ diff --git a/src/lib/onboard/experimental/portable-host-preparation.ts b/src/lib/onboard/experimental/portable-host-preparation.ts index 2e2cc836f4e..d20164c7eed 100644 --- a/src/lib/onboard/experimental/portable-host-preparation.ts +++ b/src/lib/onboard/experimental/portable-host-preparation.ts @@ -7,7 +7,7 @@ import path from "node:path"; import { dockerSpawnSync } from "../../adapters/docker/exec"; import { openRegularFileNoFollow } from "../../adapters/fs/regular-file"; -import { hardenPodmanSocketDirectory } from "../../adapters/podman"; +import { hardenPodmanSocketDirectory, localPodmanEnvironment } from "../../adapters/podman"; import { ensureConfigDir } from "../../state/config-io"; import { isPortableExperimentalProfile, PORTABLE_LOCAL_REGISTRY } from "../docker-driver-platform"; @@ -227,7 +227,7 @@ export function preparePortableExperimentalHost( timeout: HOST_COMMAND_TIMEOUT_MS, })); const dockerHost = resolvePodmanDockerHost( - podman(["info", "--format", "{{.Host.RemoteSocket.Path}}"], env), + podman(["info", "--format", "{{.Host.RemoteSocket.Path}}"], localPodmanEnvironment(env)), ); const socketPath = dockerHost.slice("unix://".length); (deps.hardenSocketDirectory ?? hardenPodmanSocketDirectory)(socketPath, Number(uid)); diff --git a/src/lib/sandbox/privileged-exec.test.ts b/src/lib/sandbox/privileged-exec.test.ts index 560fa0e4c92..1b230f445a2 100644 --- a/src/lib/sandbox/privileged-exec.test.ts +++ b/src/lib/sandbox/privileged-exec.test.ts @@ -336,10 +336,16 @@ describe("privileged sandbox exec routing", () => { it("rejects a Kubernetes registry owner before stale local-container discovery", () => { let dockerPsCalls = 0; + const resolvePortableDemoPrivilegedExecTarget = vi.fn(() => ({ + assertRuntimeAuthority: vi.fn(), + containerId: "a".repeat(64), + dockerHost: "unix:///run/user/1001/podman/podman.sock", + })); withPrivilegedExecMocks( { getSandbox: () => ({ name: "alpha", openshellDriver: "kubernetes" }), listSandboxes: () => ({ sandboxes: [{ name: "alpha" }], defaultSandbox: "alpha" }), + resolvePortableDemoPrivilegedExecTarget, dockerCapture: () => { dockerPsCalls += 1; return "stale-id\topenshell-alpha-stale\n"; @@ -352,6 +358,7 @@ describe("privileged sandbox exec routing", () => { }, ); expect(dockerPsCalls).toBe(0); + expect(resolvePortableDemoPrivilegedExecTarget).not.toHaveBeenCalled(); }); it("fails before docker discovery when registry disambiguation is unavailable", () => { diff --git a/src/lib/sandbox/privileged-exec.ts b/src/lib/sandbox/privileged-exec.ts index cdffd777871..ab036073d1b 100644 --- a/src/lib/sandbox/privileged-exec.ts +++ b/src/lib/sandbox/privileged-exec.ts @@ -211,6 +211,10 @@ function privilegedSandboxExecArgv( ): string[] { const entry = readSandboxEntry(sandboxName); if (!entry) throw missingRegistryEntryError(sandboxName); + const driver = normalizeDriver(entry.openshellDriver); + if (driver !== null && driver !== "docker" && driver !== "vm") { + throw unsupportedDirectDriverError(sandboxName, driver); + } const portableTarget = resolvePortableDemoPrivilegedExecTarget(sandboxName); if (portableTarget) { if (expectedContainerId !== undefined && portableTarget.containerId !== expectedContainerId) { @@ -235,11 +239,6 @@ function privilegedSandboxExecArgv( ...cmd, ]; } - const driver = normalizeDriver(entry?.openshellDriver); - if (driver !== null && driver !== "docker" && driver !== "vm") { - throw unsupportedDirectDriverError(sandboxName, driver); - } - // Docker/direct-container is the only supported privileged mutation path. // Try it even when older registry entries do not record a driver, then fail // clearly if no matching sandbox container is running. diff --git a/test/e2e/live/full-e2e.test.ts b/test/e2e/live/full-e2e.test.ts index ee27c543ca4..b4ac3941866 100644 --- a/test/e2e/live/full-e2e.test.ts +++ b/test/e2e/live/full-e2e.test.ts @@ -46,6 +46,7 @@ import { runLaunchAgentTurn } from "./launch-agent-turn.ts"; import { bindApprovedPrBaseForBaseImageComparison } from "./pr-base-comparison.ts"; const SANDBOX_NAME = process.env.NEMOCLAW_SANDBOX_NAME ?? "e2e-full"; +const FULL_E2E_TARGET_ID = process.env.E2E_TARGET_ID ?? "full-e2e"; const SETUP_MODE = process.env.NEMOCLAW_E2E_SETUP_MODE ?? "source-install"; const USE_PREINSTALLED_LAUNCHABLE = SETUP_MODE === "preinstalled-launchable"; const PORTABLE_PROFILE = process.env.NEMOCLAW_EXPERIMENTAL_PROFILE === "portable"; @@ -374,7 +375,7 @@ test("full e2e: install, onboard, inference, cli operations, and cleanup", { const coldOnboardBudget = USE_PREINSTALLED_LAUNCHABLE ? null : readFullE2eColdPathBudget(); const redactionValues = [hosted.apiKey]; await artifacts.target.declare({ - id: process.env.E2E_TARGET_ID ?? "full-e2e", + id: FULL_E2E_TARGET_ID, sandboxName: SANDBOX_NAME, endpointUrl: hosted.endpointUrl, model: hosted.model, @@ -597,7 +598,7 @@ test("full e2e: install, onboard, inference, cli operations, and cleanup", { expect(registryText).not.toContain(SANDBOX_NAME); await artifacts.target.complete({ - id: "full-e2e", + id: FULL_E2E_TARGET_ID, securityPosture, status: "passed", }); diff --git a/test/e2e/support/e2e-workflow.test.ts b/test/e2e/support/e2e-workflow.test.ts index 6768c8aff4c..8b540efdd79 100644 --- a/test/e2e/support/e2e-workflow.test.ts +++ b/test/e2e/support/e2e-workflow.test.ts @@ -718,6 +718,10 @@ describe("e2e workflow boundary", () => { path.join(process.cwd(), ".github", "workflows", "portable-profile-e2e.yaml"), "utf8", ); + const fullE2eSource = fs.readFileSync( + path.join(process.cwd(), "test", "e2e", "live", "full-e2e.test.ts"), + "utf8", + ); const portableWorkflow = YAML.parse(portableWorkflowSource) as { on?: { pull_request?: { paths?: string[] }; push?: { paths?: string[] } }; }; @@ -739,6 +743,7 @@ describe("e2e workflow boundary", () => { expect(portableWorkflowSource).toContain( "NVIDIA_INFERENCE_API_KEY: ${{ secrets.NVIDIA_INFERENCE_API_KEY }}", ); + expect(fullE2eSource.match(/id: FULL_E2E_TARGET_ID,/gu)).toHaveLength(2); expect(inventory.allowedJobs).not.toHaveLength(0); expect(inventory.targetToJob.size).toBeGreaterThan(0); expect(inventory.workflowJobs.every((job) => workflowJobs.has(job))).toBe(true); diff --git a/test/helpers/destroy-flow-test-harness.ts b/test/helpers/destroy-flow-test-harness.ts index 96cd7dd71dd..825905ec13a 100644 --- a/test/helpers/destroy-flow-test-harness.ts +++ b/test/helpers/destroy-flow-test-harness.ts @@ -30,6 +30,7 @@ export type DestroyHarness = { prepareMcpBridgesForAbsentSandboxDestroySpy: MockInstance; prepareMcpBridgesForDestroySpy: MockInstance; promptSpy: MockInstance; + removePortableDemoLifecycleReceiptSpy: MockInstance; removeSandboxSpy: MockInstance; revokeHttpsPinRuntimeAdapterRouteSpy: MockInstance; restoreMcpBridgesAfterDestroyAbortSpy: MockInstance; @@ -123,6 +124,9 @@ export function createDestroyHarness(options: DestroyHarnessOptions = {}): Destr const runtime = requireDist("../../adapters/openshell/runtime.js"); const destroyGateway = requireDist("./destroy-gateway.js"); const credentialStore = requireDist("../../credentials/store.js"); + const portableDemoLifecycle = requireDist( + "../../onboard/experimental/portable-demo-lifecycle.js", + ); const sandboxProviderCleanup = requireDist("../../onboard/sandbox-provider-cleanup.js"); const nim = requireDist("../../inference/nim.js"); const ollamaProxy = requireDist("../../inference/ollama/proxy.js"); @@ -178,6 +182,9 @@ export function createDestroyHarness(options: DestroyHarnessOptions = {}): Destr registeredSandboxCount = Math.max(0, registeredSandboxCount - 1); return true; }); + const removePortableDemoLifecycleReceiptSpy = vi + .spyOn(portableDemoLifecycle, "removePortableDemoSandboxLifecycleReceipt") + .mockImplementation(() => undefined); const revokeHttpsPinRuntimeAdapterRouteSpy = vi .spyOn(httpsPinRuntimeAdapter, "revokeHttpsPinRuntimeAdapterRoute") .mockResolvedValue(true); @@ -353,6 +360,7 @@ export function createDestroyHarness(options: DestroyHarnessOptions = {}): Destr prepareMcpBridgesForAbsentSandboxDestroySpy, prepareMcpBridgesForDestroySpy, promptSpy, + removePortableDemoLifecycleReceiptSpy, removeSandboxSpy, revokeHttpsPinRuntimeAdapterRouteSpy, restoreMcpBridgesAfterDestroyAbortSpy, From 5f8b23cab918447da68c50d39a9c3d76a2184b6b Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Fri, 7 Aug 2026 15:36:04 -0700 Subject: [PATCH 09/15] test(cli): keep socket cleanup branchless Signed-off-by: Prekshi Vyas --- src/lib/adapters/podman/socket-authority.test.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/lib/adapters/podman/socket-authority.test.ts b/src/lib/adapters/podman/socket-authority.test.ts index 9a6ddc78107..14c879e8d8f 100644 --- a/src/lib/adapters/podman/socket-authority.test.ts +++ b/src/lib/adapters/podman/socket-authority.test.ts @@ -142,9 +142,7 @@ describe("Podman socket authority", () => { ).toThrow(); expect(fs.statSync(missingSocketDirectory).mode & 0o777).toBe(0o755); } finally { - if (server.listening) { - await new Promise((resolve) => server.close(() => resolve())); - } + await new Promise((resolve) => server.close(() => resolve())).catch(() => undefined); fs.rmSync(root, { force: true, recursive: true }); } }, From d4d09c784a87bce2301eefeee833e313d779fefd Mon Sep 17 00:00:00 2001 From: Senthil Ravichandran Date: Fri, 7 Aug 2026 15:44:47 -0700 Subject: [PATCH 10/15] fix(portable): bind cleanup to registry generation Signed-off-by: Senthil Ravichandran --- src/lib/actions/sandbox/destroy-execution.ts | 5 + src/lib/actions/sandbox/destroy-flow.test.ts | 6 +- src/lib/actions/sandbox/destroy.ts | 15 ++- src/lib/actions/sandbox/gateway-state.ts | 15 ++- .../adapters/podman/socket-authority.test.ts | 8 ++ src/lib/onboard.ts | 5 + .../portable-demo-lifecycle.test.ts | 125 +++++++++++++++++- .../experimental/portable-demo-lifecycle.ts | 78 ++++++++--- .../portable-host-preparation.test.ts | 18 +-- .../experimental/portable-host-preparation.ts | 8 +- .../onboard/sandbox-gpu-create-flow.test.ts | 10 +- src/lib/onboard/sandbox-gpu-create-flow.ts | 17 ++- src/lib/sandbox/privileged-exec.test.ts | 47 ++++++- src/lib/sandbox/privileged-exec.ts | 8 +- test/helpers/destroy-flow-test-harness.ts | 12 +- 15 files changed, 316 insertions(+), 61 deletions(-) diff --git a/src/lib/actions/sandbox/destroy-execution.ts b/src/lib/actions/sandbox/destroy-execution.ts index 865fae0773a..0629dc97ef7 100644 --- a/src/lib/actions/sandbox/destroy-execution.ts +++ b/src/lib/actions/sandbox/destroy-execution.ts @@ -3,6 +3,7 @@ import { R, YW } from "../../cli/terminal-style"; import { getSandboxDeleteOutcome } from "../../domain/sandbox/destroy"; +import { removePortableDemoSandboxLifecycleReceipt } from "../../onboard/experimental/portable-demo-lifecycle"; import { CURRENT_RUNTIME_PROVIDER_BUNDLES, type RuntimeProviderBundle, @@ -32,6 +33,10 @@ export function redactDestroyError(error: unknown): string { return redactFull(error instanceof Error ? error.message : String(error)); } +export function retirePortableLifecycleAuthority(sandboxName: string): void { + removePortableDemoSandboxLifecycleReceipt(sandboxName); +} + type SandboxDestroyExecutionInput = { cleanupShieldsArtifacts: (sandboxName: string) => void; force: boolean; diff --git a/src/lib/actions/sandbox/destroy-flow.test.ts b/src/lib/actions/sandbox/destroy-flow.test.ts index f49d155b026..97986044577 100644 --- a/src/lib/actions/sandbox/destroy-flow.test.ts +++ b/src/lib/actions/sandbox/destroy-flow.test.ts @@ -57,9 +57,9 @@ describe("destroySandbox flow", () => { ).resolves.toBeUndefined(); expectSuccessfulLiveDestroy(harness, exitSpy); - expect(harness.removePortableDemoLifecycleReceiptSpy).toHaveBeenCalledWith("alpha"); + expect(harness.retirePortableLifecycleReceiptSpy).toHaveBeenCalledWith("alpha"); expect(harness.removeSandboxSpy.mock.invocationCallOrder[0]).toBeLessThan( - harness.removePortableDemoLifecycleReceiptSpy.mock.invocationCallOrder[0], + harness.retirePortableLifecycleReceiptSpy.mock.invocationCallOrder[0], ); }); @@ -110,7 +110,7 @@ describe("destroySandbox flow", () => { await expect(harness.destroySandbox("alpha", { yes: true })).rejects.toThrow("process.exit(7)"); expectFailedDeletePreservesHostState(harness, exitSpy); - expect(harness.removePortableDemoLifecycleReceiptSpy).not.toHaveBeenCalled(); + expect(harness.retirePortableLifecycleReceiptSpy).not.toHaveBeenCalled(); }); it("preserves provider and registry ownership when runtime authority is unknown", async () => { diff --git a/src/lib/actions/sandbox/destroy.ts b/src/lib/actions/sandbox/destroy.ts index 3fc88dda333..0f4c55dea67 100644 --- a/src/lib/actions/sandbox/destroy.ts +++ b/src/lib/actions/sandbox/destroy.ts @@ -22,7 +22,6 @@ import { revokeHttpsPinRuntimeAdapterRoute, } from "../../inference/https-pin-runtime-adapter"; import { cleanupManagedLlamaCppRuntimeForSandbox } from "../../inference/local-model-profile/cleanup"; -import { removePortableDemoSandboxLifecycleReceipt } from "../../onboard/experimental/portable-demo-lifecycle"; import { CURRENT_RUNTIME_PROVIDER_BUNDLES, normalizeRuntimeProviderIdentity, @@ -42,7 +41,11 @@ import * as onboardSession from "../../state/onboard-session"; import { resolveNemoclawStateDir } from "../../state/paths"; import * as registry from "../../state/registry"; import { confirmSandboxDestroy } from "./destroy-confirmation"; -import { executeSandboxDestroy, redactDestroyError } from "./destroy-execution"; +import { + executeSandboxDestroy, + redactDestroyError, + retirePortableLifecycleAuthority, +} from "./destroy-execution"; import { cleanupGatewayAfterLastSandbox } from "./destroy-gateway"; import { shouldCleanupGatewayAfterConfirmedFinalDestroy } from "./destroy-gateway-cleanup"; import { prepareSandboxDestroy } from "./destroy-preflight"; @@ -614,7 +617,13 @@ async function destroySandboxUnlocked( process.exit(1); } if (removed) { - removePortableDemoSandboxLifecycleReceipt(sandboxName); + try { + retirePortableLifecycleAuthority(sandboxName); + } catch (error) { + console.warn( + ` ${YW}âš ${R} Failed to retire portable lifecycle authority for '${sandboxName}': ${redactDestroyError(error)}`, + ); + } } if (deleteSucceededOrAlreadyGone && removed && priorHttpsPinRouteId) { await revokeDestroyedSandboxHttpsPinRoute(cleanupGatewayName, priorHttpsPinRouteId); diff --git a/src/lib/actions/sandbox/gateway-state.ts b/src/lib/actions/sandbox/gateway-state.ts index 5f46646d015..434a0e2411e 100644 --- a/src/lib/actions/sandbox/gateway-state.ts +++ b/src/lib/actions/sandbox/gateway-state.ts @@ -91,13 +91,22 @@ function gatewayScopedArgs(args: string[], gatewayName?: string): string[] { /** Recover a receipt-bound portable sandbox before the live lookup rejects a stopped container. */ export function recoverPortableDemoSandboxLifecycleForConnect( sandboxName: string, - sandbox: Pick | null, + sandbox: Pick< + SandboxEntry, + "agent" | "lifecycleGeneration" | "openshellDriver" | "provider" + > | null, gatewayName: string, ): PortableDemoLifecycleRecoveryResult { - if (!sandbox) return { kind: "not-installed" }; + if (!sandbox || sandbox.openshellDriver !== "docker") return { kind: "not-installed" }; return recoverPortableDemoSandboxLifecycle( sandboxName, - { agent: sandbox.agent, gatewayName, provider: sandbox.provider }, + { + agent: sandbox.agent, + gatewayName, + lifecycleGeneration: sandbox.lifecycleGeneration, + openshellDriver: sandbox.openshellDriver, + provider: sandbox.provider, + }, { openshellBinary: getOpenshellBinary(), captureOpenshell: (args, timeoutMs) => { diff --git a/src/lib/adapters/podman/socket-authority.test.ts b/src/lib/adapters/podman/socket-authority.test.ts index 14c879e8d8f..31990d8001c 100644 --- a/src/lib/adapters/podman/socket-authority.test.ts +++ b/src/lib/adapters/podman/socket-authority.test.ts @@ -118,6 +118,7 @@ describe("Podman socket authority", () => { server.once("error", reject); server.listen(socketPath, resolve); }); + fs.chmodSync(socketPath, 0o660); hardenPodmanSocketDirectory(socketPath, uid); expect(fs.statSync(socketDirectory).mode & 0o777).toBe(0o700); @@ -141,6 +142,13 @@ describe("Podman socket authority", () => { hardenPodmanSocketDirectory(path.join(missingSocketDirectory, "missing.sock"), uid), ).toThrow(); expect(fs.statSync(missingSocketDirectory).mode & 0o777).toBe(0o755); + + fs.chmodSync(socketDirectory, 0o755); + fs.chmodSync(socketPath, 0o666); + expect(() => hardenPodmanSocketDirectory(socketPath, uid)).toThrow( + "writable by another user or group", + ); + expect(fs.statSync(socketDirectory).mode & 0o777).toBe(0o755); } finally { await new Promise((resolve) => server.close(() => resolve())).catch(() => undefined); fs.rmSync(root, { force: true, recursive: true }); diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index c6108403c90..bcd32ee23bf 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -2622,6 +2622,7 @@ async function createSandboxWithBaseImageResolution( route: selectedGpuRoute, firstCreateOutput, registryImageRef, + portableLifecycleGeneration, } = await sandboxGpuCreateFlow.runSandboxGpuCreateFlow( { sandboxName, @@ -2636,6 +2637,8 @@ async function createSandboxWithBaseImageResolution( createArgv, sandboxEnv, sandboxStartupCommand, + // biome-ignore format: keep src/lib/onboard.ts compact for growth guardrail. + ...(recreateRuntime.targetGeneration ? { registryGeneration: recreateRuntime.targetGeneration } : {}), prebuild, restoreBackupPath, terminalAgent: agentDefs.isTerminalAgent(agent), @@ -2766,6 +2769,8 @@ async function createSandboxWithBaseImageResolution( hermesToolGateways, hermesDashboardState: finalHermesDashboardState, dashboardPort: actualDashboardPort, + // biome-ignore format: keep src/lib/onboard.ts compact for growth guardrail. + ...(portableLifecycleGeneration ? { lifecycleGeneration: portableLifecycleGeneration } : {}), ...recreateRuntime.registrationFields, gatewayName: GATEWAY_NAME, gatewayPort: GATEWAY_PORT, diff --git a/src/lib/onboard/experimental/portable-demo-lifecycle.test.ts b/src/lib/onboard/experimental/portable-demo-lifecycle.test.ts index 4d21004f073..138ed4b272c 100644 --- a/src/lib/onboard/experimental/portable-demo-lifecycle.test.ts +++ b/src/lib/onboard/experimental/portable-demo-lifecycle.test.ts @@ -13,7 +13,8 @@ import { installPortableDemoSandboxLifecycle, type PortableDemoLifecycleDeps, portableDemoLifecycleInternals, - recoverPortableDemoSandboxLifecycle, + recoverPortableDemoSandboxLifecycle as recoverPortableDemoSandboxLifecycleUnchecked, + removePortableDemoSandboxLifecycleReceipt, resolvePortableDemoPrivilegedExecTarget, } from "./portable-demo-lifecycle"; @@ -179,6 +180,7 @@ function resolveTarget( ) { return resolvePortableDemoPrivilegedExecTarget("alpha", { platform: "linux", + registryGeneration: CONTAINER_ID, stateDir, podman: runtime.podman, podmanSocketAuthorityDeps: socketAuthorityDeps(), @@ -196,6 +198,22 @@ function installReceipt(stateDir: string, podman: ReturnType[1], + deps: PortableDemoLifecycleDeps = {}, +) { + return recoverPortableDemoSandboxLifecycleUnchecked( + sandboxName, + { + lifecycleGeneration: CONTAINER_ID, + openshellDriver: "docker", + ...context, + }, + deps, + ); +} + function createManagedOllamaBinary(homeDir: string): string { const binPath = path.join(homeDir, ".local", "bin", "ollama"); fs.mkdirSync(path.dirname(binPath), { recursive: true }); @@ -287,6 +305,26 @@ describe("portable demo sandbox lifecycle", () => { expect(runtime.podman).not.toHaveBeenCalled(); }); + it("ignores an installed receipt for a non-Docker registry driver (#8584)", () => { + const stateDir = temporaryStateDir(); + const runtime = createPodman(); + installReceipt(stateDir, runtime.podman); + runtime.podman.mockClear(); + + expect( + recoverPortableDemoSandboxLifecycle( + "alpha", + { + agent: "openclaw", + gatewayName: "nemoclaw", + openshellDriver: "kubernetes", + }, + { platform: "linux", stateDir, podman: runtime.podman }, + ), + ).toEqual({ kind: "not-installed" }); + expect(runtime.podman).not.toHaveBeenCalled(); + }); + it("rejects an installed receipt outside Linux (#8441)", () => { const stateDir = temporaryStateDir(); const runtime = createPodman(); @@ -343,11 +381,12 @@ describe("portable demo sandbox lifecycle", () => { const filePath = portableDemoLifecycleInternals.receiptPath("alpha", stateDir); const receipt = JSON.parse(fs.readFileSync(filePath, "utf-8")); expect(receipt).toEqual({ - schemaVersion: 2, + schemaVersion: 3, sandboxName: "alpha", sandboxId: SANDBOX_ID, containerId: CONTAINER_ID, dashboardPort: 18789, + registryGeneration: CONTAINER_ID, }); expect(fs.statSync(filePath).mode & 0o777).toBe(0o600); }); @@ -395,6 +434,81 @@ describe("portable demo sandbox lifecycle", () => { ]); }); + it("rejects a receipt outside the current registry generation before Podman access (#8584)", () => { + const stateDir = temporaryStateDir(); + const runtime = createPodman(); + installReceipt(stateDir, runtime.podman); + runtime.podman.mockClear(); + + expect(() => + resolveTarget(stateDir, runtime, { registryGeneration: "replacement-generation" }), + ).toThrow("does not belong to the current registry generation"); + expect(runtime.podman).not.toHaveBeenCalled(); + }); + + it("rejects recovery outside the current registry generation before Podman access (#8584)", () => { + const stateDir = temporaryStateDir(); + const runtime = createPodman(); + installReceipt(stateDir, runtime.podman); + runtime.podman.mockClear(); + + expect(() => + recoverPortableDemoSandboxLifecycle( + "alpha", + { + agent: "openclaw", + gatewayName: "nemoclaw", + lifecycleGeneration: "replacement-generation", + openshellDriver: "docker", + }, + { + platform: "linux", + stateDir, + podman: runtime.podman, + }, + ), + ).toThrow("does not belong to the current registry generation"); + expect(runtime.podman).not.toHaveBeenCalled(); + }); + + it("rejects a legacy receipt after same-name registry replacement (#8584)", () => { + const stateDir = temporaryStateDir(); + const runtime = createPodman(); + installReceipt(stateDir, runtime.podman); + const receiptPath = portableDemoLifecycleInternals.receiptPath("alpha", stateDir); + const receipt = JSON.parse(fs.readFileSync(receiptPath, "utf8")); + delete receipt.registryGeneration; + fs.writeFileSync(receiptPath, `${JSON.stringify({ ...receipt, schemaVersion: 2 })}\n`, { + mode: 0o600, + }); + runtime.podman.mockClear(); + + expect(() => + recoverPortableDemoSandboxLifecycle( + "alpha", + { + agent: "openclaw", + gatewayName: "nemoclaw", + lifecycleGeneration: "replacement-generation", + openshellDriver: "docker", + }, + { platform: "linux", stateDir, podman: runtime.podman }, + ), + ).toThrow("does not belong to the current registry generation"); + expect(runtime.podman).not.toHaveBeenCalled(); + }); + + it("retires portable lifecycle authority after its sandbox registry entry is removed (#8584)", () => { + const stateDir = temporaryStateDir(); + const runtime = createPodman(); + installReceipt(stateDir, runtime.podman); + const receiptPath = portableDemoLifecycleInternals.receiptPath("alpha", stateDir); + + removePortableDemoSandboxLifecycleReceipt("alpha", stateDir); + + expect(fs.existsSync(receiptPath)).toBe(false); + }); + it("refuses missing or duplicate portable containers before privileged exec (#8584)", () => { for (const matches of [[], [CONTAINER_ID, "b".repeat(64)]]) { const stateDir = temporaryStateDir(); @@ -1203,6 +1317,7 @@ describe("portable demo sandbox lifecycle", () => { installReceipt(stateDir, runtime.podman); const receiptPath = portableDemoLifecycleInternals.receiptPath("alpha", stateDir); const receipt = JSON.parse(fs.readFileSync(receiptPath, "utf-8")); + delete receipt.registryGeneration; fs.writeFileSync( receiptPath, `${JSON.stringify({ ...receipt, schemaVersion: 1 }, null, 2)}\n`, @@ -1266,7 +1381,10 @@ describe("portable demo sandbox lifecycle", () => { 5000, ); expect(launchOpenshell).toHaveBeenCalledOnce(); - expect(JSON.parse(fs.readFileSync(receiptPath, "utf-8"))).toMatchObject({ schemaVersion: 2 }); + expect(JSON.parse(fs.readFileSync(receiptPath, "utf-8"))).toMatchObject({ + schemaVersion: 3, + registryGeneration: CONTAINER_ID, + }); expect( recoverPortableDemoSandboxLifecycle( @@ -1284,6 +1402,7 @@ describe("portable demo sandbox lifecycle", () => { installReceipt(stateDir, runtime.podman); const receiptPath = portableDemoLifecycleInternals.receiptPath("alpha", stateDir); const receipt = JSON.parse(fs.readFileSync(receiptPath, "utf-8")); + delete receipt.registryGeneration; fs.writeFileSync( receiptPath, `${JSON.stringify({ ...receipt, schemaVersion: 1 }, null, 2)}\n`, diff --git a/src/lib/onboard/experimental/portable-demo-lifecycle.ts b/src/lib/onboard/experimental/portable-demo-lifecycle.ts index 1887f65c7fa..94159e8c240 100644 --- a/src/lib/onboard/experimental/portable-demo-lifecycle.ts +++ b/src/lib/onboard/experimental/portable-demo-lifecycle.ts @@ -44,7 +44,7 @@ const PODMAN_SANDBOX_NAME_LABEL = "openshell.sandbox-name"; const PODMAN_SANDBOX_CONTAINER_PREFIX = "openshell-sandbox-"; const OPENSHELL_RUNTIME_CA_CERT = "/etc/openshell-tls/openshell-ca.pem"; const OPENSHELL_RUNTIME_CA_BUNDLE = "/etc/openshell-tls/ca-bundle.pem"; -const CURRENT_RECEIPT_SCHEMA_VERSION = 2; +const CURRENT_RECEIPT_SCHEMA_VERSION = 3; const STARTUP_PROCESS_PATTERN = "^(/usr/local/bin/nemoclaw-start|(bash|/bin/bash|/usr/bin/bash) /usr/local/bin/nemoclaw-start)( |$)"; const SLEEP_BUFFER = new Int32Array(new SharedArrayBuffer(4)); @@ -57,11 +57,12 @@ type CommandResult = { }; interface PortableDemoLifecycleReceipt { - schemaVersion: 1 | 2; + schemaVersion: 1 | 2 | 3; sandboxName: string; sandboxId: string; containerId: string; dashboardPort: number; + registryGeneration?: string; } interface PodmanContainerInspection { @@ -84,6 +85,7 @@ export interface PortableDemoLifecycleDeps { podman?: (args: readonly string[], env?: NodeJS.ProcessEnv) => CommandResult; podmanSocketAuthorityDeps?: PodmanSocketAuthorityDeps; hardenSocketDirectory?: (socketPath: string) => void; + registryGeneration?: string; captureOpenshell?: (args: readonly string[], timeoutMs: number) => CommandResult; launchOpenshell?: (args: readonly string[]) => void; captureHost?: (command: string, args: readonly string[], timeoutMs: number) => CommandResult; @@ -107,6 +109,8 @@ export type PortableDemoLifecycleRecoveryResult = export interface PortableDemoLifecycleContext { agent?: string | null; gatewayName: string; + lifecycleGeneration?: string; + openshellDriver?: string | null; provider?: string | null; } @@ -230,11 +234,17 @@ function parseReceipt(value: unknown, sandboxName: string): PortableDemoLifecycl } const receipt = value; const keys = Object.keys(receipt).sort(); - if (keys.join(",") !== "containerId,dashboardPort,sandboxId,sandboxName,schemaVersion") { + const expectedKeys = + receipt.schemaVersion === CURRENT_RECEIPT_SCHEMA_VERSION + ? "containerId,dashboardPort,registryGeneration,sandboxId,sandboxName,schemaVersion" + : "containerId,dashboardPort,sandboxId,sandboxName,schemaVersion"; + if (keys.join(",") !== expectedKeys) { throw new Error("Portable demo lifecycle receipt fields are invalid"); } if ( - (receipt.schemaVersion !== 1 && receipt.schemaVersion !== CURRENT_RECEIPT_SCHEMA_VERSION) || + (receipt.schemaVersion !== 1 && + receipt.schemaVersion !== 2 && + receipt.schemaVersion !== CURRENT_RECEIPT_SCHEMA_VERSION) || receipt.sandboxName !== sandboxName || typeof receipt.containerId !== "string" || !CONTAINER_ID_PATTERN.test(receipt.containerId) || @@ -242,13 +252,34 @@ function parseReceipt(value: unknown, sandboxName: string): PortableDemoLifecycl !SANDBOX_ID_PATTERN.test(receipt.sandboxId) || !Number.isInteger(receipt.dashboardPort) || Number(receipt.dashboardPort) < 1024 || - Number(receipt.dashboardPort) > 65535 + Number(receipt.dashboardPort) > 65535 || + (receipt.schemaVersion === CURRENT_RECEIPT_SCHEMA_VERSION && + (typeof receipt.registryGeneration !== "string" || + !SANDBOX_ID_PATTERN.test(receipt.registryGeneration))) ) { throw new Error("Portable demo lifecycle receipt values are invalid"); } return receipt as unknown as PortableDemoLifecycleReceipt; } +function requireCurrentRegistryGeneration( + receipt: PortableDemoLifecycleReceipt, + registryGeneration: string | undefined, +): void { + // Legacy receipts predate an explicit generation field. Their immutable, + // exact container ID is accepted only when the current sandbox registry + // generation uses that same identity; same-name replacements cannot match. + const receiptGeneration = + receipt.schemaVersion === CURRENT_RECEIPT_SCHEMA_VERSION + ? receipt.registryGeneration + : receipt.containerId; + if (!registryGeneration || receiptGeneration !== registryGeneration) { + throw new Error( + `Portable demo lifecycle receipt for sandbox '${receipt.sandboxName}' does not belong to the current registry generation`, + ); + } +} + function loadReceipt(sandboxName: string, stateDir: string): PortableDemoLifecycleReceipt | null { let file; try { @@ -276,13 +307,6 @@ function removeReceipt(sandboxName: string, stateDir: string): void { } } -export function removePortableDemoSandboxLifecycleReceipt( - sandboxName: string, - stateDir = defaultStateDir(process.env), -): void { - removeReceipt(sandboxName, stateDir); -} - function startupEnvValue(startupArgv: readonly string[], name: string): string | null { const prefix = `${name}=`; for (let index = startupArgv.length - 2; index >= 1; index -= 1) { @@ -426,6 +450,7 @@ export function resolvePortableDemoPrivilegedExecTarget( if ((deps.platform ?? process.platform) !== "linux") { throw new Error("Portable demo lifecycle receipt is only valid on Linux"); } + requireCurrentRegistryGeneration(receipt, deps.registryGeneration); const podman = deps.podman ?? ((args, env = commandEnv) => defaultPodman(args, env)); const podmanEnv = localPodmanEnvironment(commandEnv); @@ -708,7 +733,7 @@ export function installPortableDemoSandboxLifecycle( createdStartupArgv: readonly string[], env: NodeJS.ProcessEnv = process.env, deps: PortableDemoLifecycleDeps = {}, -): void { +): string | null { const stateDir = deps.stateDir ?? defaultStateDir(env); if ( !isPortableExperimentalProfile(env) || @@ -716,7 +741,7 @@ export function installPortableDemoSandboxLifecycle( startupEnvValue(createdStartupArgv, "OPENCLAW_HOME") === null ) { removeReceipt(sandboxName, stateDir); - return; + return null; } if ((deps.platform ?? process.platform) !== "linux") { throw new Error("Portable demo lifecycle requires Linux"); @@ -724,18 +749,32 @@ export function installPortableDemoSandboxLifecycle( const commandEnv = deps.env ?? env; const podman = deps.podman ?? ((args) => defaultPodman(args, commandEnv)); const inspection = discoverPodmanContainer(sandboxName, podman); + const registryGeneration = deps.registryGeneration ?? inspection.containerId; + if (!SANDBOX_ID_PATTERN.test(registryGeneration)) { + throw new Error("Portable demo lifecycle registry generation is invalid"); + } const receipt: PortableDemoLifecycleReceipt = { schemaVersion: CURRENT_RECEIPT_SCHEMA_VERSION, sandboxName, sandboxId: inspection.sandboxId, containerId: inspection.containerId, dashboardPort: parseDashboardPort(createdStartupArgv, sandboxName), + registryGeneration, }; requireCommand( podman(["update", "--restart=unless-stopped", inspection.containerId]), `Setting the portable restart policy for sandbox '${sandboxName}'`, ); writeReceipt(receipt, stateDir); + return registryGeneration; +} + +/** Retire portable lifecycle authority only after its sandbox registry entry is removed. */ +export function removePortableDemoSandboxLifecycleReceipt( + sandboxName: string, + stateDir = defaultStateDir(process.env), +): void { + removeReceipt(sandboxName, stateDir); } /** @@ -748,13 +787,14 @@ export function recoverPortableDemoSandboxLifecycle( deps: PortableDemoLifecycleDeps = {}, ): PortableDemoLifecycleRecoveryResult { if ((context.agent ?? "openclaw") !== "openclaw") return { kind: "not-installed" }; + if (context.openshellDriver !== "docker") return { kind: "not-installed" }; const commandEnv = deps.env ?? process.env; const receipt = loadReceipt(sandboxName, deps.stateDir ?? defaultStateDir(commandEnv)); if (!receipt) return { kind: "not-installed" }; if ((deps.platform ?? process.platform) !== "linux") { throw new Error("Portable demo lifecycle receipt is only valid on Linux"); } - + requireCurrentRegistryGeneration(receipt, context.lifecycleGeneration); const podman = deps.podman ?? ((args) => defaultPodman(args, commandEnv)); const stateDir = deps.stateDir ?? defaultStateDir(commandEnv); const initialInspection = podman(["inspect", receipt.containerId]); @@ -802,7 +842,7 @@ export function recoverPortableDemoSandboxLifecycle( } recoverManagedOllama(context, commandEnv, stateDir, timing, deps); const gatewayRunning = gatewayIsRunning(receipt, gatewayName, capture, PROBE_TIMEOUT_MS); - const refreshStartup = receipt.schemaVersion < CURRENT_RECEIPT_SCHEMA_VERSION; + const refreshStartup = receipt.schemaVersion === 1; if (!refreshStartup && gatewayRunning) { return { kind: "already-running" }; } @@ -871,7 +911,11 @@ export function recoverPortableDemoSandboxLifecycle( } if (refreshStartup) { writeReceipt( - { ...receipt, schemaVersion: CURRENT_RECEIPT_SCHEMA_VERSION }, + { + ...receipt, + schemaVersion: CURRENT_RECEIPT_SCHEMA_VERSION, + registryGeneration: context.lifecycleGeneration, + }, deps.stateDir ?? defaultStateDir(commandEnv), ); } diff --git a/src/lib/onboard/experimental/portable-host-preparation.test.ts b/src/lib/onboard/experimental/portable-host-preparation.test.ts index 16850917552..858d777d44a 100644 --- a/src/lib/onboard/experimental/portable-host-preparation.test.ts +++ b/src/lib/onboard/experimental/portable-host-preparation.test.ts @@ -49,9 +49,9 @@ describe("preparePortableExperimentalHost", () => { const podman = vi.fn(() => result(0, "/run/user/1001/custom/podman.sock\n")); const hardenSocketDirectory = vi.fn(); const env: NodeJS.ProcessEnv = { - CONTAINER_CONNECTION: "remote-test", - CONTAINER_HOST: "ssh://example.test/run/user/1001/podman/podman.sock", - CONTAINER_SSHKEY: "/tmp/remote-test-key", + CONTAINER_CONNECTION: "attacker", + CONTAINER_HOST: "tcp://example.test:1234", + CONTAINER_SSHKEY: "/tmp/attacker-key", NEMOCLAW_EXPERIMENTAL_PROFILE: "portable", }; @@ -88,11 +88,13 @@ describe("preparePortableExperimentalHost", () => { CONTAINER_SSHKEY: expect.anything(), }), ); - expect(podman.mock.calls[0]?.[1]).toMatchObject({ - CONTAINERS_CONF: path.join(home, ".config/nemoclaw/portable/containers.conf"), - NETAVARK_FW: "iptables", - NEMOCLAW_EXPERIMENTAL_PROFILE: "portable", - }); + for (const [, commandEnv] of docker.mock.calls) { + expect(commandEnv).not.toHaveProperty("CONTAINER_CONNECTION"); + expect(commandEnv).not.toHaveProperty("CONTAINER_HOST"); + expect(commandEnv).not.toHaveProperty("CONTAINER_SSHKEY"); + expect(commandEnv.DOCKER_HOST).toBe("unix:///run/user/1001/custom/podman.sock"); + } + expect(env.CONTAINER_HOST).toBe("tcp://example.test:1234"); expect(hardenSocketDirectory).toHaveBeenCalledWith("/run/user/1001/custom/podman.sock", 1001); expect(docker.mock.calls[0]?.[0]).toEqual(["--version"]); expect(docker.mock.calls[2]?.[0]).toEqual([ diff --git a/src/lib/onboard/experimental/portable-host-preparation.ts b/src/lib/onboard/experimental/portable-host-preparation.ts index d20164c7eed..ca57e815eb8 100644 --- a/src/lib/onboard/experimental/portable-host-preparation.ts +++ b/src/lib/onboard/experimental/portable-host-preparation.ts @@ -226,12 +226,14 @@ export function preparePortableExperimentalHost( env: childEnv, timeout: HOST_COMMAND_TIMEOUT_MS, })); + const podmanEnv = localPodmanEnvironment(env); const dockerHost = resolvePodmanDockerHost( - podman(["info", "--format", "{{.Host.RemoteSocket.Path}}"], localPodmanEnvironment(env)), + podman(["info", "--format", "{{.Host.RemoteSocket.Path}}"], podmanEnv), ); const socketPath = dockerHost.slice("unix://".length); (deps.hardenSocketDirectory ?? hardenPodmanSocketDirectory)(socketPath, Number(uid)); env.DOCKER_HOST = dockerHost; + podmanEnv.DOCKER_HOST = dockerHost; const docker = deps.docker ?? @@ -241,8 +243,8 @@ export function preparePortableExperimentalHost( env: childEnv, timeout: REGISTRY_COMMAND_TIMEOUT_MS, })); - requireDockerCompatibleCli(docker, env); - ensureRegistryContainer(env, docker); + requireDockerCompatibleCli(docker, podmanEnv); + ensureRegistryContainer(podmanEnv, docker); } export const portableHostPreparationInternals = { diff --git a/src/lib/onboard/sandbox-gpu-create-flow.test.ts b/src/lib/onboard/sandbox-gpu-create-flow.test.ts index f380b36d698..85ea75eea54 100644 --- a/src/lib/onboard/sandbox-gpu-create-flow.test.ts +++ b/src/lib/onboard/sandbox-gpu-create-flow.test.ts @@ -673,14 +673,20 @@ describe("runSandboxGpuCreateFlow native failure and readiness", () => { it("configures the portable lifecycle after sandbox creation succeeds (#8441)", async () => { const input = createInput(); + input.registryGeneration = "current-generation"; const deps = createDeps(); - deps.installPortableDemoLifecycle = vi.fn(); + deps.installPortableDemoLifecycle = vi.fn(() => input.registryGeneration ?? null); - await expect(runSandboxGpuCreateFlow(input, deps)).resolves.toMatchObject({ route: "native" }); + await expect(runSandboxGpuCreateFlow(input, deps)).resolves.toMatchObject({ + portableLifecycleGeneration: "current-generation", + route: "native", + }); expect(deps.installPortableDemoLifecycle).toHaveBeenCalledWith( input.sandboxName, input.sandboxStartupCommand, + process.env, + { registryGeneration: "current-generation" }, ); }); diff --git a/src/lib/onboard/sandbox-gpu-create-flow.ts b/src/lib/onboard/sandbox-gpu-create-flow.ts index d9cec4789f4..1c6c2e8e2ea 100644 --- a/src/lib/onboard/sandbox-gpu-create-flow.ts +++ b/src/lib/onboard/sandbox-gpu-create-flow.ts @@ -80,6 +80,7 @@ export interface SandboxGpuCreateFlowInput { createArgv: string[]; sandboxEnv: NodeJS.ProcessEnv; sandboxStartupCommand: string[]; + registryGeneration?: string; prebuild: SandboxPrebuildResult; restoreBackupPath: string | null; terminalAgent: boolean; @@ -119,6 +120,7 @@ export interface SandboxGpuCreateFlowResult { firstCreateOutput: string; /** Mutable tag/reference retained only for registry and image-GC bookkeeping. */ registryImageRef: string | null; + portableLifecycleGeneration: string | null; } /** @@ -250,11 +252,17 @@ export async function runSandboxGpuCreateFlow( process.exit(1); } + let portableLifecycleGeneration: string | null = null; try { - (deps.installPortableDemoLifecycle ?? installPortableDemoSandboxLifecycle)( - input.sandboxName, - input.sandboxStartupCommand, - ); + portableLifecycleGeneration = + (deps.installPortableDemoLifecycle ?? installPortableDemoSandboxLifecycle)( + input.sandboxName, + input.sandboxStartupCommand, + process.env, + { + ...(input.registryGeneration ? { registryGeneration: input.registryGeneration } : {}), + }, + ) ?? null; } catch (error) { const detail = redactFull(error instanceof Error ? error.message : String(error)).slice(0, 500); console.warn(` Portable demo lifecycle setup did not complete: ${detail}`); @@ -265,5 +273,6 @@ export async function runSandboxGpuCreateFlow( route: gpuCreateOutcome.route, firstCreateOutput: attemptRunner.state.firstCreateOutput, registryImageRef, + portableLifecycleGeneration, }; } diff --git a/src/lib/sandbox/privileged-exec.test.ts b/src/lib/sandbox/privileged-exec.test.ts index 1b230f445a2..7a46f50319d 100644 --- a/src/lib/sandbox/privileged-exec.test.ts +++ b/src/lib/sandbox/privileged-exec.test.ts @@ -21,13 +21,18 @@ function restoreRequireCacheEntry(modulePath: string, priorEntry: unknown): void function withPrivilegedExecMocks( deps: { dockerCapture: (args: readonly string[], options?: { timeout?: number }) => string; - getSandbox: (name: string) => { name?: string; openshellDriver?: string | null } | null; + getSandbox: (name: string) => { + name?: string; + lifecycleGeneration?: string; + openshellDriver?: string | null; + } | null; listSandboxes: () => { sandboxes?: Array<{ name?: string | null }>; defaultSandbox?: string | null; }; resolvePortableDemoPrivilegedExecTarget?: ( sandboxName: string, + deps?: { registryGeneration?: string }, ) => { assertRuntimeAuthority: () => void; containerId: string; dockerHost: string } | null; }, run: (helper: typeof import("./privileged-exec")) => T, @@ -144,19 +149,24 @@ describe("privileged sandbox exec routing", () => { it("uses the receipt-owned Podman socket when the default Docker daemon has no container (#8584)", () => { let dockerPsCalls = 0; const assertRuntimeAuthority = vi.fn(); + const resolvePortableDemoPrivilegedExecTarget = vi.fn(() => ({ + assertRuntimeAuthority, + containerId: "a".repeat(64), + dockerHost: "unix:///run/user/1001/podman/podman.sock", + })); withPrivilegedExecMocks( { - getSandbox: () => ({ name: "alpha", openshellDriver: "docker" }), + getSandbox: () => ({ + name: "alpha", + lifecycleGeneration: "current-generation", + openshellDriver: "docker", + }), listSandboxes: () => ({ sandboxes: [{ name: "alpha" }], defaultSandbox: "alpha" }), dockerCapture: () => { dockerPsCalls += 1; return ""; }, - resolvePortableDemoPrivilegedExecTarget: () => ({ - assertRuntimeAuthority, - containerId: "a".repeat(64), - dockerHost: "unix:///run/user/1001/podman/podman.sock", - }), + resolvePortableDemoPrivilegedExecTarget, }, ({ privilegedSandboxExecArgv }) => { expect(privilegedSandboxExecArgv("alpha", ["id"], false, true)).toEqual([ @@ -206,6 +216,29 @@ describe("privileged sandbox exec routing", () => { ); expect(dockerPsCalls).toBe(0); expect(assertRuntimeAuthority).toHaveBeenCalledOnce(); + expect(resolvePortableDemoPrivilegedExecTarget).toHaveBeenCalledWith("alpha", { + registryGeneration: "current-generation", + }); + }); + + it("rejects a non-direct driver before consulting a stale portable receipt (#8584)", () => { + const resolvePortableDemoPrivilegedExecTarget = vi.fn(); + + withPrivilegedExecMocks( + { + getSandbox: () => ({ name: "alpha", openshellDriver: "kubernetes" }), + listSandboxes: () => ({ sandboxes: [{ name: "alpha" }], defaultSandbox: "alpha" }), + dockerCapture: vi.fn(), + resolvePortableDemoPrivilegedExecTarget, + }, + ({ privilegedSandboxExecArgv }) => { + expect(() => privilegedSandboxExecArgv("alpha", ["id"])).toThrow( + "refusing local Docker discovery for a non-direct driver", + ); + }, + ); + + expect(resolvePortableDemoPrivilegedExecTarget).not.toHaveBeenCalled(); }); it("bounds direct sandbox container discovery", () => { diff --git a/src/lib/sandbox/privileged-exec.ts b/src/lib/sandbox/privileged-exec.ts index ab036073d1b..c310cffdd63 100644 --- a/src/lib/sandbox/privileged-exec.ts +++ b/src/lib/sandbox/privileged-exec.ts @@ -11,6 +11,7 @@ const OPENSHELL_SANDBOX_NAME_LABEL = "openshell.ai/sandbox-name"; type SandboxEntry = { name?: string; + lifecycleGeneration?: string; openshellDriver?: string | null; }; @@ -215,7 +216,12 @@ function privilegedSandboxExecArgv( if (driver !== null && driver !== "docker" && driver !== "vm") { throw unsupportedDirectDriverError(sandboxName, driver); } - const portableTarget = resolvePortableDemoPrivilegedExecTarget(sandboxName); + const portableTarget = + driver === "docker" + ? resolvePortableDemoPrivilegedExecTarget(sandboxName, { + ...(entry.lifecycleGeneration ? { registryGeneration: entry.lifecycleGeneration } : {}), + }) + : null; if (portableTarget) { if (expectedContainerId !== undefined && portableTarget.containerId !== expectedContainerId) { throw new Error( diff --git a/test/helpers/destroy-flow-test-harness.ts b/test/helpers/destroy-flow-test-harness.ts index 825905ec13a..cd7b37f6aa7 100644 --- a/test/helpers/destroy-flow-test-harness.ts +++ b/test/helpers/destroy-flow-test-harness.ts @@ -30,8 +30,8 @@ export type DestroyHarness = { prepareMcpBridgesForAbsentSandboxDestroySpy: MockInstance; prepareMcpBridgesForDestroySpy: MockInstance; promptSpy: MockInstance; - removePortableDemoLifecycleReceiptSpy: MockInstance; removeSandboxSpy: MockInstance; + retirePortableLifecycleReceiptSpy: MockInstance; revokeHttpsPinRuntimeAdapterRouteSpy: MockInstance; restoreMcpBridgesAfterDestroyAbortSpy: MockInstance; runOpenshellSpy: MockInstance; @@ -124,9 +124,6 @@ export function createDestroyHarness(options: DestroyHarnessOptions = {}): Destr const runtime = requireDist("../../adapters/openshell/runtime.js"); const destroyGateway = requireDist("./destroy-gateway.js"); const credentialStore = requireDist("../../credentials/store.js"); - const portableDemoLifecycle = requireDist( - "../../onboard/experimental/portable-demo-lifecycle.js", - ); const sandboxProviderCleanup = requireDist("../../onboard/sandbox-provider-cleanup.js"); const nim = requireDist("../../inference/nim.js"); const ollamaProxy = requireDist("../../inference/ollama/proxy.js"); @@ -134,6 +131,7 @@ export function createDestroyHarness(options: DestroyHarnessOptions = {}): Destr const tunnelServices = requireDist("../../tunnel/services.js"); const onboardSession = requireDist("../../state/onboard-session.js"); const registry = requireDist("../../state/registry.js"); + const destroyExecution = requireDist("./destroy-execution.js"); const sandboxSession = requireDist("../../state/sandbox-session.js"); const shields = requireDist("../../shields/index.js"); const timerControl = requireDist("../../shields/timer-control.js"); @@ -182,8 +180,8 @@ export function createDestroyHarness(options: DestroyHarnessOptions = {}): Destr registeredSandboxCount = Math.max(0, registeredSandboxCount - 1); return true; }); - const removePortableDemoLifecycleReceiptSpy = vi - .spyOn(portableDemoLifecycle, "removePortableDemoSandboxLifecycleReceipt") + const retirePortableLifecycleReceiptSpy = vi + .spyOn(destroyExecution, "retirePortableLifecycleAuthority") .mockImplementation(() => undefined); const revokeHttpsPinRuntimeAdapterRouteSpy = vi .spyOn(httpsPinRuntimeAdapter, "revokeHttpsPinRuntimeAdapterRoute") @@ -360,8 +358,8 @@ export function createDestroyHarness(options: DestroyHarnessOptions = {}): Destr prepareMcpBridgesForAbsentSandboxDestroySpy, prepareMcpBridgesForDestroySpy, promptSpy, - removePortableDemoLifecycleReceiptSpy, removeSandboxSpy, + retirePortableLifecycleReceiptSpy, revokeHttpsPinRuntimeAdapterRouteSpy, restoreMcpBridgesAfterDestroyAbortSpy, runOpenshellSpy, From 197af2f5da9274f1852ab8eaeb06e8c44ec05334 Mon Sep 17 00:00:00 2001 From: Senthil Ravichandran Date: Fri, 7 Aug 2026 16:03:08 -0700 Subject: [PATCH 11/15] refactor(onboard): compose lifecycle fields in create flow Signed-off-by: Senthil Ravichandran --- src/lib/onboard.ts | 11 +++-------- .../onboard/sandbox-gpu-create-flow.test.ts | 14 +++++++++++--- src/lib/onboard/sandbox-gpu-create-flow.ts | 19 ++++++++++++++----- 3 files changed, 28 insertions(+), 16 deletions(-) diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index bcd32ee23bf..ececd11d20b 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -2622,7 +2622,7 @@ async function createSandboxWithBaseImageResolution( route: selectedGpuRoute, firstCreateOutput, registryImageRef, - portableLifecycleGeneration, + lifecycleRegistrationFields, } = await sandboxGpuCreateFlow.runSandboxGpuCreateFlow( { sandboxName, @@ -2637,8 +2637,7 @@ async function createSandboxWithBaseImageResolution( createArgv, sandboxEnv, sandboxStartupCommand, - // biome-ignore format: keep src/lib/onboard.ts compact for growth guardrail. - ...(recreateRuntime.targetGeneration ? { registryGeneration: recreateRuntime.targetGeneration } : {}), + lifecycleRegistrationFields: recreateRuntime.registrationFields, prebuild, restoreBackupPath, terminalAgent: agentDefs.isTerminalAgent(agent), @@ -2658,8 +2657,6 @@ async function createSandboxWithBaseImageResolution( process.removeListener("exit", initialSandboxPolicy.cleanup); } - // Clean up build context regardless of outcome. - // Use fs.rmSync instead of run() to avoid spawning a shell process. // Only deregister the 'exit' safety net when inline cleanup succeeded; // otherwise leave it armed so a later process.exit() still removes the // temp dir (which may hold source and env-arg API keys). @@ -2769,9 +2766,7 @@ async function createSandboxWithBaseImageResolution( hermesToolGateways, hermesDashboardState: finalHermesDashboardState, dashboardPort: actualDashboardPort, - // biome-ignore format: keep src/lib/onboard.ts compact for growth guardrail. - ...(portableLifecycleGeneration ? { lifecycleGeneration: portableLifecycleGeneration } : {}), - ...recreateRuntime.registrationFields, + ...lifecycleRegistrationFields, gatewayName: GATEWAY_NAME, gatewayPort: GATEWAY_PORT, }), diff --git a/src/lib/onboard/sandbox-gpu-create-flow.test.ts b/src/lib/onboard/sandbox-gpu-create-flow.test.ts index 85ea75eea54..d622067fa47 100644 --- a/src/lib/onboard/sandbox-gpu-create-flow.test.ts +++ b/src/lib/onboard/sandbox-gpu-create-flow.test.ts @@ -673,12 +673,20 @@ describe("runSandboxGpuCreateFlow native failure and readiness", () => { it("configures the portable lifecycle after sandbox creation succeeds (#8441)", async () => { const input = createInput(); - input.registryGeneration = "current-generation"; + input.lifecycleRegistrationFields = { + lifecycleGeneration: "current-generation", + lifecycleLiveIdentityFingerprint: "current-fingerprint", + }; const deps = createDeps(); - deps.installPortableDemoLifecycle = vi.fn(() => input.registryGeneration ?? null); + deps.installPortableDemoLifecycle = vi.fn( + () => input.lifecycleRegistrationFields?.lifecycleGeneration ?? null, + ); await expect(runSandboxGpuCreateFlow(input, deps)).resolves.toMatchObject({ - portableLifecycleGeneration: "current-generation", + lifecycleRegistrationFields: { + lifecycleGeneration: "current-generation", + lifecycleLiveIdentityFingerprint: "current-fingerprint", + }, route: "native", }); diff --git a/src/lib/onboard/sandbox-gpu-create-flow.ts b/src/lib/onboard/sandbox-gpu-create-flow.ts index 1c6c2e8e2ea..d63cda96d82 100644 --- a/src/lib/onboard/sandbox-gpu-create-flow.ts +++ b/src/lib/onboard/sandbox-gpu-create-flow.ts @@ -3,7 +3,7 @@ import type { StreamSandboxCreateResult } from "../sandbox/create-stream"; import { redactFull } from "../security/redact"; -import type { SandboxGpuProofResult } from "../state/registry"; +import type { SandboxEntry, SandboxGpuProofResult } from "../state/registry"; import * as dockerGpuLocalInference from "./docker-gpu-local-inference"; import { collectDockerGpuPatchDiagnostics } from "./docker-gpu-patch"; import type { DockerGpuPatchDeps, DockerUlimit } from "./docker-gpu-patch-types"; @@ -66,6 +66,10 @@ function exitForManagedBootstrapRecovery(error: ManagedBootstrapRecoveryBlockedE type RunOpenshell = NonNullable; type RunCaptureOpenshell = NonNullable; type Sleep = NonNullable; +type LifecycleRegistrationFields = Pick< + SandboxEntry, + "lifecycleGeneration" | "lifecycleLiveIdentityFingerprint" +>; export interface SandboxGpuCreateFlowInput { sandboxName: string; @@ -80,7 +84,7 @@ export interface SandboxGpuCreateFlowInput { createArgv: string[]; sandboxEnv: NodeJS.ProcessEnv; sandboxStartupCommand: string[]; - registryGeneration?: string; + lifecycleRegistrationFields?: LifecycleRegistrationFields; prebuild: SandboxPrebuildResult; restoreBackupPath: string | null; terminalAgent: boolean; @@ -120,7 +124,7 @@ export interface SandboxGpuCreateFlowResult { firstCreateOutput: string; /** Mutable tag/reference retained only for registry and image-GC bookkeeping. */ registryImageRef: string | null; - portableLifecycleGeneration: string | null; + lifecycleRegistrationFields: LifecycleRegistrationFields; } /** @@ -260,7 +264,9 @@ export async function runSandboxGpuCreateFlow( input.sandboxStartupCommand, process.env, { - ...(input.registryGeneration ? { registryGeneration: input.registryGeneration } : {}), + ...(input.lifecycleRegistrationFields?.lifecycleGeneration + ? { registryGeneration: input.lifecycleRegistrationFields.lifecycleGeneration } + : {}), }, ) ?? null; } catch (error) { @@ -273,6 +279,9 @@ export async function runSandboxGpuCreateFlow( route: gpuCreateOutcome.route, firstCreateOutput: attemptRunner.state.firstCreateOutput, registryImageRef, - portableLifecycleGeneration, + lifecycleRegistrationFields: { + ...(portableLifecycleGeneration ? { lifecycleGeneration: portableLifecycleGeneration } : {}), + ...input.lifecycleRegistrationFields, + }, }; } From 58cdb28a224aec27544ca1336a82bba350b3120a Mon Sep 17 00:00:00 2001 From: Senthil Ravichandran Date: Fri, 7 Aug 2026 16:24:57 -0700 Subject: [PATCH 12/15] fix(portable): migrate legacy lifecycle receipts Signed-off-by: Senthil Ravichandran --- src/lib/actions/sandbox/gateway-state.ts | 8 +- .../portable-demo-lifecycle-migration.test.ts | 208 ++++++++++++++++++ .../experimental/portable-demo-lifecycle.ts | 116 +++++++--- src/lib/sandbox/privileged-exec.test.ts | 48 +++- src/lib/sandbox/privileged-exec.ts | 9 +- src/lib/state/registry-normalization.test.ts | 18 ++ .../state/registry/lifecycle-generation.ts | 31 +++ 7 files changed, 391 insertions(+), 47 deletions(-) create mode 100644 src/lib/onboard/experimental/portable-demo-lifecycle-migration.test.ts create mode 100644 src/lib/state/registry/lifecycle-generation.ts diff --git a/src/lib/actions/sandbox/gateway-state.ts b/src/lib/actions/sandbox/gateway-state.ts index 434a0e2411e..4e68d8e988c 100644 --- a/src/lib/actions/sandbox/gateway-state.ts +++ b/src/lib/actions/sandbox/gateway-state.ts @@ -51,6 +51,7 @@ import { type PortableDemoLifecycleRecoveryResult, recoverPortableDemoSandboxLifecycle, } from "../../onboard/experimental/portable-demo-lifecycle"; +import { compareAndSetLegacySandboxLifecycleGeneration } from "../../state/registry/lifecycle-generation"; import type { SandboxEntry } from "../../state/registry/types"; import { getSandboxDockerRuntime } from "./docker-health"; import { isDockerRuntimeDown, printDockerRuntimeDownGuidance } from "./gateway-failure-classifier"; @@ -91,10 +92,7 @@ function gatewayScopedArgs(args: string[], gatewayName?: string): string[] { /** Recover a receipt-bound portable sandbox before the live lookup rejects a stopped container. */ export function recoverPortableDemoSandboxLifecycleForConnect( sandboxName: string, - sandbox: Pick< - SandboxEntry, - "agent" | "lifecycleGeneration" | "openshellDriver" | "provider" - > | null, + sandbox: SandboxEntry | null, gatewayName: string, ): PortableDemoLifecycleRecoveryResult { if (!sandbox || sandbox.openshellDriver !== "docker") return { kind: "not-installed" }; @@ -108,6 +106,8 @@ export function recoverPortableDemoSandboxLifecycleForConnect( provider: sandbox.provider, }, { + backfillRegistryGeneration: (generation) => + compareAndSetLegacySandboxLifecycleGeneration(sandbox, generation), openshellBinary: getOpenshellBinary(), captureOpenshell: (args, timeoutMs) => { const result = captureOpenshell([...args], { diff --git a/src/lib/onboard/experimental/portable-demo-lifecycle-migration.test.ts b/src/lib/onboard/experimental/portable-demo-lifecycle-migration.test.ts new file mode 100644 index 00000000000..1db552d22b7 --- /dev/null +++ b/src/lib/onboard/experimental/portable-demo-lifecycle-migration.test.ts @@ -0,0 +1,208 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { PodmanSocketAuthorityDeps } from "../../adapters/podman"; +import { + portableDemoLifecycleInternals, + recoverPortableDemoSandboxLifecycle, + resolvePortableDemoPrivilegedExecTarget, +} from "./portable-demo-lifecycle"; + +const CONTAINER_ID = "a".repeat(64); +const SANDBOX_ID = "sandbox-id-alpha"; +const SOCKET_PATH = "/run/user/1001/podman/podman.sock"; +const temporaryDirectories: string[] = []; +const originalHome = process.env.HOME; + +function legacyStateDir(): string { + const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-portable-migration-")); + temporaryDirectories.push(stateDir); + const receiptPath = portableDemoLifecycleInternals.receiptPath("alpha", stateDir); + fs.mkdirSync(path.dirname(receiptPath), { recursive: true }); + fs.writeFileSync( + receiptPath, + `${JSON.stringify({ + schemaVersion: 2, + sandboxName: "alpha", + sandboxId: SANDBOX_ID, + containerId: CONTAINER_ID, + dashboardPort: 18789, + })}\n`, + { mode: 0o600 }, + ); + return stateDir; +} + +function socketAuthorityDeps(): PodmanSocketAuthorityDeps { + const inodes = new Map(); + return { + uid: 1001, + lstat: (filePath) => { + const socket = filePath === SOCKET_PATH; + const ino = inodes.get(filePath) ?? BigInt(7000 + inodes.size); + inodes.set(filePath, ino); + return { + dev: 8n, + ino, + mode: socket ? 0o660n : filePath === path.dirname(SOCKET_PATH) ? 0o700n : 0o755n, + uid: socket ? 1001n : filePath.startsWith("/run/user/1001") ? 1001n : 0n, + isDirectory: () => !socket, + isSocket: () => socket, + }; + }, + }; +} + +function createPodman(matches = [CONTAINER_ID]) { + return vi.fn((args: readonly string[]) => { + const command = args[0] === "--url" ? args.slice(2) : args; + if (command[0] === "info") return { status: 0, stdout: `${SOCKET_PATH}\n` }; + if (command[0] === "ps") return { status: 0, stdout: `${matches.join("\n")}\n` }; + if (command[0] === "inspect") { + return { + status: 0, + stdout: JSON.stringify([ + { + Id: CONTAINER_ID, + Name: "openshell-sandbox-alpha", + Config: { + Labels: { + "openshell.managed": "true", + "openshell.sandbox-id": SANDBOX_ID, + "openshell.sandbox-name": "alpha", + }, + }, + State: { Running: true }, + }, + ]), + }; + } + throw new Error(`Unexpected Podman command: ${args.join(" ")}`); + }); +} + +function migrationDeps( + stateDir: string, + podman: ReturnType, + backfill: (generation: string) => boolean, +) { + return { + backfillRegistryGeneration: backfill, + hardenSocketDirectory: vi.fn(), + platform: "linux" as const, + podman, + podmanSocketAuthorityDeps: socketAuthorityDeps(), + stateDir, + }; +} + +async function legacyRegistryEntry(stateDir: string) { + process.env.HOME = stateDir; + vi.resetModules(); + const registry = await import("../../state/registry"); + const { compareAndSetLegacySandboxLifecycleGeneration } = await import( + "../../state/registry/lifecycle-generation" + ); + registry.registerSandbox({ name: "alpha", agent: "openclaw", openshellDriver: "docker" }); + const expected = registry.getSandbox("alpha")!; + return { + backfill: vi.fn((generation: string) => + compareAndSetLegacySandboxLifecycleGeneration(expected, generation), + ), + registry, + }; +} + +afterEach(() => { + process.env.HOME = originalHome; + vi.resetModules(); + for (const directory of temporaryDirectories.splice(0)) { + fs.rmSync(directory, { force: true, recursive: true }); + } +}); + +describe("portable lifecycle legacy generation migration", () => { + it("claims a schema-2 receipt before privileged cleanup and upgrades it (#8584)", async () => { + const stateDir = legacyStateDir(); + const { backfill, registry } = await legacyRegistryEntry(stateDir); + + expect( + resolvePortableDemoPrivilegedExecTarget( + "alpha", + migrationDeps(stateDir, createPodman(), backfill), + ), + ).toMatchObject({ containerId: CONTAINER_ID, dockerHost: `unix://${SOCKET_PATH}` }); + expect(backfill).toHaveBeenCalledWith(CONTAINER_ID); + expect( + JSON.parse( + fs.readFileSync(portableDemoLifecycleInternals.receiptPath("alpha", stateDir), "utf8"), + ), + ).toMatchObject({ schemaVersion: 3, registryGeneration: CONTAINER_ID }); + expect(registry.getSandbox("alpha")?.lifecycleGeneration).toBe(CONTAINER_ID); + }); + + it("claims a schema-2 receipt before retained-sandbox recovery (#8584)", async () => { + const stateDir = legacyStateDir(); + const { backfill, registry } = await legacyRegistryEntry(stateDir); + + expect( + recoverPortableDemoSandboxLifecycle( + "alpha", + { agent: "openclaw", gatewayName: "nemoclaw", openshellDriver: "docker" }, + { + ...migrationDeps(stateDir, createPodman(), backfill), + captureOpenshell: (args) => + args.includes("curl") ? { status: 0, stdout: "200" } : { status: 0 }, + }, + ), + ).toEqual({ kind: "already-running" }); + expect(backfill).toHaveBeenCalledWith(CONTAINER_ID); + expect( + JSON.parse( + fs.readFileSync(portableDemoLifecycleInternals.receiptPath("alpha", stateDir), "utf8"), + ), + ).toMatchObject({ schemaVersion: 3, registryGeneration: CONTAINER_ID }); + expect(registry.getSandbox("alpha")?.lifecycleGeneration).toBe(CONTAINER_ID); + }); + + it("does not claim an ambiguous legacy portable identity (#8584)", () => { + const stateDir = legacyStateDir(); + const backfill = vi.fn(() => true); + + expect(() => + resolvePortableDemoPrivilegedExecTarget( + "alpha", + migrationDeps(stateDir, createPodman([CONTAINER_ID, "b".repeat(64)]), backfill), + ), + ).toThrow("found 2"); + expect(backfill).not.toHaveBeenCalled(); + const receipt = JSON.parse( + fs.readFileSync(portableDemoLifecycleInternals.receiptPath("alpha", stateDir), "utf8"), + ); + expect(receipt).toMatchObject({ schemaVersion: 2 }); + expect(receipt).not.toHaveProperty("registryGeneration"); + }); + + it("does not upgrade the receipt when the registry row changes before its claim (#8584)", async () => { + const stateDir = legacyStateDir(); + const { backfill, registry } = await legacyRegistryEntry(stateDir); + registry.updateSandbox("alpha", { model: "replacement" }); + + expect(() => + resolvePortableDemoPrivilegedExecTarget( + "alpha", + migrationDeps(stateDir, createPodman(), backfill), + ), + ).toThrow("could not claim the current registry generation"); + const receipt = JSON.parse( + fs.readFileSync(portableDemoLifecycleInternals.receiptPath("alpha", stateDir), "utf8"), + ); + expect(receipt).toMatchObject({ schemaVersion: 2 }); + expect(receipt).not.toHaveProperty("registryGeneration"); + }); +}); diff --git a/src/lib/onboard/experimental/portable-demo-lifecycle.ts b/src/lib/onboard/experimental/portable-demo-lifecycle.ts index 94159e8c240..675c59d80df 100644 --- a/src/lib/onboard/experimental/portable-demo-lifecycle.ts +++ b/src/lib/onboard/experimental/portable-demo-lifecycle.ts @@ -86,6 +86,7 @@ export interface PortableDemoLifecycleDeps { podmanSocketAuthorityDeps?: PodmanSocketAuthorityDeps; hardenSocketDirectory?: (socketPath: string) => void; registryGeneration?: string; + backfillRegistryGeneration?: (registryGeneration: string) => boolean; captureOpenshell?: (args: readonly string[], timeoutMs: number) => CommandResult; launchOpenshell?: (args: readonly string[]) => void; captureHost?: (command: string, args: readonly string[], timeoutMs: number) => CommandResult; @@ -265,19 +266,19 @@ function parseReceipt(value: unknown, sandboxName: string): PortableDemoLifecycl function requireCurrentRegistryGeneration( receipt: PortableDemoLifecycleReceipt, registryGeneration: string | undefined, -): void { - // Legacy receipts predate an explicit generation field. Their immutable, - // exact container ID is accepted only when the current sandbox registry - // generation uses that same identity; same-name replacements cannot match. +): boolean { + // Legacy receipts predate an explicit generation field. Their immutable + // container ID may claim a missing registry generation only after exact + // local runtime validation; an existing generation must already match. const receiptGeneration = - receipt.schemaVersion === CURRENT_RECEIPT_SCHEMA_VERSION - ? receipt.registryGeneration - : receipt.containerId; - if (!registryGeneration || receiptGeneration !== registryGeneration) { + receipt.schemaVersion === 3 ? receipt.registryGeneration : receipt.containerId; + if (registryGeneration === undefined && receipt.schemaVersion !== 3) return true; + if (receiptGeneration !== registryGeneration) { throw new Error( `Portable demo lifecycle receipt for sandbox '${receipt.sandboxName}' does not belong to the current registry generation`, ); } + return false; } function loadReceipt(sandboxName: string, stateDir: string): PortableDemoLifecycleReceipt | null { @@ -439,19 +440,7 @@ function podmanCapture( }; } -/** Resolve the receipt-owned portable container for a host-side privileged exec. */ -export function resolvePortableDemoPrivilegedExecTarget( - sandboxName: string, - deps: PortableDemoLifecycleDeps = {}, -): PortableDemoPrivilegedExecTarget | null { - const commandEnv = deps.env ?? process.env; - const receipt = loadReceipt(sandboxName, deps.stateDir ?? defaultStateDir(commandEnv)); - if (!receipt) return null; - if ((deps.platform ?? process.platform) !== "linux") { - throw new Error("Portable demo lifecycle receipt is only valid on Linux"); - } - requireCurrentRegistryGeneration(receipt, deps.registryGeneration); - +function qualifiedPodmanAuthority(commandEnv: NodeJS.ProcessEnv, deps: PortableDemoLifecycleDeps) { const podman = deps.podman ?? ((args, env = commandEnv) => defaultPodman(args, env)); const podmanEnv = localPodmanEnvironment(commandEnv); const socketPath = podmanSocketPath(podman, podmanEnv); @@ -463,8 +452,18 @@ export function resolvePortableDemoPrivilegedExecTarget( authorityDeps: deps.podmanSocketAuthorityDeps, ...(deps.podman ? { capture: podmanCapture(podman, podmanEnv) } : {}), }); - const providerPodman = (args: readonly string[]) => provider.capture(args, COMMAND_TIMEOUT_MS); - const inspection = discoverPodmanContainer(sandboxName, providerPodman); + return { + assertRuntimeAuthority: () => + assertPodmanSocketAuthority(socketAuthority, deps.podmanSocketAuthorityDeps), + dockerHost: `unix://${socketAuthority.socketPath}`, + podman: (args: readonly string[]) => provider.capture(args, COMMAND_TIMEOUT_MS), + }; +} + +function requireReceiptOwnedInspection( + receipt: PortableDemoLifecycleReceipt, + inspection: PodmanContainerInspection, +): void { if (inspection.containerId !== receipt.containerId) { throw new Error( `Portable demo lifecycle refused container '${inspection.containerId}' because the recorded container identity changed`, @@ -475,14 +474,58 @@ export function resolvePortableDemoPrivilegedExecTarget( `Portable demo lifecycle refused container '${receipt.containerId}' because its OpenShell sandbox ID changed`, ); } +} + +function backfillLegacyReceiptGeneration( + receipt: PortableDemoLifecycleReceipt, + stateDir: string, + backfillRequired: boolean, + deps: PortableDemoLifecycleDeps, +): PortableDemoLifecycleReceipt { + if (receipt.schemaVersion === 3) return receipt; + if ( + backfillRequired && + (!deps.backfillRegistryGeneration || !deps.backfillRegistryGeneration(receipt.containerId)) + ) { + throw new Error( + `Portable demo lifecycle receipt for sandbox '${receipt.sandboxName}' could not claim the current registry generation`, + ); + } + if (receipt.schemaVersion === 1) return receipt; + const migrated: PortableDemoLifecycleReceipt = { + ...receipt, + schemaVersion: CURRENT_RECEIPT_SCHEMA_VERSION, + registryGeneration: receipt.containerId, + }; + writeReceipt(migrated, stateDir); + return migrated; +} + +/** Resolve the receipt-owned portable container for a host-side privileged exec. */ +export function resolvePortableDemoPrivilegedExecTarget( + sandboxName: string, + deps: PortableDemoLifecycleDeps = {}, +): PortableDemoPrivilegedExecTarget | null { + const commandEnv = deps.env ?? process.env; + const stateDir = deps.stateDir ?? defaultStateDir(commandEnv); + const receipt = loadReceipt(sandboxName, stateDir); + if (!receipt) return null; + if ((deps.platform ?? process.platform) !== "linux") { + throw new Error("Portable demo lifecycle receipt is only valid on Linux"); + } + const backfillRequired = requireCurrentRegistryGeneration(receipt, deps.registryGeneration); + const authority = qualifiedPodmanAuthority(commandEnv, deps); + const inspection = discoverPodmanContainer(sandboxName, authority.podman); + requireReceiptOwnedInspection(receipt, inspection); if (!inspection.running) { throw new Error(`Portable sandbox '${sandboxName}' is not running`); } + authority.assertRuntimeAuthority(); + backfillLegacyReceiptGeneration(receipt, stateDir, backfillRequired, deps); return { - assertRuntimeAuthority: () => - assertPodmanSocketAuthority(socketAuthority, deps.podmanSocketAuthorityDeps), + assertRuntimeAuthority: authority.assertRuntimeAuthority, containerId: inspection.containerId, - dockerHost: `unix://${socketAuthority.socketPath}`, + dockerHost: authority.dockerHost, }; } @@ -747,7 +790,8 @@ export function installPortableDemoSandboxLifecycle( throw new Error("Portable demo lifecycle requires Linux"); } const commandEnv = deps.env ?? env; - const podman = deps.podman ?? ((args) => defaultPodman(args, commandEnv)); + const podmanEnv = localPodmanEnvironment(commandEnv); + const podman = deps.podman ?? ((args) => defaultPodman(args, podmanEnv)); const inspection = discoverPodmanContainer(sandboxName, podman); const registryGeneration = deps.registryGeneration ?? inspection.containerId; if (!SANDBOX_ID_PATTERN.test(registryGeneration)) { @@ -789,14 +833,22 @@ export function recoverPortableDemoSandboxLifecycle( if ((context.agent ?? "openclaw") !== "openclaw") return { kind: "not-installed" }; if (context.openshellDriver !== "docker") return { kind: "not-installed" }; const commandEnv = deps.env ?? process.env; - const receipt = loadReceipt(sandboxName, deps.stateDir ?? defaultStateDir(commandEnv)); + const stateDir = deps.stateDir ?? defaultStateDir(commandEnv); + let receipt = loadReceipt(sandboxName, stateDir); if (!receipt) return { kind: "not-installed" }; if ((deps.platform ?? process.platform) !== "linux") { throw new Error("Portable demo lifecycle receipt is only valid on Linux"); } - requireCurrentRegistryGeneration(receipt, context.lifecycleGeneration); - const podman = deps.podman ?? ((args) => defaultPodman(args, commandEnv)); - const stateDir = deps.stateDir ?? defaultStateDir(commandEnv); + const backfillRequired = requireCurrentRegistryGeneration(receipt, context.lifecycleGeneration); + if (backfillRequired) { + const authority = qualifiedPodmanAuthority(commandEnv, deps); + const migrationInspection = discoverPodmanContainer(sandboxName, authority.podman); + requireReceiptOwnedInspection(receipt, migrationInspection); + authority.assertRuntimeAuthority(); + receipt = backfillLegacyReceiptGeneration(receipt, stateDir, true, deps); + } + const podmanEnv = localPodmanEnvironment(commandEnv); + const podman = deps.podman ?? ((args) => defaultPodman(args, podmanEnv)); const initialInspection = podman(["inspect", receipt.containerId]); if (isMissingPodmanContainer(initialInspection)) { removeReceipt(sandboxName, stateDir); @@ -914,7 +966,7 @@ export function recoverPortableDemoSandboxLifecycle( { ...receipt, schemaVersion: CURRENT_RECEIPT_SCHEMA_VERSION, - registryGeneration: context.lifecycleGeneration, + registryGeneration: context.lifecycleGeneration ?? receipt.containerId, }, deps.stateDir ?? defaultStateDir(commandEnv), ); diff --git a/src/lib/sandbox/privileged-exec.test.ts b/src/lib/sandbox/privileged-exec.test.ts index 7a46f50319d..b7f406440a3 100644 --- a/src/lib/sandbox/privileged-exec.test.ts +++ b/src/lib/sandbox/privileged-exec.test.ts @@ -11,6 +11,7 @@ const helperPath = require.resolve("./privileged-exec"); const dockerRunPath = require.resolve("../adapters/docker/run"); const portableLifecyclePath = require.resolve("../onboard/experimental/portable-demo-lifecycle"); const registryPath = require.resolve("../state/registry"); +const lifecycleGenerationPath = require.resolve("../state/registry/lifecycle-generation"); const { containerNameMatchesSandbox, selectDirectSandboxContainer } = require(helperPath); function restoreRequireCacheEntry(modulePath: string, priorEntry: unknown): void { @@ -30,9 +31,16 @@ function withPrivilegedExecMocks( sandboxes?: Array<{ name?: string | null }>; defaultSandbox?: string | null; }; + compareAndSetLegacySandboxLifecycleGeneration?: ( + expected: { name?: string }, + generation: string, + ) => boolean; resolvePortableDemoPrivilegedExecTarget?: ( sandboxName: string, - deps?: { registryGeneration?: string }, + deps?: { + backfillRegistryGeneration?: (generation: string) => boolean; + registryGeneration?: string; + }, ) => { assertRuntimeAuthority: () => void; containerId: string; dockerHost: string } | null; }, run: (helper: typeof import("./privileged-exec")) => T, @@ -41,6 +49,7 @@ function withPrivilegedExecMocks( const priorDockerRun = require.cache[dockerRunPath]; const priorPortableLifecycle = require.cache[portableLifecyclePath]; const priorRegistry = require.cache[registryPath]; + const priorLifecycleGeneration = require.cache[lifecycleGenerationPath]; delete require.cache[helperPath]; requireCache[dockerRunPath] = { @@ -67,6 +76,15 @@ function withPrivilegedExecMocks( listSandboxes: deps.listSandboxes, }, } as any; + requireCache[lifecycleGenerationPath] = { + id: lifecycleGenerationPath, + filename: lifecycleGenerationPath, + loaded: true, + exports: { + compareAndSetLegacySandboxLifecycleGeneration: + deps.compareAndSetLegacySandboxLifecycleGeneration ?? (() => false), + }, + } as any; try { return run(require(helperPath)); @@ -75,6 +93,7 @@ function withPrivilegedExecMocks( restoreRequireCacheEntry(dockerRunPath, priorDockerRun); restoreRequireCacheEntry(portableLifecyclePath, priorPortableLifecycle); restoreRequireCacheEntry(registryPath, priorRegistry); + restoreRequireCacheEntry(lifecycleGenerationPath, priorLifecycleGeneration); } } @@ -149,11 +168,21 @@ describe("privileged sandbox exec routing", () => { it("uses the receipt-owned Podman socket when the default Docker daemon has no container (#8584)", () => { let dockerPsCalls = 0; const assertRuntimeAuthority = vi.fn(); - const resolvePortableDemoPrivilegedExecTarget = vi.fn(() => ({ - assertRuntimeAuthority, - containerId: "a".repeat(64), - dockerHost: "unix:///run/user/1001/podman/podman.sock", - })); + let backfillRegistryGeneration: ((generation: string) => boolean) | undefined; + const compareAndSetLegacySandboxLifecycleGeneration = vi.fn(() => true); + const resolvePortableDemoPrivilegedExecTarget = vi.fn( + ( + _sandboxName: string, + deps?: { backfillRegistryGeneration?: (generation: string) => boolean }, + ) => { + backfillRegistryGeneration = deps?.backfillRegistryGeneration; + return { + assertRuntimeAuthority, + containerId: "a".repeat(64), + dockerHost: "unix:///run/user/1001/podman/podman.sock", + }; + }, + ); withPrivilegedExecMocks( { getSandbox: () => ({ @@ -166,6 +195,7 @@ describe("privileged sandbox exec routing", () => { dockerPsCalls += 1; return ""; }, + compareAndSetLegacySandboxLifecycleGeneration, resolvePortableDemoPrivilegedExecTarget, }, ({ privilegedSandboxExecArgv }) => { @@ -217,8 +247,14 @@ describe("privileged sandbox exec routing", () => { expect(dockerPsCalls).toBe(0); expect(assertRuntimeAuthority).toHaveBeenCalledOnce(); expect(resolvePortableDemoPrivilegedExecTarget).toHaveBeenCalledWith("alpha", { + backfillRegistryGeneration: expect.any(Function), registryGeneration: "current-generation", }); + expect(backfillRegistryGeneration?.("legacy-generation")).toBe(true); + expect(compareAndSetLegacySandboxLifecycleGeneration).toHaveBeenCalledWith( + expect.objectContaining({ name: "alpha", openshellDriver: "docker" }), + "legacy-generation", + ); }); it("rejects a non-direct driver before consulting a stale portable receipt (#8584)", () => { diff --git a/src/lib/sandbox/privileged-exec.ts b/src/lib/sandbox/privileged-exec.ts index c310cffdd63..c466e2a0aa5 100644 --- a/src/lib/sandbox/privileged-exec.ts +++ b/src/lib/sandbox/privileged-exec.ts @@ -4,16 +4,13 @@ import { dockerCapture } from "../adapters/docker/run"; import { resolvePortableDemoPrivilegedExecTarget } from "../onboard/experimental/portable-demo-lifecycle"; import * as registry from "../state/registry"; +import { compareAndSetLegacySandboxLifecycleGeneration } from "../state/registry/lifecycle-generation"; const OPENSHELL_MANAGED_BY_LABEL = "openshell.ai/managed-by"; const OPENSHELL_MANAGED_BY_VALUE = "openshell"; const OPENSHELL_SANDBOX_NAME_LABEL = "openshell.ai/sandbox-name"; -type SandboxEntry = { - name?: string; - lifecycleGeneration?: string; - openshellDriver?: string | null; -}; +type SandboxEntry = import("../state/registry").SandboxEntry; type LabeledSandboxContainer = { id: string; @@ -220,6 +217,8 @@ function privilegedSandboxExecArgv( driver === "docker" ? resolvePortableDemoPrivilegedExecTarget(sandboxName, { ...(entry.lifecycleGeneration ? { registryGeneration: entry.lifecycleGeneration } : {}), + backfillRegistryGeneration: (generation) => + compareAndSetLegacySandboxLifecycleGeneration(entry, generation), }) : null; if (portableTarget) { diff --git a/src/lib/state/registry-normalization.test.ts b/src/lib/state/registry-normalization.test.ts index 8b1a738a936..2a1b5b05a2a 100644 --- a/src/lib/state/registry-normalization.test.ts +++ b/src/lib/state/registry-normalization.test.ts @@ -161,6 +161,24 @@ describe("sandbox registry normalization", () => { }); }); + it("backfills a lifecycle generation only for the unchanged legacy Docker row (#8584)", async () => { + const registry = await loadRegistryWith({}); + const { compareAndSetLegacySandboxLifecycleGeneration } = await import( + "./registry/lifecycle-generation" + ); + registry.registerSandbox({ name: "portable", openshellDriver: "docker" }); + const expected = registry.getSandbox("portable")!; + + expect(compareAndSetLegacySandboxLifecycleGeneration(expected, "a".repeat(64))).toBe(true); + expect(registry.getSandbox("portable")?.lifecycleGeneration).toBe("a".repeat(64)); + expect(compareAndSetLegacySandboxLifecycleGeneration(expected, "b".repeat(64))).toBe(false); + + registry.registerSandbox({ name: "changed", openshellDriver: "docker" }); + const stale = registry.getSandbox("changed")!; + registry.updateSandbox("changed", { model: "replacement" }); + expect(compareAndSetLegacySandboxLifecycleGeneration(stale, "c".repeat(64))).toBe(false); + }); + it("round-trips immutable serving profile provenance while preserving legacy rows (#8246)", async () => { const registry = await loadRegistryWith({ legacy: { name: "legacy" } }); expect(registry.getSandbox("legacy")?.servingProfileProvenance).toBeUndefined(); diff --git a/src/lib/state/registry/lifecycle-generation.ts b/src/lib/state/registry/lifecycle-generation.ts new file mode 100644 index 00000000000..f975afbefb1 --- /dev/null +++ b/src/lib/state/registry/lifecycle-generation.ts @@ -0,0 +1,31 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { isDeepStrictEqual } from "node:util"; +import { withLock } from "./lock"; +import { load, save } from "./persistence"; +import type { SandboxEntry } from "./types"; + +/** Claim a lifecycle generation for one unchanged legacy Docker registry row. */ +export function compareAndSetLegacySandboxLifecycleGeneration( + expected: SandboxEntry, + lifecycleGeneration: string, +): boolean { + if ( + expected.openshellDriver !== "docker" || + expected.lifecycleGeneration !== undefined || + lifecycleGeneration.length === 0 || + lifecycleGeneration.length > 256 || + /[\u0000-\u001f\u007f-\u009f]/u.test(lifecycleGeneration) + ) { + return false; + } + return withLock(() => { + const data = load(); + const current = data.sandboxes[expected.name]; + if (!current || !isDeepStrictEqual(current, expected)) return false; + current.lifecycleGeneration = lifecycleGeneration; + save(data); + return true; + }); +} From 89e1a4f9b8c1a69dc8aa83ae267d129adb86bc50 Mon Sep 17 00:00:00 2001 From: Senthil Ravichandran Date: Fri, 7 Aug 2026 16:29:51 -0700 Subject: [PATCH 13/15] test(portable): keep migration fixture linear Signed-off-by: Senthil Ravichandran --- .../portable-demo-lifecycle-migration.test.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/lib/onboard/experimental/portable-demo-lifecycle-migration.test.ts b/src/lib/onboard/experimental/portable-demo-lifecycle-migration.test.ts index 1db552d22b7..5fbdc1ce65f 100644 --- a/src/lib/onboard/experimental/portable-demo-lifecycle-migration.test.ts +++ b/src/lib/onboard/experimental/portable-demo-lifecycle-migration.test.ts @@ -61,10 +61,9 @@ function socketAuthorityDeps(): PodmanSocketAuthorityDeps { function createPodman(matches = [CONTAINER_ID]) { return vi.fn((args: readonly string[]) => { const command = args[0] === "--url" ? args.slice(2) : args; - if (command[0] === "info") return { status: 0, stdout: `${SOCKET_PATH}\n` }; - if (command[0] === "ps") return { status: 0, stdout: `${matches.join("\n")}\n` }; - if (command[0] === "inspect") { - return { + const handlers = { + info: () => ({ status: 0, stdout: `${SOCKET_PATH}\n` }), + inspect: () => ({ status: 0, stdout: JSON.stringify([ { @@ -80,9 +79,10 @@ function createPodman(matches = [CONTAINER_ID]) { State: { Running: true }, }, ]), - }; - } - throw new Error(`Unexpected Podman command: ${args.join(" ")}`); + }), + ps: () => ({ status: 0, stdout: `${matches.join("\n")}\n` }), + }; + return handlers[command[0] as keyof typeof handlers](); }); } From b4a656a4c78f37ef1e7744924c0a09a29b2680b5 Mon Sep 17 00:00:00 2001 From: Senthil Ravichandran Date: Fri, 7 Aug 2026 16:42:57 -0700 Subject: [PATCH 14/15] test(podman): use secure socket fixture path Signed-off-by: Senthil Ravichandran --- src/lib/adapters/podman/socket-authority.test.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/lib/adapters/podman/socket-authority.test.ts b/src/lib/adapters/podman/socket-authority.test.ts index 31990d8001c..a43b989b777 100644 --- a/src/lib/adapters/podman/socket-authority.test.ts +++ b/src/lib/adapters/podman/socket-authority.test.ts @@ -3,7 +3,6 @@ import fs from "node:fs"; import net from "node:net"; -import os from "node:os"; import path from "node:path"; import { describe, expect, it, vi } from "vitest"; @@ -106,7 +105,7 @@ describe("Podman socket authority", () => { it.runIf(process.platform !== "win32")( "hardens the current-user socket directory without following unsafe parents (#8584)", async () => { - const root = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), "nc-p-")); + const root = fs.mkdtempSync(path.join(fs.realpathSync(process.cwd()), ".nc-p-")); const server = net.createServer(); try { const socketDirectory = path.join(root, "p"); From b0b33facecd974237a7c1249f86066abd305c15d Mon Sep 17 00:00:00 2001 From: Senthil Ravichandran Date: Fri, 7 Aug 2026 17:32:57 -0700 Subject: [PATCH 15/15] fix(portable): finish interrupted receipt migration Signed-off-by: Senthil Ravichandran --- .../portable-demo-lifecycle-migration.test.ts | 36 +++++++++++++++++++ .../experimental/portable-demo-lifecycle.ts | 4 +-- 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/src/lib/onboard/experimental/portable-demo-lifecycle-migration.test.ts b/src/lib/onboard/experimental/portable-demo-lifecycle-migration.test.ts index 5fbdc1ce65f..f709c835341 100644 --- a/src/lib/onboard/experimental/portable-demo-lifecycle-migration.test.ts +++ b/src/lib/onboard/experimental/portable-demo-lifecycle-migration.test.ts @@ -170,6 +170,42 @@ describe("portable lifecycle legacy generation migration", () => { expect(registry.getSandbox("alpha")?.lifecycleGeneration).toBe(CONTAINER_ID); }); + it("finishes a schema-2 receipt upgrade after its registry claim already committed (#8584)", async () => { + const stateDir = legacyStateDir(); + const { backfill, registry } = await legacyRegistryEntry(stateDir); + expect(backfill(CONTAINER_ID)).toBe(true); + backfill.mockClear(); + const podman = createPodman(); + + expect( + recoverPortableDemoSandboxLifecycle( + "alpha", + { + agent: "openclaw", + gatewayName: "nemoclaw", + lifecycleGeneration: CONTAINER_ID, + openshellDriver: "docker", + }, + { + ...migrationDeps(stateDir, podman, backfill), + captureOpenshell: (args) => + args.includes("curl") ? { status: 0, stdout: "200" } : { status: 0 }, + }, + ), + ).toEqual({ kind: "already-running" }); + expect(backfill).not.toHaveBeenCalled(); + expect(podman).toHaveBeenCalledWith( + ["info", "--format", "{{.Host.RemoteSocket.Path}}"], + expect.any(Object), + ); + expect( + JSON.parse( + fs.readFileSync(portableDemoLifecycleInternals.receiptPath("alpha", stateDir), "utf8"), + ), + ).toMatchObject({ schemaVersion: 3, registryGeneration: CONTAINER_ID }); + expect(registry.getSandbox("alpha")?.lifecycleGeneration).toBe(CONTAINER_ID); + }); + it("does not claim an ambiguous legacy portable identity (#8584)", () => { const stateDir = legacyStateDir(); const backfill = vi.fn(() => true); diff --git a/src/lib/onboard/experimental/portable-demo-lifecycle.ts b/src/lib/onboard/experimental/portable-demo-lifecycle.ts index 675c59d80df..7f060a933e0 100644 --- a/src/lib/onboard/experimental/portable-demo-lifecycle.ts +++ b/src/lib/onboard/experimental/portable-demo-lifecycle.ts @@ -840,12 +840,12 @@ export function recoverPortableDemoSandboxLifecycle( throw new Error("Portable demo lifecycle receipt is only valid on Linux"); } const backfillRequired = requireCurrentRegistryGeneration(receipt, context.lifecycleGeneration); - if (backfillRequired) { + if (backfillRequired || receipt.schemaVersion === 2) { const authority = qualifiedPodmanAuthority(commandEnv, deps); const migrationInspection = discoverPodmanContainer(sandboxName, authority.podman); requireReceiptOwnedInspection(receipt, migrationInspection); authority.assertRuntimeAuthority(); - receipt = backfillLegacyReceiptGeneration(receipt, stateDir, true, deps); + receipt = backfillLegacyReceiptGeneration(receipt, stateDir, backfillRequired, deps); } const podmanEnv = localPodmanEnvironment(commandEnv); const podman = deps.podman ?? ((args) => defaultPodman(args, podmanEnv));