diff --git a/ci/onboard-entry-composition-budget.json b/ci/onboard-entry-composition-budget.json index 65c3c4f583e..81dee89018b 100644 --- a/ci/onboard-entry-composition-budget.json +++ b/ci/onboard-entry-composition-budget.json @@ -12,7 +12,7 @@ "provider": { "createSandboxWithBaseImageResolution": 15, "handleNimLocalSelection": 32, - "handleRemoteProviderSelection": 80, + "handleRemoteProviderSelection": 76, "handleRoutedSelection": 15, "runOnboard": 8, "selectAndValidateOllamaModel": 18 diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx index 165466115e5..e7021a7cc76 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -1009,6 +1009,14 @@ Use `--gpu` to require GPU passthrough and fail fast if an NVIDIA GPU is not det Use `--sandbox-gpu` or `--no-sandbox-gpu` to control only direct NVIDIA GPU access inside the sandbox. Use `--sandbox-gpu --sandbox-gpu-device ` to pass a specific OpenShell GPU device selector to `openshell sandbox create`; device selectors require explicit sandbox GPU enablement. On ordinary native Linux Docker-driver hosts, NemoClaw uses native OpenShell GPU injection by default and never broadens confinement automatically. + + + +Portable OpenClaw onboarding also requires native OpenShell GPU injection. +It does not use `NEMOCLAW_DOCKER_GPU_PATCH` compatibility routing, so do not set `fallback`, `1`, or another legacy nonzero value for that profile. + + + Set `NEMOCLAW_DOCKER_GPU_PATCH=fallback` to explicitly authorize one native attempt followed by one compatibility retry. NemoClaw permits the retry only after it confirms either a trusted host-side GPU routing failure or an explicit driver proof plus exact-container host configuration showing that no GPU was attached. It then saves redacted diagnostics and removes the incomplete sandbox before retrying. @@ -4825,7 +4833,7 @@ Set them before running `$$nemoclaw onboard`. | `NEMOCLAW_SANDBOX_GPU_DEVICE` | OpenShell GPU device selector | Selects the GPU device passed with `openshell sandbox create --gpu-device`. Requires explicit sandbox GPU enablement with `NEMOCLAW_SANDBOX_GPU=1` (or `--sandbox-gpu` for CLI-driven onboarding); otherwise onboarding rejects the selector instead of treating it as an implicit opt-in. | | `NEMOCLAW_SANDBOX_BASE_IMAGE_REFRESH` | `1`, `true`, `yes`, or `on` to enable | Bypasses recorded sandbox base-image resolution metadata during onboarding, recreation, and rebuild. NemoClaw reruns candidate resolution but can still use a compatible image from Docker's local image store. Versioned release candidates that exist locally but fail validation are refreshed from the registry once during normal resolution. This setting does not discard onboarding session state. | | `NEMOCLAW_SANDBOX_BASE_LOCAL_BUILD` | unset or `auto` (default); `1`, `true`, `yes`, or `on` to enable; `0`, `false`, `no`, or `off` to disable | Controls whether base-image resolution may build a compatible image locally. The default allows builds during normal CLI runs and disables them when `NODE_ENV=test` or `VITEST=true`. When source inputs or a missing/incompatible release-version base require a fresh build, disabling local builds makes resolution fail instead of using an unproven image. | -| `NEMOCLAW_DOCKER_GPU_PATCH` | unset, `auto`, `fallback`, `1`, or `0`; other legacy nonzero values remain accepted through `v0.0.x` and will be removed in `v0.1.0` | Selects Linux Docker-driver GPU routing. Unset, `auto`, or `0` uses native OpenShell GPU injection on ordinary native Linux. `fallback` explicitly opts into one native attempt followed by one bounded compatibility retry when trusted host evidence identifies a GPU-routing failure. `1` and legacy nonzero values select the compatibility patch from the outset. Docker Desktop WSL and Jetson/Tegra use the compatibility path by default; Docker Desktop WSL ignores `0`, while Jetson/Tegra accepts `0` only as a troubleshooting override that bypasses device-group propagation. | +| `NEMOCLAW_DOCKER_GPU_PATCH` | unset, `auto`, `fallback`, `1`, or `0`; other legacy nonzero values remain accepted through `v0.0.x` and will be removed in `v0.1.0` | Selects Linux Docker-driver GPU routing. Unset, `auto`, or `0` uses native OpenShell GPU injection on ordinary native Linux. `fallback` explicitly opts into one native attempt followed by one bounded compatibility retry when trusted host evidence identifies a GPU-routing failure. `1` and legacy nonzero values select the compatibility patch from the outset. Docker Desktop WSL and Jetson/Tegra use the compatibility path by default; Docker Desktop WSL ignores `0`, while Jetson/Tegra accepts `0` only as a troubleshooting override that bypasses device-group propagation. Portable OpenClaw onboarding requires native OpenShell GPU injection and does not use this setting. | | `NEMOCLAW_OPENSHELL_GATEWAY_CONTAINER_PATCH` | `1` to enable; disabled by default | This setting explicitly opts into the Linux gateway compatibility container for an older host ABI or a diagnostic run; use it only on a trusted local host because it uses host networking and mounts the Docker socket read-only even though the socket still exposes the privileged Docker API; prefer OpenShell 0.0.101's directly supported glibc 2.39+ path; see [Gateway Compatibility Container](/user-guide/openclaw/security/security-controls/gateway-authentication-controls#gateway-compatibility-container) for the container boundary and removal conditions. | | `NEMOCLAW_OPENSHELL_GATEWAY_BIN` | path | Advanced override for the `openshell-gateway` binary used by Linux Docker-driver startup. For the default port, the installer accepts the binary under an absolute `XDG_BIN_HOME` when set, otherwise `~/.local/bin/openshell-gateway`; it also accepts `/usr/local/bin/openshell-gateway` or `/usr/bin/openshell-gateway`. Another path fails service staging. The macOS Homebrew service uses the formula's binary. Defaults to the binary next to `openshell`, then common install paths. | | `NEMOCLAW_OPENSHELL_SANDBOX_BIN` | path | Advanced override for the `openshell-sandbox` binary used by Linux Docker-driver startup. The macOS Homebrew service uses the formula's driver layout. Defaults to the binary next to `openshell`, then common install paths. | @@ -4963,7 +4971,7 @@ Set the onboarding variables before running `$$nemoclaw onboard` if a slow conne |----------|---------|---------| | `NEMOCLAW_OLLAMA_PULL_TIMEOUT` | `1800` (30 minutes) | Wall-clock timeout for `ollama pull` during onboard, in seconds. Accepts integer or float values. Already-downloaded layers are kept; re-running the pull resumes them. | | `NEMOCLAW_LOCAL_INFERENCE_TIMEOUT` | `180` | Wall-clock timeout for the inference-server validation probe during onboard, in seconds. Raise on slow networks or for very large prompts. | -| `NEMOCLAW_SANDBOX_READY_TIMEOUT` | `180` | Wall-clock timeout for post-create readiness, in seconds. Raise when the sandbox image build, gateway upload, or in-sandbox boot exceeds the default (typical on 70B+ models, first-time gateway uploads over slow links, or DGX Station / remote-VM first runs). When the post-create deadline expires, onboarding deletes an orphaned sandbox and prints the retry hint. | +| `NEMOCLAW_SANDBOX_READY_TIMEOUT` | `180` | Wall-clock timeout for post-create readiness, in seconds. Raise when the sandbox image build, gateway upload, or in-sandbox boot exceeds the default (typical on 70B+ models, first-time gateway uploads over slow links, or DGX Station / remote-VM first runs). Ordinary onboarding deletes the partially created sandbox when the deadline expires and prints the retry hint. Portable OpenClaw onboarding instead preserves the sandbox when NemoClaw cannot verify its exact runtime identity. | | `NEMOCLAW_SANDBOX_READY_ERROR_DEBOUNCE` | `30` | Consecutive `Error`-phase polls the post-create readiness wait tolerates before treating `Error` as terminal. Polling starts at 250ms and backs off to a 2-second cap, while `NEMOCLAW_SANDBOX_READY_TIMEOUT` remains the overall deadline. The gateway can briefly report a just-created sandbox in `Error` while it re-registers the sandbox (seen on DGX Spark); the debounce lets that transient recover to `Ready`. `Failed` and `CrashLoopBackOff` always fail immediately. Set to `1` to restore fast-fail on the first `Error` poll. | | `NEMOCLAW_GATEWAY_RECOVERY_WAIT_SECONDS` | `30`, `90`, or `120`, depending on the recovery phase | Wall-clock timeout for OpenShell command re-registration after policy application, plus gateway health and re-registration during managed OpenClaw or Hermes recovery. A valid finite, nonnegative value overrides the internal budget for the current recovery phase. | @@ -4996,7 +5004,15 @@ $$nemoclaw recover If the Ollama pull or post-create readiness timeout fires, onboarding emits the elapsed budget plus a hint to raise the relevant variable. The Ollama pull preserves its partial download for the next attempt. -The post-create readiness wait deletes the orphaned sandbox first so the next `$$nemoclaw onboard` starts clean. +The ordinary post-create readiness wait deletes the orphaned sandbox first so the next `$$nemoclaw onboard` starts without that partially created sandbox. + + + +For portable OpenClaw onboarding, NemoClaw instead leaves the sandbox in place when it cannot verify the exact runtime identity. +Inspect it with `openshell sandbox list` and `$$nemoclaw status`, then follow the recovery guidance from `status`. + + + A post-policy re-registration failure leaves the sandbox in place and reports that OpenShell did not re-register it. ### Lifecycle Behavior Flags diff --git a/docs/reference/troubleshooting.mdx b/docs/reference/troubleshooting.mdx index 685843fdfee..a699d9c56d7 100644 --- a/docs/reference/troubleshooting.mdx +++ b/docs/reference/troubleshooting.mdx @@ -2125,7 +2125,15 @@ When the ordinary create deadline expires, NemoClaw tries to delete the partiall After successful cleanup, the output ends with `Retry: $$nemoclaw onboard`. If cleanup fails, NemoClaw instead reports that the failed sandbox could not be removed and prints `Manual cleanup: openshell sandbox delete ""`. -The failure path differs when NemoClaw recreates an OpenShell-managed Docker runtime immediately before this wait. + + +Portable OpenClaw onboarding preserves the sandbox when NemoClaw cannot verify its exact runtime identity. +It does not start dashboard forwarding on this failure path. +Inspect the preserved sandbox with the status commands below, then follow the recovery guidance from `$$nemoclaw status`. + + + +The failure path also differs when NemoClaw recreates an OpenShell-managed Docker runtime immediately before this wait. NemoClaw pins the exact OpenShell sandbox ID before recreation. Within the same deadline, NemoClaw requires two consecutive `Ready` observations that each confirm the exact ID and successful command execution. It retries only OpenShell's exact `sandbox is not ready` response. @@ -3028,6 +3036,13 @@ If GPU passthrough is not required on this host, rerun onboarding with `--no-gpu The route depends on the host environment and the operator control. Identify the matching path before applying the recovery guidance. + + +Do not apply this compatibility guidance to portable OpenClaw onboarding. +That path requires native OpenShell GPU injection and does not use `NEMOCLAW_DOCKER_GPU_PATCH`, so do not set `fallback`, `1`, or another legacy nonzero value for a portable OpenClaw run. + + + | Symptom | Route or stage | Recovery | | --- | --- | --- | | Native `--gpu` is rejected, host runtime evidence identifies GPU injection failure, or an explicit driver proof fails and host configuration confirms no GPU attachment | Ordinary Linux native attempt | The default native-only route stops. Retry with `NEMOCLAW_DOCKER_GPU_PATCH=fallback` only if you explicitly accept one bounded compatibility retry, or use `=1` to select compatibility before creation. | diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index b37ad45c184..e1c4b5041a2 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -1618,6 +1618,7 @@ async function createSandboxWithBaseImageResolution( const plannedMessagingState = envMessagingState?.plan.sandboxName === sandboxName ? envMessagingState : undefined; const managedWorkloadRuntime = managedWorkloadOnboard.createManagedWorkloadOnboardRuntime({ computePlan, managedWorkloadRebuild, tempManagedRuntime, tempManagedRuntimeCatalog, agentName: requestedAgentName, legacyDockerfilePath, customDockerfilePath: fromDockerfile ?? (preparedBuildContext ? preparedBuildContext.stagedDockerfile : null), rootDir: ROOT, model, provider, preferredInferenceApi, endpointUrl: createIntent?.endpointUrl ?? null, startupProfile: { chatUiUrl, effectiveDashboardPort: effectivePort, manageDashboard, dashboardBindAddress: process.env.NEMOCLAW_DASHBOARD_BIND, wslExposure: requestedAgentName === "openclaw" && isWsl(), hermesDashboardState, webSearch: webSearchConfig, toolDisclosure: effectiveToolDisclosure, hermesToolGateways, messagingPlan: plannedMessagingState?.plan ?? null, dcodeAutoApprovalMode: dcodeAutoApprovalPlan.mode, observabilityEnabled: createIntent?.observabilityEnabled === true, environment: process.env }, note, fallbackBuildEstimate: () => process.env.NEMOCLAW_IGNORE_RUNTIME_RESOURCES === "1" ? null : formatSandboxBuildEstimateNote(assessHost()) }, { resolveAgentInferenceApi: inferenceConfig.resolveAgentInferenceApi, getSandboxInferenceConfig }); + const ensurePreparedSandboxWorkload = () => managedWorkloadOnboard.prepareSandboxWorkloadForPortableLifecycle(managedWorkloadRuntime, sandboxGpuCreateFlow.resolvePortableLifecycleMode(agent)); // #4614: capture default AFTER prune so a stale registry row isn't read as a live sandbox. const sandboxWasLiveDefault = liveExists && wasSandboxDefault(registry.getDefault(), sandboxName); @@ -1856,6 +1857,9 @@ async function createSandboxWithBaseImageResolution( for (const hint of recreateJournal.managedMcpRecreateRefusalHints({ sandboxName, cliName: cliName(), toolDisclosure: effectiveToolDisclosure, rebuildFlag: dcodeAutoApprovalPlan.rebuildFlag, observabilityFlag: observabilityCommandFlag.explicitObservabilityFlag(createIntent?.observabilityEnabled === true, createIntent?.observabilityRequestedExplicitly === true) })) console.error(hint); process.exit(1); } + // Resolve and validate immutable workload authority before opening a recreate journal or + // mutating a live sandbox. + await ensurePreparedSandboxWorkload(); await hermesApiPortReservationScope.selectAndReserve(hermesApiPortReservationInput); if (!createIntent?.recreateTransaction) recreateRuntime = openRecreateJournal(); if (recreateRuntime.acceptedTarget) { @@ -1867,10 +1871,6 @@ async function createSandboxWithBaseImageResolution( baseImageResolutionFlow.captureBaseResolution(baseImageResolutionContext, previousEntry?.imageTag); policyPresetCarry.applyRecreatePolicyCarryForward(sandboxName, isNonInteractive(), note); - // Resolve and validate immutable workload authority before deleting a live sandbox. - const replacementWorkload = await managedWorkloadRuntime.ensurePreparedWorkload(); - managedWorkloadRuntime.ensurePreparedProfile(replacementWorkload); - const noRestorePending = pendingStateRestore === null && pendingStateRestoreBackupPath === null; if (noRestorePending && !notReadyRecreateInProgress && !shouldSkipPreRecreateBackup(process.env)) { note(" Backing up workspace state before recreating sandbox..."); @@ -1895,8 +1895,7 @@ async function createSandboxWithBaseImageResolution( } if (!liveExists) await hermesApiPortReservationScope.selectAndReserve(hermesApiPortReservationInput); - const preparedSandboxWorkload = await managedWorkloadRuntime.ensurePreparedWorkload(); - managedWorkloadRuntime.ensurePreparedProfile(preparedSandboxWorkload); + const preparedSandboxWorkload = await ensurePreparedSandboxWorkload(); applyExtraProviderReconciliation({ extraProviders: resolvedCreateIntent.extraProviders, staleExtraProviders: resolvedCreateIntent.staleExtraProviders ?? [], @@ -1950,7 +1949,7 @@ async function createSandboxWithBaseImageResolution( restoreBackupPath, terminalAgent: agentDefs.isTerminalAgent(agent), managedBootstrap, - ...sandboxGpuCreateFlow.resolveDockerStartupCommandPatch(agent, dockerDriverGateway), + ...sandboxGpuCreateFlow.resolveAgentCreateInput(agent, dockerDriverGateway), }, { runOpenshell, @@ -2854,10 +2853,8 @@ const sandboxCreateIntentResolver = sandboxCreateIntentResolution.createSandboxC filterEnabledChannelsByAgent, defaultPolicyPath: path.join(ROOT, "nemoclaw-blueprint", "policies", "openclaw-sandbox.yaml"), getAgentPolicyPath: (agent) => (agent ? agentOnboard.getAgentPolicyPath(agent) : null), - resolveGpuPlan: (config) => - dockerGpuSandboxCreate.resolveDockerGpuSandboxCreatePlan(config, { - dockerDriverGateway: isLinuxDockerDriverGatewayEnabled(), - }), + resolveGpuPlan: (config, agent) => + dockerGpuSandboxCreate.resolveAgentPlan(config, agent, isLinuxDockerDriverGatewayEnabled()), appendResourceCreateArgs: (args, resourceProfile) => appendResourceFlagsForProfile(args, resourceProfile, getOpenshellBinary(), { isNonInteractive, diff --git a/src/lib/onboard/docker-gpu-sandbox-create-plan.ts b/src/lib/onboard/docker-gpu-sandbox-create-plan.ts index 85ff02238f9..eaf5bc850ee 100644 --- a/src/lib/onboard/docker-gpu-sandbox-create-plan.ts +++ b/src/lib/onboard/docker-gpu-sandbox-create-plan.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { type DockerGpuRoutePlan, resolveDockerGpuRoutePlan } from "./docker-gpu-route"; +import { isPortableExperimentalProfile } from "./experimental/portable-profile"; import { detectWslDockerDesktopStatus } from "./wsl-docker-desktop-gpu"; type DockerGpuSandboxConfig = { @@ -31,6 +32,22 @@ export function resetIsDockerDesktopWslRuntimeCache(): void { cachedDockerDesktopWslRuntime = null; } +export function resolveAgentPlan( + config: DockerGpuSandboxConfig, + agent: { name?: string | null } | null, + dockerDriverGateway: boolean, + env: NodeJS.ProcessEnv = process.env, + platform: NodeJS.Platform = process.platform, +): DockerGpuSandboxCreatePlan { + return resolveDockerGpuSandboxCreatePlan(config, { + dockerDriverGateway, + portableLifecycle: + isPortableExperimentalProfile(env) && (agent?.name ?? "openclaw") === "openclaw", + env, + platform, + }); +} + /** * SOURCE_OF_TRUTH_REVIEW (GPU create route selection; #6110) * invalidState: one attempt combines native `--gpu` with compatibility recreation. @@ -47,16 +64,24 @@ export function resolveDockerGpuSandboxCreatePlan( dockerDesktopWsl?: boolean; detectDockerDesktopWsl?: () => boolean; env?: NodeJS.ProcessEnv; + portableLifecycle?: boolean; platform?: NodeJS.Platform; log?: (message: string) => void; }, ): DockerGpuSandboxCreatePlan { + const env = options.env ?? process.env; + const portableLifecycle = options.portableLifecycle === true; const dockerDesktopWsl = - options.dockerDesktopWsl ?? (options.detectDockerDesktopWsl ?? isDockerDesktopWslRuntime)(); + portableLifecycle + ? false + : (options.dockerDesktopWsl ?? (options.detectDockerDesktopWsl ?? isDockerDesktopWslRuntime)()); const gpuRoutePlan = resolveDockerGpuRoutePlan(config, { - dockerDriverGateway: options.dockerDriverGateway, + // The hidden portable profile reaches OpenShell through its Docker-compatible + // endpoint, but rootless Podman owns sandbox lifecycle. Keep Docker-only + // container substitution out of that lifecycle path. + dockerDriverGateway: options.dockerDriverGateway && !portableLifecycle, dockerDesktopWsl, - env: options.env, + env, platform: options.platform, log: options.log, }); diff --git a/src/lib/onboard/docker-gpu-sandbox-create-route-plan.test.ts b/src/lib/onboard/docker-gpu-sandbox-create-route-plan.test.ts index 2ab0b6af31f..5d2552d6257 100644 --- a/src/lib/onboard/docker-gpu-sandbox-create-route-plan.test.ts +++ b/src/lib/onboard/docker-gpu-sandbox-create-route-plan.test.ts @@ -5,6 +5,7 @@ import { describe, expect, it, vi } from "vitest"; import { type DockerGpuRoutePlan, + resolveAgentPlan, resolveDockerGpuSandboxCreatePlan, } from "./docker-gpu-sandbox-create"; @@ -130,4 +131,66 @@ describe("resolveDockerGpuSandboxCreatePlan", () => { expect(result.gpuRoutePlan).toBe("compatibility-only"); expect(log).toHaveBeenCalledWith(expect.stringMatching(/unrecognized.*compatibility-only/i)); }); + + it("keeps the portable profile on native GPU lifecycle operations (#9068)", () => { + const log = vi.fn(); + const detectDockerDesktopWsl = vi.fn(() => true); + + const result = resolveDockerGpuSandboxCreatePlan( + { sandboxGpuEnabled: true }, + { + dockerDriverGateway: true, + detectDockerDesktopWsl, + portableLifecycle: true, + env: { + NEMOCLAW_DOCKER_GPU_PATCH: "1", + NEMOCLAW_EXPERIMENTAL_PROFILE: "portable", + }, + platform: "linux", + log, + }, + ); + + expect(result.gpuRoutePlan).toBe("native-only"); + expect(detectDockerDesktopWsl).not.toHaveBeenCalled(); + expect(log).not.toHaveBeenCalledWith(expect.stringMatching(/compatibility-only/iu)); + }); + + it("keeps non-OpenClaw portable agents on their existing GPU route (#9068)", () => { + const result = resolveDockerGpuSandboxCreatePlan( + { sandboxGpuEnabled: true }, + { + dockerDriverGateway: true, + dockerDesktopWsl: false, + portableLifecycle: false, + env: { + NEMOCLAW_DOCKER_GPU_PATCH: "1", + NEMOCLAW_EXPERIMENTAL_PROFILE: "portable", + }, + platform: "linux", + }, + ); + + expect(result.gpuRoutePlan).toBe("compatibility-only"); + }); + + it("selects the portable lifecycle route only for OpenClaw (#9068)", () => { + const env = { + NEMOCLAW_DOCKER_GPU_PATCH: "1", + NEMOCLAW_EXPERIMENTAL_PROFILE: "portable", + }; + + expect(resolveAgentPlan({ sandboxGpuEnabled: true }, null, true, env, "linux").gpuRoutePlan).toBe( + "native-only", + ); + expect( + resolveAgentPlan( + { sandboxGpuEnabled: true }, + { name: "hermes" }, + true, + env, + "linux", + ).gpuRoutePlan, + ).toBe("compatibility-only"); + }); }); diff --git a/src/lib/onboard/docker-gpu-sandbox-create.ts b/src/lib/onboard/docker-gpu-sandbox-create.ts index d4356c79d0a..4bcda5765ca 100644 --- a/src/lib/onboard/docker-gpu-sandbox-create.ts +++ b/src/lib/onboard/docker-gpu-sandbox-create.ts @@ -38,6 +38,7 @@ export type { export { isDockerDesktopWslRuntime, resetIsDockerDesktopWslRuntimeCache, + resolveAgentPlan, resolveDockerGpuSandboxCreatePlan, } from "./docker-gpu-sandbox-create-plan"; diff --git a/src/lib/onboard/experimental/portable-demo-lifecycle-authority.test.ts b/src/lib/onboard/experimental/portable-demo-lifecycle-authority.test.ts new file mode 100644 index 00000000000..d6a5fb64ea5 --- /dev/null +++ b/src/lib/onboard/experimental/portable-demo-lifecycle-authority.test.ts @@ -0,0 +1,212 @@ +// 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 { + installPortableDemoSandboxLifecycle, + portableDemoLifecycleInternals, +} 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 STARTUP_ARGV = [ + "env", + "CHAT_UI_URL=http://127.0.0.1:18789", + "NEMOCLAW_DASHBOARD_PORT=18789", + "OPENCLAW_HOME=/sandbox", + "OPENCLAW_STATE_DIR=/sandbox/.openclaw", + "OPENCLAW_WORKSPACE_DIR=/sandbox/.openclaw/workspace", + "NEMOCLAW_SANDBOX_NAME=alpha", + "/usr/local/bin/nemoclaw-start", +]; +const temporaryDirectories: string[] = []; + +function temporaryStateDir(): string { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-portable-authority-")); + temporaryDirectories.push(directory); + return directory; +} + +function createPodman() { + const podman = vi.fn((args: readonly string[], _env?: NodeJS.ProcessEnv) => { + const command = args[0] === "--url" ? args.slice(2) : args; + switch (command[0]) { + case "info": + return { status: 0, stdout: `${SOCKET_PATH}\n` }; + case "ps": + return { status: 0, stdout: `${CONTAINER_ID}\n` }; + case "inspect": + return { + status: 0, + stdout: JSON.stringify([ + { + Id: CONTAINER_ID, + Name: `openshell-default--alpha-${SANDBOX_ID}`, + Config: { + Labels: { + "openshell.managed": "true", + "openshell.ai/sandbox-id": SANDBOX_ID, + "openshell.ai/sandbox-name": "alpha", + "openshell.ai/sandbox-namespace": "", + "openshell.ai/sandbox-workspace": "default", + }, + }, + State: { Running: true }, + }, + ]), + }; + case "update": + return { status: 0 }; + default: + throw new Error(`Unexpected Podman command: ${args.join(" ")}`); + } + }); + return { podman }; +} + +function socketAuthorityDeps(socketInode: () => bigint = () => 9001n): 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 ? socketInode() : directoryInode, + mode: socket ? 0o660n : filePath === path.dirname(SOCKET_PATH) ? 0o700n : 0o755n, + uid: socket ? 1001n : filePath.startsWith("/run/user/1001") ? 1001n : 0n, + isDirectory: () => !socket, + isSocket: () => socket, + }; + }, + }; +} + +afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) { + fs.rmSync(directory, { force: true, recursive: true }); + } +}); + +describe("portable demo lifecycle authority", () => { + it("records the exact OpenShell container and applies the unless-stopped restart policy (#8441)", () => { + const stateDir = temporaryStateDir(); + const { podman } = createPodman(); + + installPortableDemoSandboxLifecycle( + "alpha", + STARTUP_ARGV, + { NEMOCLAW_EXPERIMENTAL_PROFILE: "portable" }, + { + platform: "linux", + podman, + stateDir, + podmanSocketAuthorityDeps: socketAuthorityDeps(), + hardenSocketDirectory: vi.fn(), + }, + ); + + expect(podman.mock.calls.map(([args]) => args)).toEqual([ + ["info", "--format", "{{.Host.RemoteSocket.Path}}"], + [ + "--url", + `unix://${SOCKET_PATH}`, + "ps", + "-a", + "--no-trunc", + "--filter", + "label=openshell.managed=true", + "--filter", + "label=openshell.ai/sandbox-name=alpha", + "--filter", + "label=openshell.ai/sandbox-workspace=default", + "--format", + "{{.ID}}", + ], + ["--url", `unix://${SOCKET_PATH}`, "inspect", CONTAINER_ID], + ["--url", `unix://${SOCKET_PATH}`, "update", "--restart=unless-stopped", CONTAINER_ID], + ]); + const filePath = portableDemoLifecycleInternals.receiptPath("alpha", stateDir); + const receipt = JSON.parse(fs.readFileSync(filePath, "utf-8")); + expect(receipt).toEqual({ + schemaVersion: 3, + sandboxName: "alpha", + sandboxId: SANDBOX_ID, + containerId: CONTAINER_ID, + dashboardPort: 18789, + registryGeneration: CONTAINER_ID, + }); + expect(fs.statSync(filePath).mode & 0o777).toBe(0o600); + }); + + it("ignores ambient Podman remote selection while installing lifecycle ownership (#9068)", () => { + const stateDir = temporaryStateDir(); + const runtime = createPodman(); + + installPortableDemoSandboxLifecycle( + "alpha", + STARTUP_ARGV, + { + NEMOCLAW_EXPERIMENTAL_PROFILE: "portable", + CONTAINER_CONNECTION: "attacker", + CONTAINER_HOST: "tcp://example.test:1234", + CONTAINER_SSHKEY: "/tmp/attacker-key", + }, + { + platform: "linux", + podman: runtime.podman, + stateDir, + podmanSocketAuthorityDeps: socketAuthorityDeps(), + hardenSocketDirectory: vi.fn(), + }, + ); + + for (const [, env] of runtime.podman.mock.calls) { + expect(env).toEqual({ NEMOCLAW_EXPERIMENTAL_PROFILE: "portable" }); + } + }); + + it("refuses socket replacement before mutating the portable restart policy (#9068)", () => { + const stateDir = temporaryStateDir(); + const runtime = createPodman(); + let inode = 9001n; + const podman = vi.fn((args: readonly string[], env?: NodeJS.ProcessEnv) => { + const result = runtime.podman(args, env); + const command = args[0] === "--url" ? args.slice(2) : args; + inode = command[0] === "inspect" ? 9002n : inode; + return result; + }); + + expect(() => + installPortableDemoSandboxLifecycle( + "alpha", + STARTUP_ARGV, + { NEMOCLAW_EXPERIMENTAL_PROFILE: "portable" }, + { + platform: "linux", + podman, + stateDir, + podmanSocketAuthorityDeps: socketAuthorityDeps(() => inode), + hardenSocketDirectory: vi.fn(), + }, + ), + ).toThrow("changed after it was qualified"); + expect( + runtime.podman.mock.calls.some(([args]) => { + const command = args[0] === "--url" ? args.slice(2) : args; + return command[0] === "update"; + }), + ).toBe(false); + expect(fs.existsSync(portableDemoLifecycleInternals.receiptPath("alpha", stateDir))).toBe( + false, + ); + }); +}); diff --git a/src/lib/onboard/experimental/portable-demo-lifecycle-identity.test.ts b/src/lib/onboard/experimental/portable-demo-lifecycle-identity.test.ts index 69146b4e92b..5932eff76e3 100644 --- a/src/lib/onboard/experimental/portable-demo-lifecycle-identity.test.ts +++ b/src/lib/onboard/experimental/portable-demo-lifecycle-identity.test.ts @@ -6,6 +6,7 @@ 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 { installPortableDemoSandboxLifecycle, type PortableDemoLifecycleDeps, @@ -14,6 +15,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", @@ -26,6 +28,26 @@ const STARTUP_ARGV = [ ]; const temporaryDirectories: string[] = []; +function socketAuthorityDeps(): 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 ? 9001n : directoryInode, + 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 temporaryStateDir(): string { const directory = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-portable-identity-")); temporaryDirectories.push(directory); @@ -101,7 +123,13 @@ function installReceipt(stateDir: string, podman: ReturnType[1], deps: PortableDemoLifecycleDeps = {}, ) { + const authorityDeps = deps.podman + ? { + podmanSocketAuthorityDeps: socketAuthorityDeps(), + hardenSocketDirectory: vi.fn(), + } + : {}; return recoverPortableDemoSandboxLifecycleUnchecked( sandboxName, { @@ -226,7 +238,7 @@ function recoverPortableDemoSandboxLifecycle( openshellDriver: "docker", ...context, }, - deps, + { ...authorityDeps, ...deps }, ); } @@ -354,39 +366,6 @@ describe("portable demo sandbox lifecycle", () => { expect(runtime.podman).not.toHaveBeenCalled(); }); - it("records the exact OpenShell container and applies the unless-stopped restart policy (#8441)", () => { - const stateDir = temporaryStateDir(); - const { podman } = createPodman(); - - installReceipt(stateDir, podman); - - expect(podman).toHaveBeenCalledWith([ - "ps", - "-a", - "--no-trunc", - "--filter", - "label=openshell.managed=true", - "--filter", - "label=openshell.ai/sandbox-name=alpha", - "--filter", - "label=openshell.ai/sandbox-workspace=default", - "--format", - "{{.ID}}", - ]); - expect(podman).toHaveBeenCalledWith(["update", "--restart=unless-stopped", CONTAINER_ID]); - const filePath = portableDemoLifecycleInternals.receiptPath("alpha", stateDir); - const receipt = JSON.parse(fs.readFileSync(filePath, "utf-8")); - expect(receipt).toEqual({ - schemaVersion: 3, - sandboxName: "alpha", - sandboxId: SANDBOX_ID, - containerId: CONTAINER_ID, - dashboardPort: 18789, - registryGeneration: CONTAINER_ID, - }); - 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(); @@ -618,7 +597,13 @@ describe("portable demo sandbox lifecycle", () => { STARTUP_ARGV.at(-1)!, ], { NEMOCLAW_EXPERIMENTAL_PROFILE: "portable" }, - { platform: "linux", podman, stateDir }, + { + platform: "linux", + podman, + stateDir, + podmanSocketAuthorityDeps: socketAuthorityDeps(), + hardenSocketDirectory: vi.fn(), + }, ); const receipt = fs.readFileSync( @@ -696,7 +681,10 @@ describe("portable demo sandbox lifecycle", () => { ); expect(result).toEqual({ kind: "recovered" }); - expect(runtime.podman).toHaveBeenCalledWith(["start", CONTAINER_ID]); + expect(runtime.podman).toHaveBeenCalledWith( + ["--url", "unix:///run/user/1001/podman/podman.sock", "start", CONTAINER_ID], + expect.any(Object), + ); expect(launchOpenshell).toHaveBeenCalledWith([ "sandbox", "exec", @@ -874,7 +862,12 @@ describe("portable demo sandbox lifecycle", () => { ), ).toEqual({ kind: "recovered" }); expect(launchOpenshell).toHaveBeenCalledTimes(2); - expect(runtime.podman).not.toHaveBeenCalledWith(["start", expect.any(String)]); + expect( + runtime.podman.mock.calls.some(([args]) => { + const command = args[0] === "--url" ? args.slice(2) : args; + return command[0] === "start"; + }), + ).toBe(false); expect(fs.readFileSync(receiptPath, "utf-8")).toBe(originalReceipt); }); @@ -886,9 +879,14 @@ describe("portable demo sandbox lifecycle", () => { const otherFilePath = portableDemoLifecycleInternals.receiptPath("beta", stateDir); const otherReceipt = '{"sandboxName":"beta"}\n'; fs.writeFileSync(otherFilePath, otherReceipt, { mode: 0o600 }); - runtime.podman.mockReturnValue({ - status: 125, - stdout: `Error: no such container ${CONTAINER_ID}`, + runtime.podman.mockImplementation((args) => { + const command = args[0] === "--url" ? args.slice(2) : args; + return command[0] === "info" + ? { status: 0, stdout: `${SOCKET_PATH}\n` } + : { + status: 125, + stdout: `Error: no such container ${CONTAINER_ID}`, + }; }); expect( diff --git a/src/lib/onboard/experimental/portable-demo-lifecycle.ts b/src/lib/onboard/experimental/portable-demo-lifecycle.ts index 1db40796f8a..3df813b1baa 100644 --- a/src/lib/onboard/experimental/portable-demo-lifecycle.ts +++ b/src/lib/onboard/experimental/portable-demo-lifecycle.ts @@ -804,9 +804,8 @@ export function installPortableDemoSandboxLifecycle( throw new Error("Portable demo lifecycle requires Linux"); } const commandEnv = deps.env ?? env; - const podmanEnv = localPodmanEnvironment(commandEnv); - const podman = deps.podman ?? ((args) => defaultPodman(args, podmanEnv)); - const inspection = discoverPodmanContainer(sandboxName, podman); + const authority = qualifiedPodmanAuthority(commandEnv, deps); + const inspection = discoverPodmanContainer(sandboxName, authority.podman); const registryGeneration = deps.registryGeneration ?? inspection.containerId; if (!SANDBOX_ID_PATTERN.test(registryGeneration)) { throw new Error("Portable demo lifecycle registry generation is invalid"); @@ -819,10 +818,12 @@ export function installPortableDemoSandboxLifecycle( dashboardPort: parseDashboardPort(createdStartupArgv, sandboxName), registryGeneration, }; + authority.assertRuntimeAuthority(); requireCommand( - podman(["update", "--restart=unless-stopped", inspection.containerId]), + authority.podman(["update", "--restart=unless-stopped", inspection.containerId]), `Setting the portable restart policy for sandbox '${sandboxName}'`, ); + authority.assertRuntimeAuthority(); writeReceipt(receipt, stateDir); return registryGeneration; } @@ -854,16 +855,14 @@ export function recoverPortableDemoSandboxLifecycle( throw new Error("Portable demo lifecycle receipt is only valid on Linux"); } const backfillRequired = requireCurrentRegistryGeneration(receipt, context.lifecycleGeneration); + const authority = qualifiedPodmanAuthority(commandEnv, deps); 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, backfillRequired, deps); } - const podmanEnv = localPodmanEnvironment(commandEnv); - const podman = deps.podman ?? ((args) => defaultPodman(args, podmanEnv)); - const initialInspection = podman(["inspect", receipt.containerId]); + const initialInspection = authority.podman(["inspect", receipt.containerId]); if (isMissingPodmanContainer(initialInspection)) { removeReceipt(sandboxName, stateDir); return { kind: "not-installed" }; @@ -871,7 +870,7 @@ export function recoverPortableDemoSandboxLifecycle( let inspection = inspectPodmanContainer( receipt.containerId, sandboxName, - podman, + authority.podman, initialInspection, ); if (inspection.sandboxId !== receipt.sandboxId) { @@ -880,11 +879,13 @@ export function recoverPortableDemoSandboxLifecycle( ); } if (!inspection.running) { + authority.assertRuntimeAuthority(); requireCommand( - podman(["start", receipt.containerId]), + authority.podman(["start", receipt.containerId]), `Starting portable sandbox '${sandboxName}'`, ); - inspection = inspectPodmanContainer(receipt.containerId, sandboxName, podman); + authority.assertRuntimeAuthority(); + inspection = inspectPodmanContainer(receipt.containerId, sandboxName, authority.podman); if (!inspection.running) { throw new Error(`Portable sandbox '${sandboxName}' did not enter the running state`); } diff --git a/src/lib/onboard/machine/handlers/sandbox-checkpoint-crash-recovery.test.ts b/src/lib/onboard/machine/handlers/sandbox-checkpoint-crash-recovery.test.ts index e824e1ec536..9cf3ee8ab62 100644 --- a/src/lib/onboard/machine/handlers/sandbox-checkpoint-crash-recovery.test.ts +++ b/src/lib/onboard/machine/handlers/sandbox-checkpoint-crash-recovery.test.ts @@ -187,6 +187,44 @@ function discordMessagingPlan(): ReturnType { } describe("sandbox crash-recovery replay (#5961, #6228)", () => { + it("reuses the selected Ready portable sandbox without recreation or forward cleanup (#9068)", async () => { + const checkpoint = crashedCheckpoint({ + profile: { kind: "selected", value: "portable" }, + effectGroups: {}, + }); + const session = sessionWithCheckpoint(checkpoint); + const { deps, calls } = createDeps({ + getSandboxReuseState: () => "ready", + checkGatewayRouteCompatibility: () => ({ ok: true }), + getSandboxRegistryEntry: () => ({ + name: "my-assistant", + agent: null, + provider: "provider", + model: "model", + endpointUrl: null, + preferredInferenceApi: "openai-completions", + gatewayName: "nemoclaw", + gatewayPort: 8080, + pendingRouteReservation: true, + reservationSessionId: session.sessionId, + }), + }); + + await handleSandboxState({ + ...baseOptions(deps, session), + resume: true, + sandboxName: "my-assistant", + env: { NEMOCLAW_EXPERIMENTAL_PROFILE: "portable" }, + }); + + expect(calls.createSandbox).not.toHaveBeenCalled(); + expect(calls.stopStale).not.toHaveBeenCalled(); + expect(calls.updateSandbox).toHaveBeenCalledWith("my-assistant", { + pendingRouteReservation: undefined, + }); + expect(calls.recordSkip).toHaveBeenCalled(); + }); + it("reuses a surviving sandbox with a legacy pre-reasoning fingerprint", async () => { const { deps, calls } = createDeps({ getSandboxReuseState: () => "ready" }); const session = sessionWithCheckpoint(crashedCheckpoint()); diff --git a/src/lib/onboard/managed-workload/onboard-orchestration.ts b/src/lib/onboard/managed-workload/onboard-orchestration.ts index a383d2293c8..d7524a81a89 100644 --- a/src/lib/onboard/managed-workload/onboard-orchestration.ts +++ b/src/lib/onboard/managed-workload/onboard-orchestration.ts @@ -98,6 +98,30 @@ export interface ManagedWorkloadOnboardRuntime { ): BuiltManagedStartupOnboardProfile | null; } +export function assertPortableManagedBootstrapNotSelected( + portableLifecycle: boolean, + managedBootstrapSelected: boolean, +): void { + if (portableLifecycle && managedBootstrapSelected) { + throw new Error( + "Portable OpenClaw onboarding cannot use managed-image bootstrap because that path requires Docker lifecycle operations.", + ); + } +} + +export async function prepareSandboxWorkloadForPortableLifecycle( + runtime: ManagedWorkloadOnboardRuntime, + portableLifecycle: boolean, +): Promise { + const workload = await runtime.ensurePreparedWorkload(); + assertPortableManagedBootstrapNotSelected( + portableLifecycle, + workload.source.kind === "managed-image", + ); + runtime.ensurePreparedProfile(workload); + return workload; +} + function requireBootstrapProvider(provider: RuntimeProviderBundle | null): BootstrapProvider { if (!provider || !provider.bootstrap.supported) { throw new Error("Selected runtime provider does not support managed bootstrap onboarding."); diff --git a/src/lib/onboard/sandbox-create-intent-resolution.ts b/src/lib/onboard/sandbox-create-intent-resolution.ts index 88f2f868d20..09f2190d270 100644 --- a/src/lib/onboard/sandbox-create-intent-resolution.ts +++ b/src/lib/onboard/sandbox-create-intent-resolution.ts @@ -45,7 +45,7 @@ export interface SandboxCreateIntentResolverDeps { filterEnabledChannelsByAgent(enabledChannels: string[] | null, agent: Agent): string[] | null; defaultPolicyPath: string; getAgentPolicyPath(agent: Agent): string | null; - resolveGpuPlan(config: SandboxGpuCreateConfig): { + resolveGpuPlan(config: SandboxGpuCreateConfig, agent: Agent): { gpuRoutePlan: DockerGpuRoutePlan; logMessage: string | null; }; @@ -116,6 +116,7 @@ export function createSandboxCreateIntentResolver< const messaging = await prepareMessagingCapabilities(input); const { gpuRoutePlan, logMessage: sandboxGpuLogMessage } = deps.resolveGpuPlan( input.sandboxGpuConfig, + input.agent, ); const resourceCreateArgs: string[] = []; deps.appendResourceCreateArgs(resourceCreateArgs, input.resourceProfile); diff --git a/src/lib/onboard/sandbox-gpu-create-flow.test.ts b/src/lib/onboard/sandbox-gpu-create-flow.test.ts index 804ff828766..163293b4359 100644 --- a/src/lib/onboard/sandbox-gpu-create-flow.test.ts +++ b/src/lib/onboard/sandbox-gpu-create-flow.test.ts @@ -52,6 +52,7 @@ vi.mock("./openshell-docker-sandbox-containers", async (importOriginal) => ({ queryOpenShellDockerSandboxRuntimeSnapshot: mocks.queryOpenShellDockerSandboxRuntimeSnapshot, })); +import type { AgentDefinition } from "../agent/defs"; import type { SandboxGpuProofResult } from "../state/registry"; import { createGpuFlowDeps as createDeps, @@ -79,6 +80,8 @@ import type { import { createRuntimeProviderBundleRegistry } from "./runtime-provider/registry"; import { prepareSandboxCreateLaunch } from "./sandbox-create-launch"; import { + resolveAgentCreateInput, + resolvePortableLifecycleMode, runSandboxGpuCreateFlow, type SandboxGpuCreateFlowDeps, type SandboxGpuCreateFlowInput, @@ -187,6 +190,25 @@ function createSourceInput(): SandboxGpuCreateFlowInput { beforeEach(() => setupGpuFlowMocks(mocks)); afterEach(resetGpuFlowMocks); +describe("resolveAgentCreateInput", () => { + it("selects portable lifecycle ownership only for OpenClaw (#9068)", () => { + const env = { NEMOCLAW_EXPERIMENTAL_PROFILE: "portable" }; + + expect(resolveAgentCreateInput(null, true, env)).toMatchObject({ + persistStartupCommand: true, + portableLifecycle: true, + }); + expect( + resolveAgentCreateInput({ name: "hermes" } as AgentDefinition, true, env), + ).toMatchObject({ + persistStartupCommand: true, + portableLifecycle: false, + }); + expect(resolvePortableLifecycleMode(null, env)).toBe(true); + expect(resolvePortableLifecycleMode({ name: "hermes" } as AgentDefinition, env)).toBe(false); + }); +}); + describe("runSandboxGpuCreateFlow provider-owned managed create", () => { it("recovers before an MXC-style create without a Docker branch in central orchestration", async () => { const input = createInput(); @@ -970,6 +992,154 @@ describe("runSandboxGpuCreateFlow native failure and readiness", () => { expect(warning).toContain("Authorization: Bearer "); expect(warning).not.toContain("portable-secret"); }); + + it("uses the exact portable lifecycle without Docker container substitution (#9068)", async () => { + const input = createInput(); + input.gpuRoutePlan = "native-only"; + input.hostEnv = { NEMOCLAW_EXPERIMENTAL_PROFILE: "portable" }; + input.portableLifecycle = true; + input.lifecycleGeneration = "checkpoint-generation"; + input.persistStartupCommand = true; + const deps = createDeps(); + deps.installPortableDemoLifecycle = vi.fn(() => "installed-generation"); + + const result = await runSandboxGpuCreateFlow(input, deps); + + expect(result).toMatchObject({ + route: "native", + lifecycleRegistrationFields: { lifecycleGeneration: "installed-generation" }, + }); + expect(deps.installPortableDemoLifecycle).toHaveBeenCalledOnce(); + expect(deps.installPortableDemoLifecycle).toHaveBeenCalledWith( + input.sandboxName, + input.sandboxStartupCommand, + input.hostEnv, + { registryGeneration: "checkpoint-generation" }, + ); + expect(mocks.waitForCreatedSandboxReadyWithTrace.mock.invocationCallOrder[0]).toBeLessThan( + vi.mocked(deps.installPortableDemoLifecycle).mock.invocationCallOrder[0]!, + ); + expect(mocks.createDockerGpuSandboxCreatePatch).not.toHaveBeenCalled(); + expect(mocks.queryOpenShellDockerSandboxContainers).not.toHaveBeenCalled(); + expect(mocks.queryOpenShellDockerSandboxRuntimeSnapshot).not.toHaveBeenCalled(); + expect(mocks.streamSandboxCreate).toHaveBeenCalledWith( + "openshell", + input.createArgv.slice(1), + input.sandboxEnv, + expect.objectContaining({ waitForReadyTermination: false }), + ); + }); + + it("keeps a Ready portable sandbox in place when lifecycle enrollment fails (#9068)", async () => { + const input = createInput(); + input.gpuRoutePlan = "native-only"; + input.hostEnv = { NEMOCLAW_EXPERIMENTAL_PROFILE: "portable" }; + input.portableLifecycle = true; + const deps = createDeps(); + deps.installPortableDemoLifecycle = vi.fn(() => { + throw new Error("portable authority changed"); + }); + + await expect(runSandboxGpuCreateFlow(input, deps)).rejects.toThrow( + "portable authority changed", + ); + + expect(mocks.createDockerGpuSandboxCreatePatch).not.toHaveBeenCalled(); + expect(deps.runOpenshell).not.toHaveBeenCalledWith( + ["sandbox", "delete", "alpha"], + expect.anything(), + ); + }); + + it("does not enroll portable lifecycle ownership before GPU proof succeeds (#9068)", async () => { + const input = createInput(); + input.gpuRoutePlan = "native-only"; + input.hostEnv = { NEMOCLAW_EXPERIMENTAL_PROFILE: "portable" }; + input.portableLifecycle = true; + const deps = createDeps(); + deps.installPortableDemoLifecycle = vi.fn(() => "current-generation"); + vi.mocked(deps.verifyDirectSandboxGpu).mockImplementation(() => { + throw new Error("GPU proof failed"); + }); + + await expect(runSandboxGpuCreateFlow(input, deps)).rejects.toThrow("GPU proof failed"); + + expect(deps.installPortableDemoLifecycle).not.toHaveBeenCalled(); + expect(mocks.createDockerGpuSandboxCreatePatch).not.toHaveBeenCalled(); + }); + + it("keeps non-OpenClaw portable creation on the existing runtime patch (#9068)", async () => { + const input = createInput(); + input.hostEnv = { NEMOCLAW_EXPERIMENTAL_PROFILE: "portable" }; + input.portableLifecycle = false; + input.persistStartupCommand = true; + const deps = createDeps(); + deps.installPortableDemoLifecycle = vi.fn(() => null); + + await expect(runSandboxGpuCreateFlow(input, deps)).resolves.toMatchObject({ route: "native" }); + + expect(mocks.createDockerGpuSandboxCreatePatch).toHaveBeenCalledOnce(); + expect(deps.installPortableDemoLifecycle).toHaveBeenCalledOnce(); + }); + + it("rejects Docker compatibility before portable sandbox creation (#9068)", async () => { + const input = createInput(); + input.hostEnv = { NEMOCLAW_EXPERIMENTAL_PROFILE: "portable" }; + input.portableLifecycle = true; + + await expect(runSandboxGpuCreateFlow(input, createDeps())).rejects.toThrow( + "Docker GPU compatibility is unavailable", + ); + + expect(mocks.streamSandboxCreate).not.toHaveBeenCalled(); + expect(mocks.createDockerGpuSandboxCreatePatch).not.toHaveBeenCalled(); + expect(mocks.queryOpenShellDockerSandboxContainers).not.toHaveBeenCalled(); + }); + + it("rejects managed bootstrap before portable Docker lifecycle access (#9068)", async () => { + const input = createInput(); + input.gpuRoutePlan = "native-only"; + input.hostEnv = { NEMOCLAW_EXPERIMENTAL_PROFILE: "portable" }; + input.portableLifecycle = true; + const createOnboardRouting = vi.fn(); + const createLifecycle = vi.fn(); + input.managedBootstrap = { + runtimeProvider: { + bootstrap: { createOnboardRouting, createLifecycle }, + }, + } as unknown as NonNullable; + + await expect(runSandboxGpuCreateFlow(input, createDeps())).rejects.toThrow( + "Portable OpenClaw onboarding cannot use managed-image bootstrap", + ); + + expect(createOnboardRouting).not.toHaveBeenCalled(); + expect(createLifecycle).not.toHaveBeenCalled(); + expect(mocks.streamSandboxCreate).not.toHaveBeenCalled(); + expect(mocks.createDockerGpuSandboxCreatePatch).not.toHaveBeenCalled(); + expect(mocks.queryOpenShellDockerSandboxContainers).not.toHaveBeenCalled(); + expect(mocks.queryOpenShellDockerSandboxRuntimeSnapshot).not.toHaveBeenCalled(); + }); + + it("preserves an unready portable sandbox without lifecycle mutation (#9068)", async () => { + const input = createInput(); + input.gpuRoutePlan = "native-only"; + input.hostEnv = { NEMOCLAW_EXPERIMENTAL_PROFILE: "portable" }; + input.portableLifecycle = true; + const deps = createDeps(); + deps.installPortableDemoLifecycle = vi.fn(() => "current-generation"); + mockReadinessFailure(); + mockExit(); + + await expect(runSandboxGpuCreateFlow(input, deps)).rejects.toThrow("process.exit:1"); + + expect(deps.installPortableDemoLifecycle).not.toHaveBeenCalled(); + expect(deps.runOpenshell).not.toHaveBeenCalledWith( + ["sandbox", "delete", "alpha"], + expect.anything(), + ); + expect(errorOutput()).toContain("left the portable sandbox in place"); + }); }); describe("runSandboxGpuCreateFlow fallback ordering", () => { diff --git a/src/lib/onboard/sandbox-gpu-create-flow.ts b/src/lib/onboard/sandbox-gpu-create-flow.ts index 2a5b10d1399..d53e4e192eb 100644 --- a/src/lib/onboard/sandbox-gpu-create-flow.ts +++ b/src/lib/onboard/sandbox-gpu-create-flow.ts @@ -1,6 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import type { AgentDefinition } from "../agent/defs"; import type { StreamSandboxCreateResult } from "../sandbox/create-stream"; import { redactFull } from "../security/redact"; import type { SandboxEntry, SandboxGpuProofResult } from "../state/registry"; @@ -10,7 +11,9 @@ import type { DockerGpuPatchDeps, DockerUlimit } from "./docker-gpu-patch-types" import type { SelectedDockerGpuRoute } from "./docker-gpu-route"; import { renderCompatibilityFallbackCreateArgs } from "./docker-gpu-route"; import { adaptDockerGpuRouteForPatch } from "./docker-gpu-route-patch-adapter"; +import { resolveDockerStartupCommandPatch } from "./docker-startup-command-agent"; import { installPortableDemoSandboxLifecycle } from "./experimental/portable-demo-lifecycle"; +import { isPortableExperimentalProfile } from "./experimental/portable-profile"; import { type ManagedBootstrapAdapter, type ManagedBootstrapAgentIdentity, @@ -19,6 +22,7 @@ import { ManagedBootstrapRecoveryBlockedError, } from "./managed-bootstrap/adapter"; import type { ManagedBootstrapRuntimePatch } from "./managed-bootstrap/runtime-create"; +import { assertPortableManagedBootstrapNotSelected } from "./managed-workload/onboard-orchestration"; import type { ManagedStartupRootApplyRequest } from "./managed-startup/root-apply"; import { isImmutableDockerImageId } from "./openshell-docker-sandbox-containers"; import type { @@ -33,6 +37,24 @@ import { addTraceEvent } from "./tracing"; export { resolveDockerStartupCommandPatch } from "./docker-startup-command-agent"; +export function resolvePortableLifecycleMode( + agent: AgentDefinition | null, + env: NodeJS.ProcessEnv = process.env, +): boolean { + return isPortableExperimentalProfile(env) && (agent?.name ?? "openclaw") === "openclaw"; +} + +export function resolveAgentCreateInput( + agent: AgentDefinition | null, + dockerDriverGateway: boolean, + env: NodeJS.ProcessEnv = process.env, +) { + return { + ...resolveDockerStartupCommandPatch(agent, dockerDriverGateway), + portableLifecycle: resolvePortableLifecycleMode(agent, env), + }; +} + /* * Keep recovery rendering at this public command boundary. Providers own the * detail and remediation; central orchestration only renders their bounded, @@ -79,6 +101,9 @@ export interface SandboxGpuCreateFlowInput { gatewayPort: number; sandboxReadyTimeoutSecs: number; createArgv: string[]; + /** Host-side runtime environment used only by the selected lifecycle provider. */ + hostEnv?: NodeJS.ProcessEnv; + portableLifecycle?: boolean; sandboxEnv: NodeJS.ProcessEnv; sandboxStartupCommand: string[]; lifecycleGeneration?: SandboxEntry["lifecycleGeneration"]; @@ -140,6 +165,10 @@ export async function runSandboxGpuCreateFlow( input: SandboxGpuCreateFlowInput, deps: SandboxGpuCreateFlowDeps, ): Promise { + assertPortableManagedBootstrapNotSelected( + input.portableLifecycle === true, + input.managedBootstrap != null, + ); let registryImageRef: string | null = input.prebuild.imageRef; const attemptRunner = createSandboxGpuCreateAttemptRunner(input, deps); const gpuCreateOutcome = await sandboxGpuCreateAttempt @@ -253,20 +282,25 @@ export async function runSandboxGpuCreateFlow( process.exit(1); } - let portableLifecycleGeneration: string | null = null; - try { - portableLifecycleGeneration = - (deps.installPortableDemoLifecycle ?? installPortableDemoSandboxLifecycle)( - input.sandboxName, - input.sandboxStartupCommand, - process.env, - { - ...(input.lifecycleGeneration ? { registryGeneration: input.lifecycleGeneration } : {}), - }, - ) ?? 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}`); + let portableLifecycleGeneration = attemptRunner.state.portableLifecycleGeneration; + if (!input.portableLifecycle && !portableLifecycleGeneration) { + try { + portableLifecycleGeneration = + (deps.installPortableDemoLifecycle ?? installPortableDemoSandboxLifecycle)( + input.sandboxName, + input.sandboxStartupCommand, + process.env, + { + ...(input.lifecycleGeneration ? { registryGeneration: input.lifecycleGeneration } : {}), + }, + ) ?? 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}`); + } } return { @@ -275,8 +309,8 @@ export async function runSandboxGpuCreateFlow( firstCreateOutput: attemptRunner.state.firstCreateOutput, registryImageRef, lifecycleRegistrationFields: { - ...(portableLifecycleGeneration ? { lifecycleGeneration: portableLifecycleGeneration } : {}), ...(input.lifecycleGeneration ? { lifecycleGeneration: input.lifecycleGeneration } : {}), + ...(portableLifecycleGeneration ? { lifecycleGeneration: portableLifecycleGeneration } : {}), }, }; } diff --git a/src/lib/onboard/sandbox-gpu-create-run-attempt.ts b/src/lib/onboard/sandbox-gpu-create-run-attempt.ts index 0be49cd0023..916f76ca967 100644 --- a/src/lib/onboard/sandbox-gpu-create-run-attempt.ts +++ b/src/lib/onboard/sandbox-gpu-create-run-attempt.ts @@ -17,8 +17,12 @@ import { reportSandboxCreateFailure } from "./created-sandbox-failure"; import * as dockerGpuLocalInference from "./docker-gpu-local-inference"; import type { SelectedDockerGpuRoute } from "./docker-gpu-route"; import { createDockerGpuSandboxCreatePatch } from "./docker-gpu-sandbox-create"; +import { installPortableDemoSandboxLifecycle } from "./experimental/portable-demo-lifecycle"; import { enforceManagedBootstrapRecoveryForSandbox } from "./managed-bootstrap/adapter"; -import type { ManagedBootstrapRuntimeSnapshot } from "./managed-bootstrap/runtime-create"; +import type { + ManagedBootstrapRuntimePatch, + ManagedBootstrapRuntimeSnapshot, +} from "./managed-bootstrap/runtime-create"; import { queryOpenShellDockerSandboxContainers, queryOpenShellDockerSandboxRuntimeSnapshot, @@ -41,6 +45,7 @@ export type SandboxGpuCreateAttemptState = { compatibilityArgv: string[] | null; allowUnbuiltCompatibilitySource: boolean; nativeRuntimeSnapshot: NativeRuntimeSnapshot | null; + portableLifecycleGeneration: string | null; }; // A runtime-managed container replacement can briefly observe the original @@ -54,6 +59,43 @@ const OPENSHELL_SANDBOX_NOT_READY = type OpenShellCommandResult = ReturnType; +function createPortableRuntimePatch( + input: SandboxGpuCreateFlowInput, + deps: SandboxGpuCreateFlowDeps, + recordLifecycleGeneration: (generation: string) => void, +): ManagedBootstrapRuntimePatch { + let applied = false; + return { + maybeApplyDuringCreate() {}, + createFailureMessage: () => null, + exitOnPatchError() {}, + rollbackManagedStartupAfterCreateFailure() {}, + ensureApplied() { + if (applied) return; + const generation = (deps.installPortableDemoLifecycle ?? installPortableDemoSandboxLifecycle)( + input.sandboxName, + input.sandboxStartupCommand, + input.hostEnv ?? process.env, + { + ...(input.lifecycleGeneration ? { registryGeneration: input.lifecycleGeneration } : {}), + }, + ); + if (!generation) { + throw new Error(`Portable lifecycle setup did not record sandbox '${input.sandboxName}'.`); + } + recordLifecycleGeneration(generation); + applied = true; + }, + waitForSupervisorReconnectIfNeeded() {}, + commitAfterReady() {}, + selectedMode: () => null, + printReadinessFailureIfEnabled() {}, + async verifyGpuOrExit(verifyDirectSandboxGpu) { + return verifyDirectSandboxGpu(input.sandboxName); + }, + }; +} + function normalizedOpenShellCommandOutput(result: OpenShellCommandResult): string { return `${String(result.stderr ?? "")}\n${String(result.stdout ?? "")}` .replace(ANSI_RE, "") @@ -113,11 +155,23 @@ export function createSandboxGpuCreateAttemptRunner( input: SandboxGpuCreateFlowInput, deps: SandboxGpuCreateFlowDeps, ) { + const portableLifecycle = input.portableLifecycle === true; + if ( + portableLifecycle && + (input.gpuRoutePlan === "compatibility-only" || + input.gpuRoutePlan === "native-with-fallback" || + input.initialGpuRoute === "compatibility") + ) { + throw new Error( + "Portable sandbox creation requires native OpenShell GPU injection; Docker GPU compatibility is unavailable.", + ); + } const state: SandboxGpuCreateAttemptState = { firstCreateOutput: "", compatibilityArgv: null, allowUnbuiltCompatibilitySource: false, nativeRuntimeSnapshot: null, + portableLifecycleGeneration: null, }; const managedRouting = input.managedBootstrap?.runtimeProvider.bootstrap.createOnboardRouting({ sandboxName: input.sandboxName, @@ -127,6 +181,7 @@ export function createSandboxGpuCreateAttemptRunner( }); const nativeFallbackBaseline = !managedRouting && + !portableLifecycle && input.initialGpuRoute === "native" && input.gpuRoutePlan === "native-with-fallback" ? queryOpenShellDockerSandboxContainers(input.sandboxName) @@ -192,25 +247,32 @@ export function createSandboxGpuCreateAttemptRunner( input.persistStartupCommand === true && (route !== "native" || !input.terminalAgent || hasRequiredUlimits); const deferRestartSafeCutover = - !managedLifecycle && !compatibility && persistRestartSafeStartup; + !managedLifecycle && !portableLifecycle && !compatibility && persistRestartSafeStartup; + const portableRuntimePatch = portableLifecycle + ? createPortableRuntimePatch(input, deps, (generation) => { + state.portableLifecycleGeneration = generation; + }) + : null; const runtimePatch = managedLifecycle?.patch ?? - createDockerGpuSandboxCreatePatch({ - route, - // The startup clone preserves native CDI devices, so non-terminal agents - // keep their selected command and DCode can apply its exact required limits - // without replacing the native GPU envelope. Native terminal agents without - // required limits retain their create-time command. - persistStartupCommand: persistRestartSafeStartup, - externalRecreation: false, - sandboxName: input.sandboxName, - gpuDevice: input.sandboxGpuConfig.sandboxGpuDevice, - openshellSandboxCommand: input.sandboxStartupCommand, - requiredUlimits: input.requiredUlimits, - timeoutSecs: input.sandboxReadyTimeoutSecs, - backend: input.sandboxGpuConfig.hostGpuPlatform === "jetson" ? "jetson" : "generic", - deps, - }); + (portableRuntimePatch + ? portableRuntimePatch + : createDockerGpuSandboxCreatePatch({ + route, + // The startup clone preserves native CDI devices, so non-terminal agents + // keep their selected command and DCode can apply its exact required limits + // without replacing the native GPU envelope. Native terminal agents without + // required limits retain their create-time command. + persistStartupCommand: persistRestartSafeStartup, + externalRecreation: false, + sandboxName: input.sandboxName, + gpuDevice: input.sandboxGpuConfig.sandboxGpuDevice, + openshellSandboxCommand: input.sandboxStartupCommand, + requiredUlimits: input.requiredUlimits, + timeoutSecs: input.sandboxReadyTimeoutSecs, + backend: input.sandboxGpuConfig.hostGpuPlatform === "jetson" ? "jetson" : "generic", + deps, + })); const recovery = await managedLifecycle?.recoverUnfinished(); if (recovery) { enforceManagedBootstrapRecoveryForSandbox(recovery, input.sandboxName, (message) => @@ -412,7 +474,7 @@ export function createSandboxGpuCreateAttemptRunner( }); process.exit(createResult.status === 0 ? 1 : createResult.status); } - await runtimePatch.ensureApplied(); + if (!portableLifecycle || managedLifecycle) await runtimePatch.ensureApplied(); await runtimePatch.waitForSupervisorReconnectIfNeeded(); console.log(" Waiting for sandbox to become ready..."); const readiness = sandboxReadinessTracing.waitForCreatedSandboxReadyWithTrace({ @@ -477,6 +539,10 @@ export function createSandboxGpuCreateAttemptRunner( console.error( " NemoClaw did not start dashboard forwarding. NemoClaw left the sandbox in place for inspection and recovery.", ); + } else if (portableLifecycle) { + console.error( + " NemoClaw left the portable sandbox in place because it could not verify the exact runtime identity.", + ); } else { const deletion = deps.runOpenshell(["sandbox", "delete", input.sandboxName], { ignoreError: true, @@ -552,6 +618,7 @@ export function createSandboxGpuCreateAttemptRunner( throw new Error("Sandbox GPU proof returned failed status."); } } + if (portableRuntimePatch) await portableRuntimePatch.ensureApplied(); // GPU-enabled cutover stays reversible until the caller also proves the // configured host-local inference path. Non-GPU workloads have completed // their final authoritative Ready gate here. diff --git a/test/e2e/live/podman-cpu-lifecycle.test.ts b/test/e2e/live/podman-cpu-lifecycle.test.ts index f654b3b906a..d8ff495aacc 100644 --- a/test/e2e/live/podman-cpu-lifecycle.test.ts +++ b/test/e2e/live/podman-cpu-lifecycle.test.ts @@ -268,7 +268,8 @@ test("activates pinned OpenShell sandboxes and preserves registered-agent Podman { ...process.env, NEMOCLAW_EXPERIMENTAL_PROFILE: "portable" }, { platform: "linux", - podman: (args) => runtimeEngines.sandboxLifecycle.capture(args), + podman: (args) => + runtimeEngines.sandboxLifecycle.capture(args[0] === "--url" ? args.slice(2) : args), stateDir: portableStateDir, }, ); diff --git a/test/onboard-entry-composition.test.ts b/test/onboard-entry-composition.test.ts index 54a82c5dad8..ba824fae7fd 100644 --- a/test/onboard-entry-composition.test.ts +++ b/test/onboard-entry-composition.test.ts @@ -36,7 +36,7 @@ describe("onboarding entry composition boundary", () => { provider: { createSandboxWithBaseImageResolution: 15, handleNimLocalSelection: 32, - handleRemoteProviderSelection: 80, + handleRemoteProviderSelection: 76, handleRoutedSelection: 15, runOnboard: 8, selectAndValidateOllamaModel: 18, diff --git a/test/onboard.test.ts b/test/onboard.test.ts index 13c04f30bc3..6c4fbedfb2d 100644 --- a/test/onboard.test.ts +++ b/test/onboard.test.ts @@ -931,4 +931,185 @@ const { createSandbox } = require(${onboardPath}); ); expect(destructive).toEqual([]); }); + + it("rejects portable managed bootstrap before recreate state mutation (#9068)", () => { + const repoRoot = path.join(import.meta.dirname, ".."); + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-portable-managed-recreate-")); + const fakeBin = path.join(tmpDir, "bin"); + const scriptPath = path.join(tmpDir, "portable-managed-recreate.js"); + const onboardPath = JSON.stringify(path.join(repoRoot, "src", "lib", "onboard.ts")); + const runnerPath = JSON.stringify(path.join(repoRoot, "src", "lib", "runner.ts")); + const registryPath = JSON.stringify(path.join(repoRoot, "src", "lib", "state", "registry.ts")); + const catalogPath = JSON.stringify( + path.join(repoRoot, "src", "lib", "onboard", "managed-image", "catalog.ts"), + ); + const contractPath = JSON.stringify( + path.join(repoRoot, "src", "lib", "onboard", "managed-image", "contract.ts"), + ); + const protectionPath = JSON.stringify( + path.join(repoRoot, "src", "lib", "onboard", "sandbox-recreate-protection.ts"), + ); + const journalPath = JSON.stringify( + path.join(repoRoot, "src", "lib", "onboard", "onboard-recreate-journal.ts"), + ); + const scriptMocksPath = JSON.stringify( + path.join(repoRoot, "test", "helpers", "onboard-script-mocks.cjs"), + ); + + fs.mkdirSync(fakeBin, { recursive: true }); + writeOkOpenshell(fakeBin); + + const script = String.raw` +const runner = require(${runnerPath}); +require(${scriptMocksPath}).mockStandaloneGatewayTeardownAuthority(); +const events = []; +const normalize = (command) => + (Array.isArray(command) ? command.join(" ") : String(command)).replace(/'/g, ""); + +const contract = require(${contractPath}); +const catalog = require(${catalogPath}); +catalog.resolveManagedImageCatalogFromGhcr = async ({ release, platform }) => + Object.fromEntries(contract.SHIPPED_MANAGED_IMAGE_AGENTS.map((agent, index) => { + const image = contract.MANAGED_IMAGE_REPOSITORIES[agent]; + const digest = "sha256:" + String(index + 1).repeat(64); + return [agent, { + contractVersion: contract.MANAGED_IMAGE_CONTRACT_VERSION, + agent, + platform, + image, + digest, + reference: image + "@" + digest, + source: { + repository: contract.MANAGED_IMAGE_SOURCE_REPOSITORY, + revision: "a".repeat(40), + release, + cohort: "ghrun-9068-1", + }, + startupProfileContractVersion: contract.MANAGED_IMAGE_STARTUP_PROFILE_CONTRACT_VERSION, + capabilityContractVersion: contract.MANAGED_IMAGE_CAPABILITY_CONTRACT_VERSION, + }]; + })); + +const protectionModule = require(${protectionPath}); +const createProtection = protectionModule.createSandboxRecreateProtection; +protectionModule.createSandboxRecreateProtection = (...args) => { + const protection = createProtection(...args); + return { + ...protection, + backup: () => { + events.push({ kind: "backup" }); + return { ok: true, backup: null, failureKind: "none" }; + }, + }; +}; + +const journalModule = require(${journalPath}); +const openJournal = journalModule.openOnboardRecreateJournal; +journalModule.openOnboardRecreateJournal = (...args) => { + events.push({ kind: "openJournal" }); + return openJournal(...args); +}; + +const registry = require(${registryPath}); +const sourceSandbox = { + name: "my-assistant", + agent: null, + gpuEnabled: true, + openshellDriver: "docker", + imageTag: "openshell/sandbox-from:source", + workload: { + schemaVersion: 1, + kind: "legacy-dockerfile", + reference: "openshell/sandbox-from:source", + shared: false, + }, +}; +registry.getSandbox = () => sourceSandbox; +registry.registerSandbox = () => { events.push({ kind: "registerSandbox" }); return true; }; +registry.updateSandbox = () => { events.push({ kind: "updateSandbox" }); return true; }; +registry.removeSandbox = () => { events.push({ kind: "removeSandbox" }); return true; }; + +runner.run = (command) => { + const value = normalize(command); + events.push({ kind: "run", command: value }); + return { status: 0 }; +}; +runner.runCapture = (command) => { + const value = normalize(command); + if (value.includes("sandbox get") && value.includes("my-assistant")) { + return "Name: my-assistant\nId: sbx-portable-source\n"; + } + if (value.includes("sandbox list")) return "my-assistant Ready"; + if (value.includes("forward list")) return "my-assistant 127.0.0.1 18789 12345 running"; + return require(${scriptMocksPath}).mockOnboardRunCapture(command, { defaultCurlOutput: "ok" }) || ""; +}; + +const childProcess = require("node:child_process"); +childProcess.spawn = () => { + events.push({ kind: "spawn" }); + throw new Error("unexpected sandbox create"); +}; + +const { createSandboxWithTemporaryManagedRuntime } = require(${onboardPath}); +(async () => { + process.env.OPENSHELL_GATEWAY = "nemoclaw"; + process.env.NEMOCLAW_RECREATE_SANDBOX = "1"; + try { + await createSandboxWithTemporaryManagedRuntime( + null, + "gpt-5.4", + "nvidia-prod", + null, + "my-assistant", + ); + console.log(JSON.stringify({ error: "guard did not reject", events })); + } catch (error) { + console.log(JSON.stringify({ error: error instanceof Error ? error.message : String(error), events })); + } +})().catch((error) => { + console.error(error); + process.exit(1); +}); +`; + fs.writeFileSync(scriptPath, script); + + const env: Record = { + ...process.env, + HOME: tmpDir, + PATH: `${fakeBin}:${process.env.PATH || ""}`, + NEMOCLAW_EXPERIMENTAL_PROFILE: "portable", + NEMOCLAW_NON_INTERACTIVE: "1", + NEMOCLAW_RECREATE_SANDBOX: "1", + }; + delete env.NEMOCLAW_RECREATE_WITHOUT_BACKUP; + delete env.NEMOCLAW_TEST_MANAGED_IMAGE_FALLBACK; + const result = spawnSync(process.execPath, [scriptPath], { + cwd: repoRoot, + encoding: "utf-8", + env, + }); + + assert.equal(result.status, 0, result.stderr); + const payload = parseStdoutJson<{ + error: string; + events: Array<{ kind: string; command?: string }>; + }>(result.stdout); + expect(payload.error).toContain( + "Portable OpenClaw onboarding cannot use managed-image bootstrap", + ); + expect( + payload.events.filter( + (event) => + event.kind === "backup" || + event.kind === "openJournal" || + event.kind === "removeSandbox" || + event.kind === "registerSandbox" || + event.kind === "updateSandbox" || + event.kind === "spawn" || + /\bsandbox\s+(?:delete|create|rebuild)\b|\bprovider\s+(?:delete|create|update)\b/.test( + event.command || "", + ), + ), + ).toEqual([]); + }); });