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