diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx
index 9dbbb943654..8df4f095834 100644
--- a/docs/reference/commands.mdx
+++ b/docs/reference/commands.mdx
@@ -345,7 +345,37 @@ NemoClaw records onboarding progress so interrupted runs can continue.
Use `--resume` to continue a resumable onboarding session with the provider, model, sandbox name, agent, observability choice, custom Dockerfile path, read-only host-mount declarations, and any explicitly selected serving-profile provenance recorded by the original run.
For a profile-backed session, resume requires the same catalog, preset, and recipe digests and exits before effects if the installed definition changed.
Omit `--profile` to reuse that recorded selection, or pass the same profile explicitly; use `--fresh` to adopt a changed catalog definition.
-Legacy sessions without a profile-provenance record continue to resume normally, but cannot acquire a new `--profile` selection during resume.
+Sessions without a serving-profile provenance record can resume when their checkpoint uses schema 4, but they cannot acquire a new `--profile` selection during resume.
+
+Checkpoint schema 4 records whether onboarding uses the default profile or the portable experimental profile.
+For the portable profile, it also records the current user's canonical home reported by the operating system, that home's exact `.config` directory, the runtime root, rootless Podman endpoint path, and runtime ownership.
+It does not record ambient Docker or Podman runtime selector values.
+The runtime authority record contains no credentials.
+A plain `--resume` restores the recorded profile.
+You can also run `$$nemoclaw onboard --experimental-profile portable --resume` when the recorded profile is portable.
+NemoClaw rejects an explicit profile that conflicts with the checkpoint before it changes portable configuration, activates the user-scoped Podman socket, or changes gateway and sandbox resources.
+
+Portable resume derives `DOCKER_HOST`, `CONTAINERS_CONF`, and `NETAVARK_FW` again while it holds the onboarding lock.
+It ignores ambient Docker and Podman runtime selectors during that derivation.
+NemoClaw scopes the derived values to onboarding and restores the process environment after success or failure.
+It verifies the current user, canonical roots, socket path and ownership, Podman identity and version, and required configuration before a resumed onboarding step changes resources.
+Resume stops before writes or activation if an existing socket or configuration path is a symlink, has the wrong owner, or has an unsafe type or mode.
+NemoClaw can create missing descendants beneath a validated current-user root and reconcile content drift in its own portable configuration files.
+A missing user-scoped socket after a host reboot can be activated and verified at the recorded path.
+A new socket inode or a supported Podman upgrade does not invalidate the checkpoint.
+Portable onboarding always uses the `.config` directory beneath the canonical home reported by the operating system.
+`HOME` and `XDG_CONFIG_HOME` never select or override this authority.
+NemoClaw ignores ambient `XDG_CONFIG_HOME` during onboarding and restores its exact prior presence and value afterward.
+Resume rejects a checkpoint that records another configuration root.
+It also rejects stored authority or filesystem ownership drift without falling back to Docker.
+
+
+An active onboarding session with checkpoint schema 1, 2, or 3 cannot resume because those schemas did not record the default or portable profile authority.
+NemoClaw preserves the older session and exits before portable configuration, socket activation, or resource changes.
+Run `$$nemoclaw onboard --fresh` to discard the active session and start fresh onboarding.
+If you intend to use the portable experimental profile, run `$$nemoclaw onboard --experimental-profile portable --fresh`.
+This compatibility restriction does not prevent NemoClaw from reading a completed older session during status inspection.
+
Before the configuration review, NemoClaw records the sandbox name and the selected provider and model as an incomplete choice.
If onboarding stops at the review prompt, an interactive `--resume` run shows the prompt again.
diff --git a/docs/reference/troubleshooting.mdx b/docs/reference/troubleshooting.mdx
index df88ef826f2..6eba9e40840 100644
--- a/docs/reference/troubleshooting.mdx
+++ b/docs/reference/troubleshooting.mdx
@@ -623,7 +623,7 @@ When NemoClaw prints the state move, run the exact commands it prints:
3. Move the selected state directory into the archive as `gateway-state`.
4. Run the printed onboarding command only after the stop, the archive, and the move succeed.
Standard onboarding prints `$$nemoclaw onboard --resume`.
- The portable experimental profile prints its required fresh-onboarding command because it does not support resume.
+ The portable experimental profile prints its required fresh-onboarding command for this gateway-state recovery.
The archive remains beside the selected state directory and retains the previous gateway records and credentials.
Keep it owner-only until onboarding completes and every required sandbox and provider registration is restored.
@@ -1176,6 +1176,33 @@ This is only useful if the original failure was transient, for example a network
$$nemoclaw onboard --resume
```
+For a checkpoint schema 4 portable session, the plain command restores the portable profile from the checkpoint.
+You can also state the matching profile explicitly:
+
+```bash
+$$nemoclaw onboard --experimental-profile portable --resume
+```
+
+Portable resume does not trust ambient Docker or Podman runtime selectors.
+It derives and verifies the recorded current-user rootless Podman authority before it continues onboarding.
+If NemoClaw reports unsafe ownership, type, or mode, correct that filesystem condition and retry.
+Portable onboarding always uses the `.config` directory beneath the canonical home reported by the operating system; changing `HOME` or `XDG_CONFIG_HOME` does not select another location.
+For a recorded alternate configuration root or other user ID, home, runtime root, endpoint, runtime kind, or ownership drift, do not edit the checkpoint; run fresh onboarding.
+
+If NemoClaw reports that an active checkpoint uses schema 1, 2, or 3, the older checkpoint did not record enough profile and runtime authority for resume.
+NemoClaw preserves the session and exits before portable configuration, socket activation, or resource changes.
+Discard that active session and start fresh onboarding:
+
+```bash
+$$nemoclaw onboard --fresh
+```
+
+If you intend to use the portable experimental profile, select it again for fresh onboarding:
+
+```bash
+$$nemoclaw onboard --experimental-profile portable --fresh
+```
+
OpenClaw resume does not repeat completed non-secret sandbox, web search, messaging, or resource choices.
@@ -3295,6 +3322,12 @@ Then rerun portable onboarding:
$$nemoclaw onboard --experimental-profile portable
```
+If the failed run has a checkpoint schema 4 resumable session, resume it without exporting Docker or Podman runtime selectors:
+
+```bash
+$$nemoclaw onboard --resume
+```
+
Continue only when onboarding no longer reports that the Podman service or OpenShell Podman host gateway is unreachable.
diff --git a/src/lib/actions/onboard.ts b/src/lib/actions/onboard.ts
index a888fd0bffd..e79778f33b8 100644
--- a/src/lib/actions/onboard.ts
+++ b/src/lib/actions/onboard.ts
@@ -5,6 +5,7 @@ import { loadServingCatalog } from "../inference/serving/catalog-loader";
import type { GooglechatTunnelRuntimeDeps } from "../messaging/channels/googlechat/hooks/tunnel-runtime";
import { type OnboardCommandOptions, runOnboardCommand } from "../onboard/command";
import { type OnboardFlags, readAgentRegistryNames } from "../onboard/command-support";
+import { resolveOnboardResumeIntent } from "../onboard/session-bootstrap";
import { loadServingProfileResumeSession } from "../onboard/sandbox-registration";
import type { OnboardOptions } from "../onboard/types";
@@ -32,6 +33,7 @@ function buildOnboardCommandDeps(flags: OnboardFlags, runtimeDeps: OnboardAction
listAgents: () => [...readAgentRegistryNames()],
loadServingCatalog,
loadSession: loadServingProfileResumeSession,
+ resolveResumeIntent: resolveOnboardResumeIntent,
log: console.log,
error: console.error,
exit: (code: number) => process.exit(code),
diff --git a/src/lib/adapters/podman/index.test.ts b/src/lib/adapters/podman/index.test.ts
index 5be42c13b46..36777eb4426 100644
--- a/src/lib/adapters/podman/index.test.ts
+++ b/src/lib/adapters/podman/index.test.ts
@@ -3,7 +3,11 @@
import { describe, expect, it, vi } from "vitest";
-import { createPodmanContainerEngine, type PodmanSocketAuthority } from "./index";
+import {
+ createPodmanContainerEngine,
+ localPodmanEnvironment,
+ type PodmanSocketAuthority,
+} from "./index";
const AUTHORITY = {
directoryChain: [],
@@ -15,6 +19,21 @@ const AUTHORITY = {
} as const satisfies PodmanSocketAuthority;
describe("Podman container engine command adapter", () => {
+ it("removes ambient remote and Docker TLS selectors from local Podman commands (#9035)", () => {
+ const source = {
+ CONTAINER_HOST: "ssh://attacker.test",
+ CONTAINER_CONNECTION: "attacker",
+ CONTAINER_SSHKEY: "/tmp/attacker-key",
+ DOCKER_TLS: "1",
+ DOCKER_TLS_VERIFY: "1",
+ DOCKER_CERT_PATH: "/tmp/attacker-certs",
+ KEEP: "value",
+ };
+
+ expect(localPodmanEnvironment(source)).toEqual({ KEEP: "value" });
+ expect(source.DOCKER_TLS_VERIFY).toBe("1");
+ });
+
it("pins the exact socket around each operation-scoped command", () => {
const assertAuthority = vi.fn();
const capture = vi.fn(() => ({ status: 0, stdout: "ok", stderr: "" }));
diff --git a/src/lib/adapters/podman/index.ts b/src/lib/adapters/podman/index.ts
index 66a7902fc5f..6ce889d4709 100644
--- a/src/lib/adapters/podman/index.ts
+++ b/src/lib/adapters/podman/index.ts
@@ -31,6 +31,9 @@ export function localPodmanEnvironment(env: NodeJS.ProcessEnv): NodeJS.ProcessEn
delete local.CONTAINER_CONNECTION;
delete local.CONTAINER_HOST;
delete local.CONTAINER_SSHKEY;
+ delete local.DOCKER_TLS;
+ delete local.DOCKER_TLS_VERIFY;
+ delete local.DOCKER_CERT_PATH;
return local;
}
diff --git a/src/lib/build-context.test.ts b/src/lib/build-context.test.ts
index abfe0adf1fd..3c214aff0ae 100644
--- a/src/lib/build-context.test.ts
+++ b/src/lib/build-context.test.ts
@@ -257,14 +257,14 @@ describe("printSandboxCreateRecoveryHints", () => {
expect(out).toContain("onboard --resume");
});
- it("prints the portable-profile recovery command when the portable env is set", () => {
+ it("prints checkpoint resume recovery when the portable env is set (#9035)", () => {
const prev = process.env.NEMOCLAW_EXPERIMENTAL_PROFILE;
process.env.NEMOCLAW_EXPERIMENTAL_PROFILE = "portable";
try {
printSandboxCreateRecoveryHints("");
const out = stderr();
- expect(out).toContain("onboard --experimental-profile portable");
- expect(out).not.toContain("--resume");
+ expect(out).toContain("onboard --resume");
+ expect(out).not.toContain("onboard --experimental-profile portable");
expect(out).not.toContain("Or: nemoclaw onboard");
} finally {
prev === undefined
diff --git a/src/lib/build-context.ts b/src/lib/build-context.ts
index e5ee546600e..c1ca1ca710f 100644
--- a/src/lib/build-context.ts
+++ b/src/lib/build-context.ts
@@ -10,7 +10,7 @@ import fs from "node:fs";
import path from "node:path";
import { CLI_NAME } from "./cli/branding";
import { isPortableExperimentalProfile } from "./onboard/experimental/portable-profile";
-import { noteOnboardResumeHintShown, onboardRecoveryCommand } from "./onboard/resume-hint";
+import { noteOnboardResumeHintShown, onboardResumeRecoveryCommand } from "./onboard/resume-hint";
import { classifySandboxCreateFailure, planSandboxCreateRecovery } from "./validation";
@@ -109,7 +109,7 @@ export function printSandboxCreateRecoveryHints(
// the generic incomplete-exit backstop (#6003).
noteOnboardResumeHintShown();
const portable = isPortableExperimentalProfile();
- const recoveryCommand = onboardRecoveryCommand(portable);
+ const recoveryCommand = onboardResumeRecoveryCommand();
const failure = classifySandboxCreateFailure(output);
if (failure.kind === "image_upload_container_missing") {
const { arm64ImageRefWorkaround } = planSandboxCreateRecovery(failure, { platform, arch });
diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts
index c8cf8db6c21..cde8d12f2ee 100644
--- a/src/lib/onboard.ts
+++ b/src/lib/onboard.ts
@@ -52,6 +52,7 @@ const managedWorkloadOnboard: typeof import("./onboard/managed-workload/onboard-
require("./onboard/managed-workload/onboard-orchestration");
const onboardEntryOptions: typeof import("./onboard/entry-options") = require("./onboard/entry-options");
const onboardSessionBootstrap: typeof import("./onboard/session-bootstrap") = require("./onboard/session-bootstrap");
+const resumeRuntime: typeof import("./onboard/resume/locked-runtime") = require("./onboard/resume/locked-runtime");
const channelState: typeof import("./onboard/channel-state") = require("./onboard/channel-state");
const {
ensureOllamaLoopbackSystemdOverride,
@@ -488,7 +489,6 @@ const {
const { skippedStepMessage }: typeof import("./onboard/skipped-step-message") =
require("./onboard/skipped-step-message");
const policyPresetCarry: typeof import("./onboard/policy-preset-persistence") = require("./onboard/policy-preset-persistence");
-const { ensureUsageNoticeConsent } = require("./onboard/usage-notice");
const {
findAvailableDashboardPort,
preflightDashboardPortRangeAvailability,
@@ -3607,7 +3607,8 @@ async function preflightAuthoritativeRebuildTarget(
}
// ── Main ─────────────────────────────────────────────────────────
-const onboard = onboardEntryOptions.wrapOnboard(runOnboard, onboardSession);
+const wrappedOnboard = onboardEntryOptions.wrapOnboard(runOnboard, onboardSession);
+const onboard = onboardSessionBootstrap.wrapOnboardDeferredExit(wrappedOnboard);
async function runOnboard(opts: OnboardOptions = {}): Promise {
const hostMountScope = onboardSessionBootstrap.beginHostMountScope(opts.hostMounts);
const hermesApiPortReservationScope = agentOnboard.createHermesApiPortReservationScope();
@@ -3633,29 +3634,18 @@ async function runOnboard(opts: OnboardOptions = {}): Promise {
const { fresh, nonInteractive, cannotPrompt, resume } = entryOptions;
const { requestedFromDockerfile, requestedSandboxName } = entryOptions;
NON_INTERACTIVE = nonInteractive;
+ const validatePolicyTierBeforeRuntime =
+ isNonInteractive() && !resume && opts.experimentalProfile !== "portable";
+ if (validatePolicyTierBeforeRuntime) validatePolicyTierEnvEarly();
RECREATE_SANDBOX = opts.recreateSandbox || process.env.NEMOCLAW_RECREATE_SANDBOX === "1";
_preflightDashboardPort =
opts.controlUiPort ?? (process.env.NEMOCLAW_DASHBOARD_PORT != null ? DASHBOARD_PORT : null);
onboardRuntimeBoundary.reset();
- if (!authoritativeGateway) delete process.env.OPENSHELL_GATEWAY;
- preparedDcodeRuntime.applyGatewayEnv(process.env);
const baseImageResolutionContext = baseImageResolutionFlow.createBaseImageResolutionContext({
fresh,
initialHint: opts.baseImageResolutionHint,
initialPreResolvedMetadata: opts.preResolvedBaseImageMetadata,
});
- const onboardingComputePlan = dockerDriverPlatform.resolveCurrentOpenShellComputePlan();
- if (isNonInteractive()) validatePolicyTierEnvEarly();
- const noticeAccepted = await ensureUsageNoticeConsent({
- nonInteractive: isNonInteractive(),
- acceptedByFlag: opts.acceptThirdPartySoftware === true,
- writeLine: console.error,
- });
- if (!noticeAccepted) {
- process.exit(1);
- }
- // Validate provider/model hints before preflight so configuration errors are not reported as Docker failures.
- const stationSessionInput = onboardEntryOptions.prepareSessionInput(runtimeControlRequests, requestedSandboxName, resume, () => resumeConfig.preflightEarlyOnboardEnvForResume(isNonInteractive(), opts.authoritativeResumeConfig === true));
const ownsOnboardLock = opts.onboardLockAlreadyHeld !== true;
const lockResult = ownsOnboardLock
? onboardSession.acquireOnboardLock(
@@ -3674,47 +3664,6 @@ async function runOnboard(opts: OnboardOptions = {}): Promise {
console.error(` rm -f "${lockResult.lockFile}"`);
process.exit(1);
}
- // Stage any pre-fix plaintext credentials.json into process.env so the
- // provider upserts later in this run can pick the values up. The file is
- // NOT removed here — the secure unlink runs only after onboarding
- // completes successfully and only when every staged value was actually
- // pushed to the gateway in this run.
- stagedLegacyValues.clear();
- migratedLegacyKeys.clear();
-
- const stagedLegacyKeys = stageLegacyCredentialsToEnv();
- for (const key of stagedLegacyKeys) {
- const value = process.env[key];
- if (value) stagedLegacyValues.set(key, value);
- }
-
- // Only carry forward migration state across processes when the user is
- // explicitly continuing the same attempt via `--resume`. Even then,
- // validate each persisted entry against the *current* staged value: if
- // the legacy file was edited between runs (so the staged secret no
- // longer matches what the gateway holds), the hash mismatch drops that
- // key from migratedLegacyKeys and the cleanup gate forces a fresh
- // upsert before the file can be removed. A fresh / non-resume run
- // ignores prior persisted state entirely so a stale or unrelated
- // session record cannot satisfy the cleanup gate.
- if (resume) {
- const previousSession = onboardSession.loadSession();
- const persistedHashes = previousSession?.migratedLegacyValueHashes ?? {};
- for (const [key, hash] of Object.entries(persistedHashes)) {
- if (typeof key !== "string" || typeof hash !== "string") continue;
- const currentValue = stagedLegacyValues.get(key);
- if (currentValue === undefined) continue;
- if (legacyValueHash(currentValue) !== hash) continue;
- migratedLegacyKeys.add(key);
- }
- }
-
- if (stagedLegacyKeys.length > 0) {
- console.error(
- ` Staged ${String(stagedLegacyKeys.length)} legacy credential(s) for migration to the OpenShell gateway.`,
- );
- }
-
let lockReleased = false;
const releaseOnboardLock = () => {
if (lockReleased || !ownsOnboardLock) return;
@@ -3723,17 +3672,40 @@ async function runOnboard(opts: OnboardOptions = {}): Promise {
};
if (ownsOnboardLock) process.once("exit", releaseOnboardLock);
- if (authoritativeGateway) {
- GATEWAY_NAME = authoritativeGateway.name;
- GATEWAY_PORT = authoritativeGateway.port;
- process.env.OPENSHELL_GATEWAY = authoritativeGateway.name;
- }
+ let portableEnvScope:
+ | import("./onboard/session-bootstrap").PortableOnboardEnvironmentScope
+ | null = null;
+ // Secure removal remains gated on successful migration of every staged legacy credential.
+ let stagedLegacyKeys: string[] = [];
+
let onboardTrace: ReturnType = {
collector: null,
span: null,
};
let completed = false, returnedNormally = false;
try {
+ const lockedRuntime = await resumeRuntime.prepare(opts, resume, isNonInteractive(), onboardSession.loadSession);
+ portableEnvScope = lockedRuntime.environmentScope;
+ if (!authoritativeGateway) delete process.env.OPENSHELL_GATEWAY;
+ preparedDcodeRuntime.applyGatewayEnv(process.env);
+ if (isNonInteractive() && !validatePolicyTierBeforeRuntime) validatePolicyTierEnvEarly();
+ // Validate provider/model hints only after the locked profile and runtime authority are active.
+ const stationSessionInput = onboardEntryOptions.prepareSessionInput(
+ runtimeControlRequests,
+ requestedSandboxName,
+ resume,
+ () =>
+ resumeConfig.preflightEarlyOnboardEnvForResume(
+ isNonInteractive(),
+ opts.authoritativeResumeConfig === true,
+ ),
+ );
+ const onboardingComputePlan = dockerDriverPlatform.resolveCurrentOpenShellComputePlan();
+ if (authoritativeGateway) {
+ GATEWAY_NAME = authoritativeGateway.name;
+ GATEWAY_PORT = authoritativeGateway.port;
+ process.env.OPENSHELL_GATEWAY = authoritativeGateway.name;
+ }
onboardTrace = onboardTracing.startOnboardTrace(opts, process.env);
let selectedMessagingChannels: string[] = [];
let { session, fromDockerfile } = await onboardSessionBootstrap.prepareOnboardSessionValidated(
@@ -3746,6 +3718,8 @@ async function runOnboard(opts: OnboardOptions = {}): Promise {
nonInteractive: isNonInteractive(),
authoritativeResumeConfig: opts.authoritativeResumeConfig === true,
servingProfileProvenance: opts.servingProfileProvenance ?? null,
+ checkpointProfile: lockedRuntime.checkpointProfile,
+ portableRuntimeAuthority: lockedRuntime.preparedPortableAuthority,
agentFlag: opts.agent || null,
envAgent: process.env.NEMOCLAW_AGENT || null,
requestedHostMounts: opts.hostMounts,
@@ -3767,6 +3741,27 @@ async function runOnboard(opts: OnboardOptions = {}): Promise {
exitProcess: (code) => process.exit(code),
},
);
+ stagedLegacyValues.clear();
+ migratedLegacyKeys.clear();
+ stagedLegacyKeys = stageLegacyCredentialsToEnv();
+ for (const key of stagedLegacyKeys) {
+ const value = process.env[key];
+ if (value) stagedLegacyValues.set(key, value);
+ }
+ if (resume) {
+ const persistedHashes = session?.migratedLegacyValueHashes ?? {};
+ for (const [key, hash] of Object.entries(persistedHashes)) {
+ if (typeof key !== "string" || typeof hash !== "string") continue;
+ const currentValue = stagedLegacyValues.get(key);
+ if (currentValue === undefined || legacyValueHash(currentValue) !== hash) continue;
+ migratedLegacyKeys.add(key);
+ }
+ }
+ if (stagedLegacyKeys.length > 0) {
+ console.error(
+ ` Staged ${String(stagedLegacyKeys.length)} legacy credential(s) for migration to the OpenShell gateway.`,
+ );
+ }
const effectiveHostMounts = hostMountScope.activate(session?.metadata.hostMounts);
await onboardRuntimeBoundary.recordOnboardStarted(resume);
// Resume backstop: a session may exist without a sandboxName if sandbox
@@ -4278,6 +4273,7 @@ async function runOnboard(opts: OnboardOptions = {}): Promise {
} finally {
try {
await hermesApiPortReservationScope.release();
+ portableEnvScope?.restore();
releaseOnboardLock();
onboardRuntimeBoundary.clear();
onboardTracing.finishOnboardTrace(onboardTrace, completed);
@@ -4289,6 +4285,7 @@ async function runOnboard(opts: OnboardOptions = {}): Promise {
else process.env.OPENSHELL_LOCAL_TLS_DIR = previousOpenshellLocalTlsDir;
resetGatewayOwnerBinding();
} finally {
+ portableEnvScope?.restore();
hostMountScope.restore();
}
}
diff --git a/src/lib/onboard/checkpoint-record.test.ts b/src/lib/onboard/checkpoint-record.test.ts
index 81daeca873a..27370fbf072 100644
--- a/src/lib/onboard/checkpoint-record.test.ts
+++ b/src/lib/onboard/checkpoint-record.test.ts
@@ -21,6 +21,8 @@ function sessionWithProviderReceipts() {
const session = createSession({ sandboxName: "my-assistant" });
session.checkpoint = {
schemaVersion: CHECKPOINT_SCHEMA_VERSION,
+ profile: { kind: "selected", value: "default" },
+ runtimeAuthority: { kind: "unset" },
sessionId: session.sessionId,
machineState: "sandbox",
updatedAt: ISO,
diff --git a/src/lib/onboard/checkpoint-replay.test.ts b/src/lib/onboard/checkpoint-replay.test.ts
index b96b50249b8..66d3bce37a8 100644
--- a/src/lib/onboard/checkpoint-replay.test.ts
+++ b/src/lib/onboard/checkpoint-replay.test.ts
@@ -24,6 +24,8 @@ const ISO = "2026-01-01T00:00:00.000Z";
function checkpoint(overrides: Partial = {}): OnboardCheckpoint {
return {
schemaVersion: CHECKPOINT_SCHEMA_VERSION,
+ profile: { kind: "selected", value: "default" },
+ runtimeAuthority: { kind: "unset" },
sessionId: "s1",
machineState: "sandbox",
updatedAt: ISO,
diff --git a/src/lib/onboard/checkpoint-resume-guard.test.ts b/src/lib/onboard/checkpoint-resume-guard.test.ts
index 85b05e5e9ff..e3a2a2ff45f 100644
--- a/src/lib/onboard/checkpoint-resume-guard.test.ts
+++ b/src/lib/onboard/checkpoint-resume-guard.test.ts
@@ -29,6 +29,8 @@ const resumeInput = {
const loadedCheckpoint: OnboardCheckpoint = {
schemaVersion: CHECKPOINT_SCHEMA_VERSION,
+ profile: { kind: "selected", value: "default" },
+ runtimeAuthority: { kind: "unset" },
sessionId: "s1",
machineState: "sandbox",
updatedAt: "2026-01-01T00:00:00.000Z",
@@ -117,7 +119,7 @@ describe("resume checkpoint fail-safe (#6228)", () => {
expect(resolveResumeCheckpoint).toHaveBeenCalled();
});
- it("persists a migrated legacy checkpoint onto the session instead of re-deriving it every resume (#7022)", async () => {
+ it("refuses a legacy checkpoint without updating the session (#9035)", async () => {
let persistedSession = createSession({ sessionId: "s1", agent: "openclaw" });
const updateSession = vi.fn((mutator: (session: typeof persistedSession) => void) => {
mutator(persistedSession);
@@ -125,17 +127,10 @@ describe("resume checkpoint fail-safe (#6228)", () => {
});
const deps = makeDeps({
updateSession,
- resolveResumeCheckpoint: (): CheckpointLoadResult => ({
- status: "migrated",
- checkpoint: loadedCheckpoint,
- fromVersion: 0,
- }),
- getResumeConfigConflicts: () => {
- throw new Error("PAST_GUARD");
- },
+ resolveResumeCheckpoint: (): CheckpointLoadResult => ({ status: "legacy" }),
});
- await expect(prepareOnboardSession(resumeInput, deps)).rejects.toThrow("PAST_GUARD");
- expect(updateSession).toHaveBeenCalled();
- expect(persistedSession.checkpoint).toEqual(loadedCheckpoint);
+ await expect(prepareOnboardSession(resumeInput, deps)).rejects.toThrow();
+ expect(updateSession).not.toHaveBeenCalled();
+ expect(persistedSession.checkpoint).toBeNull();
});
});
diff --git a/src/lib/onboard/command.test.ts b/src/lib/onboard/command.test.ts
index 8f8d3b4a62f..270831c782c 100644
--- a/src/lib/onboard/command.test.ts
+++ b/src/lib/onboard/command.test.ts
@@ -19,6 +19,7 @@ import {
LOCAL_MODEL_PROFILE_ENABLED_ENV,
LOCAL_MODEL_PROFILE_RUNTIME_ENV,
} from "./local-model-profile/plan";
+import { OnboardResumeIntentError, OnboardResumeIntentRaceError } from "./session-bootstrap";
afterEach(() => {
vi.unstubAllEnvs();
@@ -245,12 +246,14 @@ describe("onboard command options", () => {
toolDisclosure: "direct",
observabilityEnabled: true,
controlUiPort: 18790,
+ deferProcessExit: true,
gpu: true,
noGpu: false,
autoYes: true,
noOllamaAutostart: true,
experimentalProfile: null,
portableInferenceActivation: null,
+ resumeIntentSnapshot: null,
servingProfile: null,
servingProfileProvenance: null,
});
@@ -274,12 +277,14 @@ describe("onboard command options", () => {
toolDisclosure: null,
observabilityEnabled: null,
controlUiPort: null,
+ deferProcessExit: true,
gpu: false,
noGpu: false,
autoYes: false,
noOllamaAutostart: false,
experimentalProfile: null,
portableInferenceActivation: null,
+ resumeIntentSnapshot: null,
servingProfile: null,
servingProfileProvenance: null,
});
@@ -338,15 +343,24 @@ describe("onboard command options", () => {
});
});
- it("rejects resume when the portable profile requires a deterministic fresh install", () => {
- const errors: string[] = [];
- expect(() =>
+ it("allows an exact portable checkpoint profile on resume (#9035)", () => {
+ expect(
resolve(
{ "experimental-profile": "portable", resume: true },
- { error: (message = "") => errors.push(message) },
+ {
+ resumeIntent: {
+ effectiveResume: true,
+ snapshot: {
+ fingerprint: "a".repeat(64),
+ sessionId: "session-1",
+ checkpointUpdatedAt: "2026-08-13T00:00:00.000Z",
+ machineRevision: 1,
+ profile: "portable",
+ },
+ },
+ },
),
- ).toThrow("exit:1");
- expect(errors).toContain(" --resume cannot be combined with --experimental-profile portable.");
+ ).toMatchObject({ resume: true, fresh: false, experimentalProfile: "portable" });
});
it("maps --no-observability to an explicit disabled request", () => {
@@ -496,6 +510,201 @@ describe("onboard command options", () => {
expect(runOnboard).toHaveBeenCalledWith(expect.objectContaining({ resume: true }));
});
+ it("re-resolves once after onboard reports a pre-read race (#9035)", async () => {
+ const snapshots = ["first", "second"].map((fingerprint) => ({
+ effectiveResume: true,
+ snapshot: {
+ fingerprint,
+ sessionId: "session-1",
+ checkpointUpdatedAt: "2026-08-13T20:00:00.000Z",
+ machineRevision: 2,
+ profile: "portable" as const,
+ },
+ }));
+ const resolveResumeIntent = vi
+ .fn()
+ .mockReturnValueOnce(snapshots[0])
+ .mockReturnValueOnce(snapshots[1]);
+ const runOnboard = vi
+ .fn()
+ .mockRejectedValueOnce(new OnboardResumeIntentRaceError())
+ .mockResolvedValueOnce(undefined);
+
+ await runOnboardCommand({
+ flags: { resume: true },
+ env: {},
+ resolveResumeIntent,
+ loadPortableInferenceDescriptor: async () => null,
+ runOnboard,
+ });
+
+ expect(resolveResumeIntent).toHaveBeenCalledTimes(2);
+ expect(runOnboard).toHaveBeenCalledTimes(2);
+ expect(runOnboard.mock.calls[1]?.[0].resumeIntentSnapshot?.fingerprint).toBe("second");
+ });
+
+ it("keeps early legacy recovery guidance agent-neutral for an alias (#9035)", async () => {
+ const errors: string[] = [];
+ await expect(
+ runOnboardCommand({
+ flags: { resume: true },
+ env: { NEMOCLAW_AGENT: "nemohermes" },
+ resolveResumeIntent: () => {
+ throw new OnboardResumeIntentError(
+ "This onboarding checkpoint predates recorded runtime authority and cannot be resumed safely. Start a new onboarding attempt with the `--fresh` option.",
+ );
+ },
+ runOnboard: vi.fn(async () => {}),
+ error: (message = "") => errors.push(message),
+ exit: exitWithCode,
+ }),
+ ).rejects.toThrow("exit:1");
+
+ expect(errors.join("\n")).toContain(
+ "Start a new onboarding attempt with the `--fresh` option.",
+ );
+ expect(errors.join("\n")).not.toContain("nemoclaw onboard");
+ });
+
+ it("fails after a second pre-read race instead of looping (#9035)", async () => {
+ const resolveResumeIntent = vi.fn(() => ({
+ effectiveResume: true,
+ snapshot: {
+ fingerprint: "changed",
+ sessionId: "session-1",
+ checkpointUpdatedAt: "2026-08-13T20:00:00.000Z",
+ machineRevision: 2,
+ profile: "default" as const,
+ },
+ }));
+ const runOnboard = vi.fn(async () => {
+ throw new OnboardResumeIntentRaceError();
+ });
+ const errors: string[] = [];
+
+ await expect(
+ runOnboardCommand({
+ flags: { resume: true },
+ env: {},
+ resolveResumeIntent,
+ runOnboard,
+ error: (message = "") => errors.push(message),
+ exit: exitWithCode,
+ }),
+ ).rejects.toThrow("exit:1");
+ expect(resolveResumeIntent).toHaveBeenCalledTimes(2);
+ expect(runOnboard).toHaveBeenCalledTimes(2);
+ expect(errors.join("\n")).toContain("checkpoint changed while resume acquired its lock");
+ });
+
+ it("does not handle an unbranded deferred-exit lookalike (#9035)", async () => {
+ const lookalike = Object.assign(new Error("unknown failure"), {
+ code: 1,
+ name: "OnboardDeferredExitError",
+ });
+ const exit = vi.fn((_code: number): never => {
+ throw new Error("unexpected exit");
+ });
+
+ await expect(
+ runOnboardCommand({
+ flags: {},
+ env: {},
+ runOnboard: async () => {
+ throw lookalike;
+ },
+ exit,
+ }),
+ ).rejects.toBe(lookalike);
+ expect(exit).not.toHaveBeenCalled();
+ });
+
+ it("restores scoped command environment before exiting after a second resume race (#9035)", async () => {
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-resume-race-environment-"));
+ const manifestPath = path.join(tmpDir, "agents.yaml");
+ fs.writeFileSync(manifestPath, "agents: []\n");
+ const env: NodeJS.ProcessEnv = {
+ NEMOCLAW_EXTRA_AGENTS_JSON: "previous-agents",
+ NEMOCLAW_OLLAMA_NO_AUTOSTART: "previous-autostart",
+ NEMOCLAW_TOOL_DISCLOSURE: "previous-disclosure",
+ };
+ let environmentAtExit: NodeJS.ProcessEnv | null = null;
+
+ try {
+ await expect(
+ runOnboardCommand({
+ flags: {
+ resume: true,
+ agents: manifestPath,
+ "no-ollama-autostart": true,
+ "tool-disclosure": "direct",
+ },
+ env,
+ resolveResumeIntent: () => ({ effectiveResume: true, snapshot: null }),
+ runOnboard: async () => {
+ throw new OnboardResumeIntentRaceError();
+ },
+ error: () => {},
+ exit: (code): never => {
+ environmentAtExit = { ...env };
+ throw new Error(`exit:${code}`);
+ },
+ }),
+ ).rejects.toThrow("exit:1");
+ expect(environmentAtExit).toEqual({
+ NEMOCLAW_EXTRA_AGENTS_JSON: "previous-agents",
+ NEMOCLAW_OLLAMA_NO_AUTOSTART: "previous-autostart",
+ NEMOCLAW_TOOL_DISCLOSURE: "previous-disclosure",
+ });
+ } finally {
+ fs.rmSync(tmpDir, { recursive: true, force: true });
+ }
+ });
+
+ it("restores every scoped command value before exiting on a handled error (#9035)", async () => {
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-handled-error-environment-"));
+ const manifestPath = path.join(tmpDir, "agents.yaml");
+ fs.writeFileSync(manifestPath, "agents: []\n");
+ const env: NodeJS.ProcessEnv = {
+ NEMOCLAW_EXTRA_AGENTS_JSON: "previous-agents",
+ NEMOCLAW_OLLAMA_NO_AUTOSTART: "previous-autostart",
+ NEMOCLAW_SERVING_PRESET: "previous-serving",
+ NEMOCLAW_TOOL_DISCLOSURE: "previous-disclosure",
+ };
+ let environmentAtExit: NodeJS.ProcessEnv | null = null;
+
+ try {
+ await expect(
+ runOnboardCommand({
+ flags: {
+ agents: manifestPath,
+ "no-ollama-autostart": true,
+ profile: COMPATIBLE_NANO_PROFILE.id,
+ "tool-disclosure": "direct",
+ },
+ env,
+ listServingProfiles: () => [COMPATIBLE_NANO_PROFILE],
+ runOnboard: async () => {
+ throw invalidGatewayManagementDeclarationError("unsupported contract");
+ },
+ error: () => {},
+ exit: (code): never => {
+ environmentAtExit = { ...env };
+ throw new Error(`exit:${code}`);
+ },
+ }),
+ ).rejects.toThrow("exit:1");
+ expect(environmentAtExit).toEqual({
+ NEMOCLAW_EXTRA_AGENTS_JSON: "previous-agents",
+ NEMOCLAW_OLLAMA_NO_AUTOSTART: "previous-autostart",
+ NEMOCLAW_SERVING_PRESET: "previous-serving",
+ NEMOCLAW_TOOL_DISCLOSURE: "previous-disclosure",
+ });
+ } finally {
+ fs.rmSync(tmpDir, { recursive: true, force: true });
+ }
+ });
+
it("scopes the selected catalog preset to one onboarding run (#8384)", async () => {
const env: NodeJS.ProcessEnv = {};
let observed: string | undefined;
@@ -567,14 +776,14 @@ describe("onboard command options", () => {
});
expect(observed).toEqual({
- NEMOCLAW_EXPERIMENTAL_PROFILE: "portable",
- NEMOCLAW_PROVIDER: "ollama",
- NEMOCLAW_MODEL: "qwen3-vl:4b",
- NEMOCLAW_OLLAMA_NO_AUTOSTART: "1",
- NEMOCLAW_POLICY_MODE: "custom",
+ NEMOCLAW_EXPERIMENTAL_PROFILE: "previous-profile",
+ NEMOCLAW_PROVIDER: "previous-provider",
+ NEMOCLAW_MODEL: "previous-model",
+ NEMOCLAW_OLLAMA_NO_AUTOSTART: "0",
+ NEMOCLAW_POLICY_MODE: "previous-mode",
NEMOCLAW_POLICY_PRESETS: explicitPresets,
- NEMOCLAW_POLICY_TIER: "personal",
- NEMOCLAW_TOOL_DISCLOSURE: "direct",
+ NEMOCLAW_POLICY_TIER: "previous-tier",
+ NEMOCLAW_TOOL_DISCLOSURE: "progressive",
});
expect(env).toMatchObject({
NEMOCLAW_EXPERIMENTAL_PROFILE: "previous-profile",
@@ -588,25 +797,22 @@ describe("onboard command options", () => {
});
});
- it(
- "defaults portable onboarding to the broad Personal preset when no list is supplied (#8991)",
- async () => {
- const env: NodeJS.ProcessEnv = {};
- let observedPresets: string | undefined;
+ it("defers the portable policy default to the scoped onboarding environment (#8991)", async () => {
+ const env: NodeJS.ProcessEnv = {};
+ let observedPresets: string | undefined;
- await runOnboardCommand({
- flags: { "experimental-profile": "portable" },
- env,
- loadPortableInferenceDescriptor: async () => null,
- runOnboard: async () => {
- observedPresets = env.NEMOCLAW_POLICY_PRESETS;
- },
- });
+ await runOnboardCommand({
+ flags: { "experimental-profile": "portable" },
+ env,
+ loadPortableInferenceDescriptor: async () => null,
+ runOnboard: async () => {
+ observedPresets = env.NEMOCLAW_POLICY_PRESETS;
+ },
+ });
- expect(observedPresets).toBe("personal-open-internet");
- expect(env.NEMOCLAW_POLICY_PRESETS).toBeUndefined();
- },
- );
+ expect(observedPresets).toBeUndefined();
+ expect(env.NEMOCLAW_POLICY_PRESETS).toBeUndefined();
+ });
it("does not change an explicit preset list outside portable onboarding (#8991)", async () => {
const env: NodeJS.ProcessEnv = {
@@ -636,12 +842,10 @@ describe("onboard command options", () => {
model: "vendor/model-1",
expiresAt: "2026-08-10T18:05:00Z",
});
- expect(env).toMatchObject({
- NEMOCLAW_PROVIDER: "custom",
- NEMOCLAW_MODEL: "vendor/model-1",
- NEMOCLAW_ENDPOINT_URL: "https://inference.example.test/v1",
- NEMOCLAW_PREFERRED_API: "openai-completions",
- });
+ expect(env.NEMOCLAW_PROVIDER).toBeUndefined();
+ expect(env.NEMOCLAW_MODEL).toBeUndefined();
+ expect(env.NEMOCLAW_ENDPOINT_URL).toBeUndefined();
+ expect(env.NEMOCLAW_PREFERRED_API).toBeUndefined();
expect(env.COMPATIBLE_API_KEY).toBeUndefined();
expect(process.env.COMPATIBLE_API_KEY).toBeUndefined();
expect(getCredential("COMPATIBLE_API_KEY")).toBe("runtime-only-secret");
@@ -798,17 +1002,65 @@ describe("onboard command options", () => {
expect(output).not.toContain(" at ");
});
- it.each(
- RECREATE_SELECTIONS,
- )("reports a gateway authority refusal when recreation is selected by %s (#8103)", async (_selection, flags, env) => {
+ it("redacts credentials in a gateway declaration diagnostic (#9035)", async () => {
const errors: string[] = [];
await expect(
runOnboardCommand({
- flags,
- env,
+ flags: {},
+ env: {},
+ runOnboard: async () => {
+ throw invalidGatewayManagementDeclarationError(
+ "invalid metadata NVIDIA_API_KEY=nvapi-secret-value",
+ );
+ },
+ error: (message = "") => errors.push(message),
+ exit: exitWithCode,
+ }),
+ ).rejects.toThrow("exit:1");
+
+ expect(errors.join("\n")).toContain("Invalid gateway management declaration");
+ expect(errors.join("\n")).toContain("NVIDIA_API_KEY=");
+ expect(errors.join("\n")).not.toContain("nvapi-secret-value");
+ });
+
+ it.each(RECREATE_SELECTIONS)(
+ "reports a gateway authority refusal when recreation is selected by %s (#8103)",
+ async (_selection, flags, env) => {
+ const errors: string[] = [];
+ await expect(
+ runOnboardCommand({
+ flags,
+ env,
+ runOnboard: async () => {
+ throw new GatewayAuthorityError(
+ "Gateway lifecycle authority changed since onboarding (packaged-service -> standalone).",
+ );
+ },
+ error: (message = "") => errors.push(message),
+ exit: exitWithCode,
+ }),
+ ).rejects.toThrow("exit:1");
+
+ const output = errors.join("\n");
+ expect(output).toContain(
+ "Refusing sandbox recreate because the gateway lifecycle authority could not be revalidated.",
+ );
+ expect(output).toContain("packaged-service -> standalone");
+ expect(output).toContain("Re-run onboarding to bind the current gateway authority");
+ expect(output).not.toContain(".js:");
+ expect(output).not.toContain(" at ");
+ },
+ );
+
+ it("redacts credentials while preserving gateway authority context (#9035)", async () => {
+ const errors: string[] = [];
+ await expect(
+ runOnboardCommand({
+ flags: { "recreate-sandbox": true },
+ env: {},
runOnboard: async () => {
throw new GatewayAuthorityError(
- "Gateway lifecycle authority changed since onboarding (packaged-service -> standalone).",
+ "Gateway lifecycle authority changed; OPENAI_API_KEY=secret-authority-value.",
);
},
error: (message = "") => errors.push(message),
@@ -817,13 +1069,9 @@ describe("onboard command options", () => {
).rejects.toThrow("exit:1");
const output = errors.join("\n");
- expect(output).toContain(
- "Refusing sandbox recreate because the gateway lifecycle authority could not be revalidated.",
- );
- expect(output).toContain("packaged-service -> standalone");
- expect(output).toContain("Re-run onboarding to bind the current gateway authority");
- expect(output).not.toContain(".js:");
- expect(output).not.toContain(" at ");
+ expect(output).toContain("gateway lifecycle authority could not be revalidated");
+ expect(output).toContain("OPENAI_API_KEY=");
+ expect(output).not.toContain("secret-authority-value");
});
it("escapes terminal controls in gateway declaration errors before printing (#7627)", async () => {
diff --git a/src/lib/onboard/command.ts b/src/lib/onboard/command.ts
index 1c2b9bb373a..968bf40f0cb 100644
--- a/src/lib/onboard/command.ts
+++ b/src/lib/onboard/command.ts
@@ -27,7 +27,6 @@ import {
import { applyAgentsManifestEnv } from "./agents-manifest";
import type { OnboardFlags } from "./command-support";
import {
- EXPERIMENTAL_PROFILE_ENV,
type ExperimentalOnboardProfile,
PORTABLE_EXPERIMENTAL_PROFILE,
} from "./docker-driver-platform";
@@ -49,6 +48,15 @@ import { parseReadOnlyHostMounts } from "./host-mount";
import { DCODE_OBSERVABILITY_FEATURE } from "./observability-policy-presets";
import { isOpenclawAgent } from "./openclaw-otel-policy-presets";
import { NOTICE_ACCEPT_ENV, NOTICE_ACCEPT_FLAG_NAME } from "./usage-notice";
+import {
+ OnboardResumeIntentError,
+ isOnboardResumeIntentRaceError,
+ resolveOnboardResumeIntent,
+ type OnboardResumeIntentSnapshot,
+ type ResolvedOnboardResumeIntent,
+ isOnboardDeferredExitError,
+ redactOnboardDiagnosticText,
+} from "./session-bootstrap";
export interface OnboardCommandOptions {
tempManagedRuntime: boolean;
@@ -74,6 +82,8 @@ export interface OnboardCommandOptions {
noOllamaAutostart: boolean;
experimentalProfile: ExperimentalOnboardProfile | null;
portableInferenceActivation: PortableInferenceActivation | null;
+ deferProcessExit: true;
+ resumeIntentSnapshot: OnboardResumeIntentSnapshot | null;
servingProfile: string | null;
servingProfileProvenance: ServingProfileProvenance | null;
}
@@ -87,6 +97,8 @@ export interface ResolveOnboardOptionsDeps {
loadSession?: () => { servingProfileProvenance?: ServingProfileProvenance | null } | null;
error?: (message?: string) => void;
exit?: (code: number) => never;
+ resumeIntent?: ResolvedOnboardResumeIntent;
+ resolveResumeIntent?: typeof resolveOnboardResumeIntent;
}
export interface RunOnboardCommandDeps extends ResolveOnboardOptionsDeps {
@@ -98,7 +110,7 @@ export interface RunOnboardCommandDeps extends ResolveOnboardOptionsDeps {
function fail(deps: ResolveOnboardOptionsDeps, message: string): never {
const error = deps.error ?? console.error;
const exit = deps.exit ?? ((code: number) => process.exit(code));
- error(message);
+ error(redactOnboardDiagnosticText(message));
return exit(1);
}
@@ -213,8 +225,12 @@ function validateObservabilityAgent(
}
}
-function resolveExperimentalProfile(flags: OnboardFlags): ExperimentalOnboardProfile | null {
- return flags["experimental-profile"] === PORTABLE_EXPERIMENTAL_PROFILE
+function resolveExperimentalProfile(
+ flags: OnboardFlags,
+ resumeIntent: ResolvedOnboardResumeIntent | undefined,
+): ExperimentalOnboardProfile | null {
+ return flags["experimental-profile"] === PORTABLE_EXPERIMENTAL_PROFILE ||
+ resumeIntent?.snapshot?.profile === PORTABLE_EXPERIMENTAL_PROFILE
? PORTABLE_EXPERIMENTAL_PROFILE
: null;
}
@@ -285,6 +301,7 @@ function resolveInstallerServingProfile(
function resolveServingProfileLifecycle(
flags: OnboardFlags,
deps: ResolveOnboardOptionsDeps,
+ resume: boolean,
): ServingProfileProvenance | null {
const explicit = resolveServingProfile(flags.profile, deps);
const installerProfile = resolveInstallerServingProfile(deps);
@@ -299,7 +316,7 @@ function resolveServingProfileLifecycle(
);
}
const requested = explicit ?? installerProfile;
- if (flags.resume !== true) return requested;
+ if (!resume) return requested;
return resolveResumedServingProfile(requested, deps);
}
@@ -341,16 +358,6 @@ function resolveResumedServingProfile(
return current;
}
-function validateExperimentalProfileLifecycle(
- flags: OnboardFlags,
- profile: ExperimentalOnboardProfile | null,
- deps: ResolveOnboardOptionsDeps,
-): void {
- if (profile && flags.resume === true) {
- fail(deps, " --resume cannot be combined with --experimental-profile portable.");
- }
-}
-
function withPortableDefault(
requested: boolean | undefined,
profile: ExperimentalOnboardProfile | null,
@@ -362,10 +369,10 @@ export function resolveOnboardOptions(
flags: OnboardFlags,
deps: ResolveOnboardOptionsDeps,
): OnboardCommandOptions {
- const experimentalProfile = resolveExperimentalProfile(flags);
- validateExperimentalProfileLifecycle(flags, experimentalProfile, deps);
+ const experimentalProfile = resolveExperimentalProfile(flags, deps.resumeIntent);
+ const resume = deps.resumeIntent?.effectiveResume ?? flags.resume === true;
const agent = resolveAgent(flags.agent, deps);
- const servingProfileProvenance = resolveServingProfileLifecycle(flags, deps);
+ const servingProfileProvenance = resolveServingProfileLifecycle(flags, deps, resume);
validateObservabilityAgent(flags.observability, agent, deps);
let toolDisclosure: ToolDisclosure | null;
try {
@@ -383,8 +390,8 @@ export function resolveOnboardOptions(
false,
),
nonInteractive: withPortableDefault(flags["non-interactive"], experimentalProfile),
- resume: flags.resume === true,
- fresh: withPortableDefault(flags.fresh, experimentalProfile),
+ resume,
+ fresh: resume ? false : withPortableDefault(flags.fresh, experimentalProfile),
recreateSandbox: flags["recreate-sandbox"] === true,
fromDockerfile: resolveFileOption("--from", flags.from, deps, true),
sandboxName: flags.name ?? null,
@@ -404,6 +411,8 @@ export function resolveOnboardOptions(
noOllamaAutostart: withPortableDefault(flags["no-ollama-autostart"], experimentalProfile),
experimentalProfile,
portableInferenceActivation: null,
+ deferProcessExit: true,
+ resumeIntentSnapshot: deps.resumeIntent?.snapshot ?? null,
servingProfile: activeServingProfileId(servingProfileProvenance),
servingProfileProvenance,
};
@@ -418,24 +427,30 @@ function promptCancellationCode(error: unknown): "EOF" | "SIGINT" | null {
return code === "EOF" || code === "SIGINT" ? code : null;
}
-function handleOnboardCommandError(error: unknown, deps: RunOnboardCommandDeps): void {
+function reportOnboardCommandError(deps: RunOnboardCommandDeps, message: string): number {
+ const redacted = message.split("\n").map(redactOnboardDiagnosticText).join("\n");
+ (deps.error ?? console.error)(redacted);
+ return 1;
+}
+
+function handleOnboardCommandError(error: unknown, deps: RunOnboardCommandDeps): number | null {
const cancellationCode = promptCancellationCode(error);
if (cancellationCode === "SIGINT") {
// The prompt has already restored terminal state and re-raised SIGINT.
// Let the onboard signal handler print resumable-step guidance and
// preserve status 130 without leaking this rejected prompt error through
// oclif as a raw stack trace (#7439).
- return;
+ return null;
}
// A rejected NEMOCLAW_GATEWAY_MANAGEMENT contract is operator input error,
// not a crash: print the validation reason as a clean single-line CLI error
// and exit nonzero instead of re-throwing it into a Node.js stack trace
- // (#7627). `fail` sets exit code 1.
+ // (#7627).
if (error instanceof GatewayManagementDeclarationError) {
- fail(deps, ` ${error.message}`);
+ return reportOnboardCommandError(deps, ` ${error.message}`);
}
if (error instanceof PortableInferenceDescriptorError) {
- fail(deps, ` ${error.message}`);
+ return reportOnboardCommandError(deps, ` ${error.message}`);
}
// Gateway-authority refusals are reported, never rethrown. Recreation is not
// selected in one place: `--recreate-sandbox` sets the flag, but `runOnboard`
@@ -445,50 +460,16 @@ function handleOnboardCommandError(error: unknown, deps: RunOnboardCommandDeps):
// the recreate journal's authority revalidation is the only source of this
// typed error, so the operation label holds however recreation was selected.
if (error instanceof GatewayAuthorityError) {
- fail(deps, gatewayAuthorityFailureLines(error, "sandbox recreate").join("\n"));
+ return reportOnboardCommandError(
+ deps,
+ gatewayAuthorityFailureLines(error, "sandbox recreate").join("\n"),
+ );
}
// Stdin EOF at any onboarding prompt is a cancellation, not a failure:
// print a clear message and exit non-zero instead of either crashing with
// a stack trace or — as in the original bug — exiting 0 silently (#5976).
if (cancellationCode !== "EOF") throw error;
- fail(deps, " Installation cancelled");
-}
-
-function applyPortableEnvironment(
- options: OnboardCommandOptions,
- env: NodeJS.ProcessEnv,
-): () => void {
- if (!options.experimentalProfile) return () => {};
- const activation = options.portableInferenceActivation;
- const portableEnvDefaults = {
- [EXPERIMENTAL_PROFILE_ENV]: options.experimentalProfile ?? undefined,
- [TOOL_DISCLOSURE_ENV]: "direct",
- NEMOCLAW_PROVIDER: activation ? "custom" : "ollama",
- NEMOCLAW_MODEL: activation?.model ?? "qwen3-vl:4b",
- NEMOCLAW_ENDPOINT_URL: activation?.baseUrl,
- NEMOCLAW_PREFERRED_API: activation ? "openai-completions" : undefined,
- NEMOCLAW_OLLAMA_NO_AUTOSTART: "1",
- NEMOCLAW_POLICY_MODE: "custom",
- NEMOCLAW_POLICY_PRESETS: env.NEMOCLAW_POLICY_PRESETS ?? "personal-open-internet",
- NEMOCLAW_POLICY_TIER: "personal",
- } as const;
- const previousPortableEnv = new Map();
- const restore = () => {
- for (const [key, value] of previousPortableEnv) {
- if (value === undefined) delete env[key];
- else env[key] = value;
- }
- };
- try {
- for (const [key, value] of Object.entries(portableEnvDefaults)) {
- previousPortableEnv.set(key, env[key]);
- if (value !== undefined) env[key] = value;
- }
- } catch (error) {
- restore();
- throw error;
- }
- return restore;
+ return reportOnboardCommandError(deps, " Installation cancelled");
}
function applyServingProfileEnvironment(
@@ -541,33 +522,113 @@ async function activatePortableInference(
}
export async function runOnboardCommand(deps: RunOnboardCommandDeps): Promise {
- const resolvedOptions = resolveOnboardOptions(deps.flags, deps);
const env = deps.env ?? process.env;
- let restorePortableEnvironment = () => {};
+ for (let attempt = 0; attempt < 2; attempt += 1) {
+ const result = await runOnboardCommandAttempt(deps, env, attempt);
+ if (result === "retry") continue;
+ if (typeof result === "number") deps.exit?.(result) ?? process.exit(result);
+ return;
+ }
+}
+
+type OnboardCommandAttemptResult = "complete" | "retry" | number;
+
+interface OnboardCommandEnvironmentSnapshot {
+ agentsManifest: string | undefined;
+ toolDisclosure: string | undefined;
+ ollamaAutostart: { present: boolean; value: string | undefined };
+}
+
+function resolveCommandResumeIntent(deps: RunOnboardCommandDeps): ResolvedOnboardResumeIntent {
+ const explicitProfile =
+ deps.flags["experimental-profile"] === PORTABLE_EXPERIMENTAL_PROFILE ? "portable" : null;
+ try {
+ return deps.resolveResumeIntent
+ ? deps.resolveResumeIntent({
+ explicitResume: deps.flags.resume === true,
+ fresh: deps.flags.fresh === true,
+ explicitProfile,
+ })
+ : { effectiveResume: deps.flags.resume === true, snapshot: null };
+ } catch (error) {
+ if (error instanceof OnboardResumeIntentError) fail(deps, ` ${error.message}`);
+ throw error;
+ }
+}
+
+function handleOnboardCommandAttemptError(
+ error: unknown,
+ deps: RunOnboardCommandDeps,
+ attempt: number,
+): OnboardCommandAttemptResult {
+ if (isOnboardResumeIntentRaceError(error)) {
+ if (attempt === 0) return "retry";
+ return reportOnboardCommandError(
+ deps,
+ " The onboarding checkpoint changed while resume acquired its lock. Retry the command.",
+ );
+ }
+ if (isOnboardDeferredExitError(error)) return error.code;
+ return handleOnboardCommandError(error, deps) ?? "complete";
+}
+
+function restoreOnboardCommandEnvironment(
+ env: NodeJS.ProcessEnv,
+ options: OnboardCommandOptions,
+ snapshot: OnboardCommandEnvironmentSnapshot,
+ restoreServingProfileEnvironment: () => void,
+): void {
+ if (options.agentsManifest) {
+ if (snapshot.agentsManifest === undefined) delete env.NEMOCLAW_EXTRA_AGENTS_JSON;
+ else env.NEMOCLAW_EXTRA_AGENTS_JSON = snapshot.agentsManifest;
+ }
+ restoreServingProfileEnvironment();
+ if (snapshot.toolDisclosure === undefined) delete env[TOOL_DISCLOSURE_ENV];
+ else env[TOOL_DISCLOSURE_ENV] = snapshot.toolDisclosure;
+ if (snapshot.ollamaAutostart.present) {
+ env.NEMOCLAW_OLLAMA_NO_AUTOSTART = snapshot.ollamaAutostart.value ?? "";
+ } else {
+ delete env.NEMOCLAW_OLLAMA_NO_AUTOSTART;
+ }
+}
+
+async function runOnboardCommandAttempt(
+ deps: RunOnboardCommandDeps,
+ env: NodeJS.ProcessEnv,
+ attempt: number,
+): Promise {
+ const resumeIntent = resolveCommandResumeIntent(deps);
+ const resolvedOptions = resolveOnboardOptions(deps.flags, { ...deps, resumeIntent });
let restoreServingProfileEnvironment = () => {};
- const previousAgentsManifest = env.NEMOCLAW_EXTRA_AGENTS_JSON;
+ const environmentSnapshot: OnboardCommandEnvironmentSnapshot = {
+ agentsManifest: env.NEMOCLAW_EXTRA_AGENTS_JSON,
+ toolDisclosure: env[TOOL_DISCLOSURE_ENV],
+ ollamaAutostart: {
+ present: Object.prototype.hasOwnProperty.call(env, "NEMOCLAW_OLLAMA_NO_AUTOSTART"),
+ value: env.NEMOCLAW_OLLAMA_NO_AUTOSTART,
+ },
+ };
let options = resolvedOptions;
try {
const activation = await activatePortableInference(resolvedOptions, deps, env);
options = activation.options;
- restorePortableEnvironment = applyPortableEnvironment(options, env);
restoreServingProfileEnvironment = applyServingProfileEnvironment(options, env);
- if (options.noOllamaAutostart) env.NEMOCLAW_OLLAMA_NO_AUTOSTART = "1";
- // Keep direct callers and the legacy monolithic onboard path on the same
- // canonical source. No value is written for the default so resume/rebuild
- // can distinguish an explicit request from an unset environment.
const toolDisclosure = toolDisclosureEnvironmentOverride(options, deps.flags);
if (toolDisclosure) env[TOOL_DISCLOSURE_ENV] = toolDisclosure;
+ if (options.noOllamaAutostart && !options.experimentalProfile) {
+ env.NEMOCLAW_OLLAMA_NO_AUTOSTART = "1";
+ }
if (options.agentsManifest) applyAgentsManifestEnv(options.agentsManifest, env);
await withCredentialOverrides(activation.credentialOverrides, () => deps.runOnboard(options));
+ return "complete";
} catch (error) {
- handleOnboardCommandError(error, deps);
+ return handleOnboardCommandAttemptError(error, deps, attempt);
} finally {
- if (options.agentsManifest) {
- if (previousAgentsManifest === undefined) delete env.NEMOCLAW_EXTRA_AGENTS_JSON;
- else env.NEMOCLAW_EXTRA_AGENTS_JSON = previousAgentsManifest;
- }
- restoreServingProfileEnvironment();
- restorePortableEnvironment();
+ restoreOnboardCommandEnvironment(
+ env,
+ options,
+ environmentSnapshot,
+ restoreServingProfileEnvironment,
+ );
}
}
diff --git a/src/lib/onboard/docker-driver-gateway-failure.test.ts b/src/lib/onboard/docker-driver-gateway-failure.test.ts
index 208677f152a..2d02955fe79 100644
--- a/src/lib/onboard/docker-driver-gateway-failure.test.ts
+++ b/src/lib/onboard/docker-driver-gateway-failure.test.ts
@@ -401,7 +401,7 @@ describe("reportDockerDriverGatewayStartFailure (#3111)", () => {
resolveGatewayStopCommand: () => null,
});
const joined = errSpy.mock.calls.map((c: string[]) => c.join(" ")).join("\n");
- expect(joined).toContain("nemoclaw onboard --experimental-profile portable");
+ expect(joined).toContain("nemoclaw onboard --experimental-profile portable --fresh");
expect(joined).not.toContain("nemoclaw onboard --resume");
} finally {
fs.rmSync(dir, { recursive: true, force: true });
diff --git a/src/lib/onboard/docker-driver-gateway-failure.ts b/src/lib/onboard/docker-driver-gateway-failure.ts
index 36fa3bb951a..626d51a77b2 100644
--- a/src/lib/onboard/docker-driver-gateway-failure.ts
+++ b/src/lib/onboard/docker-driver-gateway-failure.ts
@@ -14,8 +14,13 @@ import { classifyGatewayStartFailure } from "../validation";
import type { ChildExitState } from "./child-exit-tracker";
import { getOpenShellGatewayServiceStopCommand } from "./docker-driver-gateway-service";
+import { isPortableExperimentalProfile } from "./experimental/portable-profile";
import { printDockerDaemonRecovery } from "./gateway-start-failure";
-import { noteOnboardResumeHintShown, onboardRecoveryCommand } from "./resume-hint";
+import {
+ noteOnboardResumeHintShown,
+ onboardFreshRecoveryCommand,
+ onboardResumeRecoveryCommand,
+} from "./resume-hint";
export type ReportDockerDriverGatewayStartFailureOpts = {
exitOnFailure: boolean;
@@ -56,6 +61,9 @@ function printIncompatibleGatewayDatabaseRecovery(
printError: (message?: string) => void,
): void {
const stateDir = path.dirname(logPath);
+ const recoveryCommand = isPortableExperimentalProfile()
+ ? onboardFreshRecoveryCommand(true)
+ : onboardResumeRecoveryCommand();
printError(" The installed OpenShell version cannot use the existing gateway database.");
printError(` Database: ${path.join(stateDir, "openshell.db")}`);
printError(" The database records a migration that this OpenShell version does not include.");
@@ -64,7 +72,7 @@ function printIncompatibleGatewayDatabaseRecovery(
if (!stopCommand && isGatewayStateInUse?.() !== false) {
printError(" NemoClaw could not confirm that the standalone gateway process stopped.");
printError(" Stop the gateway, then run onboarding again:");
- printError(` ${onboardRecoveryCommand()}`);
+ printError(` ${recoveryCommand}`);
printError(
" A gateway process that keeps running after the move writes to a path that no longer holds its state.",
);
@@ -87,7 +95,7 @@ function printIncompatibleGatewayDatabaseRecovery(
" The selected gateway state contains credentials and all registrations for this gateway.",
);
printError(" Keep the archive owner-only until every required registration is restored.");
- const move = `mkdir -m 700 ${archivePathArg} && mv ${stateDirArg} ${archivedStatePathArg} && ${onboardRecoveryCommand()}`;
+ const move = `mkdir -m 700 ${archivePathArg} && mv ${stateDirArg} ${archivedStatePathArg} && ${recoveryCommand}`;
printError(
stopCommand
? " Stop the gateway, create the archive, move the selected gateway state, then continue onboarding:"
diff --git a/src/lib/onboard/exit-step-failure.test.ts b/src/lib/onboard/exit-step-failure.test.ts
index 37362b1536a..379ce8f3dbd 100644
--- a/src/lib/onboard/exit-step-failure.test.ts
+++ b/src/lib/onboard/exit-step-failure.test.ts
@@ -104,8 +104,8 @@ describe("terminal step failure helper", () => {
complete = false;
listeners[0](1);
errorSpy.mockRestore();
- expect(errors.join("\n")).toContain("onboard --experimental-profile portable");
- expect(errors.join("\n")).not.toContain("onboard --resume");
+ expect(errors.join("\n")).toContain("onboard --resume");
+ expect(errors.join("\n")).toContain("onboard --experimental-profile portable --fresh");
const loaded = requireLoadedSession();
expect(loaded.steps.inference.status).toBe("failed");
diff --git a/src/lib/onboard/experimental/portable-host-preparation.test.ts b/src/lib/onboard/experimental/portable-host-preparation.test.ts
index 858d777d44a..67f9716c63f 100644
--- a/src/lib/onboard/experimental/portable-host-preparation.test.ts
+++ b/src/lib/onboard/experimental/portable-host-preparation.test.ts
@@ -7,7 +7,13 @@ import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
-import { preparePortableExperimentalHost } from "./portable-host-preparation";
+import type { PodmanSocketAuthority } from "../../adapters/podman";
+import type { CheckpointPortableRuntimeAuthority } from "../../state/onboard-checkpoint-types";
+import { createPortableOnboardEnvironmentScope } from "../session-bootstrap";
+import {
+ portableHostPreparationInternals,
+ preparePortableExperimentalHost,
+} from "./portable-host-preparation";
type SpawnResult = ReturnType;
@@ -15,6 +21,30 @@ function result(status = 0, stdout = ""): SpawnResult {
return { status, stdout, stderr: "" } as SpawnResult;
}
+function runtimeAuthority(homeDir: string): CheckpointPortableRuntimeAuthority {
+ return {
+ schemaVersion: 1,
+ kind: "podman",
+ ownership: "current-user",
+ uid: 1001,
+ homeDir,
+ configHome: path.join(homeDir, ".config"),
+ runtimeDir: "/run/user/1001",
+ socketPath: "/run/user/1001/podman/podman.sock",
+ };
+}
+
+function socketAuthority(): PodmanSocketAuthority {
+ return {
+ directoryChain: [],
+ device: "1",
+ inode: "2",
+ mode: String(0o140660),
+ ownerUid: "1001",
+ socketPath: "/run/user/1001/podman/podman.sock",
+ };
+}
+
describe("preparePortableExperimentalHost", () => {
const tempDirs: string[] = [];
@@ -49,9 +79,14 @@ describe("preparePortableExperimentalHost", () => {
const podman = vi.fn(() => result(0, "/run/user/1001/custom/podman.sock\n"));
const hardenSocketDirectory = vi.fn();
const env: NodeJS.ProcessEnv = {
+ HOME: "/tmp/hostile-home",
+ XDG_CONFIG_HOME: "/tmp/hostile-xdg-config",
CONTAINER_CONNECTION: "attacker",
CONTAINER_HOST: "tcp://example.test:1234",
CONTAINER_SSHKEY: "/tmp/attacker-key",
+ DOCKER_TLS: "1",
+ DOCKER_TLS_VERIFY: "1",
+ DOCKER_CERT_PATH: "/tmp/attacker-certs",
NEMOCLAW_EXPERIMENTAL_PROFILE: "portable",
};
@@ -63,6 +98,7 @@ describe("preparePortableExperimentalHost", () => {
podman,
docker,
hardenSocketDirectory,
+ validateConfigAuthority: vi.fn(),
});
expect(env).toMatchObject({
@@ -70,6 +106,8 @@ describe("preparePortableExperimentalHost", () => {
DOCKER_HOST: "unix:///run/user/1001/custom/podman.sock",
NETAVARK_FW: "iptables",
});
+ expect(env.HOME).toBe("/tmp/hostile-home");
+ expect(env.XDG_CONFIG_HOME).toBe("/tmp/hostile-xdg-config");
expect(systemctl.mock.calls.map(([args]) => args)).toEqual([
[
"--user",
@@ -92,6 +130,9 @@ describe("preparePortableExperimentalHost", () => {
expect(commandEnv).not.toHaveProperty("CONTAINER_CONNECTION");
expect(commandEnv).not.toHaveProperty("CONTAINER_HOST");
expect(commandEnv).not.toHaveProperty("CONTAINER_SSHKEY");
+ expect(commandEnv).not.toHaveProperty("DOCKER_TLS");
+ expect(commandEnv).not.toHaveProperty("DOCKER_TLS_VERIFY");
+ expect(commandEnv).not.toHaveProperty("DOCKER_CERT_PATH");
expect(commandEnv.DOCKER_HOST).toBe("unix:///run/user/1001/custom/podman.sock");
}
expect(env.CONTAINER_HOST).toBe("tcp://example.test:1234");
@@ -146,6 +187,7 @@ describe("preparePortableExperimentalHost", () => {
podman,
docker,
hardenSocketDirectory: vi.fn(),
+ validateConfigAuthority: vi.fn(),
},
);
@@ -173,6 +215,7 @@ describe("preparePortableExperimentalHost", () => {
podman: () => result(0, "/run/user/1001/podman/podman.sock"),
docker: () => result(0, "unexpected-owner"),
hardenSocketDirectory: vi.fn(),
+ validateConfigAuthority: vi.fn(),
}),
).toThrow(/unmanaged container/);
});
@@ -207,6 +250,7 @@ describe("preparePortableExperimentalHost", () => {
podman: () => result(0, "/run/user/1001/podman/podman.sock"),
docker,
hardenSocketDirectory: vi.fn(),
+ validateConfigAuthority: vi.fn(),
},
),
).toThrow(/Inspecting the managed portable registry failed: registry inspection timed out/);
@@ -228,6 +272,7 @@ describe("preparePortableExperimentalHost", () => {
systemctl: () => result(),
podman: () => result(0, "tcp://127.0.0.1:1234"),
docker: vi.fn(),
+ validateConfigAuthority: vi.fn(),
},
),
).toThrow(/invalid socket path/);
@@ -260,6 +305,7 @@ describe("preparePortableExperimentalHost", () => {
podman: () => result(0, "/run/user/1001/podman/podman.sock"),
docker,
hardenSocketDirectory: vi.fn(),
+ validateConfigAuthority: vi.fn(),
};
expect(() => preparePortableExperimentalHost(env, deps)).toThrow(/podman-docker/);
@@ -276,4 +322,233 @@ describe("preparePortableExperimentalHost", () => {
"run",
]);
});
+
+ it("reuses a running managed registry (#9035)", () => {
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-portable-"));
+ tempDirs.push(home);
+ const docker = vi
+ .fn<(args: readonly string[], env: NodeJS.ProcessEnv) => SpawnResult>()
+ .mockReturnValueOnce(result())
+ .mockReturnValueOnce(result(0, "1 true"));
+
+ preparePortableExperimentalHost(
+ { NEMOCLAW_EXPERIMENTAL_PROFILE: "portable" },
+ {
+ platform: "linux",
+ home,
+ uid: 1001,
+ systemctl: () => result(),
+ podman: () => result(0, "/run/user/1001/podman/podman.sock"),
+ docker,
+ hardenSocketDirectory: vi.fn(),
+ validateConfigAuthority: vi.fn(),
+ },
+ );
+
+ expect(docker.mock.calls.map(([args]) => args[0])).toEqual(["--version", "inspect"]);
+ });
+
+ it("rejects a moved user home before config writes or socket activation", () => {
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-portable-"));
+ const movedHome = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-portable-moved-"));
+ tempDirs.push(home, movedHome);
+ const systemctl = vi.fn(() => result());
+
+ expect(() =>
+ preparePortableExperimentalHost(
+ { NEMOCLAW_EXPERIMENTAL_PROFILE: "portable" },
+ {
+ platform: "linux",
+ home: movedHome,
+ uid: 1001,
+ systemctl,
+ validateConfigAuthority: vi.fn(),
+ },
+ runtimeAuthority(home),
+ ),
+ ).toThrow(/does not match the current user or runtime kind/);
+ expect(systemctl).not.toHaveBeenCalled();
+ expect(fs.existsSync(path.join(home, ".config"))).toBe(false);
+ });
+
+ it("ignores hostile HOME and XDG authority selectors and restores them exactly (#9035)", () => {
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-portable-"));
+ tempDirs.push(home);
+ const env: NodeJS.ProcessEnv = {
+ HOME: "/tmp/hostile-home",
+ XDG_CONFIG_HOME: "",
+ NEMOCLAW_EXPERIMENTAL_PROFILE: "hostile-profile",
+ };
+ const before = { ...env };
+ const scope = createPortableOnboardEnvironmentScope(env, null);
+ const docker = vi
+ .fn<(args: readonly string[], env: NodeJS.ProcessEnv) => SpawnResult>()
+ .mockReturnValueOnce(result())
+ .mockReturnValueOnce(result(0, "1 true"));
+
+ try {
+ const prepared = preparePortableExperimentalHost(scope.env, {
+ platform: "linux",
+ home,
+ uid: 1001,
+ systemctl: () => result(),
+ podman: () => result(0, "/run/user/1001/podman/podman.sock"),
+ docker,
+ hardenSocketDirectory: vi.fn(),
+ validateConfigAuthority: vi.fn(),
+ });
+ expect(prepared?.authority.homeDir).toBe(home);
+ expect(prepared?.authority.configHome).toBe(path.join(home, ".config"));
+ expect(scope.env.HOME).toBe("/tmp/hostile-home");
+ expect(scope.env.XDG_CONFIG_HOME).toBeUndefined();
+ throw new Error("controlled failure");
+ } catch (error) {
+ expect(error).toMatchObject({ message: "controlled failure" });
+ } finally {
+ scope.restore();
+ }
+
+ expect(env).toEqual(before);
+ expect(Object.prototype.hasOwnProperty.call(env, "XDG_CONFIG_HOME")).toBe(true);
+ expect(env.XDG_CONFIG_HOME).toBe("");
+ });
+
+ it("rejects a stored alternate config root before any portable effect (#9035)", () => {
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-portable-"));
+ tempDirs.push(home);
+ const systemctl = vi.fn(() => result());
+ const validateConfigAuthority = vi.fn();
+ const authority = {
+ ...runtimeAuthority(home),
+ configHome: path.join(home, "alternate-config"),
+ };
+
+ expect(() =>
+ preparePortableExperimentalHost(
+ {
+ HOME: "/tmp/hostile-home",
+ XDG_CONFIG_HOME: "/tmp/hostile-xdg-config",
+ NEMOCLAW_EXPERIMENTAL_PROFILE: "portable",
+ },
+ {
+ platform: "linux",
+ home,
+ uid: 1001,
+ systemctl,
+ validateConfigAuthority,
+ },
+ authority,
+ ),
+ ).toThrow(/configuration root does not match the current OS user home/);
+ expect(validateConfigAuthority).not.toHaveBeenCalled();
+ expect(systemctl).not.toHaveBeenCalled();
+ expect(fs.existsSync(path.join(home, ".config"))).toBe(false);
+ });
+
+ it("rejects an unsafe pre-existing socket before config writes or activation", () => {
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-portable-"));
+ tempDirs.push(home);
+ const systemctl = vi.fn(() => result());
+ const captureSocketAuthority = vi.fn(() => {
+ throw new Error("Podman socket authority is owned by uid 2000; expected current uid 1001.");
+ });
+
+ expect(() =>
+ preparePortableExperimentalHost(
+ { NEMOCLAW_EXPERIMENTAL_PROFILE: "portable" },
+ {
+ platform: "linux",
+ home,
+ uid: 1001,
+ systemctl,
+ captureSocketAuthority,
+ validateConfigAuthority: vi.fn(),
+ },
+ runtimeAuthority(home),
+ ),
+ ).toThrow(/owned by uid 2000/);
+ expect(systemctl).not.toHaveBeenCalled();
+ expect(fs.existsSync(path.join(home, ".config"))).toBe(false);
+ });
+
+ it("accepts reboot socket rotation and requalifies current Podman identity", () => {
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-portable-"));
+ tempDirs.push(home);
+ const missing = Object.assign(new Error("missing socket"), { code: "ENOENT" });
+ const currentAuthority = socketAuthority();
+ const captureSocketAuthority = vi
+ .fn<(socketPath: string, uid: number) => PodmanSocketAuthority>()
+ .mockImplementationOnce(() => {
+ throw missing;
+ })
+ .mockReturnValueOnce(currentAuthority);
+ const qualifyPodman = vi.fn();
+ const assertSocketAuthority = vi.fn();
+ const docker = vi
+ .fn<(args: readonly string[], env: NodeJS.ProcessEnv) => SpawnResult>()
+ .mockReturnValueOnce(result())
+ .mockReturnValueOnce(result(0, "1 true"));
+
+ const prepared = preparePortableExperimentalHost(
+ { NEMOCLAW_EXPERIMENTAL_PROFILE: "portable" },
+ {
+ platform: "linux",
+ home,
+ uid: 1001,
+ systemctl: () => result(),
+ podman: () => result(0, "/run/user/1001/podman/podman.sock"),
+ docker,
+ hardenSocketDirectory: vi.fn(),
+ captureSocketAuthority,
+ assertSocketAuthority,
+ qualifyPodman,
+ validateConfigAuthority: vi.fn(),
+ },
+ runtimeAuthority(home),
+ );
+
+ expect(prepared?.authority).toEqual(runtimeAuthority(home));
+ expect(captureSocketAuthority).toHaveBeenCalledTimes(2);
+ expect(qualifyPodman).toHaveBeenCalledWith(currentAuthority);
+ expect(assertSocketAuthority).toHaveBeenCalledWith(currentAuthority);
+ });
+
+ it("rejects symlinked portable configuration authority (#9035)", () => {
+ const home = fs.mkdtempSync(path.join(process.cwd(), "tmp-portable-authority-"));
+ tempDirs.push(home);
+ const runtimeDir = path.join(home, "runtime");
+ const configTarget = path.join(home, "config-target");
+ const configHome = path.join(home, "config-link");
+ fs.mkdirSync(runtimeDir, { mode: 0o700 });
+ fs.mkdirSync(configTarget, { mode: 0o700 });
+ fs.symlinkSync(configTarget, configHome);
+
+ expect(() =>
+ portableHostPreparationInternals.validateOwnedConfigAuthority({
+ homeDir: home,
+ configHome,
+ runtimeDir,
+ socketPath: null,
+ uid: process.getuid?.() ?? -1,
+ }),
+ ).toThrow(/not a real directory/);
+ });
+
+ it("rejects writable portable configuration authority (#9035)", () => {
+ const home = fs.mkdtempSync(path.join(process.cwd(), "tmp-portable-authority-"));
+ tempDirs.push(home);
+ const configHome = path.join(home, "config");
+ fs.mkdirSync(configHome, { mode: 0o770 });
+ fs.chmodSync(configHome, 0o770);
+
+ expect(() =>
+ portableHostPreparationInternals.validateOwnedConfigAuthority({
+ homeDir: home,
+ configHome,
+ runtimeDir: home,
+ socketPath: null,
+ uid: process.getuid?.() ?? -1,
+ }),
+ ).toThrow(/unsafe write permissions/);
+ });
});
diff --git a/src/lib/onboard/experimental/portable-host-preparation.ts b/src/lib/onboard/experimental/portable-host-preparation.ts
index ca57e815eb8..1b5107cbf6d 100644
--- a/src/lib/onboard/experimental/portable-host-preparation.ts
+++ b/src/lib/onboard/experimental/portable-host-preparation.ts
@@ -2,14 +2,24 @@
// SPDX-License-Identifier: Apache-2.0
import { spawnSync } from "node:child_process";
+import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { dockerSpawnSync } from "../../adapters/docker/exec";
import { openRegularFileNoFollow } from "../../adapters/fs/regular-file";
-import { hardenPodmanSocketDirectory, localPodmanEnvironment } from "../../adapters/podman";
+import {
+ assertPodmanSocketAuthority,
+ capturePodmanSocketAuthority,
+ createPodmanContainerEngine,
+ hardenPodmanSocketDirectory,
+ localPodmanEnvironment,
+ type PodmanSocketAuthority,
+} from "../../adapters/podman";
import { ensureConfigDir } from "../../state/config-io";
+import type { CheckpointPortableRuntimeAuthority } from "../../state/onboard-checkpoint-types";
import { isPortableExperimentalProfile, PORTABLE_LOCAL_REGISTRY } from "../docker-driver-platform";
+import { qualifyPodmanHost } from "../runtime-provider/podman-preflight";
const REGISTRY_CONTAINER = "nemoclaw-portable-registry";
const REGISTRY_LABEL = "com.nvidia.nemoclaw.portable=1";
@@ -28,6 +38,11 @@ firewall_driver = "iptables"
[engine]
env = ["NETAVARK_FW=iptables"]
`;
+const PORTABLE_CONFIG_RELATIVE_FILES = [
+ "containers/registries.conf.d/99-nemoclaw-portable.conf",
+ "containers/containers.conf.d/99-nemoclaw-portable.conf",
+ "nemoclaw/portable/containers.conf",
+] as const;
type SpawnResult = ReturnType;
@@ -39,6 +54,22 @@ export interface PortableHostPreparationDeps {
podman?: (args: readonly string[], env: NodeJS.ProcessEnv) => SpawnResult;
docker?: (args: readonly string[], env: NodeJS.ProcessEnv) => SpawnResult;
hardenSocketDirectory?: (socketPath: string, uid: number) => void;
+ captureSocketAuthority?: (socketPath: string, uid: number) => PodmanSocketAuthority;
+ assertSocketAuthority?: (authority: PodmanSocketAuthority) => void;
+ qualifyPodman?: (authority: PodmanSocketAuthority) => void;
+ validateConfigAuthority?: (input: {
+ homeDir: string;
+ configHome: string;
+ runtimeDir: string;
+ socketPath: string | null;
+ uid: number;
+ }) => void;
+}
+
+export interface PortableHostPreparationResult {
+ readonly authority: CheckpointPortableRuntimeAuthority;
+ readonly socketAuthority: PodmanSocketAuthority | null;
+ readonly containersConf: string;
}
function commandDetail(result: SpawnResult): string {
@@ -105,8 +136,7 @@ function writePrivateConfig(filePath: string, value: string): void {
}
}
-function writePortableRuntimeConfig(home: string, env: NodeJS.ProcessEnv): string {
- const configHome = env.XDG_CONFIG_HOME?.trim() || path.join(home, ".config");
+function writePortableRuntimeConfig(configHome: string): string {
writePrivateConfig(
path.join(configHome, "containers", "registries.conf.d", "99-nemoclaw-portable.conf"),
REGISTRY_FRAGMENT,
@@ -124,6 +154,117 @@ function writePortableRuntimeConfig(home: string, env: NodeJS.ProcessEnv): strin
return containersConf;
}
+function canonicalAbsolute(value: string, label: string): string {
+ const resolved = path.resolve(value);
+ if (resolved !== value || !path.isAbsolute(value) || /[\0\r\n]/u.test(value)) {
+ throw new Error(`Portable runtime ${label} must be a normalized absolute path.`);
+ }
+ return resolved;
+}
+
+function validateOwnedConfigAuthority(input: {
+ homeDir: string;
+ configHome: string;
+ runtimeDir: string;
+ socketPath: string | null;
+ uid: number;
+}): void {
+ const { homeDir, configHome, runtimeDir, socketPath, uid } = input;
+ const assertDirectory = (directory: string, owner: number | null): fs.Stats => {
+ const stat = fs.lstatSync(directory);
+ if (stat.isSymbolicLink() || !stat.isDirectory()) {
+ throw new Error(`Portable runtime path '${directory}' is not a real directory.`);
+ }
+ if (owner !== null && stat.uid !== owner) {
+ throw new Error(`Portable runtime path '${directory}' is not owned by the current user.`);
+ }
+ if ((stat.mode & 0o022) !== 0) {
+ throw new Error(`Portable runtime path '${directory}' has unsafe write permissions.`);
+ }
+ return stat;
+ };
+ const assertSystemAncestors = (directory: string): void => {
+ for (let current = path.dirname(directory); ; current = path.dirname(current)) {
+ assertDirectory(current, null);
+ const parent = path.dirname(current);
+ if (parent === current) break;
+ }
+ };
+ const assertOwnedRoot = (directory: string): void => {
+ assertDirectory(directory, uid);
+ assertSystemAncestors(directory);
+ };
+ const assertOwnedDescendants = (root: string, target: string): void => {
+ const relative = path.relative(root, target);
+ if (
+ relative === "" ||
+ path.isAbsolute(relative) ||
+ relative === ".." ||
+ relative.startsWith(`..${path.sep}`)
+ ) {
+ if (relative === "") return;
+ throw new Error(`Portable runtime path '${target}' is outside '${root}'.`);
+ }
+ let current = root;
+ for (const component of relative.split(path.sep)) {
+ current = path.join(current, component);
+ try {
+ assertDirectory(current, uid);
+ } catch (error) {
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") continue;
+ throw error;
+ }
+ }
+ };
+ const assertConfigRoot = (): void => {
+ try {
+ assertOwnedRoot(configHome);
+ return;
+ } catch (error) {
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
+ }
+ let anchor = path.dirname(configHome);
+ while (true) {
+ try {
+ assertOwnedRoot(anchor);
+ return;
+ } catch (error) {
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
+ }
+ const parent = path.dirname(anchor);
+ if (parent === anchor) break;
+ anchor = parent;
+ }
+ throw new Error("Portable runtime config root has no validated owned ancestor.");
+ };
+ const assertExistingConfigFile = (filePath: string): void => {
+ try {
+ const stat = fs.lstatSync(filePath);
+ if (
+ stat.isSymbolicLink() ||
+ !stat.isFile() ||
+ stat.uid !== uid ||
+ stat.nlink !== 1 ||
+ (stat.mode & 0o022) !== 0
+ ) {
+ throw new Error(`Portable runtime config '${filePath}' is not a safe owned file.`);
+ }
+ } catch (error) {
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
+ }
+ };
+
+ assertOwnedRoot(homeDir);
+ assertOwnedRoot(runtimeDir);
+ assertConfigRoot();
+ for (const relative of PORTABLE_CONFIG_RELATIVE_FILES) {
+ const filePath = path.join(configHome, relative);
+ assertOwnedDescendants(configHome, path.dirname(filePath));
+ assertExistingConfigFile(filePath);
+ }
+ if (socketPath) assertOwnedDescendants(runtimeDir, path.dirname(socketPath));
+}
+
function ensureRegistryContainer(
env: NodeJS.ProcessEnv,
docker: NonNullable,
@@ -132,7 +273,7 @@ function ensureRegistryContainer(
[
"inspect",
"--format",
- '{{ index .Config.Labels "com.nvidia.nemoclaw.portable" }}',
+ '{{ index .Config.Labels "com.nvidia.nemoclaw.portable" }} {{.State.Running}}',
REGISTRY_CONTAINER,
],
env,
@@ -141,11 +282,15 @@ function ensureRegistryContainer(
requireCommand(inspection, "Inspecting the managed portable registry");
}
const exists = inspection.status === 0;
- if (exists && String(inspection.stdout ?? "").trim() !== "1") {
+ const [owner, running] = String(inspection.stdout ?? "")
+ .trim()
+ .split(/\s+/u);
+ if (exists && owner !== "1") {
throw new Error(
`Refusing to replace existing unmanaged container '${REGISTRY_CONTAINER}'. Rename or remove it and retry.`,
);
}
+ if (exists && running === "true") return;
if (exists) {
requireCommand(
docker(["rm", "-f", REGISTRY_CONTAINER], env),
@@ -176,18 +321,64 @@ function ensureRegistryContainer(
export function preparePortableExperimentalHost(
env: NodeJS.ProcessEnv = process.env,
deps: PortableHostPreparationDeps = {},
-): void {
- if (!isPortableExperimentalProfile(env)) return;
+ expectedAuthority?: CheckpointPortableRuntimeAuthority | null,
+): PortableHostPreparationResult | null {
+ if (!isPortableExperimentalProfile(env)) return null;
if ((deps.platform ?? process.platform) !== "linux") {
throw new Error("The portable experimental profile requires Linux.");
}
- const uid = deps.uid ?? process.getuid?.();
+ const uid = deps.uid ?? process.geteuid?.() ?? process.getuid?.();
if (!Number.isInteger(uid) || Number(uid) < 0) {
throw new Error("The portable experimental profile could not resolve the current user ID.");
}
- const home = deps.home ?? env.HOME ?? os.homedir();
+ const currentHome = canonicalAbsolute(deps.home ?? os.userInfo().homedir, "home directory");
+ const home = canonicalAbsolute(expectedAuthority?.homeDir ?? currentHome, "home directory");
+ const configHome = path.join(home, ".config");
+ const runtimeDir = canonicalAbsolute(
+ expectedAuthority?.runtimeDir ?? path.join("/run/user", String(uid)),
+ "user runtime directory",
+ );
+ const expectedSocketPath = expectedAuthority
+ ? canonicalAbsolute(expectedAuthority.socketPath, "socket path")
+ : null;
+ if (expectedAuthority) {
+ if (expectedAuthority.configHome !== configHome) {
+ throw new Error(
+ "Portable runtime authority configuration root does not match the current OS user home.",
+ );
+ }
+ if (
+ expectedAuthority.uid !== Number(uid) ||
+ expectedAuthority.kind !== "podman" ||
+ expectedAuthority.ownership !== "current-user" ||
+ home !== currentHome ||
+ runtimeDir !== path.join("/run/user", String(uid))
+ ) {
+ throw new Error(
+ "Portable runtime authority does not match the current user or runtime kind.",
+ );
+ }
+ }
+ const validateConfigAuthority = deps.validateConfigAuthority ?? validateOwnedConfigAuthority;
+ validateConfigAuthority({
+ homeDir: home,
+ configHome,
+ runtimeDir,
+ socketPath: expectedSocketPath,
+ uid: Number(uid),
+ });
+ if (expectedSocketPath) {
+ try {
+ (
+ deps.captureSocketAuthority ??
+ ((socketPath, ownerUid) => capturePodmanSocketAuthority(socketPath, { uid: ownerUid }))
+ )(expectedSocketPath, Number(uid));
+ } catch (error) {
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
+ }
+ }
env.NETAVARK_FW = "iptables";
- env.CONTAINERS_CONF = writePortableRuntimeConfig(home, env);
+ env.CONTAINERS_CONF = writePortableRuntimeConfig(configHome);
const systemctl =
deps.systemctl ??
@@ -231,7 +422,34 @@ export function preparePortableExperimentalHost(
podman(["info", "--format", "{{.Host.RemoteSocket.Path}}"], podmanEnv),
);
const socketPath = dockerHost.slice("unix://".length);
+ if (expectedAuthority && socketPath !== expectedAuthority.socketPath) {
+ throw new Error("Portable Podman socket path does not match the onboarding checkpoint.");
+ }
+ const relativeSocket = path.relative(runtimeDir, socketPath);
+ if (
+ relativeSocket === "" ||
+ path.isAbsolute(relativeSocket) ||
+ relativeSocket === ".." ||
+ relativeSocket.startsWith(`..${path.sep}`)
+ ) {
+ throw new Error("Portable Podman socket is outside the current user runtime directory.");
+ }
(deps.hardenSocketDirectory ?? hardenPodmanSocketDirectory)(socketPath, Number(uid));
+ const socketAuthority = deps.captureSocketAuthority
+ ? deps.captureSocketAuthority(socketPath, Number(uid))
+ : deps.hardenSocketDirectory
+ ? null
+ : capturePodmanSocketAuthority(socketPath, { uid: Number(uid) });
+ if (socketAuthority) {
+ (
+ deps.qualifyPodman ??
+ ((authority) => {
+ qualifyPodmanHost(
+ createPodmanContainerEngine({ operation: "host-doctor", socketAuthority: authority }),
+ );
+ })
+ )(socketAuthority);
+ }
env.DOCKER_HOST = dockerHost;
podmanEnv.DOCKER_HOST = dockerHost;
@@ -245,6 +463,23 @@ export function preparePortableExperimentalHost(
}));
requireDockerCompatibleCli(docker, podmanEnv);
ensureRegistryContainer(podmanEnv, docker);
+ if (socketAuthority) {
+ (deps.assertSocketAuthority ?? assertPodmanSocketAuthority)(socketAuthority);
+ }
+ return {
+ authority: {
+ schemaVersion: 1,
+ kind: "podman",
+ ownership: "current-user",
+ uid: Number(uid),
+ homeDir: home,
+ configHome,
+ runtimeDir,
+ socketPath,
+ },
+ socketAuthority,
+ containersConf: env.CONTAINERS_CONF,
+ };
}
export const portableHostPreparationInternals = {
@@ -252,5 +487,6 @@ export const portableHostPreparationInternals = {
REGISTRY_IMAGE,
REGISTRY_FRAGMENT,
PORTABLE_CONTAINERS_CONF,
+ validateOwnedConfigAuthority,
resolvePodmanDockerHost,
};
diff --git a/src/lib/onboard/fatal-runtime-preflight.test.ts b/src/lib/onboard/fatal-runtime-preflight.test.ts
index 3e393fa9ba3..8b9620c2cb0 100644
--- a/src/lib/onboard/fatal-runtime-preflight.test.ts
+++ b/src/lib/onboard/fatal-runtime-preflight.test.ts
@@ -356,24 +356,23 @@ describe("runFatalOnboardRuntimePreflight", () => {
expect(result.sandboxGpuConfig.mode).toBe("0");
});
- it("admits the read-only host report before portable preparation effects", () => {
+ it("does not duplicate locked portable preparation inside runtime preflight", () => {
vi.stubEnv("NEMOCLAW_EXPERIMENTAL_PROFILE", "portable");
- mocks.preparePortableExperimentalHost.mockImplementationOnce(() => {
- throw new Error("portable host prepared");
- });
const assess = vi.fn(() => hostWithRuntime("docker"));
- expect(() =>
- runFatalOnboardRuntimePreflight(
- {},
- { nonInteractive: true, assessHost: assess, detectGpu: () => null },
- ),
- ).toThrow("portable host prepared");
- expect(assess).toHaveBeenCalledOnce();
- expect(mocks.preparePortableExperimentalHost).toHaveBeenCalledWith(process.env);
- expect(assess.mock.invocationCallOrder[0]).toBeLessThan(
- mocks.preparePortableExperimentalHost.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY,
+ runFatalOnboardRuntimePreflight(
+ {},
+ {
+ nonInteractive: true,
+ assessHost: assess,
+ detectGpu: () => null,
+ warnIfHostProxyMissesLoopback: vi.fn(),
+ assertDockerBridgeAndContainerDnsHealthy: vi.fn(),
+ validateSandboxGpuPreflight: vi.fn(),
+ },
);
+ expect(assess).toHaveBeenCalledOnce();
+ expect(mocks.preparePortableExperimentalHost).not.toHaveBeenCalled();
});
it("defers image and container checks until the caller explicitly runs them", () => {
@@ -642,12 +641,9 @@ describe("readiness-gated runtime preflight", () => {
expect(calls).toEqual(["gateway", "host", "gateway", "host", "gpu", "bridge"]);
});
- it("replaces portable host and gateway facts before runtime probe effects", async () => {
+ it("uses the already-qualified portable host facts for runtime probe effects", async () => {
vi.stubEnv("NEMOCLAW_EXPERIMENTAL_PROFILE", "portable");
const calls: string[] = [];
- mocks.preparePortableExperimentalHost.mockImplementationOnce(() => {
- calls.push("portable");
- });
await runReadinessGatedRuntimePreflight(
{},
@@ -668,16 +664,8 @@ describe("readiness-gated runtime preflight", () => {
},
);
- expect(calls).toEqual([
- "gateway",
- "host",
- "portable",
- "host",
- "gateway",
- "host",
- "gpu",
- "bridge",
- ]);
+ expect(calls).toEqual(["gateway", "host", "gateway", "host", "gpu", "bridge"]);
+ expect(mocks.preparePortableExperimentalHost).not.toHaveBeenCalled();
});
it("does not run image or container checks when refreshed gateway facts block", async () => {
diff --git a/src/lib/onboard/fatal-runtime-preflight.ts b/src/lib/onboard/fatal-runtime-preflight.ts
index 8f98c55a224..eef29cd7ecb 100644
--- a/src/lib/onboard/fatal-runtime-preflight.ts
+++ b/src/lib/onboard/fatal-runtime-preflight.ts
@@ -24,7 +24,6 @@ import {
isLinuxDockerDriverGatewayEnabled,
isPortableExperimentalProfile,
} from "./docker-driver-platform";
-import { preparePortableExperimentalHost } from "./experimental/portable-host-preparation";
import { warnIfHostProxyMissesLoopback } from "./http-proxy-preflight";
import { assessHost, type HostAssessment, planHostAdvisories } from "./preflight";
import {
@@ -63,7 +62,6 @@ export interface FatalRuntimePreflightContext {
* override so the bounded Docker proof can run when needed.
*/
detectGpu?: typeof detectGpu;
- preparePortableExperimentalHost?: typeof preparePortableExperimentalHost;
warnIfHostProxyMissesLoopback?: typeof warnIfHostProxyMissesLoopback;
assertDockerBridgeAndContainerDnsHealthy?: typeof assertDockerBridgeAndContainerDnsHealthy;
validateSandboxGpuPreflight?: typeof validateSandboxGpuPreflight;
@@ -443,8 +441,6 @@ export function runFatalOnboardRuntimePreflight(
const exitProcess = context.exitProcess ?? exitProcessByDefault;
const assess = context.assessHost ?? assessHost;
const detect = context.detectGpu ?? detectGpu;
- const preparePortable =
- context.preparePortableExperimentalHost ?? preparePortableExperimentalHost;
const now = context.now ?? (() => new Date());
let observedAt = now().toISOString();
let host = assess();
@@ -456,29 +452,6 @@ export function runFatalOnboardRuntimePreflight(
let explicitlyOptedOutGpuPassthrough =
sandboxGpuConfig.mode === "0" || options.optedOutGpuPassthrough === true;
- if (isPortableExperimentalProfile()) {
- // Portable setup is an explicit remediation. Admit only its narrow,
- // pre-mutation exception set, apply it, then replace every observation
- // with a fresh canonical host report before continuing.
- assertOnboardHostReadiness(host, gpu, {
- explicitlyOptedOutGpuPassthrough,
- resuming: context.resuming,
- allowPortableHostPreparation: true,
- exitProcess,
- observedAt,
- now,
- });
- preparePortable(process.env);
- observedAt = now().toISOString();
- host = assess();
- gpu = detect({ proveArm64WslDockerDesktopGpu: null });
- sandboxGpuConfig = resolveSandboxGpuConfig(gpu, {
- flag: resolveSandboxGpuFlagFromOptions(options),
- device: options.sandboxGpuDevice ?? null,
- });
- explicitlyOptedOutGpuPassthrough =
- sandboxGpuConfig.mode === "0" || options.optedOutGpuPassthrough === true;
- }
const readinessReport = assertOnboardHostReadiness(host, gpu, {
explicitlyOptedOutGpuPassthrough,
resuming: context.resuming,
diff --git a/src/lib/onboard/gateway-start-failure-integration.test.ts b/src/lib/onboard/gateway-start-failure-integration.test.ts
index ed070e780e6..1ecf6989590 100644
--- a/src/lib/onboard/gateway-start-failure-integration.test.ts
+++ b/src/lib/onboard/gateway-start-failure-integration.test.ts
@@ -85,16 +85,17 @@ describe("startGatewayWithOptions docker-unreachable abort (#2347)", () => {
expect(joined).not.toContain("systemctl");
});
- it("prints the rootless-Podman recovery hint when portable=true (#8873)", () => {
+ it("prints the rootless-Podman resume hint when portable=true (#9035)", () => {
const printed: string[] = [];
printDockerDaemonRecovery((message = "") => printed.push(message), "linux", true);
const joined = printed.join("\n");
expect(joined).toContain("rootless Podman API service is not reachable");
expect(joined).toContain("Start Podman");
- expect(joined).toContain("nemoclaw onboard --experimental-profile portable");
+ expect(joined).toContain("nemoclaw onboard --resume");
+ expect(joined).not.toContain("nemoclaw onboard --experimental-profile portable");
expect(joined).not.toContain("sudo systemctl start docker");
expect(joined).not.toContain("colima start");
- expect(joined).not.toContain("--resume");
+ expect(joined).toContain("--resume");
});
});
diff --git a/src/lib/onboard/gateway-start-failure.ts b/src/lib/onboard/gateway-start-failure.ts
index f5af8057f0c..c911f894cb5 100644
--- a/src/lib/onboard/gateway-start-failure.ts
+++ b/src/lib/onboard/gateway-start-failure.ts
@@ -5,7 +5,7 @@ import { compactText } from "../core/url-utils";
import { redact } from "../security/redact";
import { classifyGatewayStartFailure } from "../validation";
import { isPortableExperimentalProfile } from "./experimental/portable-profile";
-import { onboardRecoveryCommand } from "./resume-hint";
+import { onboardResumeRecoveryCommand } from "./resume-hint";
const ANSI_RE = /\x1B(?:\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1B\\)|[@-_])/g;
@@ -52,7 +52,7 @@ export function printDockerDaemonRecovery(
if (portable) {
printError(" The rootless Podman API service is not reachable.");
printError("");
- printError(` Start Podman, then rerun: ${onboardRecoveryCommand(portable)}`);
+ printError(` Start Podman, then rerun: ${onboardResumeRecoveryCommand()}`);
return;
}
@@ -126,7 +126,7 @@ export function createFinalGatewayStartFailureHandler(deps: FinalGatewayStartFai
printError(
` docker volume ls -q --filter "name=openshell-cluster-${gatewayName}" | xargs -r docker volume rm`,
);
- printError(` ${onboardRecoveryCommand()}`);
+ printError(` ${onboardResumeRecoveryCommand()}`);
return exitProcess(1);
};
}
diff --git a/src/lib/onboard/machine/events.ts b/src/lib/onboard/machine/events.ts
index 192b098a02e..1aed568b574 100644
--- a/src/lib/onboard/machine/events.ts
+++ b/src/lib/onboard/machine/events.ts
@@ -12,6 +12,8 @@ import {
} from "./definition";
import type { OnboardMachineContext, OnboardMachineEventType, OnboardMachineState } from "./types";
+export { redactSensitiveText };
+
type OnboardSessionStepDefinition = OnboardMachineStateWithStepDefinition;
export type OnboardSessionStepName = OnboardSessionStepDefinition["stepName"];
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 fb48fe8b859..e824e1ec536 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
@@ -41,6 +41,8 @@ function defaultCreateFingerprint(sandboxName = "my-assistant"): string {
function crashedCheckpoint(overrides: Partial = {}): OnboardCheckpoint {
return {
schemaVersion: CHECKPOINT_SCHEMA_VERSION,
+ profile: { kind: "selected", value: "default" },
+ runtimeAuthority: { kind: "unset" },
sessionId: "sess-1",
machineState: "sandbox",
updatedAt: "2026-01-01T00:00:00.000Z",
diff --git a/src/lib/onboard/machine/handlers/sandbox-messaging.test.ts b/src/lib/onboard/machine/handlers/sandbox-messaging.test.ts
index 95e82d1a8a4..5ca2f0d978b 100644
--- a/src/lib/onboard/machine/handlers/sandbox-messaging.test.ts
+++ b/src/lib/onboard/machine/handlers/sandbox-messaging.test.ts
@@ -249,6 +249,8 @@ function withMessagingCheckpoint(
): Session {
const checkpoint: OnboardCheckpoint = {
schemaVersion: CHECKPOINT_SCHEMA_VERSION,
+ profile: { kind: "selected", value: "default" },
+ runtimeAuthority: { kind: "unset" },
sessionId: session.sessionId,
machineState: session.machine.state,
updatedAt: "2026-01-01T00:00:00.000Z",
diff --git a/src/lib/onboard/machine/handlers/sandbox-provider-effect-replay.test.ts b/src/lib/onboard/machine/handlers/sandbox-provider-effect-replay.test.ts
index 3c2da680eeb..9aa05a3c4e9 100644
--- a/src/lib/onboard/machine/handlers/sandbox-provider-effect-replay.test.ts
+++ b/src/lib/onboard/machine/handlers/sandbox-provider-effect-replay.test.ts
@@ -89,6 +89,8 @@ describe("handleSandboxState provider effect replay", () => {
});
session.checkpoint = {
schemaVersion: CHECKPOINT_SCHEMA_VERSION,
+ profile: { kind: "selected", value: "default" },
+ runtimeAuthority: { kind: "unset" },
sessionId: session.sessionId,
machineState: "sandbox",
updatedAt: "2026-01-01T00:00:00.000Z",
@@ -182,6 +184,8 @@ describe("handleSandboxState provider effect replay", () => {
});
session.checkpoint = {
schemaVersion: CHECKPOINT_SCHEMA_VERSION,
+ profile: { kind: "selected", value: "default" },
+ runtimeAuthority: { kind: "unset" },
sessionId: session.sessionId,
machineState: "sandbox",
updatedAt: "2026-01-01T00:00:00.000Z",
@@ -259,6 +263,8 @@ describe("handleSandboxState provider effect replay", () => {
const session = createSession({ sandboxName: "my-assistant" });
session.checkpoint = {
schemaVersion: CHECKPOINT_SCHEMA_VERSION,
+ profile: { kind: "selected", value: "default" },
+ runtimeAuthority: { kind: "unset" },
sessionId: session.sessionId,
machineState: "sandbox",
updatedAt: "2026-01-01T00:00:00.000Z",
@@ -328,6 +334,8 @@ describe("handleSandboxState provider effect replay", () => {
});
session.checkpoint = {
schemaVersion: CHECKPOINT_SCHEMA_VERSION,
+ profile: { kind: "selected", value: "default" },
+ runtimeAuthority: { kind: "unset" },
sessionId: session.sessionId,
machineState: "sandbox",
updatedAt: "2026-01-01T00:00:00.000Z",
@@ -422,6 +430,8 @@ describe("handleSandboxState provider effect replay", () => {
});
session.checkpoint = {
schemaVersion: CHECKPOINT_SCHEMA_VERSION,
+ profile: { kind: "selected", value: "default" },
+ runtimeAuthority: { kind: "unset" },
sessionId: session.sessionId,
machineState: "sandbox",
updatedAt: "2026-01-01T00:00:00.000Z",
@@ -574,6 +584,8 @@ describe("handleSandboxState provider effect replay", () => {
});
session.checkpoint = {
schemaVersion: CHECKPOINT_SCHEMA_VERSION,
+ profile: { kind: "selected", value: "default" },
+ runtimeAuthority: { kind: "unset" },
sessionId: session.sessionId,
machineState: "sandbox",
updatedAt: "2026-01-01T00:00:00.000Z",
@@ -642,6 +654,8 @@ describe("handleSandboxState provider effect replay", () => {
});
session.checkpoint = {
schemaVersion: CHECKPOINT_SCHEMA_VERSION,
+ profile: { kind: "selected", value: "default" },
+ runtimeAuthority: { kind: "unset" },
sessionId: session.sessionId,
machineState: "sandbox",
updatedAt: "2026-01-01T00:00:00.000Z",
@@ -761,6 +775,8 @@ describe("handleSandboxState provider effect replay", () => {
});
session.checkpoint = {
schemaVersion: CHECKPOINT_SCHEMA_VERSION,
+ profile: { kind: "selected", value: "default" },
+ runtimeAuthority: { kind: "unset" },
sessionId: session.sessionId,
machineState: "sandbox",
updatedAt: "2026-01-01T00:00:00.000Z",
diff --git a/src/lib/onboard/machine/handlers/sandbox-rebuild-web-search-reuse.test.ts b/src/lib/onboard/machine/handlers/sandbox-rebuild-web-search-reuse.test.ts
index a608079157a..11784bb243c 100644
--- a/src/lib/onboard/machine/handlers/sandbox-rebuild-web-search-reuse.test.ts
+++ b/src/lib/onboard/machine/handlers/sandbox-rebuild-web-search-reuse.test.ts
@@ -54,6 +54,8 @@ function rebuiltCheckpoint(
): OnboardCheckpoint {
return {
schemaVersion: CHECKPOINT_SCHEMA_VERSION,
+ profile: { kind: "selected", value: "default" },
+ runtimeAuthority: { kind: "unset" },
sessionId: "sess-1",
machineState: "sandbox",
updatedAt: AT,
diff --git a/src/lib/onboard/machine/handlers/sandbox.test.ts b/src/lib/onboard/machine/handlers/sandbox.test.ts
index af4d1a02e35..bae58798c3a 100644
--- a/src/lib/onboard/machine/handlers/sandbox.test.ts
+++ b/src/lib/onboard/machine/handlers/sandbox.test.ts
@@ -142,6 +142,8 @@ describe("handleSandboxState", () => {
const session = createSession({ sandboxName: "my-assistant" });
session.checkpoint = {
schemaVersion: CHECKPOINT_SCHEMA_VERSION,
+ profile: { kind: "selected", value: "default" },
+ runtimeAuthority: { kind: "unset" },
sessionId: session.sessionId,
machineState: "sandbox",
updatedAt: "2026-01-01T00:00:00.000Z",
@@ -607,6 +609,8 @@ describe("handleSandboxState", () => {
});
session.checkpoint = {
schemaVersion: CHECKPOINT_SCHEMA_VERSION,
+ profile: { kind: "selected", value: "default" },
+ runtimeAuthority: { kind: "unset" },
sessionId: session.sessionId,
machineState: "agent_setup",
updatedAt: "2026-01-01T00:00:00.000Z",
@@ -649,6 +653,8 @@ describe("handleSandboxState", () => {
session.steps.sandbox.status = "complete";
session.checkpoint = {
schemaVersion: CHECKPOINT_SCHEMA_VERSION,
+ profile: { kind: "selected", value: "default" },
+ runtimeAuthority: { kind: "unset" },
sessionId: session.sessionId,
machineState: "sandbox",
updatedAt: "2026-01-01T00:00:00.000Z",
@@ -684,6 +690,8 @@ describe("handleSandboxState", () => {
});
session.checkpoint = {
schemaVersion: CHECKPOINT_SCHEMA_VERSION,
+ profile: { kind: "selected", value: "default" },
+ runtimeAuthority: { kind: "unset" },
sessionId: session.sessionId,
machineState: "sandbox",
updatedAt: "2026-01-01T00:00:00.000Z",
@@ -727,6 +735,8 @@ describe("handleSandboxState", () => {
});
session.checkpoint = {
schemaVersion: CHECKPOINT_SCHEMA_VERSION,
+ profile: { kind: "selected", value: "default" },
+ runtimeAuthority: { kind: "unset" },
sessionId: session.sessionId,
machineState: "sandbox",
updatedAt: "2026-01-01T00:00:00.000Z",
diff --git a/src/lib/onboard/machine/hooks.ts b/src/lib/onboard/machine/hooks.ts
index 09663eb9c64..cd698461037 100644
--- a/src/lib/onboard/machine/hooks.ts
+++ b/src/lib/onboard/machine/hooks.ts
@@ -4,10 +4,10 @@
import fs from "node:fs";
import path from "node:path";
-import { redactSensitiveText } from "../../security/redact";
import {
addOnboardMachineEventListener,
emitOnboardMachineEvent,
+ redactSensitiveText,
sanitizeOnboardMachineEventMetadata,
type OnboardMachineEvent,
type OnboardMachineEventListener,
diff --git a/src/lib/onboard/machine/runtime.ts b/src/lib/onboard/machine/runtime.ts
index dab6748b15f..18c71fb0d9b 100644
--- a/src/lib/onboard/machine/runtime.ts
+++ b/src/lib/onboard/machine/runtime.ts
@@ -209,6 +209,7 @@ export class OnboardRuntime {
const enteredAt = this.deps.now();
const updated = this.deps.updateSession((session) => {
session.machine = snapshotFor(to, enteredAt, session.machine.revision + 1);
+ onboardSession.syncCheckpointMachineState(session, to, enteredAt);
if (to === "failed") {
session.status = "failed";
} else if (to === "complete") {
@@ -265,6 +266,7 @@ export class OnboardRuntime {
session.resumable = false;
session.failure = null;
session.machine = snapshotFor("complete", enteredAt, session.machine.revision + 1);
+ onboardSession.syncCheckpointMachineState(session, "complete", enteredAt);
return session;
});
}
@@ -383,6 +385,7 @@ export class OnboardRuntime {
recordedAt,
});
session.machine = snapshotFor("failed", recordedAt, session.machine.revision + 1);
+ onboardSession.syncCheckpointMachineState(session, "failed", recordedAt);
return session;
});
diff --git a/src/lib/onboard/policy-selection-prompts.test.ts b/src/lib/onboard/policy-selection-prompts.test.ts
index a2007ffa09b..d5d863daca9 100644
--- a/src/lib/onboard/policy-selection-prompts.test.ts
+++ b/src/lib/onboard/policy-selection-prompts.test.ts
@@ -91,9 +91,10 @@ function createHarness({
note: vi.fn(),
prompt,
selectFromNumberedMenuOrExit,
- makeOnboardCancelExit: (rollback, cleanup) => () => {
+ makeOnboardCancelExit: (rollback, cleanup, exit) => () => {
cleanup();
rollback.markCancelled();
+ exit?.(1);
},
sandboxCancelRollback: { markCancelled },
useColor: false,
@@ -193,23 +194,44 @@ describe("createPolicySelectionPromptHelpers", () => {
await expect(result).resolves.toEqual([{ name: "npm", access: "read" }]);
});
- it("selectPolicyTier marks rollback and restores raw mode on SIGTERM", () => {
+ it("selectPolicyTier rejects through the prompt after SIGTERM cleanup (#9035)", async () => {
const { helpers, markCancelled, processEvents, stdin } = createHarness();
- void helpers.selectPolicyTier();
+ const selection = helpers.selectPolicyTier();
processEvents.emit("SIGTERM");
+ await expect(selection).rejects.toMatchObject({ code: 1 });
expect(markCancelled).toHaveBeenCalledOnce();
expect(stdin.setRawMode).toHaveBeenLastCalledWith(false);
expect(stdin.listenerCount("data")).toBe(0);
});
- it("presetsCheckboxSelector marks rollback and restores raw mode on Ctrl-C", () => {
+ it("presetsCheckboxSelector rejects through the prompt after Ctrl-C cleanup (#9035)", async () => {
const { helpers, markCancelled, stdin } = createHarness();
- void helpers.presetsCheckboxSelector([{ name: "npm", description: "npm registry" }], []);
+ const selection = helpers.presetsCheckboxSelector(
+ [{ name: "npm", description: "npm registry" }],
+ [],
+ );
+ stdin.emit("data", "\x03");
+
+ await expect(selection).rejects.toMatchObject({ code: 1 });
+ expect(markCancelled).toHaveBeenCalledOnce();
+ expect(stdin.setRawMode).toHaveBeenLastCalledWith(false);
+ expect(stdin.listenerCount("data")).toBe(0);
+ });
+
+ it("selectTierPresetsAndAccess rejects through the prompt after Ctrl-C cleanup (#9035)", async () => {
+ const { helpers, markCancelled, stdin } = createHarness();
+
+ const selection = helpers.selectTierPresetsAndAccess("balanced", [
+ { name: "npm" },
+ { name: "pypi" },
+ { name: "github" },
+ ]);
stdin.emit("data", "\x03");
+ await expect(selection).rejects.toMatchObject({ code: 1 });
expect(markCancelled).toHaveBeenCalledOnce();
expect(stdin.setRawMode).toHaveBeenLastCalledWith(false);
expect(stdin.listenerCount("data")).toBe(0);
diff --git a/src/lib/onboard/policy-selection-prompts.ts b/src/lib/onboard/policy-selection-prompts.ts
index ac66acca6c7..b6d06873a7b 100644
--- a/src/lib/onboard/policy-selection-prompts.ts
+++ b/src/lib/onboard/policy-selection-prompts.ts
@@ -3,6 +3,7 @@
import type { TierDefinition } from "../policy/tiers";
import type { SandboxCancelRollback } from "./cancel-rollback";
+import { OnboardDeferredExitError } from "./session-bootstrap";
type PresetWithDescription = { name: string; description?: string };
type PresetWithAccess = { name: string; access: string };
@@ -41,6 +42,7 @@ export interface PolicySelectionPromptDeps {
makeOnboardCancelExit(
rollback: Pick,
cleanup: () => void,
+ exit?: (code: number) => void,
): () => void;
sandboxCancelRollback: Pick;
useColor: boolean;
@@ -164,7 +166,7 @@ export function createPolicySelectionPromptHelpers(deps: PolicySelectionPromptDe
stdin.resume();
stdin.setEncoding("utf8");
- return new Promise((resolve) => {
+ return new Promise((resolve, reject) => {
const cleanup = () => {
stdin.setRawMode(false);
stdin.pause();
@@ -175,7 +177,9 @@ export function createPolicySelectionPromptHelpers(deps: PolicySelectionPromptDe
processEvents.removeListener("SIGTERM", onSigterm);
};
- const onSigterm = makeOnboardCancelExit(sandboxCancelRollback, cleanup);
+ const onSigterm = makeOnboardCancelExit(sandboxCancelRollback, cleanup, (code) =>
+ reject(new OnboardDeferredExitError(code)),
+ );
processEvents.once("SIGTERM", onSigterm);
const onData = (key: string) => {
@@ -187,7 +191,7 @@ export function createPolicySelectionPromptHelpers(deps: PolicySelectionPromptDe
selectedIdx = cursor;
redraw();
} else if (key === "\x03") {
- makeOnboardCancelExit(sandboxCancelRollback, cleanup)();
+ onSigterm();
} else if (key === "\x1b[A" || key === "k") {
cursor = (cursor - 1 + n) % n;
redraw();
@@ -344,7 +348,7 @@ export function createPolicySelectionPromptHelpers(deps: PolicySelectionPromptDe
stdin.resume();
stdin.setEncoding("utf8");
- return new Promise((resolve) => {
+ return new Promise((resolve, reject) => {
const cleanup = () => {
stdin.setRawMode(false);
stdin.pause();
@@ -355,7 +359,9 @@ export function createPolicySelectionPromptHelpers(deps: PolicySelectionPromptDe
processEvents.removeListener("SIGTERM", onSigterm);
};
- const onSigterm = makeOnboardCancelExit(sandboxCancelRollback, cleanup);
+ const onSigterm = makeOnboardCancelExit(sandboxCancelRollback, cleanup, (code) =>
+ reject(new OnboardDeferredExitError(code)),
+ );
processEvents.once("SIGTERM", onSigterm);
const onData = (key: string) => {
@@ -368,7 +374,7 @@ export function createPolicySelectionPromptHelpers(deps: PolicySelectionPromptDe
.map((preset) => ({ name: preset.name, access: accessModes[preset.name] })),
);
} else if (key === "\x03") {
- makeOnboardCancelExit(sandboxCancelRollback, cleanup)();
+ onSigterm();
} else if (key === "\x1b[A" || key === "k") {
cursor = (cursor - 1 + n) % n;
redraw();
@@ -490,7 +496,7 @@ export function createPolicySelectionPromptHelpers(deps: PolicySelectionPromptDe
stdin.resume();
stdin.setEncoding("utf8");
- return new Promise((resolve) => {
+ return new Promise((resolve, reject) => {
const cleanup = () => {
stdin.setRawMode(false);
stdin.pause();
@@ -501,7 +507,9 @@ export function createPolicySelectionPromptHelpers(deps: PolicySelectionPromptDe
processEvents.removeListener("SIGTERM", onSigterm);
};
- const onSigterm = makeOnboardCancelExit(sandboxCancelRollback, cleanup);
+ const onSigterm = makeOnboardCancelExit(sandboxCancelRollback, cleanup, (code) =>
+ reject(new OnboardDeferredExitError(code)),
+ );
processEvents.once("SIGTERM", onSigterm);
const onData = (key: string) => {
@@ -510,7 +518,7 @@ export function createPolicySelectionPromptHelpers(deps: PolicySelectionPromptDe
stdout.write("\n");
resolve([...selected]);
} else if (key === "\x03") {
- makeOnboardCancelExit(sandboxCancelRollback, cleanup)();
+ onSigterm();
} else if (key === "\x1b[A" || key === "k") {
cursor = (cursor - 1 + n) % n;
redraw();
diff --git a/src/lib/onboard/portable-environment-scope.test.ts b/src/lib/onboard/portable-environment-scope.test.ts
new file mode 100644
index 00000000000..c464cf7be8c
--- /dev/null
+++ b/src/lib/onboard/portable-environment-scope.test.ts
@@ -0,0 +1,135 @@
+// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import { describe, expect, it } from "vitest";
+
+import {
+ createDefaultResumeProfileEnvironmentScope,
+ createPortableOnboardEnvironmentScope,
+ PORTABLE_RUNTIME_ENV_KEYS,
+} from "./session-bootstrap";
+
+const CLEARED_PORTABLE_RUNTIME_ENV_KEYS = PORTABLE_RUNTIME_ENV_KEYS.filter(
+ (key) => key !== "NEMOCLAW_EXPERIMENTAL_PROFILE",
+);
+
+describe("portable onboarding environment scope", () => {
+ it("restores default checkpoint classification over hostile ambient portable intent", () => {
+ const env: NodeJS.ProcessEnv = { NEMOCLAW_EXPERIMENTAL_PROFILE: "portable" };
+ const scope = createDefaultResumeProfileEnvironmentScope(env);
+ expect(env.NEMOCLAW_EXPERIMENTAL_PROFILE).toBeUndefined();
+ scope.restore();
+ expect(env.NEMOCLAW_EXPERIMENTAL_PROFILE).toBe("portable");
+ });
+
+ it("clears hostile runtime selectors and installs only canonical derived authority", () => {
+ const env: NodeJS.ProcessEnv = {
+ DOCKER_HOST: "tcp://attacker.test:2375",
+ DOCKER_CONTEXT: "hostile-context",
+ DOCKER_CONFIG: "/tmp/hostile-docker-config",
+ DOCKER_TLS: "1",
+ DOCKER_TLS_VERIFY: "1",
+ DOCKER_CERT_PATH: "/tmp/hostile-docker-certs",
+ XDG_CONFIG_HOME: "/tmp/hostile-xdg-config",
+ CONTAINERS_CONF: "/tmp/hostile-containers.conf",
+ NETAVARK_FW: "firewalld",
+ CONTAINER_HOST: "ssh://attacker.test",
+ CONTAINER_CONNECTION: "attacker",
+ CONTAINER_SSHKEY: "/tmp/attacker-key",
+ };
+
+ const scope = createPortableOnboardEnvironmentScope(env, null);
+
+ for (const key of CLEARED_PORTABLE_RUNTIME_ENV_KEYS) {
+ expect(env).not.toHaveProperty(key);
+ }
+ expect(env.NEMOCLAW_EXPERIMENTAL_PROFILE).toBe("portable");
+ scope.installRuntime({
+ containersConf: "/home/alice/.config/nemoclaw/portable/containers.conf",
+ socketPath: "/run/user/1000/podman/podman.sock",
+ });
+ expect(env).toMatchObject({
+ DOCKER_HOST: "unix:///run/user/1000/podman/podman.sock",
+ CONTAINERS_CONF: "/home/alice/.config/nemoclaw/portable/containers.conf",
+ NETAVARK_FW: "iptables",
+ });
+ expect(env.CONTAINER_HOST).toBeUndefined();
+ expect(env.CONTAINER_CONNECTION).toBeUndefined();
+ expect(env.CONTAINER_SSHKEY).toBeUndefined();
+ });
+
+ it("restores absent, empty, and valued keys exactly after success or failure", () => {
+ const env: NodeJS.ProcessEnv = {
+ DOCKER_HOST: "",
+ DOCKER_TLS: "",
+ DOCKER_TLS_VERIFY: "1",
+ DOCKER_CERT_PATH: "/previous/docker-certs",
+ HOME: "/hostile/home",
+ XDG_CONFIG_HOME: "",
+ CONTAINERS_CONF: "/previous/containers.conf",
+ NEMOCLAW_EXPERIMENTAL_PROFILE: "previous",
+ NEMOCLAW_POLICY_PRESETS: "weather,github",
+ };
+ const before = { ...env };
+ const scope = createPortableOnboardEnvironmentScope(env, {
+ schemaVersion: 1,
+ baseUrl: "https://inference.example.test/v1",
+ model: "vendor/model",
+ expiresAt: "2026-08-13T20:00:00.000Z",
+ });
+
+ try {
+ scope.installRuntime({
+ containersConf: "/canonical/containers.conf",
+ socketPath: "/run/user/1000/podman/podman.sock",
+ });
+ throw new Error("controlled failure");
+ } catch (error) {
+ expect(error).toMatchObject({ message: "controlled failure" });
+ } finally {
+ scope.restore();
+ }
+
+ expect(env).toEqual(before);
+ expect(Object.prototype.hasOwnProperty.call(env, "DOCKER_HOST")).toBe(true);
+ expect(env.DOCKER_HOST).toBe("");
+ expect(Object.prototype.hasOwnProperty.call(env, "CONTAINER_HOST")).toBe(false);
+ scope.restore();
+ expect(env).toEqual(before);
+ });
+
+ it("clears ambient inference selectors while preserving an explicit resume policy list (#9035)", () => {
+ const env: NodeJS.ProcessEnv = {
+ NEMOCLAW_PROVIDER: "ollama",
+ NEMOCLAW_MODEL: "hostile-model",
+ NEMOCLAW_ENDPOINT_URL: "https://attacker.test/v1",
+ NEMOCLAW_PREFERRED_API: "openai-completions",
+ NEMOCLAW_POLICY_MODE: "custom",
+ NEMOCLAW_POLICY_PRESETS: "github,npm,pypi,public-reference,weather",
+ NEMOCLAW_POLICY_TIER: "personal",
+ NEMOCLAW_TOOL_DISCLOSURE: "progressive",
+ };
+ const before = { ...env };
+ const scope = createPortableOnboardEnvironmentScope(env, null, { resume: true });
+
+ expect(env).toMatchObject({
+ NEMOCLAW_EXPERIMENTAL_PROFILE: "portable",
+ NEMOCLAW_OLLAMA_NO_AUTOSTART: "1",
+ NEMOCLAW_POLICY_MODE: "custom",
+ NEMOCLAW_POLICY_PRESETS: "github,npm,pypi,public-reference,weather",
+ });
+ for (const key of [
+ "NEMOCLAW_PROVIDER",
+ "NEMOCLAW_MODEL",
+ "NEMOCLAW_ENDPOINT_URL",
+ "NEMOCLAW_PREFERRED_API",
+ "NEMOCLAW_POLICY_TIER",
+ "NEMOCLAW_TOOL_DISCLOSURE",
+ ]) {
+ expect(env).not.toHaveProperty(key);
+ }
+
+ scope.restore();
+ expect(env).toEqual(before);
+ });
+});
diff --git a/src/lib/onboard/portable-resume-intent.test.ts b/src/lib/onboard/portable-resume-intent.test.ts
new file mode 100644
index 00000000000..415e374089b
--- /dev/null
+++ b/src/lib/onboard/portable-resume-intent.test.ts
@@ -0,0 +1,174 @@
+// 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 } from "vitest";
+
+import { serializeCheckpoint } from "../state/onboard-checkpoint";
+import { deriveCheckpointFromSession } from "../state/onboard-checkpoint-migrate";
+import type { CheckpointPortableRuntimeAuthority } from "../state/onboard-checkpoint-types";
+import { createSession } from "../state/onboard-session";
+import {
+ assertLockedResumeIntentSnapshot,
+ OnboardResumeIntentError,
+ OnboardResumeIntentRaceError,
+ resolveOnboardResumeIntent,
+} from "./resume/portable-resume-intent";
+
+const AUTHORITY: CheckpointPortableRuntimeAuthority = {
+ schemaVersion: 1,
+ kind: "podman",
+ ownership: "current-user",
+ uid: 1000,
+ homeDir: "/home/alice",
+ configHome: "/home/alice/.config",
+ runtimeDir: "/run/user/1000",
+ socketPath: "/run/user/1000/podman/podman.sock",
+};
+
+function rawSession(profile: "default" | "portable" = "portable"): string {
+ const session = createSession({ sessionId: "session-portable-resume" });
+ session.checkpoint = deriveCheckpointFromSession(session, {
+ profile,
+ runtimeAuthority: profile === "portable" ? AUTHORITY : null,
+ });
+ return JSON.stringify(
+ { ...session, checkpoint: serializeCheckpoint(session.checkpoint) },
+ null,
+ 2,
+ );
+}
+
+describe("portable resume intent", () => {
+ const tempDirs: string[] = [];
+
+ afterEach(() => {
+ for (const tempDir of tempDirs) fs.rmSync(tempDir, { recursive: true, force: true });
+ });
+
+ function sessionFile(contents = rawSession()): string {
+ const directory = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-resume-intent-"));
+ tempDirs.push(directory);
+ const file = path.join(directory, "onboard-session.json");
+ fs.writeFileSync(file, contents, { mode: 0o600 });
+ return file;
+ }
+
+ it("reconstructs portable profile intent for plain and matching explicit resume (#9035)", () => {
+ const file = sessionFile();
+ const plain = resolveOnboardResumeIntent({
+ explicitResume: false,
+ fresh: false,
+ explicitProfile: null,
+ sessionFile: file,
+ });
+ const explicit = resolveOnboardResumeIntent({
+ explicitResume: true,
+ fresh: false,
+ explicitProfile: "portable",
+ sessionFile: file,
+ });
+
+ expect(plain).toMatchObject({ effectiveResume: true, snapshot: { profile: "portable" } });
+ expect(explicit.snapshot).toEqual(plain.snapshot);
+ });
+
+ it("rejects an explicit profile conflict before the checkpoint can be mutated (#9035)", () => {
+ const file = sessionFile(rawSession("default"));
+ const before = fs.readFileSync(file, "utf8");
+
+ expect(() =>
+ resolveOnboardResumeIntent({
+ explicitResume: true,
+ fresh: false,
+ explicitProfile: "portable",
+ sessionFile: file,
+ }),
+ ).toThrow(OnboardResumeIntentError);
+ expect(fs.readFileSync(file, "utf8")).toBe(before);
+ });
+
+ it("refuses active schema v1-v3 checkpoints byte-for-byte with --fresh guidance (#9035)", () => {
+ for (const schemaVersion of [1, 2, 3]) {
+ const legacy = JSON.parse(rawSession()) as Record;
+ legacy.checkpoint = { schemaVersion };
+ const file = sessionFile(JSON.stringify(legacy, null, 2));
+ const before = fs.readFileSync(file, "utf8");
+
+ expect(() =>
+ resolveOnboardResumeIntent({
+ explicitResume: true,
+ fresh: false,
+ explicitProfile: null,
+ sessionFile: file,
+ }),
+ ).toThrow(/predates recorded runtime authority.*--fresh/su);
+ expect(fs.readFileSync(file, "utf8")).toBe(before);
+ }
+ });
+
+ it("rejects terminal sessions before portable preparation can run (#9035)", () => {
+ const parsed = JSON.parse(rawSession()) as Record;
+ parsed.status = "complete";
+ parsed.resumable = false;
+ const file = sessionFile(JSON.stringify(parsed, null, 2));
+
+ expect(() =>
+ resolveOnboardResumeIntent({
+ explicitResume: true,
+ fresh: false,
+ explicitProfile: null,
+ sessionFile: file,
+ }),
+ ).toThrow("No resumable onboarding session was found.");
+ });
+
+ it("rejects checkpoint tampering and envelope disagreement (#9035)", () => {
+ const parsed = JSON.parse(rawSession()) as Record;
+ const checkpoint = parsed.checkpoint as Record;
+ checkpoint.unexpected = "tampered";
+ const tampered = sessionFile(JSON.stringify(parsed, null, 2));
+ expect(() =>
+ resolveOnboardResumeIntent({
+ explicitResume: true,
+ fresh: false,
+ explicitProfile: null,
+ sessionFile: tampered,
+ }),
+ ).toThrow(/unreadable/);
+
+ const mismatched = JSON.parse(rawSession()) as Record;
+ mismatched.sessionId = "copied-envelope";
+ const copied = sessionFile(JSON.stringify(mismatched, null, 2));
+ expect(() =>
+ resolveOnboardResumeIntent({
+ explicitResume: true,
+ fresh: false,
+ explicitProfile: null,
+ sessionFile: copied,
+ }),
+ ).toThrow(/unreadable/);
+ });
+
+ it("detects a changed session fingerprint and accepts an unchanged snapshot (#9035)", () => {
+ const file = sessionFile();
+ const intent = resolveOnboardResumeIntent({
+ explicitResume: true,
+ fresh: false,
+ explicitProfile: null,
+ sessionFile: file,
+ });
+ expect(intent.snapshot).not.toBeNull();
+ assertLockedResumeIntentSnapshot(intent.snapshot!, file);
+
+ const changed = JSON.parse(fs.readFileSync(file, "utf8")) as Record;
+ changed.updatedAt = "2026-08-13T21:00:00.000Z";
+ fs.writeFileSync(file, JSON.stringify(changed, null, 2));
+ expect(() => assertLockedResumeIntentSnapshot(intent.snapshot!, file)).toThrow(
+ OnboardResumeIntentRaceError,
+ );
+ });
+});
diff --git a/src/lib/onboard/portable-resume-lock-boundary.test.ts b/src/lib/onboard/portable-resume-lock-boundary.test.ts
new file mode 100644
index 00000000000..3dc017d9794
--- /dev/null
+++ b/src/lib/onboard/portable-resume-lock-boundary.test.ts
@@ -0,0 +1,216 @@
+// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import { spawn } from "node:child_process";
+import { once } from "node:events";
+import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
+
+import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
+
+const originalEnv = { ...process.env };
+const STOP_AFTER_PREPARATION = "stop after observed portable preparation";
+let tempHome: string;
+let configWriteMarker: string;
+let socketActivationMarker: string;
+let preparationObservedLock = false;
+let activeLockFile = "";
+const preparePortableHost = vi.fn((): never => {
+ fs.writeFileSync(configWriteMarker, "prepared", { mode: 0o600 });
+ fs.writeFileSync(socketActivationMarker, "activated", { mode: 0o600 });
+ throw new Error(STOP_AFTER_PREPARATION);
+});
+
+beforeAll(() => {
+ tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-portable-lock-boundary-"));
+});
+
+beforeEach(() => {
+ configWriteMarker = path.join(tempHome, "portable-config-written");
+ socketActivationMarker = path.join(tempHome, "podman-socket-activated");
+ fs.rmSync(configWriteMarker, { force: true });
+ fs.rmSync(socketActivationMarker, { force: true });
+ preparationObservedLock = false;
+ preparePortableHost.mockClear();
+ process.env = {
+ ...originalEnv,
+ HOME: tempHome,
+ NEMOCLAW_GATEWAY_PORT: "19093",
+ NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE: "1",
+ };
+});
+
+afterEach(() => {
+ vi.restoreAllMocks();
+ process.env = { ...originalEnv };
+});
+
+afterAll(() => {
+ fs.rmSync(tempHome, { recursive: true, force: true });
+});
+
+async function loadBoundaryModules() {
+ const command = await import("./command");
+ const session = await import("../state/onboard-session");
+ activeLockFile = session.LOCK_FILE;
+ const onboardModule = (await import("../onboard")) as {
+ onboard(options?: import("./types").OnboardOptions): Promise;
+ onboardSession: typeof import("../state/onboard-session");
+ };
+ const checkpointMigration = await import("../state/onboard-checkpoint-migrate");
+ const resumeIntent = await import("./resume/portable-resume-intent");
+ return { command, onboardModule, session, checkpointMigration, resumeIntent };
+}
+
+function runWithObservedPreparation(
+ onboardModule: { onboard(options?: import("./types").OnboardOptions): Promise },
+ options: import("./command").OnboardCommandOptions,
+): Promise {
+ return onboardModule.onboard({
+ ...options,
+ preparePortableHost: () => {
+ preparationObservedLock = fs.existsSync(activeLockFile);
+ return preparePortableHost();
+ },
+ });
+}
+
+describe("portable resume command lock boundary", () => {
+ it("rejects a losing CLI before portable config writes or socket activation (#9035)", async () => {
+ const { command, onboardModule, session } = await loadBoundaryModules();
+ const childScript = `
+ const fs = require("node:fs");
+ const path = require("node:path");
+ const lockFile = process.argv[1];
+ fs.mkdirSync(path.dirname(lockFile), { recursive: true });
+ const fd = fs.openSync(lockFile, "wx", 0o600);
+ fs.writeSync(fd, JSON.stringify({
+ pid: process.pid,
+ startedAt: new Date().toISOString(),
+ command: "separate nemoclaw onboard process",
+ }));
+ process.stdout.write("locked\\n");
+ setInterval(() => {}, 1000);
+ `;
+ const child = spawn(process.execPath, ["-e", childScript, session.LOCK_FILE], {
+ stdio: ["ignore", "pipe", "inherit"],
+ });
+ await once(child.stdout, "data");
+ vi.spyOn(console, "error").mockImplementation(() => {});
+ vi.spyOn(process, "exit").mockImplementation(((code?: number) => {
+ throw new Error(`exit:${String(code ?? 0)}`);
+ }) as typeof process.exit);
+
+ try {
+ await expect(
+ command.runOnboardCommand({
+ flags: {
+ fresh: true,
+ "experimental-profile": "portable",
+ "yes-i-accept-third-party-software": true,
+ },
+ env: process.env,
+ resolveResumeIntent: () => ({ effectiveResume: false, snapshot: null }),
+ runOnboard: (options) => runWithObservedPreparation(onboardModule, options),
+ }),
+ ).rejects.toThrow("exit:1");
+ expect(preparePortableHost).not.toHaveBeenCalled();
+ expect(fs.existsSync(configWriteMarker)).toBe(false);
+ expect(fs.existsSync(socketActivationMarker)).toBe(false);
+ } finally {
+ const exited = once(child, "exit");
+ child.kill();
+ await exited;
+ fs.rmSync(session.LOCK_FILE, { force: true });
+ }
+ }, 15_000);
+
+ it("releases the first lock before one bounded pre-read retry and preparation (#9035)", async () => {
+ const { command, onboardModule, session, checkpointMigration, resumeIntent } =
+ await loadBoundaryModules();
+ expect(onboardModule.onboardSession.SESSION_FILE).toBe(session.SESSION_FILE);
+ expect(onboardModule.onboardSession.LOCK_FILE).toBe(session.LOCK_FILE);
+ const currentUser = os.userInfo();
+ const authority = {
+ schemaVersion: 1 as const,
+ kind: "podman" as const,
+ ownership: "current-user" as const,
+ uid: currentUser.uid,
+ homeDir: currentUser.homedir,
+ configHome: path.join(currentUser.homedir, ".config"),
+ runtimeDir: `/run/user/${String(currentUser.uid)}`,
+ socketPath: `/run/user/${String(currentUser.uid)}/podman/podman.sock`,
+ };
+ const stored = session.createSession({ sessionId: "portable-lock-race" });
+ stored.status = "failed";
+ stored.resumable = true;
+ stored.checkpoint = checkpointMigration.deriveCheckpointFromSession(stored, {
+ profile: "portable",
+ runtimeAuthority: authority,
+ });
+ session.saveSession(stored);
+
+ let resolutions = 0;
+ const resolvedFingerprints: string[] = [];
+ const resolvedRaw: string[] = [];
+ const afterResolution = [
+ () => {
+ const changed = JSON.parse(fs.readFileSync(session.SESSION_FILE, "utf8")) as Record<
+ string,
+ unknown
+ >;
+ changed.updatedAt = "2026-08-13T21:00:00.000Z";
+ fs.writeFileSync(session.SESSION_FILE, JSON.stringify(changed, null, 2));
+ },
+ () => {},
+ ];
+ const resolveResumeIntent = (options: {
+ explicitResume: boolean;
+ fresh: boolean;
+ explicitProfile: "default" | "portable" | null;
+ }) => {
+ const resolved = resumeIntent.resolveOnboardResumeIntent({
+ ...options,
+ sessionFile: session.SESSION_FILE,
+ });
+ resolutions += 1;
+ resolvedFingerprints.push(resolved.snapshot!.fingerprint);
+ afterResolution[resolutions - 1]!();
+ resolvedRaw.push(fs.readFileSync(session.SESSION_FILE, "utf8"));
+ return resolved;
+ };
+
+ const failure = await command
+ .runOnboardCommand({
+ flags: { resume: true },
+ env: process.env,
+ resolveResumeIntent,
+ loadPortableInferenceDescriptor: async () => null,
+ runOnboard: (options) => runWithObservedPreparation(onboardModule, options),
+ })
+ .then(
+ () => null,
+ (error: unknown) => error,
+ );
+
+ const afterFailure = resumeIntent.resolveOnboardResumeIntent({
+ explicitResume: true,
+ fresh: false,
+ explicitProfile: null,
+ sessionFile: session.SESSION_FILE,
+ });
+ expect(JSON.parse(fs.readFileSync(session.SESSION_FILE, "utf8"))).toEqual(
+ JSON.parse(resolvedRaw.at(-1)!),
+ );
+ expect(afterFailure.snapshot?.fingerprint).toBe(resolvedFingerprints.at(-1));
+ expect(failure).toMatchObject({ message: STOP_AFTER_PREPARATION });
+
+ expect(resolutions).toBe(2);
+ expect(preparePortableHost).toHaveBeenCalledTimes(1);
+ expect(preparationObservedLock).toBe(true);
+ expect(fs.readFileSync(configWriteMarker, "utf8")).toBe("prepared");
+ expect(fs.readFileSync(socketActivationMarker, "utf8")).toBe("activated");
+ expect(fs.existsSync(session.LOCK_FILE)).toBe(false);
+ });
+});
diff --git a/src/lib/onboard/resume-hint.test.ts b/src/lib/onboard/resume-hint.test.ts
index b8593699f12..3aa45c0a4ae 100644
--- a/src/lib/onboard/resume-hint.test.ts
+++ b/src/lib/onboard/resume-hint.test.ts
@@ -23,29 +23,31 @@ describe("onboard resume hint", () => {
expect(text).toContain("--fresh");
});
- it("prints the portable-profile recovery guidance when the portable env is set (#8873)", () => {
+ it("prints portable resume recovery guidance when the portable env is set (#9035)", () => {
const prev = process.env[portableEnv];
process.env[portableEnv] = "portable";
try {
const lines: string[] = [];
printOnboardResumeHint(undefined, (message) => lines.push(message));
const text = lines.join("\n");
- expect(text).toContain("onboard --experimental-profile portable");
- expect(text).not.toContain("--resume");
+ expect(text).toContain("onboard --resume");
+ expect(text).toContain("restored from the checkpoint");
+ expect(text).toContain("onboard --experimental-profile portable --fresh");
} finally {
prev === undefined ? delete process.env[portableEnv] : (process.env[portableEnv] = prev);
}
});
- it("uses an explicit portable profile after the environment is restored (#8873)", () => {
+ it("prints portable resume guidance after the environment is restored (#9035)", () => {
const prev = process.env[portableEnv];
delete process.env[portableEnv];
try {
const lines: string[] = [];
printOnboardResumeHint(true, (message) => lines.push(message));
const text = lines.join("\n");
- expect(text).toContain("onboard --experimental-profile portable");
- expect(text).not.toContain("--resume");
+ expect(text).toContain("onboard --resume");
+ expect(text).toContain("restored from the checkpoint");
+ expect(text).toContain("onboard --experimental-profile portable --fresh");
} finally {
prev === undefined ? delete process.env[portableEnv] : (process.env[portableEnv] = prev);
}
diff --git a/src/lib/onboard/resume-hint.ts b/src/lib/onboard/resume-hint.ts
index 242838f5a1a..e19a27e2f23 100644
--- a/src/lib/onboard/resume-hint.ts
+++ b/src/lib/onboard/resume-hint.ts
@@ -4,10 +4,16 @@
import { CLI_NAME } from "../cli/branding";
import { isPortableExperimentalProfile } from "./experimental/portable-profile";
-export function onboardRecoveryCommand(portable = isPortableExperimentalProfile()): string {
+export function onboardResumeRecoveryCommand(): string {
+ return `${CLI_NAME} onboard --resume`;
+}
+
+export function onboardFreshRecoveryCommand(
+ portable = isPortableExperimentalProfile(),
+): string {
return portable
- ? `${CLI_NAME} onboard --experimental-profile portable`
- : `${CLI_NAME} onboard --resume`;
+ ? `${CLI_NAME} onboard --experimental-profile portable --fresh`
+ : `${CLI_NAME} onboard --fresh`;
}
// Whether an onboard `--resume` recovery hint has already been emitted this run.
@@ -24,9 +30,7 @@ let resumeHintShown = false;
* never mention how to resume, so users assume a failed run requires a full
* reinstall (#6003). The incomplete-exit handler calls this as a catch-all when
* a resumable step was in progress, covering every exit that does not already
- * print its own recovery guidance. The recovery command adapts to whether the
- * run selected the portable experimental profile (which forces `--fresh` and
- * rejects `--resume`) (#8873).
+ * print its own recovery guidance.
*/
export function printOnboardResumeHint(
portable = isPortableExperimentalProfile(),
@@ -36,11 +40,14 @@ export function printOnboardResumeHint(
resumeHintShown = true;
log("");
if (portable) {
- log(" Onboarding did not finish. Portable onboarding always starts fresh; rerun:");
- log(` ${onboardRecoveryCommand(portable)}`);
+ log(" Onboarding did not finish. Resume from the step that failed with:");
+ log(` ${onboardResumeRecoveryCommand()}`);
+ log(" The portable profile and rootless Podman authority are restored from the checkpoint.");
+ log(" To start over instead, run:");
+ log(` ${onboardFreshRecoveryCommand(true)}`);
} else {
log(" Onboarding did not finish. Resume from the step that failed with:");
- log(` ${onboardRecoveryCommand(portable)}`);
+ log(` ${onboardResumeRecoveryCommand()}`);
log(" Completed steps are skipped; pass --fresh instead to start over.");
}
}
diff --git a/src/lib/onboard/resume/locked-runtime.test.ts b/src/lib/onboard/resume/locked-runtime.test.ts
new file mode 100644
index 00000000000..0675860e7a3
--- /dev/null
+++ b/src/lib/onboard/resume/locked-runtime.test.ts
@@ -0,0 +1,48 @@
+// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import { describe, expect, it, vi } from "vitest";
+
+import { decisionUnset } from "../../state/onboard-checkpoint-decision";
+import {
+ CHECKPOINT_SCHEMA_VERSION,
+ type OnboardCheckpoint,
+} from "../../state/onboard-checkpoint-types";
+import { prepare } from "./locked-runtime";
+
+const portableCheckpointWithoutAuthority: OnboardCheckpoint = {
+ schemaVersion: CHECKPOINT_SCHEMA_VERSION,
+ profile: { kind: "selected", value: "portable" },
+ runtimeAuthority: { kind: "unset" },
+ sessionId: "portable-missing-authority",
+ machineState: "preflight",
+ updatedAt: "2026-08-13T20:00:00.000Z",
+ sandboxIdentity: decisionUnset(),
+ webSearch: decisionUnset(),
+ messaging: decisionUnset(),
+ resourceProfile: decisionUnset(),
+ gatewayAuthority: decisionUnset(),
+ effectGroups: {},
+ bindings: { credentialEnvs: [], registeredProviders: [] },
+ sandboxRecreate: null,
+};
+
+describe("locked onboarding runtime preparation", () => {
+ it("rejects portable resume without selected authority before host preparation (#9035)", async () => {
+ const preparePortableHost = vi.fn();
+
+ await expect(
+ prepare(
+ {
+ resume: true,
+ experimentalProfile: "portable",
+ preparePortableHost,
+ },
+ true,
+ true,
+ () => ({ checkpoint: portableCheckpointWithoutAuthority }),
+ ),
+ ).rejects.toThrow(/requires recorded runtime authority.*--fresh/su);
+ expect(preparePortableHost).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/lib/onboard/resume/locked-runtime.ts b/src/lib/onboard/resume/locked-runtime.ts
new file mode 100644
index 00000000000..cf9618366b1
--- /dev/null
+++ b/src/lib/onboard/resume/locked-runtime.ts
@@ -0,0 +1,149 @@
+// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import type {
+ CheckpointOnboardProfile,
+ CheckpointPortableRuntimeAuthority,
+ OnboardCheckpoint,
+} from "../../state/onboard-checkpoint-types";
+import {
+ assertLockedResumeIntentSnapshot,
+ createDefaultResumeProfileEnvironmentScope,
+ createPortableOnboardEnvironmentScope,
+ preparePortableExperimentalHost,
+ type PortableOnboardEnvironmentScope,
+} from "../session-bootstrap";
+import type { OnboardOptions } from "../types";
+import { ensureUsageNoticeConsent } from "../usage-notice";
+
+export interface LockedOnboardRuntimePreparation {
+ readonly checkpointProfile: CheckpointOnboardProfile;
+ readonly environmentScope: PortableOnboardEnvironmentScope | null;
+ readonly preparedPortableAuthority: CheckpointPortableRuntimeAuthority | null;
+}
+
+async function ensureNoticeAccepted(
+ options: OnboardOptions,
+ nonInteractive: boolean,
+): Promise {
+ const accepted = await ensureUsageNoticeConsent({
+ nonInteractive,
+ acceptedByFlag: options.acceptThirdPartySoftware === true,
+ writeLine: console.error,
+ });
+ if (!accepted) process.exit(1);
+}
+
+function resolveCheckpointProfile(
+ options: OnboardOptions,
+ resume: boolean,
+ loadSession: () => { readonly checkpoint?: OnboardCheckpoint | null } | null,
+): {
+ checkpointProfile: CheckpointOnboardProfile;
+ expectedPortableAuthority: CheckpointPortableRuntimeAuthority | null;
+} {
+ if (resume && options.resumeIntentSnapshot) {
+ assertLockedResumeIntentSnapshot(options.resumeIntentSnapshot);
+ }
+ const storedCheckpoint = (resume ? loadSession() : null)?.checkpoint ?? null;
+ if (resume && !storedCheckpoint) {
+ throw new Error(
+ "This onboarding checkpoint predates recorded runtime authority and cannot be resumed safely. Start a new onboarding attempt with the `--fresh` option.",
+ );
+ }
+ const checkpointProfile =
+ storedCheckpoint?.profile.value ??
+ (options.experimentalProfile === "portable" ? "portable" : "default");
+ if (
+ resume &&
+ options.experimentalProfile !== null &&
+ options.experimentalProfile !== undefined &&
+ options.experimentalProfile !== checkpointProfile
+ ) {
+ throw new Error(
+ `The requested onboarding profile '${options.experimentalProfile}' does not match checkpoint profile '${checkpointProfile}'.`,
+ );
+ }
+ const expectedPortableAuthority =
+ storedCheckpoint?.runtimeAuthority.kind === "selected"
+ ? storedCheckpoint.runtimeAuthority.value
+ : null;
+ if (resume && checkpointProfile === "portable" && !expectedPortableAuthority) {
+ throw new Error(
+ "Portable onboarding resume requires recorded runtime authority. Start a new onboarding attempt with the `--fresh` option.",
+ );
+ }
+ if (resume && checkpointProfile === "portable" && !options.resumeIntentSnapshot) {
+ throw new Error("Portable onboarding resume requires a validated checkpoint snapshot.");
+ }
+ return { checkpointProfile, expectedPortableAuthority };
+}
+
+function prepareEnvironment(
+ options: OnboardOptions,
+ resume: boolean,
+ checkpointProfile: CheckpointOnboardProfile,
+ expectedPortableAuthority: CheckpointPortableRuntimeAuthority | null,
+): {
+ environmentScope: PortableOnboardEnvironmentScope | null;
+ preparedPortableAuthority: CheckpointPortableRuntimeAuthority | null;
+} {
+ if (checkpointProfile !== "portable") {
+ return {
+ environmentScope: resume ? createDefaultResumeProfileEnvironmentScope(process.env) : null,
+ preparedPortableAuthority: null,
+ };
+ }
+ const environmentScope = createPortableOnboardEnvironmentScope(
+ process.env,
+ options.portableInferenceActivation ?? null,
+ { resume },
+ );
+ try {
+ const prepared = (options.preparePortableHost ?? preparePortableExperimentalHost)(
+ process.env,
+ {},
+ expectedPortableAuthority,
+ );
+ if (!prepared) throw new Error("Portable runtime preparation did not run.");
+ environmentScope.installRuntime({
+ containersConf: prepared.containersConf,
+ socketPath: prepared.authority.socketPath,
+ });
+ return { environmentScope, preparedPortableAuthority: prepared.authority };
+ } catch (error) {
+ environmentScope.restore();
+ throw error;
+ }
+}
+
+export async function prepare(
+ options: OnboardOptions,
+ resume: boolean,
+ nonInteractive: boolean,
+ loadSession: () => { readonly checkpoint?: OnboardCheckpoint | null } | null,
+): Promise {
+ const { checkpointProfile, expectedPortableAuthority } = resolveCheckpointProfile(
+ options,
+ resume,
+ loadSession,
+ );
+ let environmentScope: PortableOnboardEnvironmentScope | null = null;
+ try {
+ // Fresh runs obtain consent before bounded host preparation writes. Resumes
+ // requalify recorded authority first, before any other write.
+ if (!resume) await ensureNoticeAccepted(options, nonInteractive);
+ const prepared = prepareEnvironment(
+ options,
+ resume,
+ checkpointProfile,
+ expectedPortableAuthority,
+ );
+ environmentScope = prepared.environmentScope;
+ if (resume) await ensureNoticeAccepted(options, nonInteractive);
+ return { checkpointProfile, ...prepared };
+ } catch (error) {
+ environmentScope?.restore();
+ throw error;
+ }
+}
diff --git a/src/lib/onboard/resume/portable-resume-intent.ts b/src/lib/onboard/resume/portable-resume-intent.ts
new file mode 100644
index 00000000000..f7f84b48402
--- /dev/null
+++ b/src/lib/onboard/resume/portable-resume-intent.ts
@@ -0,0 +1,169 @@
+// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import { createHash } from "node:crypto";
+import fs from "node:fs";
+
+import { inspectCheckpoint } from "../../state/onboard-checkpoint";
+import type { CheckpointOnboardProfile } from "../../state/onboard-checkpoint-types";
+
+function isObjectRecord(value: unknown): value is Record {
+ return typeof value === "object" && value !== null && !Array.isArray(value);
+}
+
+export interface OnboardResumeIntentSnapshot {
+ readonly fingerprint: string;
+ readonly sessionId: string;
+ readonly checkpointUpdatedAt: string;
+ readonly machineRevision: number;
+ readonly profile: CheckpointOnboardProfile;
+}
+
+export class OnboardResumeIntentError extends Error {
+ constructor(message: string) {
+ super(message);
+ this.name = "OnboardResumeIntentError";
+ }
+}
+
+export class OnboardResumeIntentRaceError extends Error {
+ readonly nemoclawOnboardResumeIntentRace = true;
+
+ constructor() {
+ super("The onboarding checkpoint changed while resume acquired its lock.");
+ this.name = "OnboardResumeIntentRaceError";
+ }
+}
+
+export function isOnboardResumeIntentRaceError(
+ error: unknown,
+): error is OnboardResumeIntentRaceError {
+ return (
+ error instanceof OnboardResumeIntentRaceError ||
+ (typeof error === "object" &&
+ error !== null &&
+ Reflect.get(error, "nemoclawOnboardResumeIntentRace") === true)
+ );
+}
+
+export interface ResolvedOnboardResumeIntent {
+ readonly effectiveResume: boolean;
+ readonly snapshot: OnboardResumeIntentSnapshot | null;
+}
+
+function fingerprint(raw: string): string {
+ return createHash("sha256").update(raw, "utf8").digest("hex");
+}
+
+function sessionFingerprint(value: Record): string {
+ return fingerprint(JSON.stringify(value));
+}
+
+function readRawSession(filePath: string): { value: Record } | null {
+ if (!fs.existsSync(filePath)) return null;
+ let raw: string;
+ let value: unknown;
+ try {
+ raw = fs.readFileSync(filePath, "utf8");
+ value = JSON.parse(raw);
+ } catch {
+ throw new OnboardResumeIntentError(
+ "The onboarding resume checkpoint is unreadable and cannot be safely continued.",
+ );
+ }
+ if (!isObjectRecord(value)) {
+ throw new OnboardResumeIntentError(
+ "The onboarding resume checkpoint is unreadable and cannot be safely continued.",
+ );
+ }
+ return { value };
+}
+
+export function resolveOnboardResumeIntent(options: {
+ readonly explicitResume: boolean;
+ readonly fresh: boolean;
+ readonly explicitProfile: CheckpointOnboardProfile | null;
+ readonly sessionFile: string;
+}): ResolvedOnboardResumeIntent {
+ const stored = readRawSession(options.sessionFile);
+ const status = stored?.value.status;
+ const effectiveResume = options.explicitResume || (!options.fresh && status === "in_progress");
+ if (!effectiveResume) return { effectiveResume: false, snapshot: null };
+ if (!stored) {
+ throw new OnboardResumeIntentError("No resumable onboarding session was found.");
+ }
+ if (
+ stored.value.resumable === false ||
+ (stored.value.status !== "in_progress" && stored.value.status !== "failed")
+ ) {
+ throw new OnboardResumeIntentError("No resumable onboarding session was found.");
+ }
+ const inspected = inspectCheckpoint(stored.value.checkpoint);
+ if (inspected.status === "legacy" || inspected.status === "none") {
+ throw new OnboardResumeIntentError(
+ "This onboarding checkpoint predates recorded runtime authority and cannot be resumed safely. Start a new onboarding attempt with the `--fresh` option.",
+ );
+ }
+ if (inspected.status === "unsupported_future") {
+ throw new OnboardResumeIntentError(
+ `This onboarding checkpoint uses unsupported schema v${String(inspected.foundVersion)}. Upgrade the CLI or start a new onboarding attempt with the \`--fresh\` option.`,
+ );
+ }
+ if (inspected.status !== "loaded") {
+ throw new OnboardResumeIntentError(
+ "The onboarding resume checkpoint is unreadable and cannot be safely continued.",
+ );
+ }
+ const sessionId = typeof stored.value.sessionId === "string" ? stored.value.sessionId : "";
+ const machine = isObjectRecord(stored.value.machine) ? stored.value.machine : null;
+ const machineRevision = machine?.revision;
+ if (
+ sessionId === "" ||
+ sessionId !== inspected.checkpoint.sessionId ||
+ machine?.state !== inspected.checkpoint.machineState ||
+ !Number.isSafeInteger(machineRevision) ||
+ Number(machineRevision) < 0
+ ) {
+ throw new OnboardResumeIntentError(
+ "The onboarding resume checkpoint is unreadable and cannot be safely continued.",
+ );
+ }
+ const profile = inspected.checkpoint.profile.value;
+ if (options.explicitProfile && options.explicitProfile !== profile) {
+ throw new OnboardResumeIntentError(
+ `The requested onboarding profile '${options.explicitProfile}' does not match checkpoint profile '${profile}'.`,
+ );
+ }
+ return {
+ effectiveResume: true,
+ snapshot: {
+ fingerprint: sessionFingerprint(stored.value),
+ sessionId,
+ checkpointUpdatedAt: inspected.checkpoint.updatedAt,
+ machineRevision: Number(machineRevision),
+ profile,
+ },
+ };
+}
+
+export function assertLockedResumeIntentSnapshot(
+ expected: OnboardResumeIntentSnapshot,
+ sessionFile: string,
+): void {
+ const stored = readRawSession(sessionFile);
+ if (!stored || sessionFingerprint(stored.value) !== expected.fingerprint) {
+ throw new OnboardResumeIntentRaceError();
+ }
+ const inspected = inspectCheckpoint(stored.value.checkpoint);
+ const machine = isObjectRecord(stored.value.machine) ? stored.value.machine : null;
+ if (
+ inspected.status !== "loaded" ||
+ inspected.checkpoint.sessionId !== expected.sessionId ||
+ inspected.checkpoint.updatedAt !== expected.checkpointUpdatedAt ||
+ inspected.checkpoint.profile.value !== expected.profile ||
+ machine?.state !== inspected.checkpoint.machineState ||
+ machine?.revision !== expected.machineRevision
+ ) {
+ throw new OnboardResumeIntentRaceError();
+ }
+}
diff --git a/src/lib/onboard/session-bootstrap.test.ts b/src/lib/onboard/session-bootstrap.test.ts
index 164dc5641c5..7fb32ee15c7 100644
--- a/src/lib/onboard/session-bootstrap.test.ts
+++ b/src/lib/onboard/session-bootstrap.test.ts
@@ -124,6 +124,50 @@ describe("prepareOnboardSession", () => {
expect(getSession()?.sessionId).not.toBe("old-session");
});
+ it("publishes portable runtime intent in the first atomic session envelope", async () => {
+ const { deps } = createDeps();
+ const authority = {
+ schemaVersion: 1 as const,
+ kind: "podman" as const,
+ ownership: "current-user" as const,
+ uid: 1000,
+ homeDir: "/home/alice",
+ configHome: "/home/alice/.config",
+ runtimeDir: "/run/user/1000",
+ socketPath: "/run/user/1000/podman/podman.sock",
+ };
+
+ const result = await prepareOnboardSession(
+ {
+ resume: false,
+ fresh: false,
+ requestedFromDockerfile: null,
+ requestedSandboxName: null,
+ cannotPrompt: true,
+ nonInteractive: true,
+ checkpointProfile: "portable",
+ portableRuntimeAuthority: authority,
+ },
+ deps,
+ );
+
+ expect(deps.createSession).toHaveBeenCalledTimes(1);
+ expect(deps.saveSession).toHaveBeenCalledTimes(1);
+ expect(deps.saveSession).toHaveBeenCalledWith(
+ expect.objectContaining({
+ checkpoint: expect.objectContaining({
+ schemaVersion: 4,
+ profile: { kind: "selected", value: "portable" },
+ runtimeAuthority: { kind: "selected", value: authority },
+ }),
+ }),
+ );
+ expect(result.session?.checkpoint?.runtimeAuthority).toEqual({
+ kind: "selected",
+ value: authority,
+ });
+ });
+
it("checkpoints exact serving profile provenance before fresh onboarding effects (#8246)", async () => {
const { deps } = createDeps();
const result = await prepareOnboardSession(
@@ -595,6 +639,8 @@ describe("prepareOnboardSession", () => {
const session = createSession({ agent: "hermes", sandboxName: null });
const checkpoint: OnboardCheckpoint = {
schemaVersion: CHECKPOINT_SCHEMA_VERSION,
+ profile: { kind: "selected", value: "default" },
+ runtimeAuthority: { kind: "unset" },
sessionId: session.sessionId,
machineState: "sandbox",
updatedAt: "2026-01-01T00:00:00.000Z",
@@ -647,6 +693,8 @@ describe("prepareOnboardSession", () => {
});
session.checkpoint = {
schemaVersion: CHECKPOINT_SCHEMA_VERSION,
+ profile: { kind: "selected", value: "default" },
+ runtimeAuthority: { kind: "unset" },
sessionId: session.sessionId,
machineState: "sandbox",
updatedAt: "2026-01-01T00:00:00.000Z",
diff --git a/src/lib/onboard/session-bootstrap.ts b/src/lib/onboard/session-bootstrap.ts
index dd7ec78727b..a4adb147253 100644
--- a/src/lib/onboard/session-bootstrap.ts
+++ b/src/lib/onboard/session-bootstrap.ts
@@ -2,15 +2,41 @@
// SPDX-License-Identifier: Apache-2.0
import type { ServingProfileProvenance } from "../inference/serving/types";
+import { redactSensitiveText } from "../security/redact";
import { isDecisionSelected } from "../state/onboard-checkpoint-decision";
-import { loadResumeCheckpoint } from "../state/onboard-checkpoint-migrate";
-import type { CheckpointLoadResult } from "../state/onboard-checkpoint-types";
+import {
+ deriveCheckpointFromSession,
+ loadResumeCheckpoint,
+ ONBOARD_CHECKPOINT_SESSION_FILE,
+} from "../state/onboard-checkpoint-migrate";
+import type {
+ CheckpointLoadResult,
+ CheckpointOnboardProfile,
+ CheckpointPortableRuntimeAuthority,
+} from "../state/onboard-checkpoint-types";
import type { Session } from "../state/onboard-session";
-import { DEFAULT_TOOL_DISCLOSURE, type ToolDisclosure } from "../tool-disclosure";
+import {
+ DEFAULT_TOOL_DISCLOSURE,
+ TOOL_DISCLOSURE_ENV,
+ type ToolDisclosure,
+} from "../tool-disclosure";
import { recordCheckpointSandboxIdentity } from "./checkpoint-record";
import { checkpointProvesSandboxStepComplete } from "./checkpoint-replay";
+import { EXPERIMENTAL_PROFILE_ENV } from "./docker-driver-platform";
+import type { PortableInferenceActivation } from "./experimental/portable-inference-descriptor";
import type { ResumeConfigConflict } from "./resume-config";
import type { StationExpressResumeIntent } from "./station-express-resume";
+import {
+ assertLockedResumeIntentSnapshot as assertLockedResumeIntentSnapshotAtPath,
+ isOnboardResumeIntentRaceError,
+ OnboardResumeIntentError,
+ OnboardResumeIntentRaceError,
+ resolveOnboardResumeIntent as resolveOnboardResumeIntentAtPath,
+ type OnboardResumeIntentSnapshot,
+ type ResolvedOnboardResumeIntent,
+} from "./resume/portable-resume-intent";
+
+export { preparePortableExperimentalHost } from "./experimental/portable-host-preparation";
export {
beginHostMountScope,
@@ -18,6 +44,211 @@ export {
reportReadOnlyHostMounts,
verifyReadOnlyHostMountSources,
} from "./host-mount";
+export {
+ isOnboardResumeIntentRaceError,
+ OnboardResumeIntentError,
+ OnboardResumeIntentRaceError,
+ type OnboardResumeIntentSnapshot,
+ type ResolvedOnboardResumeIntent,
+};
+
+export function resolveOnboardResumeIntent(options: {
+ readonly explicitResume: boolean;
+ readonly fresh: boolean;
+ readonly explicitProfile: CheckpointOnboardProfile | null;
+ readonly sessionFile?: string;
+}): ResolvedOnboardResumeIntent {
+ return resolveOnboardResumeIntentAtPath({
+ ...options,
+ sessionFile: options.sessionFile ?? ONBOARD_CHECKPOINT_SESSION_FILE,
+ });
+}
+
+export function assertLockedResumeIntentSnapshot(
+ expected: OnboardResumeIntentSnapshot,
+ sessionFile: string = ONBOARD_CHECKPOINT_SESSION_FILE,
+): void {
+ assertLockedResumeIntentSnapshotAtPath(expected, sessionFile);
+}
+
+export const PORTABLE_RUNTIME_ENV_KEYS = [
+ EXPERIMENTAL_PROFILE_ENV,
+ "DOCKER_HOST",
+ "DOCKER_CONTEXT",
+ "DOCKER_CONFIG",
+ "DOCKER_TLS",
+ "DOCKER_TLS_VERIFY",
+ "DOCKER_CERT_PATH",
+ "XDG_CONFIG_HOME",
+ "CONTAINERS_CONF",
+ "NETAVARK_FW",
+ "CONTAINER_HOST",
+ "CONTAINER_CONNECTION",
+ "CONTAINER_SSHKEY",
+] as const;
+
+const PORTABLE_DEFAULT_ENV_KEYS = [
+ TOOL_DISCLOSURE_ENV,
+ "NEMOCLAW_PROVIDER",
+ "NEMOCLAW_MODEL",
+ "NEMOCLAW_ENDPOINT_URL",
+ "NEMOCLAW_PREFERRED_API",
+ "NEMOCLAW_OLLAMA_NO_AUTOSTART",
+ "NEMOCLAW_POLICY_MODE",
+ "NEMOCLAW_POLICY_PRESETS",
+ "NEMOCLAW_POLICY_TIER",
+] as const;
+
+const PORTABLE_OWNED_ENV_KEYS = [
+ ...PORTABLE_RUNTIME_ENV_KEYS,
+ ...PORTABLE_DEFAULT_ENV_KEYS,
+] as const;
+
+interface PreviousEnvironmentValue {
+ readonly present: boolean;
+ readonly value: string | undefined;
+}
+
+export interface PortableOnboardEnvironmentScope {
+ readonly env: NodeJS.ProcessEnv;
+ installRuntime(input: { containersConf: string; socketPath: string }): void;
+ restore(): void;
+}
+
+export function createDefaultResumeProfileEnvironmentScope(
+ env: NodeJS.ProcessEnv,
+): PortableOnboardEnvironmentScope {
+ const present = Object.prototype.hasOwnProperty.call(env, EXPERIMENTAL_PROFILE_ENV);
+ const value = env[EXPERIMENTAL_PROFILE_ENV];
+ delete env[EXPERIMENTAL_PROFILE_ENV];
+ let restored = false;
+ return {
+ env,
+ installRuntime() {
+ throw new Error("Default onboarding resume cannot install portable runtime authority.");
+ },
+ restore() {
+ if (restored) return;
+ restored = true;
+ if (present) env[EXPERIMENTAL_PROFILE_ENV] = value ?? "";
+ else delete env[EXPERIMENTAL_PROFILE_ENV];
+ },
+ };
+}
+
+const ONBOARD_DEFERRED_EXIT_ERROR = Symbol.for("nemoclaw.onboard.deferred-exit-error");
+
+export class OnboardDeferredExitError extends Error {
+ readonly [ONBOARD_DEFERRED_EXIT_ERROR] = true;
+ readonly code: number;
+
+ constructor(code: number) {
+ super(`Onboarding requested exit ${String(code)}.`);
+ this.name = "OnboardDeferredExitError";
+ this.code = code;
+ }
+}
+
+export function isOnboardDeferredExitError(error: unknown): error is OnboardDeferredExitError {
+ const candidate = error as
+ | (Error & { code?: unknown; [ONBOARD_DEFERRED_EXIT_ERROR]?: unknown })
+ | null;
+ return (
+ candidate instanceof Error &&
+ candidate[ONBOARD_DEFERRED_EXIT_ERROR] === true &&
+ candidate.name === "OnboardDeferredExitError" &&
+ typeof candidate.code === "number" &&
+ Number.isInteger(candidate.code)
+ );
+}
+
+interface DeferredExitOptions {
+ readonly deferProcessExit?: boolean;
+}
+
+export function wrapOnboardDeferredExit(
+ run: (options?: TOptions) => Promise,
+): (options?: TOptions) => Promise {
+ return async (options?: TOptions): Promise => {
+ const resolvedOptions = options ?? ({} as TOptions);
+ const originalProcessExit = process.exit;
+ let deferredExit: OnboardDeferredExitError | null = null;
+ process.exit = ((code?: number): never => {
+ throw new OnboardDeferredExitError(code ?? 0);
+ }) as typeof process.exit;
+ try {
+ await run(resolvedOptions);
+ } catch (error) {
+ if (!isOnboardDeferredExitError(error)) throw error;
+ deferredExit = error;
+ } finally {
+ process.exit = originalProcessExit;
+ }
+ if (!deferredExit) return;
+ if (resolvedOptions.deferProcessExit === true) throw deferredExit;
+ originalProcessExit(deferredExit.code);
+ };
+}
+
+export function redactOnboardDiagnosticText(message: string): string {
+ return redactSensitiveText(message) ?? "";
+}
+
+export function createPortableOnboardEnvironmentScope(
+ env: NodeJS.ProcessEnv,
+ activation: PortableInferenceActivation | null,
+ options: { readonly resume?: boolean } = {},
+): PortableOnboardEnvironmentScope {
+ const previous = new Map();
+ for (const key of PORTABLE_OWNED_ENV_KEYS) {
+ previous.set(key, {
+ present: Object.prototype.hasOwnProperty.call(env, key),
+ value: env[key],
+ });
+ }
+ for (const key of PORTABLE_OWNED_ENV_KEYS) delete env[key];
+ env[EXPERIMENTAL_PROFILE_ENV] = "portable";
+ env.NEMOCLAW_OLLAMA_NO_AUTOSTART = "1";
+ if (activation) {
+ env.NEMOCLAW_PROVIDER = "custom";
+ env.NEMOCLAW_MODEL = activation.model;
+ env.NEMOCLAW_ENDPOINT_URL = activation.baseUrl;
+ env.NEMOCLAW_PREFERRED_API = "openai-completions";
+ }
+ if (!options.resume) {
+ env[TOOL_DISCLOSURE_ENV] = "direct";
+ env.NEMOCLAW_PROVIDER = activation ? "custom" : "ollama";
+ env.NEMOCLAW_MODEL = activation?.model ?? "qwen3-vl:4b";
+ env.NEMOCLAW_POLICY_MODE = "custom";
+ env.NEMOCLAW_POLICY_PRESETS =
+ previous.get("NEMOCLAW_POLICY_PRESETS")?.value ?? "personal-open-internet";
+ env.NEMOCLAW_POLICY_TIER = "personal";
+ } else {
+ const requestedPolicyPresets = previous.get("NEMOCLAW_POLICY_PRESETS")?.value?.trim();
+ if (requestedPolicyPresets) {
+ env.NEMOCLAW_POLICY_MODE = "custom";
+ env.NEMOCLAW_POLICY_PRESETS = requestedPolicyPresets;
+ }
+ }
+
+ let restored = false;
+ return {
+ env,
+ installRuntime({ containersConf, socketPath }) {
+ env.NETAVARK_FW = "iptables";
+ env.CONTAINERS_CONF = containersConf;
+ env.DOCKER_HOST = `unix://${socketPath}`;
+ },
+ restore() {
+ if (restored) return;
+ restored = true;
+ for (const [key, value] of previous) {
+ if (value.present) env[key] = value.value ?? "";
+ else delete env[key];
+ }
+ },
+ };
+}
export interface OnboardSessionBootstrapInput {
resume: boolean;
@@ -34,6 +265,8 @@ export interface OnboardSessionBootstrapInput {
stationExpressIntent?: StationExpressResumeIntent | null;
requestedHostMounts?: readonly import("../state/registry/types").SandboxHostMount[];
servingProfileProvenance?: ServingProfileProvenance | null;
+ checkpointProfile?: CheckpointOnboardProfile;
+ portableRuntimeAuthority?: CheckpointPortableRuntimeAuthority | null;
}
export interface OnboardSessionBootstrapDeps {
@@ -132,6 +365,14 @@ function reportCorruptResumeCheckpoint(deps: OnboardSessionBootstrapDeps): never
deps.exitProcess(1);
}
+function reportLegacyResumeCheckpoint(deps: OnboardSessionBootstrapDeps): never {
+ deps.error(
+ " This onboarding checkpoint predates recorded runtime authority and cannot be resumed safely.",
+ );
+ deps.error(` Start a new attempt: ${deps.cliName()} onboard --fresh`);
+ deps.exitProcess(1);
+}
+
function guardResumeCheckpoint(deps: OnboardSessionBootstrapDeps): void {
const result = deps.resolveResumeCheckpoint();
if (result?.status === "unsupported_future") {
@@ -140,12 +381,8 @@ function guardResumeCheckpoint(deps: OnboardSessionBootstrapDeps): void {
if (result?.status === "corrupt") {
reportCorruptResumeCheckpoint(deps);
}
- if (result?.status === "migrated") {
- const migratedCheckpoint = result.checkpoint;
- deps.updateSession((current) => {
- current.checkpoint = migratedCheckpoint;
- return current;
- });
+ if (result?.status === "legacy") {
+ reportLegacyResumeCheckpoint(deps);
}
}
@@ -290,24 +527,27 @@ function prepareFreshSession(
const fromDockerfile = input.requestedFromDockerfile
? deps.resolvePath(input.requestedFromDockerfile)
: null;
- const session = deps.saveSession(
- deps.createSession({
- mode: mode(input.nonInteractive),
- toolDisclosure: input.requestedToolDisclosure ?? DEFAULT_TOOL_DISCLOSURE,
- observabilityEnabled: input.requestedObservabilityEnabled === true,
- observabilityRequestedExplicitly: typeof input.requestedObservabilityEnabled === "boolean",
- stationExpressIntent: input.stationExpressIntent ?? null,
- servingProfileProvenance: input.servingProfileProvenance ?? null,
- metadata: {
- gatewayName: "nemoclaw",
- fromDockerfile: fromDockerfile || null,
- ...(input.requestedHostMounts && input.requestedHostMounts.length > 0
- ? { hostMounts: input.requestedHostMounts.map((mount) => ({ ...mount })) }
- : {}),
- },
- }),
- );
- return { session, fromDockerfile };
+ const session = deps.createSession({
+ mode: mode(input.nonInteractive),
+ toolDisclosure: input.requestedToolDisclosure ?? DEFAULT_TOOL_DISCLOSURE,
+ observabilityEnabled: input.requestedObservabilityEnabled === true,
+ observabilityRequestedExplicitly: typeof input.requestedObservabilityEnabled === "boolean",
+ stationExpressIntent: input.stationExpressIntent ?? null,
+ servingProfileProvenance: input.servingProfileProvenance ?? null,
+ metadata: {
+ gatewayName: "nemoclaw",
+ fromDockerfile: fromDockerfile || null,
+ ...(input.requestedHostMounts && input.requestedHostMounts.length > 0
+ ? { hostMounts: input.requestedHostMounts.map((mount) => ({ ...mount })) }
+ : {}),
+ },
+ });
+ session.checkpoint = deriveCheckpointFromSession(session, {
+ profile: input.checkpointProfile ?? "default",
+ runtimeAuthority: input.portableRuntimeAuthority ?? null,
+ });
+ const savedSession = deps.saveSession(session);
+ return { session: savedSession, fromDockerfile };
}
export async function prepareOnboardSession(
diff --git a/src/lib/onboard/session-recovery.ts b/src/lib/onboard/session-recovery.ts
index 713a5089874..24e7e98cc71 100644
--- a/src/lib/onboard/session-recovery.ts
+++ b/src/lib/onboard/session-recovery.ts
@@ -4,6 +4,7 @@
import {
createSessionRecoveryReceiptId,
MACHINE_SNAPSHOT_VERSION,
+ syncCheckpointMachineState,
type Session,
} from "../state/onboard-session";
import { isTerminalOnboardMachineState } from "./machine/transitions";
@@ -126,6 +127,7 @@ export function applySessionRecovery(
revision,
},
};
+ syncCheckpointMachineState(session, plan.entry, appliedAt);
}
return plan;
}
diff --git a/src/lib/onboard/types.ts b/src/lib/onboard/types.ts
index eacf140d74a..c6254404187 100644
--- a/src/lib/onboard/types.ts
+++ b/src/lib/onboard/types.ts
@@ -105,6 +105,8 @@ export type OnboardOptions = {
targetGatewayPort?: number | null;
/** Internal rebuild handoff: the outer destructive lifecycle owns the onboard lock. */
onboardLockAlreadyHeld?: boolean;
+ /** Internal command handoff: propagate an exit request after onboarding restores its scopes. */
+ deferProcessExit?: boolean;
/** Internal rebuild handoff: target fingerprint of the journal opened before deletion. */
recreateJournalTargetIntentFingerprint?: string | null;
/** Internal one-shot handoff for a prevalidated managed DCode replacement. */
@@ -151,6 +153,14 @@ export type OnboardOptions = {
noGpu?: boolean;
autoYes?: boolean;
experimentalProfile?: import("./docker-driver-platform").ExperimentalOnboardProfile | null;
+ /** Read-only checkpoint identity captured before the onboarding lock. */
+ resumeIntentSnapshot?: import("./session-bootstrap").OnboardResumeIntentSnapshot | null;
+ /** Secret-free inference activation used by the locked portable environment scope. */
+ portableInferenceActivation?:
+ | import("./experimental/portable-inference-descriptor").PortableInferenceActivation
+ | null;
+ /** Internal portable host-preparation dependency for boundary verification. */
+ preparePortableHost?: typeof import("./experimental/portable-host-preparation").preparePortableExperimentalHost;
/** Exact secret-free serving catalog identity selected by the generic profile UX. */
servingProfileProvenance?: import("../inference/serving/types").ServingProfileProvenance | null;
};
diff --git a/src/lib/state/onboard-checkpoint-decision.ts b/src/lib/state/onboard-checkpoint-decision.ts
index 678d396e1b4..2606d3aec35 100644
--- a/src/lib/state/onboard-checkpoint-decision.ts
+++ b/src/lib/state/onboard-checkpoint-decision.ts
@@ -62,11 +62,16 @@ export function parseCheckpointDecision(
parseValue: (value: unknown) => Value | null,
): CheckpointDecision | null {
if (typeof raw !== "object" || raw === null) return null;
- const kind = (raw as { kind?: unknown }).kind;
- if (kind === "unset") return decisionUnset();
- if (kind === "declined") return decisionDeclined();
+ const record = raw as Record;
+ const kind = record.kind;
+ const keys = Object.keys(record).sort();
+ if (kind === "unset" && keys.length === 1 && keys[0] === "kind") return decisionUnset();
+ if (kind === "declined" && keys.length === 1 && keys[0] === "kind") {
+ return decisionDeclined();
+ }
if (kind === "selected") {
- const parsed = parseValue((raw as { value?: unknown }).value);
+ if (keys.length !== 2 || keys[0] !== "kind" || keys[1] !== "value") return null;
+ const parsed = parseValue(record.value);
return parsed === null ? null : decisionSelected(parsed);
}
return null;
diff --git a/src/lib/state/onboard-checkpoint-migrate.test.ts b/src/lib/state/onboard-checkpoint-migrate.test.ts
index 3587583b54a..6b7e4fe1ceb 100644
--- a/src/lib/state/onboard-checkpoint-migrate.test.ts
+++ b/src/lib/state/onboard-checkpoint-migrate.test.ts
@@ -12,11 +12,7 @@ import {
loadResumeCheckpoint,
resolveCheckpointForResume,
} from "./onboard-checkpoint-migrate";
-import {
- CHECKPOINT_SCHEMA_VERSION,
- type CheckpointLoadResult,
- type OnboardCheckpoint,
-} from "./onboard-checkpoint-types";
+import { CHECKPOINT_SCHEMA_VERSION, type OnboardCheckpoint } from "./onboard-checkpoint-types";
import { createSession, normalizeSession, type Session } from "./onboard-session";
function rawJson(value: unknown): Record {
@@ -80,8 +76,10 @@ describe("deriveCheckpointFromSession", () => {
describe("resolveCheckpointForResume", () => {
const validCheckpoint: OnboardCheckpoint = {
schemaVersion: CHECKPOINT_SCHEMA_VERSION,
+ profile: { kind: "selected", value: "default" },
+ runtimeAuthority: { kind: "unset" },
sessionId: "sess-1",
- machineState: "sandbox",
+ machineState: "init",
updatedAt: "2026-01-01T00:00:00.000Z",
sandboxIdentity: decisionSelected({ name: "my-sandbox", agent: "openclaw" }),
webSearch: decisionUnset(),
@@ -110,19 +108,15 @@ describe("resolveCheckpointForResume", () => {
});
});
- it("migrates a legacy session that has no embedded checkpoint", () => {
+ it("refuses a legacy session that has no embedded checkpoint", () => {
const raw = rawJson(completedSession());
const result = resolveCheckpointForResume(raw);
- expect(result.status).toBe("migrated");
- const migrated = result as Extract;
- expect(migrated.checkpoint.sandboxIdentity).toEqual(
- decisionSelected({ name: "my-sandbox", agent: "openclaw" }),
- );
+ expect(result).toEqual({ status: "legacy" });
});
- it("reports a corrupt embedded checkpoint rather than migrating over it", () => {
+ it("refuses an active legacy checkpoint without rewriting it", () => {
const raw = { ...rawJson(completedSession()), checkpoint: { schemaVersion: 1 } };
- expect(resolveCheckpointForResume(raw)).toEqual({ status: "corrupt" });
+ expect(resolveCheckpointForResume(raw)).toEqual({ status: "legacy", foundVersion: 1 });
});
it("rejects a checkpoint copied from a different session's file instead of trusting it", () => {
diff --git a/src/lib/state/onboard-checkpoint-migrate.ts b/src/lib/state/onboard-checkpoint-migrate.ts
index b405fd977f4..4ff6fbd4270 100644
--- a/src/lib/state/onboard-checkpoint-migrate.ts
+++ b/src/lib/state/onboard-checkpoint-migrate.ts
@@ -20,12 +20,16 @@ import {
type CheckpointDecision,
type CheckpointLoadResult,
type CheckpointMessagingSelection,
+ type CheckpointOnboardProfile,
+ type CheckpointPortableRuntimeAuthority,
type CheckpointResourceProfile,
type CheckpointSandboxIdentity,
type OnboardCheckpoint,
} from "./onboard-checkpoint-types";
import { normalizeSession, SESSION_FILE, type Session } from "./onboard-session";
+export { SESSION_FILE as ONBOARD_CHECKPOINT_SESSION_FILE };
+
function identityDecision(session: Session): CheckpointDecision {
const { sandboxName, agent } = session;
if (
@@ -67,12 +71,27 @@ function resourceDecision(session: Session): CheckpointDecision;
readonly webSearch: CheckpointDecision;
readonly messaging: CheckpointDecision;
@@ -131,10 +156,6 @@ export interface OnboardCheckpoint {
export type CheckpointLoadResult =
| { readonly status: "none" }
| { readonly status: "loaded"; readonly checkpoint: OnboardCheckpoint }
- | {
- readonly status: "migrated";
- readonly checkpoint: OnboardCheckpoint;
- readonly fromVersion: number;
- }
+ | { readonly status: "legacy"; readonly foundVersion?: 1 | 2 | 3 }
| { readonly status: "unsupported_future"; readonly foundVersion: number }
| { readonly status: "corrupt" };
diff --git a/src/lib/state/onboard-checkpoint.test.ts b/src/lib/state/onboard-checkpoint.test.ts
index a3315c39ef3..adbe0edc18b 100644
--- a/src/lib/state/onboard-checkpoint.test.ts
+++ b/src/lib/state/onboard-checkpoint.test.ts
@@ -25,6 +25,8 @@ const ISO = "2026-01-01T00:00:00.000Z";
function baseCheckpoint(overrides: Partial = {}): OnboardCheckpoint {
return {
schemaVersion: CHECKPOINT_SCHEMA_VERSION,
+ profile: { kind: "selected", value: "default" },
+ runtimeAuthority: { kind: "unset" },
sessionId: "s1",
machineState: "sandbox",
updatedAt: ISO,
@@ -150,28 +152,134 @@ describe("checkpoint schema inspection", () => {
expect(result).toEqual({ status: "loaded", checkpoint });
});
- it("migrates a valid v2 checkpoint with no recreate journal", () => {
+ it("round-trips the single portable profile and current-user Podman authority", () => {
+ const checkpoint = baseCheckpoint({
+ profile: { kind: "selected", value: "portable" },
+ runtimeAuthority: {
+ kind: "selected",
+ value: {
+ schemaVersion: 1,
+ kind: "podman",
+ ownership: "current-user",
+ uid: 1000,
+ homeDir: "/home/alice",
+ configHome: "/home/alice/.config",
+ runtimeDir: "/run/user/1000",
+ socketPath: "/run/user/1000/podman/podman.sock",
+ },
+ },
+ });
+
+ expect(inspectCheckpoint(serializeCheckpoint(checkpoint))).toEqual({
+ status: "loaded",
+ checkpoint,
+ });
+ });
+
+ it("rejects a portable configuration root outside the canonical OS home (#9035)", () => {
+ const checkpoint = baseCheckpoint({
+ profile: { kind: "selected", value: "portable" },
+ runtimeAuthority: {
+ kind: "selected",
+ value: {
+ schemaVersion: 1,
+ kind: "podman",
+ ownership: "current-user",
+ uid: 1000,
+ homeDir: "/home/alice",
+ configHome: "/srv/alice-config",
+ runtimeDir: "/run/user/1000",
+ socketPath: "/run/user/1000/podman/podman.sock",
+ },
+ },
+ });
+
+ expect(inspectCheckpoint(serializeCheckpoint(checkpoint))).toEqual({ status: "corrupt" });
+ });
+
+ it.each([
+ {
+ label: "default profile with selected runtime authority",
+ mutate: (checkpoint: Record) => {
+ checkpoint.runtimeAuthority = {
+ kind: "selected",
+ value: {
+ schemaVersion: 1,
+ kind: "podman",
+ ownership: "current-user",
+ uid: 1000,
+ homeDir: "/home/alice",
+ configHome: "/home/alice/.config",
+ runtimeDir: "/run/user/1000",
+ socketPath: "/run/user/1000/podman/podman.sock",
+ },
+ };
+ },
+ },
+ {
+ label: "portable profile without runtime authority",
+ mutate: (checkpoint: Record) => {
+ checkpoint.profile = { kind: "selected", value: "portable" };
+ },
+ },
+ {
+ label: "socket outside the recorded runtime root",
+ mutate: (checkpoint: Record) => {
+ checkpoint.profile = { kind: "selected", value: "portable" };
+ checkpoint.runtimeAuthority = {
+ kind: "selected",
+ value: {
+ schemaVersion: 1,
+ kind: "podman",
+ ownership: "current-user",
+ uid: 1000,
+ homeDir: "/home/alice",
+ configHome: "/home/alice/.config",
+ runtimeDir: "/run/user/1000",
+ socketPath: "/run/user/10000/podman.sock",
+ },
+ };
+ },
+ },
+ {
+ label: "unknown nested key",
+ mutate: (checkpoint: Record) => {
+ checkpoint.sandboxIdentity = {
+ kind: "selected",
+ value: { name: "my-sandbox", agent: "openclaw", unexpected: true },
+ };
+ },
+ },
+ {
+ label: "unknown effect group",
+ mutate: (checkpoint: Record) => {
+ checkpoint.effectGroups = { unexpected_effect: { completedAt: ISO, fingerprint: "x" } };
+ },
+ },
+ ])("rejects invalid v4 cross-field authority: $label", ({ mutate }) => {
+ const serialized = serializeCheckpoint(baseCheckpoint());
+ mutate(serialized);
+ expect(inspectCheckpoint(serialized)).toEqual({ status: "corrupt" });
+ });
+
+ it("classifies a v2 checkpoint as legacy without inventing runtime authority", () => {
const serialized = serializeCheckpoint(baseCheckpoint());
serialized.schemaVersion = 2;
delete serialized.sandboxRecreate;
const result = inspectCheckpoint(serialized);
- expect(result).toMatchObject({ status: "migrated", fromVersion: 2 });
- expect(result.status === "migrated" && result.checkpoint.sandboxRecreate).toBeNull();
+ expect(result).toEqual({ status: "legacy", foundVersion: 2 });
});
- it("migrates a valid v1 checkpoint with an unset gateway authority", () => {
+ it("classifies a v1 checkpoint as legacy without inventing runtime authority", () => {
const serialized = serializeCheckpoint(baseCheckpoint());
serialized.schemaVersion = 1;
delete serialized.gatewayAuthority;
const result = inspectCheckpoint(serialized);
- expect(result).toMatchObject({ status: "migrated", fromVersion: 1 });
- expect(result.status === "migrated" && result.checkpoint.gatewayAuthority).toEqual(
- decisionUnset(),
- );
+ expect(result).toEqual({ status: "legacy", foundVersion: 1 });
});
it("round-trips a selected externally supervised gateway authority", () => {
@@ -219,16 +327,13 @@ describe("checkpoint schema inspection", () => {
});
});
- it("loads an older recreate journal without a source workload receipt", () => {
+ it("rejects a current recreate journal without its source workload receipt", () => {
const serialized = serializedRecreateCheckpoint();
delete (serialized.sandboxRecreate as Record).sourceWorkload;
const result = inspectCheckpoint(serialized);
- expect(result.status).toBe("loaded");
- expect(
- result.status === "loaded" && result.checkpoint.sandboxRecreate?.sourceWorkload,
- ).toBeNull();
+ expect(result).toEqual({ status: "corrupt" });
});
it("rejects a source-workload cleanup receipt whose reference does not match its image", () => {
diff --git a/src/lib/state/onboard-checkpoint.ts b/src/lib/state/onboard-checkpoint.ts
index c6ba80d5e6f..d4bb280b8aa 100644
--- a/src/lib/state/onboard-checkpoint.ts
+++ b/src/lib/state/onboard-checkpoint.ts
@@ -19,8 +19,12 @@ import {
type CheckpointGatewaySupervisor,
type CheckpointLoadResult,
type CheckpointMessagingSelection,
+ type CheckpointOnboardProfile,
+ type CheckpointPortableRuntimeAuthority,
+ type CheckpointProfileDecision,
type CheckpointProviderBinding,
type CheckpointResourceProfile,
+ type CheckpointRuntimeAuthorityDecision,
type CheckpointSandboxIdentity,
type CheckpointSandboxRecreatePhase,
type CheckpointSandboxRecreateSourceWorkload,
@@ -45,6 +49,105 @@ const SANDBOX_RECREATE_PHASES = new Set([
]);
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
const SHA256_PATTERN = /^[a-f0-9]{64}$/;
+const CHECKPOINT_KEYS = [
+ "schemaVersion",
+ "sessionId",
+ "machineState",
+ "updatedAt",
+ "profile",
+ "runtimeAuthority",
+ "sandboxIdentity",
+ "webSearch",
+ "messaging",
+ "resourceProfile",
+ "gatewayAuthority",
+ "effectGroups",
+ "bindings",
+ "sandboxRecreate",
+] as const;
+
+function hasExactKeys(value: Record, expected: readonly string[]): boolean {
+ const actual = Object.keys(value).sort();
+ const wanted = [...expected].sort();
+ return actual.length === wanted.length && actual.every((key, index) => key === wanted[index]);
+}
+
+function readCanonicalAbsolutePath(value: unknown): string | null {
+ if (typeof value !== "string" || value === "" || /[\0\r\n]/u.test(value)) return null;
+ if (!path.isAbsolute(value) || path.normalize(value) !== value) return null;
+ return value;
+}
+
+function isStrictDescendant(root: string, candidate: string): boolean {
+ const relative = path.relative(root, candidate);
+ return (
+ relative !== "" &&
+ !path.isAbsolute(relative) &&
+ relative !== ".." &&
+ !relative.startsWith(`..${path.sep}`)
+ );
+}
+
+function parseProfile(value: unknown): CheckpointProfileDecision | null {
+ if (!isObjectRecord(value) || !hasExactKeys(value, ["kind", "value"])) return null;
+ if (value.kind !== "selected" || (value.value !== "default" && value.value !== "portable")) {
+ return null;
+ }
+ return { kind: "selected", value: value.value as CheckpointOnboardProfile };
+}
+
+function parsePortableRuntimeAuthority(value: unknown): CheckpointPortableRuntimeAuthority | null {
+ if (
+ !isObjectRecord(value) ||
+ !hasExactKeys(value, [
+ "schemaVersion",
+ "kind",
+ "ownership",
+ "uid",
+ "homeDir",
+ "configHome",
+ "runtimeDir",
+ "socketPath",
+ ])
+ ) {
+ return null;
+ }
+ if (
+ value.schemaVersion !== 1 ||
+ value.kind !== "podman" ||
+ value.ownership !== "current-user" ||
+ !Number.isSafeInteger(value.uid) ||
+ Number(value.uid) < 0
+ ) {
+ return null;
+ }
+ const homeDir = readCanonicalAbsolutePath(value.homeDir);
+ const configHome = readCanonicalAbsolutePath(value.configHome);
+ const runtimeDir = readCanonicalAbsolutePath(value.runtimeDir);
+ const socketPath = readCanonicalAbsolutePath(value.socketPath);
+ if (!homeDir || !configHome || !runtimeDir || !socketPath) return null;
+ if (configHome !== path.join(homeDir, ".config")) return null;
+ if (runtimeDir !== path.join("/run/user", String(value.uid))) return null;
+ if (!isStrictDescendant(runtimeDir, socketPath)) return null;
+ return {
+ schemaVersion: 1,
+ kind: "podman",
+ ownership: "current-user",
+ uid: Number(value.uid),
+ homeDir,
+ configHome,
+ runtimeDir,
+ socketPath,
+ };
+}
+
+function parseRuntimeAuthority(value: unknown): CheckpointRuntimeAuthorityDecision | null {
+ if (!isObjectRecord(value)) return null;
+ if (hasExactKeys(value, ["kind"]) && value.kind === "unset") return { kind: "unset" };
+ if (!hasExactKeys(value, ["kind", "value"]) || value.kind !== "selected") return null;
+ const authority = parsePortableRuntimeAuthority(value.value);
+ return authority ? { kind: "selected", value: authority } : null;
+}
function readString(value: unknown): string | null {
return typeof value === "string" ? value : null;
@@ -70,7 +173,7 @@ function readCanonicalIsoTimestamp(value: unknown): string | null {
}
function parseSandboxIdentityValue(value: unknown): CheckpointSandboxIdentity | null {
- if (!isObjectRecord(value)) return null;
+ if (!isObjectRecord(value) || !hasExactKeys(value, ["name", "agent"])) return null;
const name = readString(value.name);
const agent = readString(value.agent);
if (name === null || agent === null || agent.length === 0) return null;
@@ -79,19 +182,26 @@ function parseSandboxIdentityValue(value: unknown): CheckpointSandboxIdentity |
}
function parseResourceProfileValue(value: unknown): CheckpointResourceProfile | null {
- if (!isObjectRecord(value)) return null;
+ if (!isObjectRecord(value) || !hasExactKeys(value, ["cpu", "memory"])) return null;
const cpu = readString(value.cpu);
const memory = readString(value.memory);
return cpu !== null && memory !== null ? { cpu, memory } : null;
}
function parseWebSearchValue(value: unknown): WebSearchConfig | null {
- if (!isObjectRecord(value)) return null;
+ if (
+ !isObjectRecord(value) ||
+ (!hasExactKeys(value, ["fetchEnabled"]) && !hasExactKeys(value, ["fetchEnabled", "provider"]))
+ ) {
+ return null;
+ }
return normalizeWebSearchConfig(value as Partial);
}
function parseMessagingValue(value: unknown): CheckpointMessagingSelection | null {
- if (!isObjectRecord(value)) return null;
+ if (!isObjectRecord(value) || !hasExactKeys(value, ["selectedChannels", "disabledChannels"])) {
+ return null;
+ }
const selectedChannels = readStringArray(value.selectedChannels);
const disabledChannels = readStringArray(value.disabledChannels);
if (selectedChannels === null || disabledChannels === null) return null;
@@ -99,7 +209,9 @@ function parseMessagingValue(value: unknown): CheckpointMessagingSelection | nul
}
function parseEffectGroupRecord(value: unknown): CheckpointEffectGroupRecord | null {
- if (!isObjectRecord(value)) return null;
+ if (!isObjectRecord(value) || !hasExactKeys(value, ["completedAt", "fingerprint"])) {
+ return null;
+ }
const completedAt = readCanonicalIsoTimestamp(value.completedAt);
const fingerprint = readString(value.fingerprint);
if (completedAt === null || fingerprint === null || fingerprint.length === 0) return null;
@@ -110,6 +222,13 @@ function parseEffectGroups(
value: unknown,
): Partial> | null {
if (!isObjectRecord(value)) return null;
+ if (
+ Object.keys(value).some(
+ (name) => !EFFECT_GROUP_NAMES.includes(name as CheckpointEffectGroupName),
+ )
+ ) {
+ return null;
+ }
const groups: Partial> = {};
for (const name of EFFECT_GROUP_NAMES) {
const raw = value[name];
@@ -122,7 +241,9 @@ function parseEffectGroups(
}
function parseProviderBinding(value: unknown): CheckpointProviderBinding | null {
- if (!isObjectRecord(value)) return null;
+ if (!isObjectRecord(value) || !hasExactKeys(value, ["name", "type", "credentialEnv"])) {
+ return null;
+ }
const name = readString(value.name);
const type = readString(value.type);
const credentialEnv = readString(value.credentialEnv);
@@ -142,7 +263,9 @@ function parseProviderBindings(value: unknown): CheckpointProviderBinding[] | nu
}
function parseGatewaySupervisor(value: unknown): CheckpointGatewaySupervisor | null {
- if (!isObjectRecord(value)) return null;
+ if (!isObjectRecord(value) || !hasExactKeys(value, ["kind", "serviceName", "execPath"])) {
+ return null;
+ }
const kind = value.kind;
const serviceName = readString(value.serviceName);
const execPath = readString(value.execPath);
@@ -157,7 +280,21 @@ function canonicalGatewayName(gatewayPort: number): string {
}
function parseGatewayAuthorityValue(value: unknown): CheckpointGatewayAuthority | null {
- if (!isObjectRecord(value)) return null;
+ if (
+ !isObjectRecord(value) ||
+ !hasExactKeys(value, [
+ "gatewayName",
+ "gatewayPort",
+ "mode",
+ "source",
+ "endpoint",
+ "stateDir",
+ "supervisor",
+ "requiredCapabilities",
+ ])
+ ) {
+ return null;
+ }
const gatewayName = readString(value.gatewayName);
const gatewayPort = value.gatewayPort;
const mode = value.mode;
@@ -243,7 +380,9 @@ function parseGatewayAuthorityValue(value: unknown): CheckpointGatewayAuthority
}
function parseBindings(value: unknown): CheckpointBindings | null {
- if (!isObjectRecord(value)) return null;
+ if (!isObjectRecord(value) || !hasExactKeys(value, ["credentialEnvs", "registeredProviders"])) {
+ return null;
+ }
const credentialEnvs = readStringArray(value.credentialEnvs);
const registeredProviders = parseProviderBindings(value.registeredProviders);
if (credentialEnvs === null || registeredProviders === null) return null;
@@ -269,7 +408,9 @@ function parseSandboxRecreateSourceWorkload(
): CheckpointSandboxRecreateSourceWorkload | null | undefined {
// Journals written before the source-workload cleanup receipt remain resumable.
if (value === undefined || value === null) return null;
- if (!isObjectRecord(value)) return undefined;
+ if (!isObjectRecord(value) || !hasExactKeys(value, ["openshellDriver", "imageTag", "workload"])) {
+ return undefined;
+ }
const rawOpenshellDriver = value.openshellDriver;
const openshellDriver =
rawOpenshellDriver === null ? null : readBoundedJournalString(rawOpenshellDriver, 128);
@@ -286,6 +427,7 @@ function parseSandboxRecreateSourceWorkload(
if (rawWorkload === null) return { openshellDriver, imageTag, workload: null };
if (
!isObjectRecord(rawWorkload) ||
+ !hasExactKeys(rawWorkload, ["kind", "reference", "shared"]) ||
rawWorkload.kind !== "legacy-dockerfile" ||
typeof rawWorkload.shared !== "boolean"
) {
@@ -306,7 +448,28 @@ function parseSandboxRecreateSourceWorkload(
function parseSandboxRecreateTransaction(
value: unknown,
): CheckpointSandboxRecreateTransaction | null {
- if (!isObjectRecord(value)) return null;
+ if (
+ !isObjectRecord(value) ||
+ !hasExactKeys(value, [
+ "version",
+ "id",
+ "revision",
+ "sandboxName",
+ "gatewayName",
+ "gatewayPort",
+ "sourceRegistryFingerprint",
+ "sourceLiveIdentityFingerprint",
+ "sourceWorkload",
+ "targetIntentFingerprint",
+ "targetGeneration",
+ "targetLiveIdentityFingerprint",
+ "phase",
+ "startedAt",
+ "updatedAt",
+ ])
+ ) {
+ return null;
+ }
const id = readString(value.id);
const sandboxName = readString(value.sandboxName);
const gatewayName = readString(value.gatewayName);
@@ -382,12 +545,23 @@ function parseSchema(
gatewayAuthorityRaw: unknown,
sandboxRecreateRaw: unknown,
): OnboardCheckpoint | null {
+ if (!hasExactKeys(value, CHECKPOINT_KEYS)) return null;
const sessionId = readString(value.sessionId);
const machineState = value.machineState;
const updatedAt = readCanonicalIsoTimestamp(value.updatedAt);
if (sessionId === null || updatedAt === null) return null;
if (typeof machineState !== "string" || !isOnboardMachineState(machineState)) return null;
+ const profile = parseProfile(value.profile);
+ const runtimeAuthority = parseRuntimeAuthority(value.runtimeAuthority);
+ if (!profile || !runtimeAuthority) return null;
+ if (
+ (profile.value === "default" && runtimeAuthority.kind !== "unset") ||
+ (profile.value === "portable" && runtimeAuthority.kind !== "selected")
+ ) {
+ return null;
+ }
+
const sandboxIdentity = requireDecision(value.sandboxIdentity, parseSandboxIdentityValue);
const webSearch = requireDecision(value.webSearch, parseWebSearchValue);
const messaging = requireDecision(value.messaging, parseMessagingValue);
@@ -418,6 +592,8 @@ function parseSchema(
sessionId,
machineState,
updatedAt,
+ profile,
+ runtimeAuthority,
sandboxIdentity,
webSearch,
messaging,
@@ -444,14 +620,8 @@ export function inspectCheckpoint(raw: unknown): CheckpointLoadResult {
const checkpoint = parseSchema(raw, raw.gatewayAuthority, raw.sandboxRecreate);
return checkpoint ? { status: "loaded", checkpoint } : { status: "corrupt" };
}
- if (version === 2) {
- const checkpoint = parseSchema(raw, raw.gatewayAuthority, null);
- return checkpoint ? { status: "migrated", checkpoint, fromVersion: 2 } : { status: "corrupt" };
- }
- if (version === 1) {
- const checkpoint = parseSchema(raw, { kind: "unset" }, null);
- return checkpoint ? { status: "migrated", checkpoint, fromVersion: 1 } : { status: "corrupt" };
- }
+ if (version === 1 || version === 2 || version === 3)
+ return { status: "legacy", foundVersion: version };
return { status: "corrupt" };
}
@@ -461,6 +631,8 @@ export function serializeCheckpoint(checkpoint: OnboardCheckpoint): Record {
});
describe("cross-process onboard lock", () => {
- it("rejects a concurrent CLI process before gateway creation", async () => {
+ it("reports the holder without acquiring a competing lock", async () => {
const childScript = `
const fs = require("node:fs");
const path = require("node:path");
diff --git a/src/lib/state/onboard-session.test.ts b/src/lib/state/onboard-session.test.ts
index ad91ac89016..3a40611b0cc 100644
--- a/src/lib/state/onboard-session.test.ts
+++ b/src/lib/state/onboard-session.test.ts
@@ -264,7 +264,9 @@ describe("onboard session", () => {
session.markStepStarted("provider_selection");
session.updateSession((current) => {
current.checkpoint = {
- schemaVersion: 3,
+ schemaVersion: 4,
+ profile: { kind: "selected", value: "default" },
+ runtimeAuthority: { kind: "unset" },
sessionId: current.sessionId,
machineState: "init",
updatedAt: new Date().toISOString(),
@@ -1039,6 +1041,31 @@ describe("onboard session", () => {
expect(session.loadSession()).toBeNull();
});
+ it("keeps completed legacy checkpoint sessions readable as status evidence", () => {
+ const completed = session.createSession({ sessionId: "legacy-completed" });
+ completed.status = "complete";
+ completed.resumable = false;
+ completed.machine = {
+ version: 1,
+ state: "complete",
+ stateEnteredAt: completed.updatedAt,
+ revision: 8,
+ };
+ const raw = JSON.parse(JSON.stringify(completed)) as Record;
+ raw.checkpoint = { schemaVersion: 3, sessionId: completed.sessionId };
+ fs.mkdirSync(path.dirname(session.SESSION_FILE), { recursive: true });
+ fs.writeFileSync(session.SESSION_FILE, JSON.stringify(raw, null, 2), { mode: 0o600 });
+
+ const loaded = requireLoadedSession(session.loadSession());
+ expect(loaded).toMatchObject({
+ sessionId: "legacy-completed",
+ status: "complete",
+ resumable: false,
+ machine: { state: "complete", revision: 8 },
+ checkpoint: null,
+ });
+ });
+
it("acquires and releases the onboard lock", () => {
const acquired = session.acquireOnboardLock("nemoclaw onboard");
expect(acquired.acquired).toBe(true);
diff --git a/src/lib/state/onboard-session.ts b/src/lib/state/onboard-session.ts
index f30eaaf2f55..4c9193f6b79 100644
--- a/src/lib/state/onboard-session.ts
+++ b/src/lib/state/onboard-session.ts
@@ -702,6 +702,16 @@ function transitionMachineSnapshot(
return;
}
session.machine = createMachineSnapshot(state, now, current.revision + 1);
+ syncCheckpointMachineState(session, state, now);
+}
+
+export function syncCheckpointMachineState(
+ session: Session,
+ state: OnboardMachineState,
+ updatedAt: string,
+): void {
+ if (!session.checkpoint) return;
+ session.checkpoint = { ...session.checkpoint, machineState: state, updatedAt };
}
export function createSession(overrides: Partial = {}): Session {
@@ -911,6 +921,13 @@ export function normalizeSession(data: Session | SessionJsonValue | undefined):
normalized.machine =
parseMachineSnapshot(data.machine, normalized.sessionId) ?? inferMachineSnapshot(normalized);
+ if (
+ normalized.checkpoint &&
+ (normalized.checkpoint.sessionId !== normalized.sessionId ||
+ normalized.checkpoint.machineState !== normalized.machine.state)
+ ) {
+ return null;
+ }
preserveInvalidSessionToolDisclosure(data, normalized);
preserveInvalidSessionHostMounts(data, normalized);
diff --git a/test/cli/onboard-compatibility.test.ts b/test/cli/onboard-compatibility.test.ts
index 874ab19461a..e342a505927 100644
--- a/test/cli/onboard-compatibility.test.ts
+++ b/test/cli/onboard-compatibility.test.ts
@@ -11,6 +11,8 @@ import SetupCliCommand from "../../src/commands/setup";
import SetupSparkCliCommand from "../../src/commands/setup-spark";
import { runOnboardAction } from "../../src/lib/actions/global";
import { emitOnboardMachineEvent } from "../../src/lib/onboard/machine/events";
+import { deriveCheckpointFromSession } from "../../src/lib/state/onboard-checkpoint-migrate";
+import { createSession } from "../../src/lib/state/onboard-session";
import { PARSER_EXIT_CODE, run, runWithEnv } from "./helpers";
@@ -46,45 +48,31 @@ function writeOpenShellVersionStub(localBin: string): void {
}
function writeIncompleteResumeSession(nemoclawDir: string): void {
+ const session = createSession({
+ sessionId: "session-1",
+ mode: "interactive",
+ provider: "nvidia-prod",
+ model: "nvidia/nemotron-3-super-120b-a12b",
+ lastStepStarted: "inference",
+ lastCompletedStep: "inference",
+ metadata: { gatewayName: "nemoclaw", fromDockerfile: null },
+ steps: {
+ preflight: { status: "complete", startedAt: null, completedAt: null, error: null },
+ gateway: { status: "complete", startedAt: null, completedAt: null, error: null },
+ provider_selection: {
+ status: "complete",
+ startedAt: null,
+ completedAt: null,
+ error: null,
+ },
+ inference: { status: "complete", startedAt: null, completedAt: null, error: null },
+ sandbox: { status: "pending", startedAt: null, completedAt: null, error: null },
+ },
+ });
+ session.checkpoint = deriveCheckpointFromSession(session, { profile: "default" });
fs.writeFileSync(
path.join(nemoclawDir, "onboard-session.json"),
- JSON.stringify(
- {
- version: 1,
- sessionId: "session-1",
- resumable: true,
- status: "in_progress",
- mode: "interactive",
- startedAt: "2026-05-03T00:00:00.000Z",
- updatedAt: "2026-05-03T00:00:00.000Z",
- lastStepStarted: "inference",
- lastCompletedStep: "inference",
- failure: null,
- sandboxName: null,
- provider: "nvidia-prod",
- model: "nvidia/nemotron-3-super-120b-a12b",
- endpointUrl: null,
- credentialEnv: null,
- preferredInferenceApi: null,
- nimContainer: null,
- policyPresets: null,
- metadata: { gatewayName: "nemoclaw" },
- steps: {
- preflight: { status: "complete", startedAt: null, completedAt: null, error: null },
- gateway: { status: "complete", startedAt: null, completedAt: null, error: null },
- provider_selection: {
- status: "complete",
- startedAt: null,
- completedAt: null,
- error: null,
- },
- inference: { status: "complete", startedAt: null, completedAt: null, error: null },
- sandbox: { status: "pending", startedAt: null, completedAt: null, error: null },
- },
- },
- null,
- 2,
- ),
+ JSON.stringify(session, null, 2),
{ mode: 0o600 },
);
}
@@ -290,16 +278,11 @@ describe("CLI onboard compatibility", () => {
);
});
- it("resume rejection clarifies --resume semantics and points to onboard (#2281)", () => {
+ it("resume rejection reports the missing session without choosing an agent CLI (#9035)", () => {
// Keep the real executable/runtime exit contract for the user-facing diagnostic.
const r = run("onboard --resume --non-interactive --yes-i-accept-third-party-software --yes");
expect(r.code).toBe(1);
- expect(r.out.includes("No resumable onboarding session was found")).toBeTruthy();
- expect(r.out.includes("--resume only continues an interrupted onboarding run")).toBeTruthy();
- expect(
- r.out.includes("To change configuration on an existing sandbox, rebuild it"),
- ).toBeTruthy();
- expect(r.out.includes("nemoclaw onboard")).toBeTruthy();
+ expect(r.out.trim()).toBe("No resumable onboarding session was found.");
});
it("does not let whitespace-only NEMOCLAW_SANDBOX_NAME satisfy the resume guard (#2753)", () => {
diff --git a/test/nemo-deepagents-alias.test.ts b/test/nemo-deepagents-alias.test.ts
index 14d30915e72..d0d22d9ab3a 100644
--- a/test/nemo-deepagents-alias.test.ts
+++ b/test/nemo-deepagents-alias.test.ts
@@ -189,22 +189,20 @@ describe("nemo-deepagents alias", () => {
expect(out).toContain("nemo-deepagents");
});
- it("nemoclaw onboard --agent langchain-deepagents-code keeps the nemoclaw CLI name in suggestions", () => {
+ it("nemoclaw onboard --agent deep agents uses an agent-neutral no-session diagnostic (#9035)", () => {
const { code, out } = runNemoClaw(
"onboard --agent langchain-deepagents-code --resume --non-interactive --yes-i-accept-third-party-software",
);
expect(code).toBe(1);
- expect(out).toContain("nemoclaw onboard");
- expect(out).not.toMatch(/\bnemo-deepagents\b/);
+ expect(out.trim()).toBe("No resumable onboarding session was found.");
});
- it("NEMOCLAW_AGENT=langchain-deepagents-code nemoclaw also keeps the nemoclaw CLI name", () => {
+ it("NEMOCLAW_AGENT=deep agents uses an agent-neutral no-session diagnostic (#9035)", () => {
const { code, out } = runNemoClaw(
"onboard --resume --non-interactive --yes-i-accept-third-party-software",
{ NEMOCLAW_AGENT: "langchain-deepagents-code" },
);
expect(code).toBe(1);
- expect(out).toContain("nemoclaw onboard");
- expect(out).not.toMatch(/\bnemo-deepagents\b/);
+ expect(out.trim()).toBe("No resumable onboarding session was found.");
});
});
diff --git a/test/nemohermes-alias.test.ts b/test/nemohermes-alias.test.ts
index 971d24d6c57..961b101d0d2 100644
--- a/test/nemohermes-alias.test.ts
+++ b/test/nemohermes-alias.test.ts
@@ -120,27 +120,20 @@ describe("nemohermes alias", () => {
expect(out).toContain("nemohermes");
});
- it("nemoclaw onboard --agent hermes keeps the nemoclaw CLI name in suggestions (#3358)", () => {
- // Regression for NVB#6165494 / issue #3358: a user who launches via
- // `nemoclaw` (with --agent hermes or NEMOCLAW_AGENT=hermes) should never
- // see `nemohermes` suggested back as the command to run, because they may
- // not have the alias installed on PATH.
+ it("nemoclaw onboard --agent hermes uses an agent-neutral no-session diagnostic (#9035)", () => {
const { code, out } = runNemoClaw(
"onboard --agent hermes --resume --non-interactive --yes-i-accept-third-party-software",
);
expect(code).toBe(1);
- expect(out).toContain("nemoclaw onboard");
- expect(out).not.toMatch(/\bnemohermes\b/);
+ expect(out.trim()).toBe("No resumable onboarding session was found.");
});
- it("NEMOCLAW_AGENT=hermes nemoclaw also keeps the nemoclaw CLI name (#3358)", () => {
- // The exact repro path reported by NV QA on Brev v0.0.38.
+ it("NEMOCLAW_AGENT=hermes uses an agent-neutral no-session diagnostic (#9035)", () => {
const { code, out } = runNemoClaw(
"onboard --resume --non-interactive --yes-i-accept-third-party-software",
{ NEMOCLAW_AGENT: "hermes" },
);
expect(code).toBe(1);
- expect(out).toContain("nemoclaw onboard");
- expect(out).not.toMatch(/\bnemohermes\b/);
+ expect(out.trim()).toBe("No resumable onboarding session was found.");
});
});
diff --git a/test/onboard-fsm-live-slices.test.ts b/test/onboard-fsm-live-slices.test.ts
index 14ad1510556..74007f7918a 100644
--- a/test/onboard-fsm-live-slices.test.ts
+++ b/test/onboard-fsm-live-slices.test.ts
@@ -282,6 +282,8 @@ function seedResumeSession(state, sandboxComplete = true) {
session.steps[step].status = "complete";
}
if (sandboxComplete) session.steps.sandbox.status = "complete";
+ session.checkpoint = require(${JSON.stringify(path.join(repoRoot, "src", "lib", "state", "onboard-checkpoint-migrate.ts"))})
+ .deriveCheckpointFromSession(session, { profile: "default" });
onboardSession.saveSession(session);
}
diff --git a/test/onboard-inference-reconciliation.test.ts b/test/onboard-inference-reconciliation.test.ts
index 50e58d1f9d3..14dfd2275a7 100644
--- a/test/onboard-inference-reconciliation.test.ts
+++ b/test/onboard-inference-reconciliation.test.ts
@@ -166,6 +166,9 @@ describe("onboard helpers", () => {
const sessionPath = JSON.stringify(
path.join(repoRoot, "src", "lib", "state", "onboard-session.ts"),
);
+ const checkpointPath = JSON.stringify(
+ path.join(repoRoot, "src", "lib", "state", "onboard-checkpoint-migrate.ts"),
+ );
const credentialsPath = JSON.stringify(
path.join(repoRoot, "src", "lib", "credentials", "store.ts"),
);
@@ -199,6 +202,7 @@ describe("onboard helpers", () => {
const runner = require(${runnerPath});
const registry = require(${registryPath});
const onboardSession = require(${sessionPath});
+const { deriveCheckpointFromSession } = require(${checkpointPath});
const credentials = require(${credentialsPath});
const nim = require(${nimPath});
const gatewayState = require(${gatewayStatePath});
@@ -327,26 +331,26 @@ const complete = () => ({
completedAt: new Date().toISOString(),
error: null,
});
-onboardSession.saveSession(
- onboardSession.createSession({
- mode: "interactive",
- agent: "hermes",
- sandboxName: null,
- provider: "hermes-provider",
- model: "moonshotai/kimi-k2.6",
- endpointUrl: "https://8.8.8.8/v1",
- credentialEnv: "NOUS_API_KEY",
- hermesAuthMethod: "api_key",
- hermesToolGateways: [],
- policyPresets: ["nous-web"],
- metadata: { gatewayName: "nemoclaw", fromDockerfile: null },
- steps: {
- preflight: complete(),
- gateway: complete(),
- provider_selection: complete(),
- },
- }),
-);
+const resumeSession = onboardSession.createSession({
+ mode: "interactive",
+ agent: "hermes",
+ sandboxName: null,
+ provider: "hermes-provider",
+ model: "moonshotai/kimi-k2.6",
+ endpointUrl: "https://8.8.8.8/v1",
+ credentialEnv: "NOUS_API_KEY",
+ hermesAuthMethod: "api_key",
+ hermesToolGateways: [],
+ policyPresets: ["nous-web"],
+ metadata: { gatewayName: "nemoclaw", fromDockerfile: null },
+ steps: {
+ preflight: complete(),
+ gateway: complete(),
+ provider_selection: complete(),
+ },
+});
+resumeSession.checkpoint = deriveCheckpointFromSession(resumeSession, { profile: "default" });
+onboardSession.saveSession(resumeSession);
const originalMarkStepComplete = onboardSession.markStepComplete;
onboardSession.markStepComplete = (stepName, updates = {}) => {
diff --git a/test/onboard-lifecycle.test.ts b/test/onboard-lifecycle.test.ts
index e6c29a9eba5..3180435df54 100644
--- a/test/onboard-lifecycle.test.ts
+++ b/test/onboard-lifecycle.test.ts
@@ -64,6 +64,9 @@ function runLifecycleEntrypoint(mode: "fresh" | "resume" | "recovery"): Lifecycl
const eventsPath = JSON.stringify(
path.join(repoRoot, "src", "lib", "onboard", "machine", "events.ts"),
);
+ const checkpointPath = JSON.stringify(
+ path.join(repoRoot, "src", "lib", "state", "onboard-checkpoint-migrate.ts"),
+ );
fs.writeFileSync(
scriptPath,
@@ -90,14 +93,15 @@ OnboardRuntimeBoundary.prototype.recordOnboardStarted = async function(resumed)
};
const onboardModule = require(${onboardPath});
+const { deriveCheckpointFromSession } = require(${checkpointPath});
if (${JSON.stringify(mode)} === "resume") {
- onboardModule.onboardSession.saveSession(
- onboardModule.onboardSession.createSession({
- mode: "non-interactive",
- sandboxName: "resume-lifecycle",
- metadata: { gatewayName: "nemoclaw", fromDockerfile: null },
- }),
- );
+ const session = onboardModule.onboardSession.createSession({
+ mode: "non-interactive",
+ sandboxName: "resume-lifecycle",
+ metadata: { gatewayName: "nemoclaw", fromDockerfile: null },
+ });
+ session.checkpoint = deriveCheckpointFromSession(session, { profile: "default" });
+ onboardModule.onboardSession.saveSession(session);
}
if (${JSON.stringify(mode)} === "recovery") {
const session = onboardModule.onboardSession.createSession({
@@ -119,6 +123,7 @@ if (${JSON.stringify(mode)} === "recovery") {
metadata: { gatewayName: "nemoclaw", fromDockerfile: null },
});
session.steps.gateway.status = "failed";
+ session.checkpoint = deriveCheckpointFromSession(session, { profile: "default" });
onboardModule.onboardSession.saveSession(session);
}
@@ -172,6 +177,9 @@ function runResumeConflictEntrypoint(
const eventsPath = JSON.stringify(
path.join(repoRoot, "src", "lib", "onboard", "machine", "events.ts"),
);
+ const checkpointPath = JSON.stringify(
+ path.join(repoRoot, "src", "lib", "state", "onboard-checkpoint-migrate.ts"),
+ );
fs.writeFileSync(
scriptPath,
@@ -204,21 +212,22 @@ process.exit = ((code = 0) => {
});
const onboardModule = require(${onboardPath});
-onboardModule.onboardSession.saveSession(
- onboardModule.onboardSession.createSession({
- mode: "non-interactive",
- sandboxName: "recorded-sandbox",
- metadata: { gatewayName: "nemoclaw", fromDockerfile: null },
- steps: {
- sandbox: {
- status: "complete",
- startedAt: "2026-05-27T00:00:00.000Z",
- completedAt: "2026-05-27T00:00:01.000Z",
- error: null,
- },
+const { deriveCheckpointFromSession } = require(${checkpointPath});
+const session = onboardModule.onboardSession.createSession({
+ mode: "non-interactive",
+ sandboxName: "recorded-sandbox",
+ metadata: { gatewayName: "nemoclaw", fromDockerfile: null },
+ steps: {
+ sandbox: {
+ status: "complete",
+ startedAt: "2026-05-27T00:00:00.000Z",
+ completedAt: "2026-05-27T00:00:01.000Z",
+ error: null,
},
- }),
-);
+ },
+});
+session.checkpoint = deriveCheckpointFromSession(session, { profile: "default" });
+onboardModule.onboardSession.saveSession(session);
onboardModule.onboard({
resume: true,
diff --git a/test/onboard-prepared-gateway-handoff.test.ts b/test/onboard-prepared-gateway-handoff.test.ts
index 8a49bd53500..f1d896a9748 100644
--- a/test/onboard-prepared-gateway-handoff.test.ts
+++ b/test/onboard-prepared-gateway-handoff.test.ts
@@ -33,6 +33,9 @@ function runHandoffScenario(scenario: HandoffScenario): HandoffResult {
const sessionPath = JSON.stringify(
path.join(repoRoot, "src", "lib", "state", "onboard-session.ts"),
);
+ const checkpointPath = JSON.stringify(
+ path.join(repoRoot, "src", "lib", "state", "onboard-checkpoint-migrate.ts"),
+ );
const initialFlowPath = JSON.stringify(
path.join(repoRoot, "src", "lib", "onboard", "machine", "initial-flow-phases.ts"),
);
@@ -42,6 +45,7 @@ function runHandoffScenario(scenario: HandoffScenario): HandoffResult {
`
const initialFlow = require(${initialFlowPath});
const onboardSession = require(${sessionPath});
+const { deriveCheckpointFromSession } = require(${checkpointPath});
const scenario = ${JSON.stringify(scenario)};
const stopAtInitialFlow = new Error("stop at initial onboarding flow");
let flowCalls = 0;
@@ -54,14 +58,16 @@ initialFlow.runInitialOnboardFlowSlice = async () => {
};
if (scenario === "prepared") {
- onboardSession.saveSession(onboardSession.createSession({
+ const session = onboardSession.createSession({
mode: "non-interactive",
agent: "langchain-deepagents-code",
sandboxName: "prepared-dcode",
provider: "compatible-endpoint",
model: "nvidia/nemotron-3-super-120b-a12b",
metadata: { gatewayName: "nemoclaw", fromDockerfile: null },
- }));
+ });
+ session.checkpoint = deriveCheckpointFromSession(session, { profile: "default" });
+ onboardSession.saveSession(session);
}
process.env.OPENSHELL_GATEWAY = "ambient-other-gateway";
diff --git a/test/onboard-sandbox-name.test.ts b/test/onboard-sandbox-name.test.ts
index 68cd4acdf89..fc0e226118e 100644
--- a/test/onboard-sandbox-name.test.ts
+++ b/test/onboard-sandbox-name.test.ts
@@ -15,6 +15,8 @@ import {
NAME_ALLOWED_FORMAT,
suggestNameSlug,
} from "../src/lib/name-validation.js";
+import { deriveCheckpointFromSession } from "../src/lib/state/onboard-checkpoint-migrate.js";
+import { createSession } from "../src/lib/state/onboard-session.js";
const {
getDefaultSandboxNameForAgent,
@@ -285,21 +287,18 @@ const hostileName = "bad" + esc + "[31mX" + esc + "[0m";
try {
const sessionDir = path.join(tmpDir, ".nemoclaw");
fs.mkdirSync(sessionDir, { recursive: true, mode: 0o700 });
+ const session = createSession({
+ sessionId: "null-sandbox-name",
+ status: "in_progress",
+ resumable: true,
+ mode: "interactive",
+ agent: "langchain-deepagents-code",
+ sandboxName: null,
+ });
+ session.checkpoint = deriveCheckpointFromSession(session, { profile: "default" });
fs.writeFileSync(
path.join(sessionDir, "onboard-session.json"),
- JSON.stringify(
- {
- version: 1,
- sessionId: "null-sandbox-name",
- status: "in_progress",
- resumable: true,
- mode: "interactive",
- agent: "langchain-deepagents-code",
- sandboxName: null,
- },
- null,
- 2,
- ),
+ JSON.stringify(session, null, 2),
);
const result = spawnSync(
diff --git a/test/policy-tiers-onboard.test.ts b/test/policy-tiers-onboard.test.ts
index 12b697b0bad..07f5ff177b1 100644
--- a/test/policy-tiers-onboard.test.ts
+++ b/test/policy-tiers-onboard.test.ts
@@ -215,15 +215,21 @@ describe("policy tier onboarding adapter contracts", () => {
const script = String.raw`
const fs = require("node:fs");
const path = require("node:path");
-process.env.NEMOCLAW_NON_INTERACTIVE = "1";
+process.env.NEMOCLAW_NON_INTERACTIVE = "preserve-direct";
process.env.NEMOCLAW_POLICY_TIER = "invalid_tier";
const { onboard } = require(${onboardPath});
const exitMarker = "__NEMOCLAW_TEST_PROCESS_EXIT__";
-process.exit = (code = 0) => {
+let exitObservation = null;
+const originalExit = (code = 0) => {
+ exitObservation = {
+ processExitRestored: process.exit === originalExit,
+ nonInteractiveEnv: process.env.NEMOCLAW_NON_INTERACTIVE,
+ };
const err = new Error(exitMarker);
err.code = Number(code);
throw err;
};
+process.exit = originalExit;
(async () => {
try {
await onboard({
@@ -245,6 +251,7 @@ process.exit = (code = 0) => {
usageNoticeExists: fs.existsSync(path.join(stateDir, "usage-notice.json")),
lockExists: fs.existsSync(path.join(stateDir, "onboard.lock")),
sessionExists: fs.existsSync(path.join(stateDir, "onboard-session.json")),
+ exitObservation,
}) + "\n");
process.exitCode = err.code;
}
@@ -257,6 +264,10 @@ process.exit = (code = 0) => {
assert.equal(payload.usageNoticeExists, false, "usage notice must not be accepted/written");
assert.equal(payload.lockExists, false, "onboard lock must not be created");
assert.equal(payload.sessionExists, false, "onboard session must not be created");
+ assert.deepEqual(payload.exitObservation, {
+ processExitRestored: true,
+ nonInteractiveEnv: "preserve-direct",
+ });
assert.match(
result.stderr,
/Unknown policy tier: invalid_tier\. Valid: restricted, balanced, open, personal/,