From 2bfadee5131efc75c4bf452b0f97803952c32a22 Mon Sep 17 00:00:00 2001 From: Senthil Ravichandran Date: Thu, 13 Aug 2026 18:09:31 -0700 Subject: [PATCH 1/7] fix(onboard): restore portable authority on resume Signed-off-by: Senthil Ravichandran --- docs/reference/commands.mdx | 32 +- docs/reference/troubleshooting.mdx | 35 +- src/lib/actions/onboard.ts | 2 + src/lib/adapters/podman/index.test.ts | 21 +- src/lib/adapters/podman/index.ts | 3 + src/lib/build-context.test.ts | 6 +- src/lib/build-context.ts | 4 +- src/lib/onboard.ts | 188 +++++++---- src/lib/onboard/checkpoint-record.test.ts | 2 + src/lib/onboard/checkpoint-replay.test.ts | 2 + .../onboard/checkpoint-resume-guard.test.ts | 18 +- src/lib/onboard/command.test.ts | 307 ++++++++++++++---- src/lib/onboard/command.ts | 214 +++++++----- .../docker-driver-gateway-failure.test.ts | 2 +- .../onboard/docker-driver-gateway-failure.ts | 14 +- src/lib/onboard/exit-step-failure.test.ts | 4 +- .../portable-host-preparation.test.ts | 277 +++++++++++++++- .../experimental/portable-host-preparation.ts | 256 ++++++++++++++- .../onboard/fatal-runtime-preflight.test.ts | 44 +-- src/lib/onboard/fatal-runtime-preflight.ts | 27 -- .../gateway-start-failure-integration.test.ts | 7 +- src/lib/onboard/gateway-start-failure.ts | 6 +- .../sandbox-checkpoint-crash-recovery.test.ts | 2 + .../handlers/sandbox-messaging.test.ts | 2 + .../sandbox-provider-effect-replay.test.ts | 16 + .../sandbox-rebuild-web-search-reuse.test.ts | 2 + .../onboard/machine/handlers/sandbox.test.ts | 10 + src/lib/onboard/machine/runtime.ts | 3 + .../portable-environment-scope.test.ts | 132 ++++++++ .../onboard/portable-resume-intent.test.ts | 172 ++++++++++ .../portable-resume-lock-boundary.test.ts | 209 ++++++++++++ src/lib/onboard/resume-hint.test.ts | 14 +- src/lib/onboard/resume-hint.ts | 25 +- .../onboard/resume/portable-resume-intent.ts | 169 ++++++++++ src/lib/onboard/session-bootstrap.test.ts | 48 +++ src/lib/onboard/session-bootstrap.ts | 241 ++++++++++++-- src/lib/onboard/session-recovery.ts | 2 + src/lib/onboard/types.ts | 8 + src/lib/state/onboard-checkpoint-decision.ts | 13 +- .../state/onboard-checkpoint-migrate.test.ts | 22 +- src/lib/state/onboard-checkpoint-migrate.ts | 52 ++- src/lib/state/onboard-checkpoint-types.ts | 33 +- src/lib/state/onboard-checkpoint.test.ts | 131 +++++++- src/lib/state/onboard-checkpoint.ts | 210 ++++++++++-- ...onboard-session-cross-process-lock.test.ts | 2 +- src/lib/state/onboard-session.test.ts | 29 +- src/lib/state/onboard-session.ts | 17 + 47 files changed, 2629 insertions(+), 406 deletions(-) create mode 100644 src/lib/onboard/portable-environment-scope.test.ts create mode 100644 src/lib/onboard/portable-resume-intent.test.ts create mode 100644 src/lib/onboard/portable-resume-lock-boundary.test.ts create mode 100644 src/lib/onboard/resume/portable-resume-intent.ts diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx index bcf438c9679..360e1e206b6 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 combine `--resume` with `--experimental-profile portable` 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..37603c1ae37 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -3637,25 +3637,11 @@ async function runOnboard(opts: OnboardOptions = {}): Promise { _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 +3660,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 +3668,113 @@ 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; - } + const originalProcessExit = process.exit; + process.exit = ((code?: number): never => { + throw new onboardSessionBootstrap.OnboardDeferredExitError(code ?? 0); + }) as typeof process.exit; + let portableEnvScope: + | import("./onboard/session-bootstrap").PortableOnboardEnvironmentScope + | null = null; + // 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. + let stagedLegacyKeys: string[] = []; + let onboardTrace: ReturnType = { collector: null, span: null, }; let completed = false, returnedNormally = false; try { + if (resume && opts.resumeIntentSnapshot) { + onboardSessionBootstrap.assertLockedResumeIntentSnapshot(opts.resumeIntentSnapshot); + } + const storedSessionBeforePreparation = resume ? onboardSession.loadSession() : null; + const storedCheckpoint = storedSessionBeforePreparation?.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 ?? + (opts.experimentalProfile === "portable" ? "portable" : "default"); + if ( + resume && + opts.experimentalProfile !== null && + opts.experimentalProfile !== undefined && + opts.experimentalProfile !== checkpointProfile + ) { + throw new Error( + `The requested onboarding profile '${opts.experimentalProfile}' does not match checkpoint profile '${checkpointProfile}'.`, + ); + } + if (resume && checkpointProfile === "portable" && !opts.resumeIntentSnapshot) { + throw new Error("Portable onboarding resume requires a validated checkpoint snapshot."); + } + const expectedPortableAuthority = + storedCheckpoint?.runtimeAuthority.kind === "selected" + ? storedCheckpoint.runtimeAuthority.value + : null; + let preparedPortableAuthority: + | import("./state/onboard-checkpoint-types").CheckpointPortableRuntimeAuthority + | null = null; + const ensureNoticeAccepted = async () => { + const accepted = await ensureUsageNoticeConsent({ + nonInteractive: isNonInteractive(), + acceptedByFlag: opts.acceptThirdPartySoftware === true, + writeLine: console.error, + }); + if (!accepted) process.exit(1); + }; + // A fresh portable run must obtain consent before its bounded host preparation writes. + // A resumed run requalifies its recorded authority first, before any other write. + if (!resume) await ensureNoticeAccepted(); + if (checkpointProfile === "portable") { + portableEnvScope = onboardSessionBootstrap.createPortableOnboardEnvironmentScope( + process.env, + opts.portableInferenceActivation ?? null, + { resume }, + ); + const prepared = (opts.preparePortableHost ?? onboardSessionBootstrap.preparePortableExperimentalHost)( + process.env, + {}, + expectedPortableAuthority, + ); + if (!prepared) throw new Error("Portable runtime preparation did not run."); + preparedPortableAuthority = prepared.authority; + portableEnvScope.installRuntime({ + containersConf: prepared.containersConf, + socketPath: prepared.authority.socketPath, + }); + } else if (resume) { + portableEnvScope = onboardSessionBootstrap.createDefaultResumeProfileEnvironmentScope( + process.env, + ); + } + if (resume) await ensureNoticeAccepted(); + if (!authoritativeGateway) delete process.env.OPENSHELL_GATEWAY; + preparedDcodeRuntime.applyGatewayEnv(process.env); + if (isNonInteractive()) 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 +3787,8 @@ async function runOnboard(opts: OnboardOptions = {}): Promise { nonInteractive: isNonInteractive(), authoritativeResumeConfig: opts.authoritativeResumeConfig === true, servingProfileProvenance: opts.servingProfileProvenance ?? null, + checkpointProfile, + portableRuntimeAuthority: preparedPortableAuthority, agentFlag: opts.agent || null, envAgent: process.env.NEMOCLAW_AGENT || null, requestedHostMounts: opts.hostMounts, @@ -3767,6 +3810,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 +4342,8 @@ async function runOnboard(opts: OnboardOptions = {}): Promise { } finally { try { await hermesApiPortReservationScope.release(); + portableEnvScope?.restore(); + process.exit = originalProcessExit; releaseOnboardLock(); onboardRuntimeBoundary.clear(); onboardTracing.finishOnboardTrace(onboardTrace, completed); @@ -4289,6 +4355,8 @@ async function runOnboard(opts: OnboardOptions = {}): Promise { else process.env.OPENSHELL_LOCAL_TLS_DIR = previousOpenshellLocalTlsDir; resetGatewayOwnerBinding(); } finally { + portableEnvScope?.restore(); + process.exit = originalProcessExit; 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..7cc8283a91c 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); @@ -126,16 +128,12 @@ describe("resume checkpoint fail-safe (#6228)", () => { const deps = makeDeps({ updateSession, resolveResumeCheckpoint: (): CheckpointLoadResult => ({ - status: "migrated", - checkpoint: loadedCheckpoint, - fromVersion: 0, + status: "legacy", + foundVersion: 3, }), - getResumeConfigConflicts: () => { - throw new Error("PAST_GUARD"); - }, }); - 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..5be40d180d7 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(); @@ -251,6 +252,7 @@ describe("onboard command options", () => { noOllamaAutostart: true, experimentalProfile: null, portableInferenceActivation: null, + resumeIntentSnapshot: null, servingProfile: null, servingProfileProvenance: null, }); @@ -280,6 +282,7 @@ describe("onboard command options", () => { noOllamaAutostart: false, experimentalProfile: null, portableInferenceActivation: null, + resumeIntentSnapshot: null, servingProfile: null, servingProfileProvenance: null, }); @@ -338,15 +341,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 +508,179 @@ 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("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 +752,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 +773,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 +818,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,33 +978,34 @@ 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) => { - 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.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("escapes terminal controls in gateway declaration errors before printing (#7627)", async () => { const errors: string[] = []; diff --git a/src/lib/onboard/command.ts b/src/lib/onboard/command.ts index 1c2b9bb373a..61019fdf302 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, + OnboardResumeIntentRaceError, + isOnboardResumeIntentRaceError, + resolveOnboardResumeIntent, + type OnboardResumeIntentSnapshot, + type ResolvedOnboardResumeIntent, + OnboardDeferredExitError, +} from "./session-bootstrap"; export interface OnboardCommandOptions { tempManagedRuntime: boolean; @@ -74,6 +82,7 @@ export interface OnboardCommandOptions { noOllamaAutostart: boolean; experimentalProfile: ExperimentalOnboardProfile | null; portableInferenceActivation: PortableInferenceActivation | null; + resumeIntentSnapshot: OnboardResumeIntentSnapshot | null; servingProfile: string | null; servingProfileProvenance: ServingProfileProvenance | null; } @@ -87,6 +96,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 { @@ -213,8 +224,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 +300,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 +315,7 @@ function resolveServingProfileLifecycle( ); } const requested = explicit ?? installerProfile; - if (flags.resume !== true) return requested; + if (!resume) return requested; return resolveResumedServingProfile(requested, deps); } @@ -341,16 +357,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 +368,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 +389,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 +410,7 @@ export function resolveOnboardOptions( noOllamaAutostart: withPortableDefault(flags["no-ollama-autostart"], experimentalProfile), experimentalProfile, portableInferenceActivation: null, + resumeIntentSnapshot: deps.resumeIntent?.snapshot ?? null, servingProfile: activeServingProfileId(servingProfileProvenance), servingProfileProvenance, }; @@ -418,24 +425,29 @@ 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 { + (deps.error ?? console.error)(message); + 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 +457,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 +519,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 (error instanceof OnboardDeferredExitError) 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/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/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/portable-environment-scope.test.ts b/src/lib/onboard/portable-environment-scope.test.ts new file mode 100644 index 00000000000..46982fdb6f3 --- /dev/null +++ b/src/lib/onboard/portable-environment-scope.test.ts @@ -0,0 +1,132 @@ +// 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"; + +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 PORTABLE_RUNTIME_ENV_KEYS) { + if (key === "NEMOCLAW_EXPERIMENTAL_PROFILE") continue; + 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..3774aea0954 --- /dev/null +++ b/src/lib/onboard/portable-resume-intent.test.ts @@ -0,0 +1,172 @@ +// 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)", () => { + const legacy = JSON.parse(rawSession()) as Record; + legacy.checkpoint = { schemaVersion: 3 }; + 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..3bd840bd1d1 --- /dev/null +++ b/src/lib/onboard/portable-resume-lock-boundary.test.ts @@ -0,0 +1,209 @@ +// 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"); + 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; + } + }); + + 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 resolveResumeIntent = (options: { + explicitResume: boolean; + fresh: boolean; + explicitProfile: "default" | "portable" | null; + }) => { + const resolved = resumeIntent.resolveOnboardResumeIntent({ + ...options, + sessionFile: session.SESSION_FILE, + }); + resolutions += 1; + if (resolved.snapshot) resolvedFingerprints.push(resolved.snapshot.fingerprint); + resolvedRaw.push(fs.readFileSync(session.SESSION_FILE, "utf8")); + if (resolutions === 1) { + 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)); + } + 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/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..27c9e15a330 100644 --- a/src/lib/onboard/session-bootstrap.ts +++ b/src/lib/onboard/session-bootstrap.ts @@ -3,14 +3,35 @@ import type { ServingProfileProvenance } from "../inference/serving/types"; 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 +39,163 @@ 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]; + }, + }; +} + +export class OnboardDeferredExitError extends Error { + readonly code: number; + + constructor(code: number) { + super(`Onboarding requested exit ${String(code)}.`); + this.name = "OnboardDeferredExitError"; + this.code = code; + } +} + +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 +212,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 +312,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 +328,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 +474,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..801876bbb4a 100644 --- a/src/lib/onboard/types.ts +++ b/src/lib/onboard/types.ts @@ -151,6 +151,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..6502e9579aa 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", foundVersion: 3 }); }); - 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..15ec32f7689 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); From 111eccefc782da40396ecafc1d18e004d7f5ff16 Mon Sep 17 00:00:00 2001 From: Senthil Ravichandran Date: Thu, 13 Aug 2026 18:23:06 -0700 Subject: [PATCH 2/7] refactor(onboard): extract locked runtime preparation Signed-off-by: Senthil Ravichandran --- src/lib/onboard.ts | 81 +------------ src/lib/onboard/resume/locked-runtime.ts | 146 +++++++++++++++++++++++ 2 files changed, 152 insertions(+), 75 deletions(-) create mode 100644 src/lib/onboard/resume/locked-runtime.ts diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 37603c1ae37..8c7f782cff9 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, @@ -3675,11 +3675,7 @@ async function runOnboard(opts: OnboardOptions = {}): Promise { let portableEnvScope: | import("./onboard/session-bootstrap").PortableOnboardEnvironmentScope | null = null; - // 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. + // Secure removal remains gated on successful migration of every staged legacy credential. let stagedLegacyKeys: string[] = []; let onboardTrace: ReturnType = { @@ -3688,73 +3684,8 @@ async function runOnboard(opts: OnboardOptions = {}): Promise { }; let completed = false, returnedNormally = false; try { - if (resume && opts.resumeIntentSnapshot) { - onboardSessionBootstrap.assertLockedResumeIntentSnapshot(opts.resumeIntentSnapshot); - } - const storedSessionBeforePreparation = resume ? onboardSession.loadSession() : null; - const storedCheckpoint = storedSessionBeforePreparation?.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 ?? - (opts.experimentalProfile === "portable" ? "portable" : "default"); - if ( - resume && - opts.experimentalProfile !== null && - opts.experimentalProfile !== undefined && - opts.experimentalProfile !== checkpointProfile - ) { - throw new Error( - `The requested onboarding profile '${opts.experimentalProfile}' does not match checkpoint profile '${checkpointProfile}'.`, - ); - } - if (resume && checkpointProfile === "portable" && !opts.resumeIntentSnapshot) { - throw new Error("Portable onboarding resume requires a validated checkpoint snapshot."); - } - const expectedPortableAuthority = - storedCheckpoint?.runtimeAuthority.kind === "selected" - ? storedCheckpoint.runtimeAuthority.value - : null; - let preparedPortableAuthority: - | import("./state/onboard-checkpoint-types").CheckpointPortableRuntimeAuthority - | null = null; - const ensureNoticeAccepted = async () => { - const accepted = await ensureUsageNoticeConsent({ - nonInteractive: isNonInteractive(), - acceptedByFlag: opts.acceptThirdPartySoftware === true, - writeLine: console.error, - }); - if (!accepted) process.exit(1); - }; - // A fresh portable run must obtain consent before its bounded host preparation writes. - // A resumed run requalifies its recorded authority first, before any other write. - if (!resume) await ensureNoticeAccepted(); - if (checkpointProfile === "portable") { - portableEnvScope = onboardSessionBootstrap.createPortableOnboardEnvironmentScope( - process.env, - opts.portableInferenceActivation ?? null, - { resume }, - ); - const prepared = (opts.preparePortableHost ?? onboardSessionBootstrap.preparePortableExperimentalHost)( - process.env, - {}, - expectedPortableAuthority, - ); - if (!prepared) throw new Error("Portable runtime preparation did not run."); - preparedPortableAuthority = prepared.authority; - portableEnvScope.installRuntime({ - containersConf: prepared.containersConf, - socketPath: prepared.authority.socketPath, - }); - } else if (resume) { - portableEnvScope = onboardSessionBootstrap.createDefaultResumeProfileEnvironmentScope( - process.env, - ); - } - if (resume) await ensureNoticeAccepted(); + 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()) validatePolicyTierEnvEarly(); @@ -3787,8 +3718,8 @@ async function runOnboard(opts: OnboardOptions = {}): Promise { nonInteractive: isNonInteractive(), authoritativeResumeConfig: opts.authoritativeResumeConfig === true, servingProfileProvenance: opts.servingProfileProvenance ?? null, - checkpointProfile, - portableRuntimeAuthority: preparedPortableAuthority, + checkpointProfile: lockedRuntime.checkpointProfile, + portableRuntimeAuthority: lockedRuntime.preparedPortableAuthority, agentFlag: opts.agent || null, envAgent: process.env.NEMOCLAW_AGENT || null, requestedHostMounts: opts.hostMounts, diff --git a/src/lib/onboard/resume/locked-runtime.ts b/src/lib/onboard/resume/locked-runtime.ts new file mode 100644 index 00000000000..70f3a67c789 --- /dev/null +++ b/src/lib/onboard/resume/locked-runtime.ts @@ -0,0 +1,146 @@ +// 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}'.`, + ); + } + if (resume && checkpointProfile === "portable" && !options.resumeIntentSnapshot) { + throw new Error("Portable onboarding resume requires a validated checkpoint snapshot."); + } + return { + checkpointProfile, + expectedPortableAuthority: + storedCheckpoint?.runtimeAuthority.kind === "selected" + ? storedCheckpoint.runtimeAuthority.value + : null, + }; +} + +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; + } +} From 98f940251577f259d3573eb7cf8145080d0ae8c3 Mon Sep 17 00:00:00 2001 From: Senthil Ravichandran Date: Thu, 13 Aug 2026 18:28:18 -0700 Subject: [PATCH 3/7] test(onboard): keep portable resume tests linear Signed-off-by: Senthil Ravichandran --- .../portable-environment-scope.test.ts | 7 ++++-- .../portable-resume-lock-boundary.test.ts | 22 +++++++++++-------- 2 files changed, 18 insertions(+), 11 deletions(-) diff --git a/src/lib/onboard/portable-environment-scope.test.ts b/src/lib/onboard/portable-environment-scope.test.ts index 46982fdb6f3..c464cf7be8c 100644 --- a/src/lib/onboard/portable-environment-scope.test.ts +++ b/src/lib/onboard/portable-environment-scope.test.ts @@ -9,6 +9,10 @@ import { 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" }; @@ -36,8 +40,7 @@ describe("portable onboarding environment scope", () => { const scope = createPortableOnboardEnvironmentScope(env, null); - for (const key of PORTABLE_RUNTIME_ENV_KEYS) { - if (key === "NEMOCLAW_EXPERIMENTAL_PROFILE") continue; + for (const key of CLEARED_PORTABLE_RUNTIME_ENV_KEYS) { expect(env).not.toHaveProperty(key); } expect(env.NEMOCLAW_EXPERIMENTAL_PROFILE).toBe("portable"); diff --git a/src/lib/onboard/portable-resume-lock-boundary.test.ts b/src/lib/onboard/portable-resume-lock-boundary.test.ts index 3bd840bd1d1..62181035f0f 100644 --- a/src/lib/onboard/portable-resume-lock-boundary.test.ts +++ b/src/lib/onboard/portable-resume-lock-boundary.test.ts @@ -151,6 +151,17 @@ describe("portable resume command lock boundary", () => { 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; @@ -161,16 +172,9 @@ describe("portable resume command lock boundary", () => { sessionFile: session.SESSION_FILE, }); resolutions += 1; - if (resolved.snapshot) resolvedFingerprints.push(resolved.snapshot.fingerprint); + resolvedFingerprints.push(resolved.snapshot!.fingerprint); resolvedRaw.push(fs.readFileSync(session.SESSION_FILE, "utf8")); - if (resolutions === 1) { - 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)); - } + afterResolution[resolutions - 1]!(); return resolved; }; From 2625c2bbb518d52137b6c691ba3cfed85f36d33a Mon Sep 17 00:00:00 2001 From: Senthil Ravichandran Date: Thu, 13 Aug 2026 18:52:20 -0700 Subject: [PATCH 4/7] docs(onboard): clarify portable resume command Signed-off-by: Senthil Ravichandran --- docs/reference/commands.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx index d5184db4b8c..fa997cc340d 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -352,7 +352,7 @@ For the portable profile, it also records the current user's canonical home repo 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 combine `--resume` with `--experimental-profile portable` when the recorded profile is portable. +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. From 9e2071379b46dec709a05b6e88d7fe73904fa734 Mon Sep 17 00:00:00 2001 From: Senthil Ravichandran Date: Thu, 13 Aug 2026 20:03:40 -0700 Subject: [PATCH 5/7] fix(onboard): restore portable resume failure handling Signed-off-by: Senthil Ravichandran --- src/lib/onboard.ts | 31 +++++--- .../onboard/checkpoint-resume-guard.test.ts | 5 +- src/lib/onboard/command.test.ts | 67 +++++++++++++++++ src/lib/onboard/command.ts | 13 ++-- src/lib/onboard/machine/events.ts | 2 + src/lib/onboard/machine/hooks.ts | 2 +- .../onboard/policy-selection-prompts.test.ts | 16 +++-- src/lib/onboard/policy-selection-prompts.ts | 26 ++++--- .../onboard/portable-resume-intent.test.ts | 30 ++++---- .../portable-resume-lock-boundary.test.ts | 7 +- src/lib/onboard/resume/locked-runtime.test.ts | 48 +++++++++++++ src/lib/onboard/resume/locked-runtime.ts | 17 +++-- src/lib/onboard/session-bootstrap.ts | 27 ++++++- src/lib/onboard/types.ts | 2 + .../state/onboard-checkpoint-migrate.test.ts | 2 +- src/lib/state/onboard-checkpoint-migrate.ts | 2 +- src/lib/state/onboard-checkpoint-types.ts | 2 +- test/cli/onboard-compatibility.test.ts | 71 +++++++------------ test/nemo-deepagents-alias.test.ts | 10 ++- test/nemohermes-alias.test.ts | 15 ++-- test/onboard-fsm-live-slices.test.ts | 2 + test/onboard-inference-reconciliation.test.ts | 44 ++++++------ test/onboard-lifecycle.test.ts | 51 +++++++------ test/onboard-prepared-gateway-handoff.test.ts | 10 ++- test/onboard-sandbox-name.test.ts | 25 ++++--- test/policy-tiers-onboard.test.ts | 15 +++- 26 files changed, 364 insertions(+), 178 deletions(-) create mode 100644 src/lib/onboard/resume/locked-runtime.test.ts diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 8c7f782cff9..3e2daa753b9 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -3607,7 +3607,25 @@ async function preflightAuthoritativeRebuildTarget( } // ── Main ───────────────────────────────────────────────────────── -const onboard = onboardEntryOptions.wrapOnboard(runOnboard, onboardSession); +const wrappedOnboard = onboardEntryOptions.wrapOnboard(runOnboard, onboardSession); +async function onboard(opts: OnboardOptions = {}): Promise { + const originalProcessExit = process.exit; + let deferredExit: import("./onboard/session-bootstrap").OnboardDeferredExitError | null = null; + process.exit = ((code?: number): never => { + throw new onboardSessionBootstrap.OnboardDeferredExitError(code ?? 0); + }) as typeof process.exit; + try { + await wrappedOnboard(opts); + } catch (error) { + if (!onboardSessionBootstrap.isOnboardDeferredExitError(error)) throw error; + deferredExit = error; + } finally { + process.exit = originalProcessExit; + } + if (!deferredExit) return; + if (opts.deferProcessExit === true) throw deferredExit; + originalProcessExit(deferredExit.code); +} async function runOnboard(opts: OnboardOptions = {}): Promise { const hostMountScope = onboardSessionBootstrap.beginHostMountScope(opts.hostMounts); const hermesApiPortReservationScope = agentOnboard.createHermesApiPortReservationScope(); @@ -3633,6 +3651,9 @@ 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); @@ -3668,10 +3689,6 @@ async function runOnboard(opts: OnboardOptions = {}): Promise { }; if (ownsOnboardLock) process.once("exit", releaseOnboardLock); - const originalProcessExit = process.exit; - process.exit = ((code?: number): never => { - throw new onboardSessionBootstrap.OnboardDeferredExitError(code ?? 0); - }) as typeof process.exit; let portableEnvScope: | import("./onboard/session-bootstrap").PortableOnboardEnvironmentScope | null = null; @@ -3688,7 +3705,7 @@ async function runOnboard(opts: OnboardOptions = {}): Promise { portableEnvScope = lockedRuntime.environmentScope; if (!authoritativeGateway) delete process.env.OPENSHELL_GATEWAY; preparedDcodeRuntime.applyGatewayEnv(process.env); - if (isNonInteractive()) validatePolicyTierEnvEarly(); + if (isNonInteractive() && !validatePolicyTierBeforeRuntime) validatePolicyTierEnvEarly(); // Validate provider/model hints only after the locked profile and runtime authority are active. const stationSessionInput = onboardEntryOptions.prepareSessionInput( runtimeControlRequests, @@ -4274,7 +4291,6 @@ async function runOnboard(opts: OnboardOptions = {}): Promise { try { await hermesApiPortReservationScope.release(); portableEnvScope?.restore(); - process.exit = originalProcessExit; releaseOnboardLock(); onboardRuntimeBoundary.clear(); onboardTracing.finishOnboardTrace(onboardTrace, completed); @@ -4287,7 +4303,6 @@ async function runOnboard(opts: OnboardOptions = {}): Promise { resetGatewayOwnerBinding(); } finally { portableEnvScope?.restore(); - process.exit = originalProcessExit; hostMountScope.restore(); } } diff --git a/src/lib/onboard/checkpoint-resume-guard.test.ts b/src/lib/onboard/checkpoint-resume-guard.test.ts index 7cc8283a91c..e3a2a2ff45f 100644 --- a/src/lib/onboard/checkpoint-resume-guard.test.ts +++ b/src/lib/onboard/checkpoint-resume-guard.test.ts @@ -127,10 +127,7 @@ describe("resume checkpoint fail-safe (#6228)", () => { }); const deps = makeDeps({ updateSession, - resolveResumeCheckpoint: (): CheckpointLoadResult => ({ - status: "legacy", - foundVersion: 3, - }), + resolveResumeCheckpoint: (): CheckpointLoadResult => ({ status: "legacy" }), }); await expect(prepareOnboardSession(resumeInput, deps)).rejects.toThrow(); expect(updateSession).not.toHaveBeenCalled(); diff --git a/src/lib/onboard/command.test.ts b/src/lib/onboard/command.test.ts index 5be40d180d7..270831c782c 100644 --- a/src/lib/onboard/command.test.ts +++ b/src/lib/onboard/command.test.ts @@ -246,6 +246,7 @@ describe("onboard command options", () => { toolDisclosure: "direct", observabilityEnabled: true, controlUiPort: 18790, + deferProcessExit: true, gpu: true, noGpu: false, autoYes: true, @@ -276,6 +277,7 @@ describe("onboard command options", () => { toolDisclosure: null, observabilityEnabled: null, controlUiPort: null, + deferProcessExit: true, gpu: false, noGpu: false, autoYes: false, @@ -595,6 +597,28 @@ describe("onboard command options", () => { 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"); @@ -978,6 +1002,27 @@ describe("onboard command options", () => { expect(output).not.toContain(" at "); }); + it("redacts credentials in a gateway declaration diagnostic (#9035)", async () => { + const errors: string[] = []; + await expect( + runOnboardCommand({ + 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) => { @@ -1007,6 +1052,28 @@ describe("onboard command options", () => { }, ); + 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; OPENAI_API_KEY=secret-authority-value.", + ); + }, + error: (message = "") => errors.push(message), + exit: exitWithCode, + }), + ).rejects.toThrow("exit:1"); + + const output = errors.join("\n"); + 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 () => { const errors: string[] = []; await expect( diff --git a/src/lib/onboard/command.ts b/src/lib/onboard/command.ts index 61019fdf302..968bf40f0cb 100644 --- a/src/lib/onboard/command.ts +++ b/src/lib/onboard/command.ts @@ -50,12 +50,12 @@ import { isOpenclawAgent } from "./openclaw-otel-policy-presets"; import { NOTICE_ACCEPT_ENV, NOTICE_ACCEPT_FLAG_NAME } from "./usage-notice"; import { OnboardResumeIntentError, - OnboardResumeIntentRaceError, isOnboardResumeIntentRaceError, resolveOnboardResumeIntent, type OnboardResumeIntentSnapshot, type ResolvedOnboardResumeIntent, - OnboardDeferredExitError, + isOnboardDeferredExitError, + redactOnboardDiagnosticText, } from "./session-bootstrap"; export interface OnboardCommandOptions { @@ -82,6 +82,7 @@ export interface OnboardCommandOptions { noOllamaAutostart: boolean; experimentalProfile: ExperimentalOnboardProfile | null; portableInferenceActivation: PortableInferenceActivation | null; + deferProcessExit: true; resumeIntentSnapshot: OnboardResumeIntentSnapshot | null; servingProfile: string | null; servingProfileProvenance: ServingProfileProvenance | null; @@ -109,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); } @@ -410,6 +411,7 @@ export function resolveOnboardOptions( noOllamaAutostart: withPortableDefault(flags["no-ollama-autostart"], experimentalProfile), experimentalProfile, portableInferenceActivation: null, + deferProcessExit: true, resumeIntentSnapshot: deps.resumeIntent?.snapshot ?? null, servingProfile: activeServingProfileId(servingProfileProvenance), servingProfileProvenance, @@ -426,7 +428,8 @@ function promptCancellationCode(error: unknown): "EOF" | "SIGINT" | null { } function reportOnboardCommandError(deps: RunOnboardCommandDeps, message: string): number { - (deps.error ?? console.error)(message); + const redacted = message.split("\n").map(redactOnboardDiagnosticText).join("\n"); + (deps.error ?? console.error)(redacted); return 1; } @@ -565,7 +568,7 @@ function handleOnboardCommandAttemptError( " The onboarding checkpoint changed while resume acquired its lock. Retry the command.", ); } - if (error instanceof OnboardDeferredExitError) return error.code; + if (isOnboardDeferredExitError(error)) return error.code; return handleOnboardCommandError(error, deps) ?? "complete"; } 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/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/policy-selection-prompts.test.ts b/src/lib/onboard/policy-selection-prompts.test.ts index a2007ffa09b..464a626385f 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,28 @@ 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); 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-resume-intent.test.ts b/src/lib/onboard/portable-resume-intent.test.ts index 3774aea0954..415e374089b 100644 --- a/src/lib/onboard/portable-resume-intent.test.ts +++ b/src/lib/onboard/portable-resume-intent.test.ts @@ -92,20 +92,22 @@ describe("portable resume intent", () => { }); it("refuses active schema v1-v3 checkpoints byte-for-byte with --fresh guidance (#9035)", () => { - const legacy = JSON.parse(rawSession()) as Record; - legacy.checkpoint = { schemaVersion: 3 }; - 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); + 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)", () => { diff --git a/src/lib/onboard/portable-resume-lock-boundary.test.ts b/src/lib/onboard/portable-resume-lock-boundary.test.ts index 62181035f0f..3dc017d9794 100644 --- a/src/lib/onboard/portable-resume-lock-boundary.test.ts +++ b/src/lib/onboard/portable-resume-lock-boundary.test.ts @@ -29,6 +29,8 @@ beforeAll(() => { 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 = { @@ -120,8 +122,9 @@ describe("portable resume command lock boundary", () => { 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 } = @@ -173,8 +176,8 @@ describe("portable resume command lock boundary", () => { }); resolutions += 1; resolvedFingerprints.push(resolved.snapshot!.fingerprint); - resolvedRaw.push(fs.readFileSync(session.SESSION_FILE, "utf8")); afterResolution[resolutions - 1]!(); + resolvedRaw.push(fs.readFileSync(session.SESSION_FILE, "utf8")); return resolved; }; 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 index 70f3a67c789..cf9618366b1 100644 --- a/src/lib/onboard/resume/locked-runtime.ts +++ b/src/lib/onboard/resume/locked-runtime.ts @@ -64,16 +64,19 @@ function resolveCheckpointProfile( `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: - storedCheckpoint?.runtimeAuthority.kind === "selected" - ? storedCheckpoint.runtimeAuthority.value - : null, - }; + return { checkpointProfile, expectedPortableAuthority }; } function prepareEnvironment( diff --git a/src/lib/onboard/session-bootstrap.ts b/src/lib/onboard/session-bootstrap.ts index 27c9e15a330..1896c7876aa 100644 --- a/src/lib/onboard/session-bootstrap.ts +++ b/src/lib/onboard/session-bootstrap.ts @@ -2,6 +2,7 @@ // 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 { deriveCheckpointFromSession, @@ -14,7 +15,11 @@ import type { CheckpointPortableRuntimeAuthority, } from "../state/onboard-checkpoint-types"; import type { Session } from "../state/onboard-session"; -import { DEFAULT_TOOL_DISCLOSURE, TOOL_DISCLOSURE_ENV, 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"; @@ -131,7 +136,10 @@ export function createDefaultResumeProfileEnvironmentScope( }; } +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) { @@ -141,6 +149,23 @@ export class OnboardDeferredExitError extends Error { } } +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) + ); +} + +export function redactOnboardDiagnosticText(message: string): string { + return redactSensitiveText(message) ?? ""; +} + export function createPortableOnboardEnvironmentScope( env: NodeJS.ProcessEnv, activation: PortableInferenceActivation | null, diff --git a/src/lib/onboard/types.ts b/src/lib/onboard/types.ts index 801876bbb4a..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. */ diff --git a/src/lib/state/onboard-checkpoint-migrate.test.ts b/src/lib/state/onboard-checkpoint-migrate.test.ts index 6502e9579aa..6b7e4fe1ceb 100644 --- a/src/lib/state/onboard-checkpoint-migrate.test.ts +++ b/src/lib/state/onboard-checkpoint-migrate.test.ts @@ -111,7 +111,7 @@ describe("resolveCheckpointForResume", () => { it("refuses a legacy session that has no embedded checkpoint", () => { const raw = rawJson(completedSession()); const result = resolveCheckpointForResume(raw); - expect(result).toEqual({ status: "legacy", foundVersion: 3 }); + expect(result).toEqual({ status: "legacy" }); }); it("refuses an active legacy checkpoint without rewriting it", () => { diff --git a/src/lib/state/onboard-checkpoint-migrate.ts b/src/lib/state/onboard-checkpoint-migrate.ts index 15ec32f7689..4ff6fbd4270 100644 --- a/src/lib/state/onboard-checkpoint-migrate.ts +++ b/src/lib/state/onboard-checkpoint-migrate.ts @@ -146,7 +146,7 @@ export function resolveCheckpointForResume(rawSession: unknown): CheckpointLoadR return inspected; } - return { status: "legacy", foundVersion: 3 }; + return { status: "legacy" }; } export function loadResumeCheckpoint(): CheckpointLoadResult { diff --git a/src/lib/state/onboard-checkpoint-types.ts b/src/lib/state/onboard-checkpoint-types.ts index c466491a815..18820630c7a 100644 --- a/src/lib/state/onboard-checkpoint-types.ts +++ b/src/lib/state/onboard-checkpoint-types.ts @@ -156,6 +156,6 @@ export interface OnboardCheckpoint { export type CheckpointLoadResult = | { readonly status: "none" } | { readonly status: "loaded"; readonly checkpoint: OnboardCheckpoint } - | { readonly status: "legacy"; readonly foundVersion: 1 | 2 | 3 } + | { readonly status: "legacy"; readonly foundVersion?: 1 | 2 | 3 } | { readonly status: "unsupported_future"; readonly foundVersion: number } | { readonly status: "corrupt" }; 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/, From 657bdeaa0804fc20e24cb29c61018dcf7ec77deb Mon Sep 17 00:00:00 2001 From: Senthil Ravichandran Date: Thu, 13 Aug 2026 20:15:37 -0700 Subject: [PATCH 6/7] refactor(onboard): extract deferred exit wrapper Signed-off-by: Senthil Ravichandran --- src/lib/onboard.ts | 19 +------------------ src/lib/onboard/session-bootstrap.ts | 28 ++++++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 18 deletions(-) diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 3e2daa753b9..cde8d12f2ee 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -3608,24 +3608,7 @@ async function preflightAuthoritativeRebuildTarget( // ── Main ───────────────────────────────────────────────────────── const wrappedOnboard = onboardEntryOptions.wrapOnboard(runOnboard, onboardSession); -async function onboard(opts: OnboardOptions = {}): Promise { - const originalProcessExit = process.exit; - let deferredExit: import("./onboard/session-bootstrap").OnboardDeferredExitError | null = null; - process.exit = ((code?: number): never => { - throw new onboardSessionBootstrap.OnboardDeferredExitError(code ?? 0); - }) as typeof process.exit; - try { - await wrappedOnboard(opts); - } catch (error) { - if (!onboardSessionBootstrap.isOnboardDeferredExitError(error)) throw error; - deferredExit = error; - } finally { - process.exit = originalProcessExit; - } - if (!deferredExit) return; - if (opts.deferProcessExit === true) throw deferredExit; - originalProcessExit(deferredExit.code); -} +const onboard = onboardSessionBootstrap.wrapOnboardDeferredExit(wrappedOnboard); async function runOnboard(opts: OnboardOptions = {}): Promise { const hostMountScope = onboardSessionBootstrap.beginHostMountScope(opts.hostMounts); const hermesApiPortReservationScope = agentOnboard.createHermesApiPortReservationScope(); diff --git a/src/lib/onboard/session-bootstrap.ts b/src/lib/onboard/session-bootstrap.ts index 1896c7876aa..a4adb147253 100644 --- a/src/lib/onboard/session-bootstrap.ts +++ b/src/lib/onboard/session-bootstrap.ts @@ -162,6 +162,34 @@ export function isOnboardDeferredExitError(error: unknown): error is OnboardDefe ); } +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) ?? ""; } From 338493906f3ac2abc96e2049b2e124c1314897da Mon Sep 17 00:00:00 2001 From: Senthil Ravichandran Date: Thu, 13 Aug 2026 20:25:55 -0700 Subject: [PATCH 7/7] test(onboard): cover preset selection cancellation Signed-off-by: Senthil Ravichandran --- src/lib/onboard/policy-selection-prompts.test.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/lib/onboard/policy-selection-prompts.test.ts b/src/lib/onboard/policy-selection-prompts.test.ts index 464a626385f..d5d863daca9 100644 --- a/src/lib/onboard/policy-selection-prompts.test.ts +++ b/src/lib/onboard/policy-selection-prompts.test.ts @@ -221,6 +221,22 @@ describe("createPolicySelectionPromptHelpers", () => { 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); + }); + it("selectPolicyTier rejects when no policy tiers are configured", async () => { const { helpers } = createHarness({ tiers: [] });