diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index faeb6c979a..0367dd0a0c 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -4137,7 +4137,7 @@ async function runOnboard(opts: OnboardOptions = {}): Promise { try { onboardTrace = onboardTracing.startOnboardTrace(opts, process.env); let selectedMessagingChannels: string[] = []; - let { session, fromDockerfile } = await onboardSessionBootstrap.prepareOnboardSession( + let { session, fromDockerfile } = await onboardSessionBootstrap.prepareOnboardSessionValidated( { resume, fresh, diff --git a/src/lib/onboard/checkpoint-record.ts b/src/lib/onboard/checkpoint-record.ts new file mode 100644 index 0000000000..762898306a --- /dev/null +++ b/src/lib/onboard/checkpoint-record.ts @@ -0,0 +1,130 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { WebSearchConfig } from "../inference/web-search"; +import type { SandboxMessagingPlan } from "../messaging/manifest"; +import { + getActiveChannelIdsFromPlan, + getDisabledChannelIdsFromPlan, +} from "../messaging/plan-validation"; +import { decisionDeclined, decisionSelected } from "../state/onboard-checkpoint-decision"; +import { deriveCheckpointFromSession } from "../state/onboard-checkpoint-migrate"; +import type { + CheckpointEffectGroupName, + CheckpointProviderBinding, + CheckpointResourceProfile, + OnboardCheckpoint, +} from "../state/onboard-checkpoint-types"; +import type { Session } from "../state/onboard-session"; + +function baseCheckpoint(session: Session): OnboardCheckpoint { + return session.checkpoint ?? deriveCheckpointFromSession(session); +} + +export function recordCheckpointSandboxIdentity( + session: Session, + name: string, + agent: string, +): void { + const base = baseCheckpoint(session); + session.checkpoint = { + ...base, + machineState: session.machine.state, + updatedAt: new Date().toISOString(), + sandboxIdentity: decisionSelected({ name, agent }), + }; +} + +export function recordCheckpointEffectGroup( + session: Session, + group: CheckpointEffectGroupName, + fingerprint: string, +): void { + const base = baseCheckpoint(session); + const now = new Date().toISOString(); + session.checkpoint = { + ...base, + machineState: session.machine.state, + updatedAt: now, + effectGroups: { + ...base.effectGroups, + [group]: { completedAt: now, fingerprint }, + }, + }; +} + +export function recordCheckpointWebSearch( + session: Session, + webSearchConfig: WebSearchConfig | null, +): void { + const base = baseCheckpoint(session); + session.checkpoint = { + ...base, + machineState: session.machine.state, + updatedAt: new Date().toISOString(), + webSearch: webSearchConfig ? decisionSelected(webSearchConfig) : decisionDeclined(), + }; +} + +export function recordCheckpointMessaging( + session: Session, + messagingPlan: SandboxMessagingPlan | null, +): void { + const base = baseCheckpoint(session); + session.checkpoint = { + ...base, + machineState: session.machine.state, + updatedAt: new Date().toISOString(), + messaging: messagingPlan + ? decisionSelected({ + selectedChannels: getActiveChannelIdsFromPlan(messagingPlan), + disabledChannels: getDisabledChannelIdsFromPlan(messagingPlan), + }) + : decisionDeclined(), + }; +} + +export function recordCheckpointResourceProfile( + session: Session, + resourceProfile: CheckpointResourceProfile | null, +): void { + const base = baseCheckpoint(session); + session.checkpoint = { + ...base, + machineState: session.machine.state, + updatedAt: new Date().toISOString(), + resourceProfile: resourceProfile ? decisionSelected(resourceProfile) : decisionDeclined(), + }; +} + +export function recordCheckpointBindings( + session: Session, + additions: { + registeredProviders?: readonly CheckpointProviderBinding[]; + }, +): void { + const base = baseCheckpoint(session); + const credentialEnvs = additions.registeredProviders + ? [ + ...new Set([ + ...base.bindings.credentialEnvs, + ...additions.registeredProviders.map((binding) => binding.credentialEnv), + ]), + ] + : base.bindings.credentialEnvs; + const registeredProviders = additions.registeredProviders + ? [ + ...new Map( + [...base.bindings.registeredProviders, ...additions.registeredProviders].map( + (binding) => [binding.name, binding], + ), + ).values(), + ] + : base.bindings.registeredProviders; + session.checkpoint = { + ...base, + machineState: session.machine.state, + updatedAt: new Date().toISOString(), + bindings: { credentialEnvs, registeredProviders }, + }; +} diff --git a/src/lib/onboard/checkpoint-replay.test.ts b/src/lib/onboard/checkpoint-replay.test.ts new file mode 100644 index 0000000000..f741a2f92a --- /dev/null +++ b/src/lib/onboard/checkpoint-replay.test.ts @@ -0,0 +1,161 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; + +import { decisionSelected, decisionUnset } from "../state/onboard-checkpoint-decision"; +import { + CHECKPOINT_SCHEMA_VERSION, + type OnboardCheckpoint, +} from "../state/onboard-checkpoint-types"; +import { planEffectGroupReplay, planSandboxCreateReplay } from "./checkpoint-replay"; +import { bindingRevalidationGuidance, revalidateCheckpointBindings } from "./checkpoint-revalidate"; + +const ISO = "2026-01-01T00:00:00.000Z"; + +function checkpoint(overrides: Partial = {}): OnboardCheckpoint { + return { + schemaVersion: CHECKPOINT_SCHEMA_VERSION, + sessionId: "s1", + machineState: "sandbox", + updatedAt: ISO, + sandboxIdentity: decisionSelected({ name: "my-sandbox", agent: "openclaw" }), + webSearch: decisionUnset(), + messaging: decisionUnset(), + resourceProfile: decisionUnset(), + effectGroups: {}, + bindings: { credentialEnvs: [], registeredProviders: [] }, + ...overrides, + }; +} + +describe("planEffectGroupReplay", () => { + it("runs an unrecorded effect group", () => { + expect(planEffectGroupReplay(checkpoint(), "messaging_providers", true).action).toBe("run"); + }); + + it("re-runs a recorded group whose postcondition no longer holds (never blind skip)", () => { + const cp = checkpoint({ + effectGroups: { messaging_providers: { completedAt: ISO, fingerprint: "fp" } }, + }); + const decision = planEffectGroupReplay(cp, "messaging_providers", false); + expect(decision).toEqual({ + group: "messaging_providers", + action: "run", + reason: "postcondition_failed", + }); + }); + + it("skips a recorded group only after its postcondition is revalidated", () => { + const cp = checkpoint({ + effectGroups: { messaging_providers: { completedAt: ISO, fingerprint: "fp" } }, + }); + expect(planEffectGroupReplay(cp, "messaging_providers", true).action).toBe("skip"); + }); +}); + +describe("planSandboxCreateReplay never opens a second sandbox (#5961)", () => { + it("requires identity capture before any create when identity is not durable", () => { + const cp = checkpoint({ sandboxIdentity: decisionUnset() }); + expect(planSandboxCreateReplay(cp, { liveSandboxExists: false })).toEqual({ + action: "capture_identity_first", + }); + }); + + it("reuses the live sandbox when create is recorded and it still exists", () => { + const cp = checkpoint({ + effectGroups: { sandbox_create: { completedAt: ISO, fingerprint: "fp" } }, + }); + expect(planSandboxCreateReplay(cp, { liveSandboxExists: true })).toEqual({ + action: "reuse", + identity: { name: "my-sandbox", agent: "openclaw" }, + }); + }); + + it("recreates under the SAME durable identity when the sandbox is gone, never a new name", () => { + const cp = checkpoint({ + effectGroups: { sandbox_create: { completedAt: ISO, fingerprint: "fp" } }, + }); + expect(planSandboxCreateReplay(cp, { liveSandboxExists: false })).toEqual({ + action: "create", + identity: { name: "my-sandbox", agent: "openclaw" }, + }); + }); + + it("creates under the durable identity when create was never recorded", () => { + expect(planSandboxCreateReplay(checkpoint(), { liveSandboxExists: false })).toEqual({ + action: "create", + identity: { name: "my-sandbox", agent: "openclaw" }, + }); + }); + + it("reuses a live sandbox even when the create receipt was lost to a mid-create crash (#7022)", () => { + expect(planSandboxCreateReplay(checkpoint(), { liveSandboxExists: true })).toEqual({ + action: "reuse", + identity: { name: "my-sandbox", agent: "openclaw" }, + }); + }); +}); + +describe("crash-then-resume matrix proves at-most-once destructive create (#6228)", () => { + const states = [ + "sandbox", + "openclaw", + "agent_setup", + "policies", + "finalizing", + "post_verify", + ] as const; + + it.each( + states, + )("crash at %s: reuse a surviving sandbox, recreate under the same identity when it is gone", (state) => { + const cp = checkpoint({ + machineState: state, + effectGroups: { sandbox_create: { completedAt: ISO, fingerprint: "fp" } }, + }); + expect(planSandboxCreateReplay(cp, { liveSandboxExists: true }).action).toBe("reuse"); + expect(planSandboxCreateReplay(cp, { liveSandboxExists: false })).toEqual({ + action: "create", + identity: { name: "my-sandbox", agent: "openclaw" }, + }); + }); +}); + +describe("revalidateCheckpointBindings fails closed without leaking values (#6228)", () => { + it("passes when every binding is currently available", () => { + const cp = checkpoint({ + bindings: { + credentialEnvs: ["OPENAI_API_KEY"], + registeredProviders: [{ name: "p1", type: "generic", credentialEnv: "P1_API_KEY" }], + }, + }); + const result = revalidateCheckpointBindings(cp, { + availableCredentialEnvs: new Set(["OPENAI_API_KEY"]), + liveRegisteredProviders: new Set(["p1"]), + }); + expect(result).toEqual({ status: "ok" }); + expect(bindingRevalidationGuidance(result)).toBeNull(); + }); + + it("fails closed on a stale binding and reports only names, never values", () => { + const cp = checkpoint({ + bindings: { + credentialEnvs: ["OPENAI_API_KEY"], + registeredProviders: [{ name: "p1", type: "generic", credentialEnv: "P1_API_KEY" }], + }, + }); + const result = revalidateCheckpointBindings(cp, { + availableCredentialEnvs: new Set(), + liveRegisteredProviders: new Set(), + }); + expect(result).toEqual({ + status: "stale", + missingCredentialEnvs: ["OPENAI_API_KEY"], + missingProviders: ["p1"], + }); + const guidance = bindingRevalidationGuidance(result); + expect(guidance).toContain("OPENAI_API_KEY"); + expect(guidance).toContain("p1"); + }); +}); diff --git a/src/lib/onboard/checkpoint-replay.ts b/src/lib/onboard/checkpoint-replay.ts new file mode 100644 index 0000000000..fa303e1726 --- /dev/null +++ b/src/lib/onboard/checkpoint-replay.ts @@ -0,0 +1,70 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { ONBOARD_MACHINE_STATES } from "./machine/types"; +import type { OnboardMachineState } from "./machine/types"; +import { isDecisionSelected } from "../state/onboard-checkpoint-decision"; +import type { + CheckpointEffectGroupName, + CheckpointSandboxIdentity, + OnboardCheckpoint, +} from "../state/onboard-checkpoint-types"; + +export interface CheckpointedMachineSession { + readonly checkpoint: OnboardCheckpoint | null; + readonly machine: { readonly state: OnboardMachineState }; +} + +export function checkpointProvesSandboxStepComplete( + session: CheckpointedMachineSession | null | undefined, +): boolean { + if (!session?.checkpoint) return false; + const sandboxIndex = ONBOARD_MACHINE_STATES.indexOf("sandbox"); + const stateIndex = ONBOARD_MACHINE_STATES.indexOf(session.machine.state); + return stateIndex > sandboxIndex; +} + +export type EffectGroupReplayReason = + | "not_recorded" + | "postcondition_failed" + | "already_complete_revalidated"; + +export interface EffectGroupReplayDecision { + readonly group: CheckpointEffectGroupName; + readonly action: "skip" | "run"; + readonly reason: EffectGroupReplayReason; +} + +export function planEffectGroupReplay( + checkpoint: OnboardCheckpoint, + group: CheckpointEffectGroupName, + postconditionHolds: boolean, +): EffectGroupReplayDecision { + const record = checkpoint.effectGroups[group]; + if (!record) return { group, action: "run", reason: "not_recorded" }; + if (!postconditionHolds) return { group, action: "run", reason: "postcondition_failed" }; + return { group, action: "skip", reason: "already_complete_revalidated" }; +} + +export interface SandboxCreateObservation { + readonly liveSandboxExists: boolean; +} + +export type SandboxCreateReplayDecision = + | { readonly action: "reuse"; readonly identity: CheckpointSandboxIdentity } + | { readonly action: "create"; readonly identity: CheckpointSandboxIdentity } + | { readonly action: "capture_identity_first" }; + +export function planSandboxCreateReplay( + checkpoint: OnboardCheckpoint, + observed: SandboxCreateObservation, +): SandboxCreateReplayDecision { + if (!isDecisionSelected(checkpoint.sandboxIdentity)) { + return { action: "capture_identity_first" }; + } + const identity = checkpoint.sandboxIdentity.value; + if (observed.liveSandboxExists) { + return { action: "reuse", identity }; + } + return { action: "create", identity }; +} diff --git a/src/lib/onboard/checkpoint-resume-guard.test.ts b/src/lib/onboard/checkpoint-resume-guard.test.ts new file mode 100644 index 0000000000..695fd9986f --- /dev/null +++ b/src/lib/onboard/checkpoint-resume-guard.test.ts @@ -0,0 +1,139 @@ +// 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 CheckpointLoadResult, + type OnboardCheckpoint, +} from "../state/onboard-checkpoint-types"; +import { createSession } from "../state/onboard-session"; +import { type OnboardSessionBootstrapDeps, prepareOnboardSession } from "./session-bootstrap"; + +class ExitError extends Error { + constructor(readonly code: number) { + super(`exit:${code}`); + } +} + +const resumeInput = { + resume: true, + fresh: false, + requestedFromDockerfile: null, + requestedSandboxName: null, + cannotPrompt: false, + nonInteractive: false, +}; + +const loadedCheckpoint: OnboardCheckpoint = { + schemaVersion: CHECKPOINT_SCHEMA_VERSION, + sessionId: "s1", + machineState: "sandbox", + updatedAt: "2026-01-01T00:00:00.000Z", + sandboxIdentity: decisionUnset(), + webSearch: decisionUnset(), + messaging: decisionUnset(), + resourceProfile: decisionUnset(), + effectGroups: {}, + bindings: { credentialEnvs: [], registeredProviders: [] }, +}; + +function makeDeps(overrides: Partial): OnboardSessionBootstrapDeps { + return { + loadSession: () => createSession({ sessionId: "s1", agent: "openclaw" }), + clearSession: () => {}, + createSession: (o) => createSession(o), + saveSession: (s) => s, + updateSession: (mutator) => { + const session = createSession({ sessionId: "s1", agent: "openclaw" }); + mutator(session); + return session; + }, + applySessionRecovery: () => {}, + setOnboardBrandingAgent: () => {}, + getResumeConfigConflicts: () => [], + recordResumeConflict: async () => undefined, + resolvePath: (v) => v, + cliName: () => "nemoclaw", + error: () => {}, + exitProcess: (code) => { + throw new ExitError(code); + }, + resolveResumeCheckpoint: (): CheckpointLoadResult => ({ status: "none" }), + ...overrides, + }; +} + +describe("resume checkpoint fail-safe (#6228)", () => { + it("aborts with guidance on an unsupported future checkpoint instead of resuming", async () => { + const error = vi.fn(); + const deps = makeDeps({ + error, + resolveResumeCheckpoint: (): CheckpointLoadResult => ({ + status: "unsupported_future", + foundVersion: 99, + }), + }); + await expect(prepareOnboardSession(resumeInput, deps)).rejects.toBeInstanceOf(ExitError); + expect(error.mock.calls.flat().join("\n")).toContain("v99"); + }); + + it("aborts on a corrupt checkpoint rather than continuing", async () => { + const error = vi.fn(); + const deps = makeDeps({ + error, + resolveResumeCheckpoint: (): CheckpointLoadResult => ({ status: "corrupt" }), + }); + await expect(prepareOnboardSession(resumeInput, deps)).rejects.toBeInstanceOf(ExitError); + expect(error.mock.calls.flat().join("\n")).toContain("unreadable"); + }); + + it("continues past the guard when the checkpoint loads cleanly", async () => { + const deps = makeDeps({ + resolveResumeCheckpoint: (): CheckpointLoadResult => ({ + status: "loaded", + checkpoint: loadedCheckpoint, + }), + getResumeConfigConflicts: () => { + throw new Error("PAST_GUARD"); + }, + }); + await expect(prepareOnboardSession(resumeInput, deps)).rejects.toThrow("PAST_GUARD"); + }); + + it("always runs checkpoint validation before resuming — there is no path that skips it (#6228)", async () => { + const resolveResumeCheckpoint = vi.fn((): CheckpointLoadResult => ({ status: "none" })); + const deps = makeDeps({ + resolveResumeCheckpoint, + getResumeConfigConflicts: () => { + throw new Error("PAST_GUARD"); + }, + }); + await expect(prepareOnboardSession(resumeInput, deps)).rejects.toThrow("PAST_GUARD"); + expect(resolveResumeCheckpoint).toHaveBeenCalled(); + }); + + it("persists a migrated legacy checkpoint onto the session instead of re-deriving it every resume (#7022)", async () => { + let persistedSession = createSession({ sessionId: "s1", agent: "openclaw" }); + const updateSession = vi.fn((mutator: (session: typeof persistedSession) => void) => { + mutator(persistedSession); + return persistedSession; + }); + const deps = makeDeps({ + updateSession, + resolveResumeCheckpoint: (): CheckpointLoadResult => ({ + status: "migrated", + checkpoint: loadedCheckpoint, + fromVersion: 0, + }), + getResumeConfigConflicts: () => { + throw new Error("PAST_GUARD"); + }, + }); + await expect(prepareOnboardSession(resumeInput, deps)).rejects.toThrow("PAST_GUARD"); + expect(updateSession).toHaveBeenCalled(); + expect(persistedSession.checkpoint).toEqual(loadedCheckpoint); + }); +}); diff --git a/src/lib/onboard/checkpoint-revalidate.ts b/src/lib/onboard/checkpoint-revalidate.ts new file mode 100644 index 0000000000..62ce522d4c --- /dev/null +++ b/src/lib/onboard/checkpoint-revalidate.ts @@ -0,0 +1,47 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { OnboardCheckpoint } from "../state/onboard-checkpoint-types"; + +export interface BindingAvailability { + readonly availableCredentialEnvs: ReadonlySet; + readonly liveRegisteredProviders: ReadonlySet; +} + +export type BindingRevalidation = + | { readonly status: "ok" } + | { + readonly status: "stale"; + readonly missingCredentialEnvs: readonly string[]; + readonly missingProviders: readonly string[]; + }; + +export function revalidateCheckpointBindings( + checkpoint: OnboardCheckpoint, + available: BindingAvailability, +): BindingRevalidation { + const missingCredentialEnvs = checkpoint.bindings.credentialEnvs.filter( + (env) => !available.availableCredentialEnvs.has(env), + ); + const missingProviders = checkpoint.bindings.registeredProviders + .filter((provider) => !available.liveRegisteredProviders.has(provider.name)) + .map((provider) => provider.name); + if (missingCredentialEnvs.length === 0 && missingProviders.length === 0) { + return { status: "ok" }; + } + return { status: "stale", missingCredentialEnvs, missingProviders }; +} + +export function bindingRevalidationGuidance(revalidation: BindingRevalidation): string | null { + if (revalidation.status === "ok") return null; + const parts: string[] = []; + if (revalidation.missingCredentialEnvs.length > 0) { + parts.push( + `missing credential environment variables: ${revalidation.missingCredentialEnvs.join(", ")}`, + ); + } + if (revalidation.missingProviders.length > 0) { + parts.push(`missing registered providers: ${revalidation.missingProviders.join(", ")}`); + } + return `Cannot resume safely — ${parts.join("; ")}. Re-supply the values and retry.`; +} diff --git a/src/lib/onboard/credential-provider-registration.test.ts b/src/lib/onboard/credential-provider-registration.test.ts index 11adfd474c..7bf73e36e6 100644 --- a/src/lib/onboard/credential-provider-registration.test.ts +++ b/src/lib/onboard/credential-provider-registration.test.ts @@ -98,7 +98,10 @@ describe("credential provider registration", () => { async () => ({ messagingTokenDefs: tokenDefs }), ); - expect(registered).toEqual(["alpha-brave-search", "alpha-discord-bridge"]); + expect(registered).toEqual([ + { name: "alpha-brave-search", type: "brave", credentialEnv: "BRAVE_API_KEY" }, + { name: "alpha-discord-bridge", type: "generic", credentialEnv: "DISCORD_BOT_TOKEN" }, + ]); expect(session.stagedCredentialProviders).toEqual([ "alpha-brave-search", "alpha-discord-bridge", @@ -162,7 +165,9 @@ describe("credential provider registration", () => { }), ); - expect(registered).toEqual(["alpha-discord-bridge"]); + expect(registered).toEqual([ + { name: "alpha-discord-bridge", type: "generic", credentialEnv: "DISCORD_BOT_TOKEN" }, + ]); expect(session.stagedCredentialProviders).toEqual(["alpha-discord-bridge"]); expect(runOpenshell).toHaveBeenCalledWith( [ diff --git a/src/lib/onboard/credential-provider-registration.ts b/src/lib/onboard/credential-provider-registration.ts index 2318e1deed..eab370c66b 100644 --- a/src/lib/onboard/credential-provider-registration.ts +++ b/src/lib/onboard/credential-provider-registration.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import type { WebSearchConfig } from "../inference/web-search"; +import type { CheckpointProviderBinding } from "../state/onboard-checkpoint-types"; import type { Session } from "../state/onboard-session"; import * as braveProviderProfile from "./brave-provider-profile"; import * as gatewayProviderMetadata from "./gateway-provider-metadata"; @@ -134,7 +135,7 @@ export function createCredentialProviderRegistration(deps: CredentialProviderReg async function stageSandboxCredentialProviders( input: StageSandboxCredentialProvidersInput, prepareCredentialProviders: PrepareCredentialProviders, - ): Promise { + ): Promise { const messaging = await prepareCredentialProviders(input); const tokenDefs = messaging.messagingTokenDefs.filter((tokenDef) => deps.normalizeCredentialValue(tokenDef.token), @@ -151,7 +152,12 @@ export function createCredentialProviderRegistration(deps: CredentialProviderReg runOpenshell, ); setStagedCredentialProviderReceipts(registered, true, deps); - return registered; + const registeredTokenDefs = new Map(tokenDefs.map((tokenDef) => [tokenDef.name, tokenDef])); + return registered.map((name) => ({ + name, + type: registeredTokenDefs.get(name)?.providerType || "generic", + credentialEnv: registeredTokenDefs.get(name)?.envKey ?? "", + })); } return { diff --git a/src/lib/onboard/lifecycle-contracts.md b/src/lib/onboard/lifecycle-contracts.md index 7ca770dbf3..0c2fb9f922 100644 --- a/src/lib/onboard/lifecycle-contracts.md +++ b/src/lib/onboard/lifecycle-contracts.md @@ -99,7 +99,7 @@ runtime mutation | Journey and entry | Desired state, planning, and assembly | Visible and destructive boundaries | Checkpoint and secret boundary | Compensation, coverage, and gaps | |---|---|---|---|---| -| **New interactive or non-interactive onboard** — `onboard()` and `resolveOnboardEntryOptions` | Current flags, environment, and prompts. `MessagingWorkflowPlanner.buildPlan`, `prepareSandboxMessagingPreflight`, resource-profile selection, `resolveSandboxCreateIntent`, and `materializeSandboxCreatePlan` assemble policy, provider, package, resource, host-forward, and runtime-setup contributions. Non-interactive mode replaces prompts with defaults or hard aborts. | Consent/session/lock setup and preflight can persist local state, install OpenShell, or clean stale gateway artifacts before the gateway handler. Gateway reuse/recovery/start is the first provider-routing effect; inference-provider upserts follow. For OpenClaw, a validated web-search provider can be created or updated after the name/web checkpoint and before messaging; validated messaging providers can be created or updated after the messaging checkpoint and before resource selection. A name with no live sandbox has no sandbox-destructive boundary; an existing target enters the recreate contract below. | Whole-step session plus machine snapshot. OpenClaw adds narrow checkpoints after each completed secret-free sandbox prompt group; sandbox registry registration is deferred until readiness and live validation. The session stores credential environment names, redacted endpoint metadata, legacy-value digests, and non-secret names of providers registered for resume; real values remain process- or gateway-bound. | A non-Docker-GPU readiness failure attempts to delete the failed sandbox; the Docker-GPU patch path preserves it and emits patch-specific recovery diagnostics. Temp build-context cleanup is attempted inline with an exit-handler fallback; cancel rollback applies only to a brand-new sandbox. Coverage: `transition-traces.test.ts`, `sandbox-create-intent-boundary.test.ts`, and `sandbox-create-plan.test.ts`. Gap: gateway upserts can outlive a failed/interrupted create. | +| **New interactive or non-interactive onboard** — `onboard()` and `resolveOnboardEntryOptions` | Current flags, environment, and prompts. `MessagingWorkflowPlanner.buildPlan`, `prepareSandboxMessagingPreflight`, resource-profile selection, `resolveSandboxCreateIntent`, and `materializeSandboxCreatePlan` assemble policy, provider, package, resource, host-forward, and runtime-setup contributions. Non-interactive mode replaces prompts with defaults or hard aborts. | Consent/session/lock setup and preflight can persist local state, install OpenShell, or clean stale gateway artifacts before the gateway handler. Gateway reuse/recovery/start is the first provider-routing effect; inference-provider upserts follow. For OpenClaw, a validated web-search provider can be created or updated after the name/web checkpoint and before messaging; validated messaging providers can be created or updated after the messaging checkpoint and before resource selection. A name with no live sandbox has no sandbox-destructive boundary; an existing target enters the recreate contract below. | Whole-step session plus machine snapshot. OpenClaw adds narrow checkpoints after each completed secret-free sandbox prompt group; sandbox registry registration is deferred until readiness and live validation. The session stores credential environment names, redacted endpoint metadata, legacy-value digests, and non-secret names of web-search and messaging providers registered for resume; real values remain process- or gateway-bound. | A non-Docker-GPU readiness failure attempts to delete the failed sandbox; the Docker-GPU patch path preserves it and emits patch-specific recovery diagnostics. Temp build-context cleanup is attempted inline with an exit-handler fallback; cancel rollback applies only to a brand-new sandbox. Coverage: `transition-traces.test.ts`, `sandbox-create-intent-boundary.test.ts`, and `sandbox-create-plan.test.ts`. Gap: gateway upserts can outlive a failed/interrupted create. | | **`--fresh` onboard** — `resolveOnboardEntryOptions`, `prepareFreshSession`, `createBaseImageResolutionContext` | Current flags/environment/prompts replace resumable intent. `--fresh` disables auto-resume and forces base-image resolution; it does not prove that the selected sandbox name is unused. | The first destructive effect is local: the prior onboard session is cleared before a new session is saved. A matching live sandbox can later reuse or recreate through the normal sandbox decision; `--fresh` does not itself delete it. | The new session and machine snapshot replace the old resume checkpoint. Credential and effect boundaries then match new onboard or live recreate. | The discarded resume checkpoint is not restored on later failure. Covered by `entry-options.test.ts`, `session-bootstrap.test.ts`, and base-image resolution tests. | | **Resume, re-onboard, or recreate** — `onboard()`, `prepareOnboardSession`, `decideSandboxResume`, live-sandbox handling in `createSandbox` | For `--resume`, the recorded session is authoritative and conflicting current name/provider/model/image/tool-disclosure hints are rejected. A new re-onboard run takes current flags, environment, and prompts as intent while registry/gateway state provides drift evidence. The machine resolves a complete secret-free create intent, including policy, messaging/provider, GPU, resource, disabled-channel, and agent inputs, before repair/removal or live recreation. | Ordinary live recreation conditionally backs up before provider cleanup, **delete**, image removal, and registry removal. A selected pre-upgrade backup suppresses a new one; an explicit override permits recreation without backup. Resume registry removal and `repair-and-recreate` now follow complete intent validation. Temporary policy/build artifacts remain materialization effects after the delete boundary. | Resume continues the recorded session/machine snapshot; non-resume re-onboard writes a new session first. OpenClaw records completed sandbox name, web search, messaging, and resource choices with explicit progress markers, including explicit `null` choices, while the complete create intent stays process-local and is not persisted or emitted. Raw credential values remain outside the session. A missing process value can be rebound only when the same OpenClaw session recorded successfully registering that provider and its live name, type, and credential-key binding still match; otherwise interactive resume requests it again and non-interactive resume exits with environment-variable guidance. Credentials are checked before mutation and again immediately before materialization. | Restore failures warn and can still publish the replacement; managed-DCode live-selection failure leaves a running, unregistered sandbox with manual-delete guidance. Cancel rollback is not armed and there is no rebuild-style receipt rollback. Coverage: transition traces, sandbox handler tests, create-intent characterization, and the real create boundary. Gaps: #5961/#5783, #6040, early backup asymmetry, and delete-to-register window (#6228). | | **Rebuild or installer-driven upgrade** — `rebuildSandbox` in `rebuild-pipeline.ts`; `upgradeSandboxes` | Registry state is authoritative. A matching session may fill guarded legacy gaps only when its selection agrees; an unrelated/global session is never used. Ambient provider/model selection is quarantined by `isolateAmbientRecreateEnv`, apart from narrowly scoped legacy recovery. Preflight assembles target config, messaging/policy/runtime inputs, recovery inputs, and a retained replacement context. Generic agents use `preflightRebuildImage`; DCode uses its specialized managed-context preflight instead and proves the live route only for normal live rebuild. Resource profile is not part of preflight. | Consent persistence, target-gateway selection/recovery, and target-preflight registry updates can precede disposable image build/probes. Backup is the first durable recovery checkpoint when available. Shields unlock, MCP detach/scrub, and NIM stop are destructive in-place effects before the **sandbox delete** boundary. Prepared context and mutation-edge conditions are rechecked before delete, proving buildability/input identity but not replacement health or atomic swap. | Durable checkpoints are the backup/recovery manifest when one exists and the rewritten recreate session; stale recovery can reach deletion without a manifest, making that session its first new durable checkpoint. Rollback receipts/snapshots are process-local. Credential metadata comes from the target or guarded fallback; raw credentials/providers are checked against current process/gateway state, while prepared installer recovery may reconstruct a missing gateway provider from a validated host credential. | In-process rollback best-effort restores registry/MCP retry metadata, but process death after non-MCP delete can still lose it. The inner onboard selects resource profile after deletion from non-quarantined ambient input. Covered by rebuild, image-preflight, DCode, and messaging tests. Gaps: post-delete resource intent plus health-before-delete/atomic swap (#5801). | @@ -118,12 +118,12 @@ runtime mutation ## Persisted field ownership -The schema and sanitation authority is `Session` plus `normalizeSession`/`filterSafeUpdates` in `src/lib/state/onboard-session.ts`. `undefined` in an update means “leave unchanged”; accepted `null` means “clear.” On disk, many nullable fields still collapse never selected, explicitly declined, and explicitly cleared into the same `null` representation. `sandboxPromptProgress` removes that ambiguity only for the checkpointed sandbox name, web search, messaging, and resource choices; the remaining ambiguity is a #6228 contract gap, not an endorsed target. +The schema and sanitation authority is `Session` plus `normalizeSession`/`filterSafeUpdates` in `src/lib/state/onboard-session.ts`. `undefined` in an update means “leave unchanged”; accepted `null` means “clear.” On disk, many nullable fields still collapse never selected, explicitly declined, and explicitly cleared into the same `null` representation. `sandboxPromptProgress` records which of the checkpointed sandbox name, web search, messaging, and resource choices completed. The dedicated versioned `checkpoint` field (`src/lib/state/onboard-checkpoint.ts`) resolves the remaining ambiguity for those choices by modelling each as an explicit `unset`/`declined`/`selected` decision (#6228, #6227/#5783); `deriveCheckpointFromSession` reconstructs the same tri-state from legacy sessions using the completion markers. Live decision reads still consult the legacy fields; migrating every consumer onto the checkpoint decisions is a follow-up. | Field group | Fields | Writer/owner and state meaning | |---|---|---| | Session envelope | `version`, `sessionId`, `mode`, `startedAt`, `updatedAt`, `status`, `resumable` | `createSession`, save/update helpers, and completion/failure paths. Values are always known after creation. | -| Progress and recovery | `lastStepStarted`, `lastCompletedStep`, `failure`, `steps`, `machine`, `sandboxPromptProgress`, `stagedCredentialProviders` | Step-mutation helpers and `OnboardRuntime` own whole-step progress; the OpenClaw sandbox handler owns prompt-group completion markers. `stagedCredentialProviders` contains only names registered before sandbox setup so OpenClaw resume can require both durable ownership and an exact live binding. A marker is trusted only when its matching persisted value is present and valid, including an explicit `null` where supported. | +| Progress and recovery | `lastStepStarted`, `lastCompletedStep`, `failure`, `steps`, `machine`, `sandboxPromptProgress`, `stagedCredentialProviders`, `checkpoint` | Step-mutation helpers and `OnboardRuntime` own whole-step progress; the OpenClaw sandbox handler owns prompt-group completion markers. `stagedCredentialProviders` contains only names registered before sandbox setup so OpenClaw resume can require both durable ownership and an exact live binding. A marker is trusted only when its matching persisted value is present and valid, including an explicit `null` where supported. `checkpoint` is the dedicated versioned resume contract: a secret-free tri-state decision record plus durable sandbox identity, effect-group receipts, and logical web-search and messaging provider bindings, serialized alongside the session under its own `schemaVersion` with fail-closed handling of an unknown future version. The primary inference provider binding remains owned and revalidated by the provider and inference phases instead of entering this checkpoint ledger. | | Target identity | `agent`, `sandboxName`, `metadata.gatewayName`, `metadata.fromDockerfile` | Onboard selection, sandbox handler/registration, and rebuild session preparation. A completed sandbox step or valid `sandboxPromptProgress.sandboxName` marker is the trust gate for a recorded name. | | Inference intent | `provider`, `model`, `endpointUrl`, `credentialEnv`, `preferredInferenceApi`, `compatibleEndpointReasoning`, `nimContainer`, `webSearchConfig` | Provider/inference handlers and `runInferenceSet`. Known credential state is an environment-variable name or presence metadata, never the value. `redactUrl` masks userinfo and fragments, redacts values under sensitive parameter names, and redacts canonical token-shaped values even under benign parameter names. | | Agent and policy intent | `hermesAuthMethod`, `toolDisclosure`, `hermesToolGateways`, `policyPresets` | Agent setup and policy handling. Channel commands update matching-session `policyPresets` only best-effort. Nullable fields conflate unset, declined, and cleared where the CLI makes those distinctions. | @@ -148,7 +148,7 @@ The registry is separately owned by `src/lib/state/registry.ts`; backup/recovery | Issue | Gap | Current status | |---|---|---| -| #5961 | Interrupted onboard lacks durable sandbox identity/effect-group metadata | Open; #6227/#6228 | +| #5961 | Interrupted onboard lacks durable sandbox identity/effect-group metadata | Durable identity and effect-group receipts now captured in the `checkpoint`; the live sandbox handler consumes `planSandboxCreateReplay`, reuses a surviving exact-identity sandbox, and never recreates under a new name. Remaining apply-boundary interruption coverage belongs to #6228 | | #6040 | Only selected malformed terminal snapshots are repaired | Open; #6227 | | #6179 | Stale handler results can reach an invalid transition | Open; #6227 | | #5954 | Rebuild conflict was discovered after delete | Fixed by #5955 | @@ -169,5 +169,7 @@ PR #6218 separated secret-free create intent from effectful materialization. PR | Messaging conflict validation before recreate | `sandbox-messaging-preflight.test.ts`, rebuild preflight tests | One shared declarative policy across all callers | | Resume identity | `test/onboard.test.ts`, `handlers/sandbox-resume.test.ts` | Interrupted live-flow identity (#5961) | | Session sanitation, sandbox prompt checkpoints, and no-secret persistence | `src/lib/state/onboard-session-sandbox-prompts.test.ts`, `machine/handlers/sandbox-create-intent-boundary.test.ts` | Unset/declined/cleared modeling outside the checkpointed sandbox choices (#6228) | +| Versioned checkpoint schema, tri-state decisions, migration, and unknown-future fail-safe | `src/lib/state/onboard-checkpoint.test.ts`, `src/lib/state/onboard-checkpoint-migrate.test.ts` | Live decision reads still use legacy fields | +| Resumable create replay, durable identity, and stale-binding fail-closed | `src/lib/onboard/checkpoint-replay.test.ts`, `src/lib/onboard/checkpoint-resume-guard.test.ts` | End-to-end effect recording at every apply boundary in the live handler | When a child issue changes one of these contracts, update the map and the narrow owning test in that same PR. Do not add source-text scans or production scaffolding solely to preserve current orchestration order. 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 new file mode 100644 index 0000000000..4d15d0fa1c --- /dev/null +++ b/src/lib/onboard/machine/handlers/sandbox-checkpoint-crash-recovery.test.ts @@ -0,0 +1,778 @@ +// 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 { decisionSelected, decisionUnset } from "../../../state/onboard-checkpoint-decision"; +import { + CHECKPOINT_SCHEMA_VERSION, + type OnboardCheckpoint, +} from "../../../state/onboard-checkpoint-types"; +import { createSession, type Session, type SessionUpdates } from "../../../state/onboard-session"; +import { + type CredentialProviderRegistrationDeps, + createCredentialProviderRegistration, +} from "../../credential-provider-registration"; +import { detectMessagingChannelsFromEnv } from "../../messaging-channel-setup"; +import type { MessagingTokenDef } from "../../messaging-prep"; +import { handleSandboxState } from "./sandbox"; +import { baseOptions, createDeps, makeMinimalPlan } from "./sandbox-test-fixtures"; + +vi.mock("../../messaging-channel-setup", () => ({ + detectMessagingChannelsFromEnv: vi.fn(() => []), +})); + +vi.mocked(detectMessagingChannelsFromEnv).mockReturnValue([]); + +function defaultCreateFingerprint(sandboxName = "my-assistant"): string { + return [ + sandboxName, + "default", + "provider", + "model", + "openai-completions", + "", + JSON.stringify({ sandboxGpuEnabled: false, mode: "0" }), + "", + ].join("|"); +} + +function crashedCheckpoint(overrides: Partial = {}): OnboardCheckpoint { + return { + schemaVersion: CHECKPOINT_SCHEMA_VERSION, + sessionId: "sess-1", + machineState: "sandbox", + updatedAt: "2026-01-01T00:00:00.000Z", + sandboxIdentity: decisionSelected({ name: "my-assistant", agent: "openclaw" }), + webSearch: decisionUnset(), + messaging: decisionUnset(), + resourceProfile: decisionUnset(), + effectGroups: { + sandbox_create: { + completedAt: "2026-01-01T00:00:00.000Z", + fingerprint: defaultCreateFingerprint(), + }, + }, + bindings: { credentialEnvs: [], registeredProviders: [] }, + ...overrides, + }; +} + +type StubbedRunOpenshellResult = { status: number; stdout: string; stderr: string }; + +const OK_RESULT: StubbedRunOpenshellResult = { status: 0, stdout: "", stderr: "" }; + +function fakeGatewayRunOpenshell() { + const createdProviders = new Map(); + + const handleGet = (args: string[]): StubbedRunOpenshellResult => { + const name = args[args.length - 1]; + const provider = createdProviders.get(name); + return provider + ? { + status: 0, + stdout: [ + `Name: ${name}`, + `Type: ${provider.type}`, + `Credential keys: ${provider.credentialEnv}`, + "Config keys: ", + ].join("\n"), + stderr: "", + } + : { status: 1, stdout: "", stderr: "not found" }; + }; + + const handleCreate = (args: string[]): StubbedRunOpenshellResult => { + createdProviders.set(args[args.indexOf("--name") + 1] ?? "", { + type: args[args.indexOf("--type") + 1] ?? "generic", + credentialEnv: args[args.indexOf("--credential") + 1] ?? "", + }); + return OK_RESULT; + }; + + const handlersByAction: Record StubbedRunOpenshellResult> = { + get: handleGet, + create: handleCreate, + update: () => OK_RESULT, + }; + + const runOpenshell = vi.fn( + (args: string[]): StubbedRunOpenshellResult => + (args[0] === "provider" ? handlersByAction[args[1]] : undefined)?.(args) ?? OK_RESULT, + ); + return { runOpenshell, createdProviders }; +} + +function realStageSandboxCredentialProviders( + tokenDefs: MessagingTokenDef[], + crashAfterFirstSuccess: boolean, +) { + const { runOpenshell } = fakeGatewayRunOpenshell(); + const registrationSession = { stagedCredentialProviders: [] as string[] } as Session; + const registration = createCredentialProviderRegistration({ + root: "/repo", + runOpenshell: runOpenshell as unknown as CredentialProviderRegistrationDeps["runOpenshell"], + redact: (input) => input, + getGatewayName: () => "nemoclaw", + normalizeCredentialValue: (value) => (typeof value === "string" ? value.trim() : ""), + updateSession: (mutator) => (mutator(registrationSession) ?? registrationSession) as Session, + stagedLegacyValues: new Map(), + migratedLegacyKeys: new Set(), + persistMigratedLegacyKeys: vi.fn(), + }); + let crashPending = crashAfterFirstSuccess; + const stageSandboxCredentialProviders = vi.fn( + async (input: { + sandboxName: string; + enabledChannels: readonly string[]; + webSearchConfig: unknown; + agent: unknown; + }) => { + const staged = await registration.stageSandboxCredentialProviders( + input as never, + async () => ({ messagingTokenDefs: tokenDefs }), + ); + const shouldCrash = crashPending; + crashPending = false; + return shouldCrash + ? Promise.reject(new Error("gateway connection dropped mid-registration")) + : staged; + }, + ); + return { + stageSandboxCredentialProviders, + providerMatchesGatewayCredential: registration.providerMatchesGatewayCredential, + runOpenshell, + }; +} + +function sessionWithCheckpoint(checkpoint: OnboardCheckpoint): Session { + const session = createSession({ + sessionId: "sess-1", + agent: "openclaw", + sandboxName: "my-assistant", + sandboxPromptProgress: { + sandboxName: true, + webSearch: false, + messaging: false, + resourceProfile: false, + }, + }); + session.checkpoint = checkpoint; + return session; +} + +describe("sandbox crash-recovery replay (#5961, #6228)", () => { + it("reuses a surviving sandbox instead of recreating it under a stale step-incomplete decision", async () => { + const { deps, calls } = createDeps({ getSandboxReuseState: () => "ready" }); + const session = sessionWithCheckpoint(crashedCheckpoint()); + + await handleSandboxState({ + ...baseOptions(deps, session), + resume: true, + sandboxName: "my-assistant", + }); + + expect(calls.createSandbox).not.toHaveBeenCalled(); + expect(calls.recordSkip).toHaveBeenCalled(); + }); + + it("recreates only under the recorded durable identity when the sandbox is gone", async () => { + const { deps, calls } = createDeps({ getSandboxReuseState: () => "missing" }); + const session = sessionWithCheckpoint(crashedCheckpoint()); + + await handleSandboxState({ + ...baseOptions(deps, session), + resume: true, + sandboxName: "my-assistant", + }); + + expect(calls.createSandbox).toHaveBeenCalled(); + expect((calls.createSandbox.mock.calls[0] as unknown[] | undefined)?.[4]).toBe("my-assistant"); + }); + + it("rejects stale bindings before any mutation instead of guessing", async () => { + const { deps, calls } = createDeps({ getSandboxReuseState: () => "missing" }); + const session = sessionWithCheckpoint( + crashedCheckpoint({ + bindings: { credentialEnvs: ["OPENAI_API_KEY"], registeredProviders: [] }, + }), + ); + + await expect( + handleSandboxState({ + ...baseOptions(deps, session), + resume: true, + sandboxName: "my-assistant", + env: {}, + }), + ).rejects.toThrow("exit 1"); + + expect(calls.createSandbox).not.toHaveBeenCalled(); + expect(calls.error.mock.calls.flat().join("\n")).toContain("OPENAI_API_KEY"); + }); + + it("does not engage the crash-recovery path for a normal fresh create (no checkpoint receipt)", async () => { + const { deps, calls } = createDeps({ getSandboxReuseState: () => "missing" }); + const session = createSession({ sessionId: "sess-1", agent: "openclaw" }); + + await handleSandboxState({ ...baseOptions(deps, session), resume: false }); + + expect(calls.createSandbox).toHaveBeenCalled(); + }); + + it("reuses a live sandbox even when the create receipt was lost in the crash window (#7022)", async () => { + const { deps, calls } = createDeps({ getSandboxReuseState: () => "ready" }); + const session = sessionWithCheckpoint(crashedCheckpoint({ effectGroups: {} })); + + await handleSandboxState({ + ...baseOptions(deps, session), + resume: true, + sandboxName: "my-assistant", + }); + + expect(calls.createSandbox).not.toHaveBeenCalled(); + expect(calls.recordSkip).toHaveBeenCalled(); + }); + + it("does not reuse a live sandbox when the checkpoint identity does not match the resume target", async () => { + const { deps, calls } = createDeps({ getSandboxReuseState: () => "ready" }); + const session = sessionWithCheckpoint( + crashedCheckpoint({ + sandboxIdentity: decisionSelected({ name: "other-assistant", agent: "openclaw" }), + }), + ); + + await handleSandboxState({ + ...baseOptions(deps, session), + resume: true, + sandboxName: "my-assistant", + }); + + expect(calls.createSandbox).toHaveBeenCalled(); + expect(calls.recordSkip).not.toHaveBeenCalled(); + }); + + it("rejects reuse when a checkpointed provider is no longer live-registered with the gateway", async () => { + const { deps, calls } = createDeps({ + getSandboxReuseState: () => "missing", + providerMatchesGatewayCredential: () => false, + }); + const session = sessionWithCheckpoint( + crashedCheckpoint({ + bindings: { + credentialEnvs: [], + registeredProviders: [ + { name: "my-assistant-brave-search", type: "brave", credentialEnv: "BRAVE_API_KEY" }, + ], + }, + }), + ); + + await expect( + handleSandboxState({ + ...baseOptions(deps, session), + resume: true, + sandboxName: "my-assistant", + }), + ).rejects.toThrow("exit 1"); + + expect(calls.createSandbox).not.toHaveBeenCalled(); + expect(calls.error.mock.calls.flat().join("\n")).toContain("my-assistant-brave-search"); + }); + + it("accepts a checkpointed provider that is still live-registered with the gateway", async () => { + const { deps, calls } = createDeps({ + getSandboxReuseState: () => "ready", + providerMatchesGatewayCredential: (name, type, credentialEnv) => + name === "my-assistant-brave-search" && + type === "brave" && + credentialEnv === "BRAVE_API_KEY", + }); + const session = sessionWithCheckpoint( + crashedCheckpoint({ + bindings: { + credentialEnvs: [], + registeredProviders: [ + { name: "my-assistant-brave-search", type: "brave", credentialEnv: "BRAVE_API_KEY" }, + ], + }, + }), + ); + + await handleSandboxState({ + ...baseOptions(deps, session), + resume: true, + sandboxName: "my-assistant", + }); + + expect(calls.createSandbox).not.toHaveBeenCalled(); + expect(calls.recordSkip).toHaveBeenCalled(); + }); + + it("rejects reuse when a checkpointed provider name exists live under a different type or credential environment (#7022)", async () => { + const { deps, calls } = createDeps({ + getSandboxReuseState: () => "missing", + providerMatchesGatewayCredential: (name, type, credentialEnv) => + name === "my-assistant-brave-search" && + type === "generic" && + credentialEnv === "OTHER_API_KEY", + }); + const session = sessionWithCheckpoint( + crashedCheckpoint({ + bindings: { + credentialEnvs: [], + registeredProviders: [ + { name: "my-assistant-brave-search", type: "brave", credentialEnv: "BRAVE_API_KEY" }, + ], + }, + }), + ); + + await expect( + handleSandboxState({ + ...baseOptions(deps, session), + resume: true, + sandboxName: "my-assistant", + }), + ).rejects.toThrow("exit 1"); + + expect(calls.createSandbox).not.toHaveBeenCalled(); + expect(calls.error.mock.calls.flat().join("\n")).toContain("my-assistant-brave-search"); + }); + + it("records durable sandbox identity for a non-OpenClaw agent create so a crash can still be recovered", async () => { + const { deps, getSession } = createDeps({ getSandboxReuseState: () => "missing" }); + const session = createSession({ sessionId: "sess-1", agent: "hermes" }); + + await handleSandboxState({ + ...baseOptions(deps, session), + resume: false, + agent: { name: "hermes" }, + sandboxName: "my-assistant", + }); + + expect(getSession().checkpoint?.sandboxIdentity).toEqual( + decisionSelected({ name: "my-assistant", agent: "hermes" }), + ); + }); + + it.each([ + "interactive", + "non-interactive", + ] as const)("replays %s web-search provider registration without duplicating the external effect after receipt loss (#7022)", async (mode) => { + const { stageSandboxCredentialProviders, providerMatchesGatewayCredential, runOpenshell } = + realStageSandboxCredentialProviders( + [ + { + name: "my-assistant-brave-search", + envKey: "BRAVE_API_KEY", + token: "brave-secret", + providerType: "brave", + }, + ], + true, + ); + const session = createSession({ sessionId: "sess-1", agent: "openclaw", mode }); + const { deps, getSession } = createDeps( + { + getSandboxReuseState: () => "missing", + configureWebSearch: vi.fn(async () => ({ fetchEnabled: true as const })), + stageSandboxCredentialProviders, + providerMatchesGatewayCredential, + }, + session, + ); + + await expect( + handleSandboxState({ ...baseOptions(deps, session), resume: false }), + ).rejects.toThrow("gateway connection dropped mid-registration"); + + const crashedSession = getSession(); + expect(crashedSession.checkpoint?.effectGroups.web_search_provider).toBeUndefined(); + expect(crashedSession.checkpoint?.bindings.registeredProviders).toEqual([]); + + await handleSandboxState({ + ...baseOptions(deps, crashedSession), + resume: true, + sandboxName: "my-assistant", + webSearchConfig: { fetchEnabled: true }, + }); + + expect(stageSandboxCredentialProviders).toHaveBeenCalledTimes(2); + expect( + runOpenshell.mock.calls.filter(([args]) => args[0] === "provider" && args[1] === "create"), + ).toHaveLength(1); + const resumedSession = getSession(); + expect(resumedSession.checkpoint?.effectGroups.web_search_provider).toBeDefined(); + expect(resumedSession.checkpoint?.bindings.registeredProviders).toEqual([ + { name: "my-assistant-brave-search", type: "brave", credentialEnv: "BRAVE_API_KEY" }, + ]); + }); + + it.each([ + "interactive", + "non-interactive", + ] as const)("replays %s messaging provider registration without duplicating the external effect after receipt loss (#7022)", async (mode) => { + const { stageSandboxCredentialProviders, providerMatchesGatewayCredential, runOpenshell } = + realStageSandboxCredentialProviders( + [ + { + name: "my-assistant-discord-bridge", + envKey: "DISCORD_BOT_TOKEN", + token: "discord-secret", + }, + ], + true, + ); + const session = createSession({ sessionId: "sess-1", agent: "openclaw", mode }); + const messagingPlan = makeMinimalPlan("my-assistant", "openclaw", ["discord"]); + const { deps, getSession } = createDeps( + { + getSandboxReuseState: () => "missing", + readMessagingPlanFromEnv: () => messagingPlan, + stageSandboxCredentialProviders, + providerMatchesGatewayCredential, + }, + session, + ); + + await expect( + handleSandboxState({ ...baseOptions(deps, session), resume: false }), + ).rejects.toThrow("gateway connection dropped mid-registration"); + + const crashedSession = getSession(); + expect(crashedSession.checkpoint?.effectGroups.messaging_providers).toBeUndefined(); + expect(crashedSession.checkpoint?.bindings.registeredProviders).toEqual([]); + + await handleSandboxState({ + ...baseOptions(deps, crashedSession), + resume: true, + sandboxName: "my-assistant", + }); + + expect(stageSandboxCredentialProviders).toHaveBeenCalledTimes(2); + expect( + runOpenshell.mock.calls.filter(([args]) => args[0] === "provider" && args[1] === "create"), + ).toHaveLength(1); + const resumedSession = getSession(); + expect(resumedSession.checkpoint?.effectGroups.messaging_providers).toBeDefined(); + expect(resumedSession.checkpoint?.bindings.registeredProviders).toEqual([ + { name: "my-assistant-discord-bridge", type: "generic", credentialEnv: "DISCORD_BOT_TOKEN" }, + ]); + }); + + it("rejects reuse when the recorded build/policy fingerprint drifted from the current request (#7022)", async () => { + const { deps, calls } = createDeps({ getSandboxReuseState: () => "ready" }); + const session = sessionWithCheckpoint( + crashedCheckpoint({ + effectGroups: { + sandbox_create: { completedAt: "2026-01-01T00:00:00.000Z", fingerprint: "stale-build" }, + }, + }), + ); + + await expect( + handleSandboxState({ + ...baseOptions(deps, session), + resume: true, + sandboxName: "my-assistant", + }), + ).rejects.toThrow("exit 1"); + + expect(calls.createSandbox).not.toHaveBeenCalled(); + expect(calls.error.mock.calls.flat().join("\n")).toContain("--recreate-sandbox"); + }); + + it("rejects reuse when a resolved policy or package input drifted despite an unchanged build version and policy tier (#7022)", async () => { + const { deps, calls } = createDeps({ getSandboxReuseState: () => "ready" }); + const session = sessionWithCheckpoint(crashedCheckpoint()); + + await expect( + handleSandboxState({ + ...baseOptions(deps, session), + resume: true, + sandboxName: "my-assistant", + hermesToolGateways: ["nous-web"], + }), + ).rejects.toThrow("exit 1"); + + expect(calls.createSandbox).not.toHaveBeenCalled(); + expect(calls.error.mock.calls.flat().join("\n")).toContain("--recreate-sandbox"); + }); + + it("reconciles changed live extra providers without treating gateway attachments as durable build drift (#7022)", async () => { + const session = createSession({ sessionId: "sess-1", agent: "openclaw" }); + const updateSession = vi.fn((mutator: (value: typeof session) => void) => { + mutator(session); + return session; + }); + const { deps: createDeps1 } = createDeps({ + getSandboxReuseState: () => "missing", + updateSession, + planRegisteredExtraProviders: () => ({ + extraProviders: ["provider-a"], + staleExtraProviders: [], + }), + }); + + await handleSandboxState({ + ...baseOptions(createDeps1, session), + resume: false, + sandboxName: "my-assistant", + }); + + expect(session.checkpoint?.effectGroups.sandbox_create).toBeDefined(); + + const { deps: resumeDeps, calls } = createDeps({ + getSandboxReuseState: () => "missing", + updateSession, + planRegisteredExtraProviders: () => ({ + extraProviders: ["provider-b"], + staleExtraProviders: ["provider-a"], + }), + }); + + await handleSandboxState({ + ...baseOptions(resumeDeps, session), + resume: true, + sandboxName: "my-assistant", + }); + + expect(calls.createSandbox).toHaveBeenCalledTimes(1); + expect(calls.error).not.toHaveBeenCalled(); + }); + + it("rejects stable resolved create-intent drift despite an unchanged light fingerprint (#7022)", async () => { + const session = createSession({ sessionId: "sess-1", agent: "openclaw" }); + const updateSession = vi.fn((mutator: (value: typeof session) => void) => { + mutator(session); + return session; + }); + const firstRun = createDeps({ getSandboxReuseState: () => "missing", updateSession }); + + await handleSandboxState({ + ...baseOptions(firstRun.deps, session), + resume: false, + sandboxName: "my-assistant", + }); + + const resumedRun = createDeps({ getSandboxReuseState: () => "missing", updateSession }); + const defaultResolve = resumedRun.calls.resolveCreateIntent.getMockImplementation(); + expect(defaultResolve).toBeDefined(); + resumedRun.calls.resolveCreateIntent.mockImplementation(async (input) => { + const resolved = await defaultResolve!(input); + return { + ...resolved, + policy: { ...resolved.policy, basePolicyPath: "/repo/changed-policy.yaml" }, + }; + }); + + await expect( + handleSandboxState({ + ...baseOptions(resumedRun.deps, session), + resume: true, + sandboxName: "my-assistant", + }), + ).rejects.toThrow("exit 1"); + + expect(resumedRun.calls.createSandbox).not.toHaveBeenCalled(); + expect(resumedRun.calls.error.mock.calls.flat().join("\n")).toContain("--recreate-sandbox"); + }); + + it("re-revalidates checkpoint bindings immediately before the locked destructive create, catching a race after the initial check (#7022)", async () => { + let liveCheckCount = 0; + const providerMatchesGatewayCredential = vi.fn(() => { + liveCheckCount += 1; + return liveCheckCount === 1; + }); + const session = sessionWithCheckpoint( + crashedCheckpoint({ + effectGroups: {}, + bindings: { + credentialEnvs: [], + registeredProviders: [ + { name: "my-assistant-brave-search", type: "brave", credentialEnv: "BRAVE_API_KEY" }, + ], + }, + }), + ); + const updateSession = vi.fn((mutator: (value: typeof session) => void) => { + mutator(session); + return session; + }); + const { deps, calls } = createDeps({ + getSandboxReuseState: () => "missing", + providerMatchesGatewayCredential, + updateSession, + }); + + await expect( + handleSandboxState({ + ...baseOptions(deps, session), + resume: true, + sandboxName: "my-assistant", + }), + ).rejects.toThrow("exit 1"); + + expect(liveCheckCount).toBeGreaterThan(1); + expect(calls.createSandbox).not.toHaveBeenCalled(); + expect(calls.error.mock.calls.flat().join("\n")).toContain("my-assistant-brave-search"); + }); + + it("accepts a scrubbed host credential when its exact registered provider binding remains live (#7022)", async () => { + const providerMatchesGatewayCredential = vi.fn(() => true); + const session = sessionWithCheckpoint( + crashedCheckpoint({ + effectGroups: {}, + bindings: { + credentialEnvs: ["COMPATIBLE_API_KEY"], + registeredProviders: [ + { + name: "compatible-endpoint", + type: "openai", + credentialEnv: "COMPATIBLE_API_KEY", + }, + ], + }, + }), + ); + const { deps, calls } = createDeps({ + getSandboxReuseState: () => "missing", + providerMatchesGatewayCredential, + }); + + await handleSandboxState({ + ...baseOptions(deps, session), + resume: true, + sandboxName: "my-assistant", + env: {}, + }); + + expect(providerMatchesGatewayCredential).toHaveBeenCalledWith( + "compatible-endpoint", + "openai", + "COMPATIBLE_API_KEY", + ); + expect(calls.createSandbox).toHaveBeenCalledTimes(1); + }); + + it.each([ + "interactive", + "non-interactive", + ] as const)("resumes a %s onboarding attempt that crashed after create succeeded but before its completion receipt (#7022)", async (mode) => { + const recordStepComplete = vi + .fn() + .mockRejectedValueOnce(new Error("process crashed after create")); + const { deps, calls, getSession } = createDeps({ + getSandboxReuseState: () => "missing", + recordStepComplete, + }); + const session = createSession({ sessionId: "sess-1", agent: "openclaw", mode }); + + await expect( + handleSandboxState({ + ...baseOptions(deps, session), + resume: false, + sandboxName: "my-assistant", + authoritativeResumeConfig: true, + }), + ).rejects.toThrow("process crashed after create"); + + expect(calls.createSandbox).toHaveBeenCalledTimes(1); + expect(calls.promptName).not.toHaveBeenCalled(); + expect(calls.configureWebSearch).not.toHaveBeenCalled(); + const crashedSession = getSession(); + expect(crashedSession.checkpoint?.effectGroups.sandbox_create).toBeUndefined(); + expect(crashedSession.checkpoint?.sandboxIdentity).toEqual( + decisionSelected({ name: "my-assistant", agent: "openclaw" }), + ); + + const { deps: resumeDeps, calls: resumeCalls } = createDeps({ + getSandboxReuseState: () => "ready", + }); + + await handleSandboxState({ + ...baseOptions(resumeDeps, crashedSession), + resume: true, + sandboxName: "my-assistant", + authoritativeResumeConfig: true, + }); + + expect(resumeCalls.createSandbox).not.toHaveBeenCalled(); + expect(resumeCalls.recordSkip).toHaveBeenCalled(); + }); + + it.each([ + "interactive", + "non-interactive", + ] as const)("backfills effect receipts after a %s crash following sandbox registration (#7022)", async (mode) => { + let persistedSession = createSession({ sessionId: "sess-1", agent: "openclaw", mode }); + const updateSession = vi.fn((mutator: (value: Session) => Session | void) => { + persistedSession = mutator(persistedSession) ?? persistedSession; + return persistedSession; + }); + const recordStepComplete = vi.fn(async (_stepName: string, updates: SessionUpdates) => { + Object.assign(persistedSession, updates); + updateSession.mockImplementationOnce(() => { + throw new Error("process crashed after sandbox registration"); + }); + return persistedSession; + }); + const firstRun = createDeps({ + getSandboxReuseState: () => "missing", + recordStepComplete, + updateSession, + }); + + await expect( + handleSandboxState({ + ...baseOptions(firstRun.deps, persistedSession), + resume: false, + sandboxName: "my-assistant", + authoritativeResumeConfig: true, + }), + ).rejects.toThrow("process crashed after sandbox registration"); + + expect(firstRun.calls.createSandbox).toHaveBeenCalledTimes(1); + expect(firstRun.calls.updateSandbox).toHaveBeenCalledTimes(1); + expect(recordStepComplete).toHaveBeenCalledTimes(1); + expect(persistedSession.checkpoint?.effectGroups.sandbox_create).toBeUndefined(); + expect(persistedSession.checkpoint?.effectGroups.sandbox_register).toBeUndefined(); + + const resumeUpdateSession = vi.fn((mutator: (value: Session) => Session | void) => { + persistedSession = mutator(persistedSession) ?? persistedSession; + return persistedSession; + }); + const recordStateSkipped = vi.fn(async () => persistedSession); + const resumedRun = createDeps({ + getSandboxReuseState: () => "ready", + recordStateSkipped, + updateSession: resumeUpdateSession, + }); + + await handleSandboxState({ + ...baseOptions(resumedRun.deps, persistedSession), + resume: true, + sandboxName: "my-assistant", + authoritativeResumeConfig: true, + }); + + expect(resumedRun.calls.createSandbox).not.toHaveBeenCalled(); + expect(recordStateSkipped).toHaveBeenCalledTimes(1); + expect(resumedRun.calls.updateSandbox).toHaveBeenCalledWith("my-assistant", { + pendingRouteReservation: undefined, + }); + expect( + resumedRun.calls.updateSandbox.mock.calls.some(([, updates]) => + Object.prototype.hasOwnProperty.call(updates, "provider"), + ), + ).toBe(false); + expect(persistedSession.checkpoint?.effectGroups.sandbox_create?.fingerprint).toBe( + defaultCreateFingerprint(), + ); + expect(persistedSession.checkpoint?.effectGroups.sandbox_register?.fingerprint).toBe( + "my-assistant", + ); + }); +}); diff --git a/src/lib/onboard/machine/handlers/sandbox-create-intent-boundary.test.ts b/src/lib/onboard/machine/handlers/sandbox-create-intent-boundary.test.ts index 8fc910432d..96e6dbd194 100644 --- a/src/lib/onboard/machine/handlers/sandbox-create-intent-boundary.test.ts +++ b/src/lib/onboard/machine/handlers/sandbox-create-intent-boundary.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it, vi } from "vitest"; +import type { CheckpointProviderBinding } from "../../../state/onboard-checkpoint-types"; import { createSession } from "../../../state/onboard-session"; import { handleSandboxState } from "./sandbox"; import { @@ -239,14 +240,16 @@ describe("sandbox create intent machine boundary", () => { credentialEnvName === "TELEGRAM_BOT_TOKEN"), ); const stageSandboxCredentialProviders = vi - .fn<() => Promise>() + .fn<() => Promise>() .mockImplementationOnce(async () => { durableSession.stagedCredentialProviders = ["tm-brave-search"]; - return ["tm-brave-search"]; + return [{ name: "tm-brave-search", type: "brave", credentialEnv: "BRAVE_API_KEY" }]; }) .mockImplementationOnce(async () => { durableSession.stagedCredentialProviders.push("tm-telegram-bridge"); - return ["tm-telegram-bridge"]; + return [ + { name: "tm-telegram-bridge", type: "generic", credentialEnv: "TELEGRAM_BOT_TOKEN" }, + ]; }) .mockResolvedValue([]); const readMessagingPlanFromEnv = vi diff --git a/src/lib/onboard/machine/handlers/sandbox-messaging.test.ts b/src/lib/onboard/machine/handlers/sandbox-messaging.test.ts index 4ed783da65..84a7912dc4 100644 --- a/src/lib/onboard/machine/handlers/sandbox-messaging.test.ts +++ b/src/lib/onboard/machine/handlers/sandbox-messaging.test.ts @@ -5,7 +5,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { SandboxMessagingPlan } from "../../../messaging/manifest"; import { hashCredential } from "../../../security/credential-hash"; -import { createSession } from "../../../state/onboard-session"; +import { decisionSelected, decisionUnset } from "../../../state/onboard-checkpoint-decision"; +import { + CHECKPOINT_SCHEMA_VERSION, + type OnboardCheckpoint, +} from "../../../state/onboard-checkpoint-types"; +import { createSession, type Session } from "../../../state/onboard-session"; import { reconcileReusedSandboxMessaging, reconcileSandboxMessaging } from "./sandbox-messaging"; const channelIds = ["telegram", "unsupported"]; @@ -154,12 +159,40 @@ function completedCheckpointSession( return session; } +function withMessagingCheckpoint( + session: Session, + selectedChannels: string[], + disabledChannels: string[] = [], +): Session { + const checkpoint: OnboardCheckpoint = { + schemaVersion: CHECKPOINT_SCHEMA_VERSION, + sessionId: session.sessionId, + machineState: session.machine.state, + updatedAt: "2026-01-01T00:00:00.000Z", + sandboxIdentity: decisionUnset(), + webSearch: decisionUnset(), + messaging: decisionSelected({ selectedChannels, disabledChannels }), + resourceProfile: decisionUnset(), + effectGroups: {}, + bindings: { credentialEnvs: [], registeredProviders: [] }, + }; + session.checkpoint = checkpoint; + return session; +} + function reconcileDeps(plans: readonly (SandboxMessagingPlan | null)[]) { return { note: vi.fn(), showMessagingStage: vi.fn(), getRecordedMessagingChannelsForResume: vi.fn(() => null), - setupMessagingChannels: vi.fn(async () => ["telegram"]), + setupMessagingChannels: vi.fn( + async ( + _agent: unknown, + _existingChannels: string[] | null, + _sandboxName: string, + _options?: { readonly selectionCompleted?: boolean }, + ) => ["telegram"], + ), readMessagingPlanFromEnv: vi .fn() .mockReturnValueOnce(plans[0] ?? null) @@ -346,4 +379,56 @@ describe("reconcileSandboxMessaging completed checkpoint credentials", () => { expect(deps.setupMessagingChannels).not.toHaveBeenCalled(); expect(result).toEqual({ plan: persistedPlan, selectedChannels: ["telegram"] }); }); + + it("does not reconcile when the checkpointed channel selection matches the durable plan (#7022)", async () => { + const persistedPlan = telegramPlan(hashCredential("123456:previous-token") ?? ""); + const deps = reconcileDeps([null]); + deps.providerMatchesGatewayCredential.mockReturnValueOnce(true); + const session = withMessagingCheckpoint( + completedCheckpointSession(persistedPlan, ["alpha-telegram-bridge"]), + ["telegram"], + ); + + const result = await reconcileSandboxMessaging({ + resume: true, + session, + sandboxName: "alpha", + agent: { name: "openclaw" }, + deps, + }); + + expect(deps.setupMessagingChannels).not.toHaveBeenCalled(); + expect(deps.note).not.toHaveBeenCalledWith( + expect.stringContaining("Reconciling messaging selection"), + ); + expect(result).toEqual({ plan: persistedPlan, selectedChannels: ["telegram"] }); + }); + + it("reconciles the messaging selection with the checkpoint when the durable plan disagrees (#7022)", async () => { + const persistedPlan = telegramPlan(hashCredential("123456:previous-token") ?? ""); + const deps = reconcileDeps([null]); + deps.setupMessagingChannels.mockImplementationOnce( + async (_agent: unknown, existing: string[] | null) => existing ?? [], + ); + const session = withMessagingCheckpoint(completedCheckpointSession(persistedPlan), ["discord"]); + + const result = await reconcileSandboxMessaging({ + resume: true, + session, + sandboxName: "alpha", + agent: { name: "openclaw" }, + deps, + }); + + expect(deps.note).toHaveBeenCalledWith( + expect.stringContaining("Reconciling messaging selection"), + ); + expect(deps.setupMessagingChannels).toHaveBeenCalledWith( + { name: "openclaw" }, + ["discord"], + "alpha", + { selectionCompleted: true }, + ); + expect(result.selectedChannels).toEqual(["discord"]); + }); }); diff --git a/src/lib/onboard/machine/handlers/sandbox-messaging.ts b/src/lib/onboard/machine/handlers/sandbox-messaging.ts index e973e9c5b6..6be3fa9258 100644 --- a/src/lib/onboard/machine/handlers/sandbox-messaging.ts +++ b/src/lib/onboard/machine/handlers/sandbox-messaging.ts @@ -9,10 +9,17 @@ import { } from "../../../messaging"; import type { MessagingAgentId, SandboxMessagingPlan } from "../../../messaging/manifest"; import { hashCredential } from "../../../security/credential-hash"; +import { isDecisionSelected, isDecisionUnset } from "../../../state/onboard-checkpoint-decision"; import type { Session } from "../../../state/onboard-session"; import { detectMessagingChannelsFromEnv } from "../../messaging-channel-setup"; import { getActiveChannelsFromPlan, getChannelsFromPlan } from "../../messaging-plan-session"; +function sameChannelSet(a: readonly string[], b: readonly string[]): boolean { + if (a.length !== b.length) return false; + const seen = new Set(a); + return b.every((channel) => seen.has(channel)); +} + type MessagingAgentLike = { readonly name?: string; }; @@ -263,6 +270,33 @@ export function reconcileReusedSandboxMessaging( }; } +function divergedCheckpointChannels( + session: Session | null | undefined, + durablePlan: SandboxMessagingPlan | null, +): readonly string[] | null { + const checkpoint = session?.checkpoint; + if (!checkpoint) return null; + const checkpointedChannels = isDecisionSelected(checkpoint.messaging) + ? checkpoint.messaging.value.selectedChannels + : []; + const durableChannels = durablePlan ? getActiveChannelsFromPlan(durablePlan) : []; + return sameChannelSet(checkpointedChannels, durableChannels) ? null : checkpointedChannels; +} + +async function selectionFromDivergedMessagingCheckpoint( + checkpointedChannels: readonly string[], + options: ReconcileSandboxMessagingOptions, +): Promise { + if (checkpointedChannels.length === 0) { + options.deps.clearPlanEnv(); + options.deps.showMessagingStage?.(); + options.deps.note(" [resume] Reusing messaging selection: no channels."); + return { plan: null, selectedChannels: [] }; + } + options.deps.note(" [resume] Reconciling messaging selection with the recorded checkpoint."); + return selectionFromMessagingSetup([...checkpointedChannels], options, true); +} + async function selectionFromCompletedMessagingCheckpoint( envPlan: SandboxMessagingPlan | null, options: ReconcileSandboxMessagingOptions, @@ -271,6 +305,10 @@ async function selectionFromCompletedMessagingCheckpoint( // plan may already have refreshed hashes, so it cannot prove that a newly // exported credential passed the channel's validation hooks. const durablePlan = options.session?.messagingPlan ?? null; + const diverged = divergedCheckpointChannels(options.session, durablePlan); + if (diverged) { + return selectionFromDivergedMessagingCheckpoint(diverged, options); + } if (!durablePlan) { options.deps.clearPlanEnv(); options.deps.showMessagingStage?.(); @@ -338,11 +376,10 @@ export async function reconcileSandboxMessaging( ); const envPlan = options.deps.readMessagingPlanFromEnv(); const agentName = (options.agent as MessagingAgentLike | null)?.name; - if ( - (!agentName || agentName === "openclaw") && - options.resume && - options.session?.sandboxPromptProgress?.messaging === true - ) { + const messagingDecisionCompleted = options.session?.checkpoint + ? !isDecisionUnset(options.session.checkpoint.messaging) + : options.session?.sandboxPromptProgress?.messaging === true; + if ((!agentName || agentName === "openclaw") && options.resume && messagingDecisionCompleted) { return selectionFromCompletedMessagingCheckpoint(envPlan, options); } const registryPlan = options.deps.getRegistrySandboxMessagingPlan(options.sandboxName); diff --git a/src/lib/onboard/machine/handlers/sandbox-test-fixtures.ts b/src/lib/onboard/machine/handlers/sandbox-test-fixtures.ts index b8a9795fb1..7bdbcdbd6b 100644 --- a/src/lib/onboard/machine/handlers/sandbox-test-fixtures.ts +++ b/src/lib/onboard/machine/handlers/sandbox-test-fixtures.ts @@ -4,6 +4,7 @@ import { vi } from "vitest"; import type { SandboxMessagingPlan } from "../../../messaging/manifest"; +import type { CheckpointProviderBinding } from "../../../state/onboard-checkpoint-types"; import { createSession, type Session, type SessionUpdates } from "../../../state/onboard-session"; import type { SandboxStateOptions } from "./sandbox"; @@ -93,8 +94,9 @@ export function createDeps( ResourceProfile >["deps"] > = {}, + initialSession: Session = createSession(), ) { - let session = createSession(); + let session = initialSession; const calls = { checkGatewayRouteCompatibility: vi.fn(() => ({ ok: true as const })), note: vi.fn(), @@ -113,7 +115,7 @@ export function createDeps( getRecordedChannels: vi.fn(() => null), showMessagingStage: vi.fn(), setupMessaging: vi.fn(async () => [] as string[]), - stageCredentialProviders: vi.fn(async () => [] as string[]), + stageCredentialProviders: vi.fn(async () => [] as CheckpointProviderBinding[]), promptName: vi.fn(async () => "my-assistant"), selectResourceProfile: vi.fn(async () => null as ResourceProfile | null), stopStale: vi.fn(), diff --git a/src/lib/onboard/machine/handlers/sandbox.test.ts b/src/lib/onboard/machine/handlers/sandbox.test.ts index d74d038dcd..6a8de4d223 100644 --- a/src/lib/onboard/machine/handlers/sandbox.test.ts +++ b/src/lib/onboard/machine/handlers/sandbox.test.ts @@ -4,6 +4,12 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { hashCredential } from "../../../security/credential-hash"; +import { + decisionDeclined, + decisionSelected, + decisionUnset, +} from "../../../state/onboard-checkpoint-decision"; +import { CHECKPOINT_SCHEMA_VERSION } from "../../../state/onboard-checkpoint-types"; import { createSession, type Session } from "../../../state/onboard-session"; import { detectMessagingChannelsFromEnv } from "../../messaging-channel-setup"; import { handleSandboxState } from "./sandbox"; @@ -102,6 +108,78 @@ describe("handleSandboxState", () => { updates: undefined, metadata: { state: "sandbox", sandboxName: "my-assistant", agent: "openclaw" }, }); + expect(result.session?.checkpoint?.webSearch).toEqual(decisionSelected({ fetchEnabled: true })); + expect(result.session?.checkpoint?.messaging).toEqual(decisionDeclined()); + }); + + it("records credential-provider bindings and the resource-profile decision in the checkpoint (#7022)", async () => { + const { deps } = createDeps({ + configureWebSearch: vi.fn(async () => ({ fetchEnabled: true as const })), + stageSandboxCredentialProviders: vi.fn(async () => [ + { name: "my-assistant-brave-search", type: "brave", credentialEnv: "BRAVE_API_KEY" }, + ]), + providerMatchesGatewayCredential: (name, type, credentialEnv) => + name === "my-assistant-brave-search" && + type === "brave" && + credentialEnv === "BRAVE_API_KEY", + selectResourceProfileForSandbox: vi.fn(async () => ({ cpu: "2", memory: "4Gi" })), + }); + + const result = await handleSandboxState(baseOptions(deps)); + + expect(result.session?.checkpoint?.bindings.registeredProviders).toEqual([ + { name: "my-assistant-brave-search", type: "brave", credentialEnv: "BRAVE_API_KEY" }, + ]); + expect(result.session?.checkpoint?.bindings.credentialEnvs).toEqual(["BRAVE_API_KEY"]); + expect(result.session?.checkpoint?.resourceProfile).toEqual( + decisionSelected({ cpu: "2", memory: "4Gi" }), + ); + }); + + it("skips re-registering a provider whose effect-group receipt and live postcondition both hold (#7022)", async () => { + const session = createSession({ sandboxName: "my-assistant" }); + session.checkpoint = { + schemaVersion: CHECKPOINT_SCHEMA_VERSION, + sessionId: session.sessionId, + machineState: "sandbox", + updatedAt: "2026-01-01T00:00:00.000Z", + sandboxIdentity: decisionSelected({ name: "my-assistant", agent: "openclaw" }), + webSearch: decisionUnset(), + messaging: decisionUnset(), + resourceProfile: decisionUnset(), + effectGroups: { + web_search_provider: { + completedAt: "2026-01-01T00:00:00.000Z", + fingerprint: "my-assistant-brave-search", + }, + }, + bindings: { + credentialEnvs: [], + registeredProviders: [ + { name: "my-assistant-brave-search", type: "brave", credentialEnv: "BRAVE_API_KEY" }, + ], + }, + }; + const updateSession = vi.fn((mutator: (value: typeof session) => void) => { + mutator(session); + return session; + }); + const stageSandboxCredentialProviders = vi.fn(async () => [ + { name: "my-assistant-brave-search", type: "brave", credentialEnv: "BRAVE_API_KEY" }, + ]); + const { deps } = createDeps({ + updateSession, + configureWebSearch: vi.fn(async () => ({ fetchEnabled: true as const })), + stageSandboxCredentialProviders, + providerMatchesGatewayCredential: (name, type, credentialEnv) => + name === "my-assistant-brave-search" && + type === "brave" && + credentialEnv === "BRAVE_API_KEY", + }); + + await handleSandboxState({ ...baseOptions(deps, session), sandboxName: "my-assistant" }); + + expect(stageSandboxCredentialProviders).not.toHaveBeenCalled(); }); it("does not auto-enable web search from ambient credentials during authoritative rebuild", async () => { @@ -510,6 +588,156 @@ describe("handleSandboxState", () => { expect(result.session).toBe(skippedSession); }); + it("treats checkpoint machine-state progress past sandbox as step-complete even when the legacy step status is stale (#6228)", async () => { + const session = createSession({ + sandboxName: "saved", + machine: { version: 1, state: "agent_setup", stateEnteredAt: null, revision: 1 }, + }); + session.checkpoint = { + schemaVersion: CHECKPOINT_SCHEMA_VERSION, + sessionId: session.sessionId, + machineState: "agent_setup", + updatedAt: "2026-01-01T00:00:00.000Z", + sandboxIdentity: decisionUnset(), + webSearch: decisionUnset(), + messaging: decisionUnset(), + resourceProfile: decisionUnset(), + effectGroups: {}, + bindings: { credentialEnvs: [], registeredProviders: [] }, + }; + const { deps, calls } = createDeps({ + getSandboxReuseState: () => "ready", + getSandboxRegistryEntry: () => ({ + name: "saved", + provider: "provider", + model: "model", + endpointUrl: null, + preferredInferenceApi: "openai-completions", + toolDisclosure: "progressive", + fromDockerfile: null, + hermesAuthMethod: null, + }), + }); + + await handleSandboxState({ + ...baseOptions(deps, session), + resume: true, + sandboxName: "saved", + }); + + expect(session.steps.sandbox.status).not.toBe("complete"); + expect(calls.createSandbox).not.toHaveBeenCalled(); + expect(calls.recordSkip).toHaveBeenCalled(); + }); + + it("does not let a stale legacy complete marker override a checkpoint still at sandbox (#7022)", async () => { + const session = createSession({ sandboxName: "saved" }); + session.steps.sandbox.status = "complete"; + session.checkpoint = { + schemaVersion: CHECKPOINT_SCHEMA_VERSION, + sessionId: session.sessionId, + machineState: "sandbox", + updatedAt: "2026-01-01T00:00:00.000Z", + sandboxIdentity: decisionUnset(), + webSearch: decisionUnset(), + messaging: decisionUnset(), + resourceProfile: decisionUnset(), + effectGroups: {}, + bindings: { credentialEnvs: [], registeredProviders: [] }, + }; + const { deps, calls } = createDeps({ getSandboxReuseState: () => "missing" }); + + await handleSandboxState({ + ...baseOptions(deps, session), + resume: true, + sandboxName: "saved", + }); + + expect(calls.createSandbox).toHaveBeenCalled(); + }); + + it("prefers the checkpointed web-search decision over a stale legacy completion flag (#7022)", async () => { + const session = createSession({ + sandboxName: "saved", + sandboxPromptProgress: { + sandboxName: true, + webSearch: true, + messaging: false, + resourceProfile: false, + }, + }); + session.checkpoint = { + schemaVersion: CHECKPOINT_SCHEMA_VERSION, + sessionId: session.sessionId, + machineState: "sandbox", + updatedAt: "2026-01-01T00:00:00.000Z", + sandboxIdentity: decisionSelected({ name: "saved", agent: "openclaw" }), + webSearch: decisionSelected({ fetchEnabled: true, provider: "brave" }), + messaging: decisionUnset(), + resourceProfile: decisionUnset(), + effectGroups: {}, + bindings: { credentialEnvs: [], registeredProviders: [] }, + }; + const updateSession = vi.fn((mutator: (value: typeof session) => void) => { + mutator(session); + return session; + }); + const { deps, calls } = createDeps({ getSandboxReuseState: () => "missing", updateSession }); + + await handleSandboxState({ + ...baseOptions(deps, session), + resume: true, + sandboxName: "saved", + }); + + expect(calls.configureWebSearch).not.toHaveBeenCalled(); + expect((calls.createSandbox.mock.calls[0] as unknown[] | undefined)?.[5]).toEqual({ + fetchEnabled: true, + provider: "brave", + }); + }); + + it("prefers the checkpointed resource-profile decision over a stale legacy completion flag (#7022)", async () => { + const session = createSession({ + sandboxName: "saved", + sandboxPromptProgress: { + sandboxName: true, + webSearch: true, + messaging: true, + resourceProfile: true, + }, + }); + session.checkpoint = { + schemaVersion: CHECKPOINT_SCHEMA_VERSION, + sessionId: session.sessionId, + machineState: "sandbox", + updatedAt: "2026-01-01T00:00:00.000Z", + sandboxIdentity: decisionSelected({ name: "saved", agent: "openclaw" }), + webSearch: decisionDeclined(), + messaging: decisionDeclined(), + resourceProfile: decisionSelected({ cpu: "4", memory: "8Gi" }), + effectGroups: {}, + bindings: { credentialEnvs: [], registeredProviders: [] }, + }; + const updateSession = vi.fn((mutator: (value: typeof session) => void) => { + mutator(session); + return session; + }); + const { deps, calls } = createDeps({ getSandboxReuseState: () => "missing", updateSession }); + + await handleSandboxState({ + ...baseOptions(deps, session), + resume: true, + sandboxName: "saved", + }); + + expect(calls.selectResourceProfile).not.toHaveBeenCalled(); + expect((calls.createSandbox.mock.calls[0] as unknown[] | undefined)?.[11]).toEqual({ + cpu: "4", + memory: "8Gi", + }); + }); + it("recreates a resumed Hermes sandbox when its compatible Anthropic frontend is stale", async () => { const session = createSession({ agent: "hermes", diff --git a/src/lib/onboard/machine/handlers/sandbox.ts b/src/lib/onboard/machine/handlers/sandbox.ts index 9f49313e08..df33d05709 100644 --- a/src/lib/onboard/machine/handlers/sandbox.ts +++ b/src/lib/onboard/machine/handlers/sandbox.ts @@ -17,6 +17,18 @@ import { webSearchProviderForConfig, } from "../../../inference/web-search"; import type { SandboxMessagingPlan } from "../../../messaging/manifest"; +import { + decisionValue, + isDecisionSelected, + isDecisionUnset, +} from "../../../state/onboard-checkpoint-decision"; +import type { + CheckpointEffectGroupName, + CheckpointProviderBinding, + CheckpointResourceProfile, + CheckpointSandboxIdentity, + OnboardCheckpoint, +} from "../../../state/onboard-checkpoint-types"; import type { HermesAuthMethod, Session, @@ -26,6 +38,23 @@ import type { import type { SandboxEntry } from "../../../state/registry"; import { getSandboxEntryInference } from "../../../state/registry-entry-view"; import { toolDisclosureOrDefault } from "../../../tool-disclosure"; +import { + recordCheckpointBindings, + recordCheckpointEffectGroup, + recordCheckpointMessaging, + recordCheckpointResourceProfile, + recordCheckpointSandboxIdentity, + recordCheckpointWebSearch, +} from "../../checkpoint-record"; +import { + checkpointProvesSandboxStepComplete, + planEffectGroupReplay, + planSandboxCreateReplay, +} from "../../checkpoint-replay"; +import { + bindingRevalidationGuidance, + revalidateCheckpointBindings, +} from "../../checkpoint-revalidate"; import { withDashboardPortReservationLock as withHostDashboardPortReservationLock } from "../../dashboard-port"; import { type DashboardRuntimeAgent, shouldManageDashboardForAgent } from "../../dashboard-runtime"; import { @@ -179,7 +208,7 @@ export interface SandboxStateOptions< enabledChannels: readonly string[]; webSearchConfig: WebSearchConfig | null; agent: Agent; - }): Promise; + }): Promise; promptValidatedSandboxName(agent: Agent): Promise; selectResourceProfileForSandbox(): Promise; stopStaleDashboardListenersForSandbox(sandboxes: unknown[], sandboxName: string): void; @@ -332,6 +361,16 @@ function observabilityRequestValidationError( return null; } +function checkpointIdentityForResumeTarget( + checkpoint: OnboardCheckpoint, + sandboxName: string | null, + agentName: string, +): CheckpointSandboxIdentity | null { + if (!isDecisionSelected(checkpoint.sandboxIdentity)) return null; + const identity = checkpoint.sandboxIdentity.value; + return identity.name === sandboxName && identity.agent === agentName ? identity : null; +} + class SandboxStateFlow< Gpu, Agent, @@ -468,7 +507,9 @@ class SandboxStateFlow< const decision = decideSandboxResume({ resume: this.options.resume, resumeAgentChanged: this.options.resumeAgentChanged, - sandboxStepComplete: state.session?.steps?.sandbox?.status === "complete", + sandboxStepComplete: state.session?.checkpoint + ? checkpointProvesSandboxStepComplete(state.session) + : state.session?.steps?.sandbox?.status === "complete", sandboxReuseState, inferenceRouteConfigChanged: hasHermesCompatibleAnthropicInferenceRouteDrift({ agentName: (this.options.agent as { name?: string } | null)?.name, @@ -500,7 +541,157 @@ class SandboxStateFlow< ...toolDisclosureSignals, ...dcodeResumeSignals, }); - return dcodeResume.preserveManagedDcodeRegistryEntry(this.options, decision); + const managedDcodeDecision = dcodeResume.preserveManagedDcodeRegistryEntry( + this.options, + decision, + ); + return this.applyCheckpointCrashRecovery(managedDcodeDecision, state, sandboxReuseState); + } + + // A "create" decision from decideSandboxResume means only that the sandbox + // step was never marked complete; it does not check whether a previous run + // already executed the destructive create effect before crashing. When a + // durable checkpoint proves that (recorded identity + a sandbox_create + // effect receipt), disambiguate using live state instead of blindly + // recreating under the same name (#5961, #6228). + private applyCheckpointCrashRecovery( + decision: SandboxResumeDecision, + state: SandboxStepState, + sandboxReuseState: string, + ): SandboxResumeDecision { + if (decision.kind !== "create") return decision; + const checkpoint = state.session?.checkpoint; + const agentName = (this.options.agent as { name?: string } | null)?.name ?? "openclaw"; + const identity = + checkpoint && checkpointIdentityForResumeTarget(checkpoint, state.sandboxName, agentName); + if (!checkpoint || !identity) return decision; + + const recordedFingerprint = checkpoint.effectGroups.sandbox_create?.fingerprint; + const currentLightFingerprint = this.currentSandboxCreateFingerprint(identity.name); + if ( + recordedFingerprint && + recordedFingerprint !== currentLightFingerprint && + !recordedFingerprint.startsWith(`${currentLightFingerprint}|`) + ) { + return this.rejectDriftedCheckpointFingerprint(identity.name); + } + + const bindingCheck = revalidateCheckpointBindings( + checkpoint, + this.checkpointBindingAvailability(checkpoint), + ); + if (bindingCheck.status === "stale") return this.rejectStaleCheckpointBindings(bindingCheck); + + const replay = planSandboxCreateReplay(checkpoint, { + liveSandboxExists: sandboxReuseState === "ready", + }); + return replay.action === "reuse" && replay.identity.name === state.sandboxName + ? { kind: "reuse" } + : decision; + } + + private currentSandboxCreateFingerprint( + sandboxName: string, + createIntent?: ResolvedSandboxCreateIntent, + ): string { + const { nemoclawVersion: builtFingerprint } = this.deps.getSandboxAgentRegistryFields( + this.options.agent, + !this.options.fromDockerfile, + ); + const policyFingerprint = this.options.authoritativePolicyTier ?? "default"; + const lightFingerprint = [ + typeof builtFingerprint === "string" ? builtFingerprint : sandboxName, + policyFingerprint, + this.options.provider, + this.options.model, + this.options.preferredInferenceApi ?? "default", + this.options.fromDockerfile ?? "", + JSON.stringify(this.options.sandboxGpuConfig ?? null), + [...this.options.hermesToolGateways].sort().join(","), + ].join("|"); + if (!createIntent) return lightFingerprint; + // Extra providers are live gateway attachments, not durable build intent. + // Resume deliberately re-plans them so newly live providers are attached + // and stale records are omitted. Binding those ambient lists into the + // receipt would reject the established repair/resume reconciliation path. + const { + extraProviders: _extraProviders, + staleExtraProviders: _staleExtraProviders, + ...durableCreateIntent + } = createIntent; + return `${lightFingerprint}|${JSON.stringify(durableCreateIntent)}`; + } + + private assertCheckpointCreateInputsStillMatch( + state: SandboxStepState, + sandboxName: string, + createIntent: ResolvedSandboxCreateIntent, + ): void { + const recordedFingerprint = state.session?.checkpoint?.effectGroups.sandbox_create?.fingerprint; + if (!recordedFingerprint) return; + if (recordedFingerprint !== this.currentSandboxCreateFingerprint(sandboxName, createIntent)) { + this.rejectDriftedCheckpointFingerprint(sandboxName); + } + } + + private rejectDriftedCheckpointFingerprint(sandboxName: string): never { + this.deps.error( + ` A previous onboarding attempt recorded sandbox '${sandboxName}' with different build or policy inputs than this run requests.`, + ); + this.deps.error(" Pass --recreate-sandbox to rebuild it with the current settings."); + return this.deps.exitProcess(1); + } + + private providerBindingsLive(checkpoint: OnboardCheckpoint): boolean { + if (checkpoint.bindings.registeredProviders.length === 0) return false; + return checkpoint.bindings.registeredProviders.every((binding) => + this.deps.providerMatchesGatewayCredential(binding.name, binding.type, binding.credentialEnv), + ); + } + + private checkpointBindingAvailability(checkpoint: OnboardCheckpoint): { + availableCredentialEnvs: ReadonlySet; + liveRegisteredProviders: ReadonlySet; + } { + const liveRegisteredBindings = checkpoint.bindings.registeredProviders.filter((binding) => + this.deps.providerMatchesGatewayCredential(binding.name, binding.type, binding.credentialEnv), + ); + return { + availableCredentialEnvs: new Set( + [ + ...Object.keys(this.options.env).filter((name) => + Boolean(this.options.env[name]?.trim()), + ), + // Provider setup deliberately scrubs raw credentials from process.env + // after registration. The exact live name/type/credential-key binding + // is sufficient evidence for that scrubbed credential key (#7022). + ...liveRegisteredBindings.map((binding) => binding.credentialEnv), + ].filter(Boolean), + ), + liveRegisteredProviders: new Set(liveRegisteredBindings.map((binding) => binding.name)), + }; + } + + private rejectStaleCheckpointBindings( + bindingCheck: Extract, { status: "stale" }>, + ): never { + const guidance = bindingRevalidationGuidance(bindingCheck); + if (guidance) this.deps.error(guidance); + this.deps.error( + " A previous onboarding attempt was interrupted after starting sandbox creation.", + ); + this.deps.error(" Re-run with the required credentials available to continue safely."); + return this.deps.exitProcess(1); + } + + private assertCheckpointBindingsStillLive(state: SandboxStepState): void { + const checkpoint = state.session?.checkpoint; + if (!checkpoint) return; + const bindingCheck = revalidateCheckpointBindings( + checkpoint, + this.checkpointBindingAvailability(checkpoint), + ); + if (bindingCheck.status === "stale") this.rejectStaleCheckpointBindings(bindingCheck); } private applyObservabilityRequest( @@ -618,14 +809,52 @@ class SandboxStateFlow< reason: "resume", sandboxName: state.sandboxName, }); + const recordedSession = this.backfillReusedSandboxCheckpointReceipts( + skippedSession, + state.sandboxName, + ); return { ...state, - session: skippedSession, + session: recordedSession, selectedMessagingChannels: messaging.selectedChannels, }; }); } + private backfillReusedSandboxCheckpointReceipts( + session: Session, + sandboxName: string | null, + ): Session { + if (!sandboxName || !session.checkpoint) return session; + const agentName = (this.options.agent as { name?: string } | null)?.name ?? "openclaw"; + if (!checkpointIdentityForResumeTarget(session.checkpoint, sandboxName, agentName)) { + return session; + } + if ( + session.checkpoint.effectGroups.sandbox_create && + session.checkpoint.effectGroups.sandbox_register + ) { + return session; + } + return this.deps.updateSession((current) => { + const checkpoint = current.checkpoint; + if (!checkpoint || !checkpointIdentityForResumeTarget(checkpoint, sandboxName, agentName)) { + return current; + } + if (!checkpoint.effectGroups.sandbox_create) { + recordCheckpointEffectGroup( + current, + "sandbox_create", + this.currentSandboxCreateFingerprint(sandboxName), + ); + } + if (!checkpoint.effectGroups.sandbox_register) { + recordCheckpointEffectGroup(current, "sandbox_register", sandboxName); + } + return current; + }); + } + private backfillReusedSandboxFidelity(state: SandboxStepState): void { if (!state.sandboxName) return; const existing = this.deps.getSandboxRegistryEntry(state.sandboxName); @@ -688,10 +917,13 @@ class SandboxStateFlow< const explicitlyConfigured = parseExplicitWebSearchProvider( this.options.env[WEB_SEARCH_PROVIDER_ENV], ).specified; + const checkpoint = state.session?.checkpoint; const completedSelection = this.resumesSandboxPrompts && this.options.resume && - state.session?.sandboxPromptProgress?.webSearch === true; + (checkpoint + ? !isDecisionUnset(checkpoint.webSearch) + : state.session?.sandboxPromptProgress?.webSearch === true); if (!this.options.authoritativeResumeConfig && !explicitlyConfigured && !completedSelection) { return this.deps.configureWebSearch( null, @@ -699,10 +931,17 @@ class SandboxStateFlow< state.webSearchSupportProbePath, ); } + const checkpointedValue = checkpoint + ? (decisionValue(checkpoint.webSearch) as unknown as WebSearchConfig | null) + : null; if (completedSelection && !explicitlyConfigured && !state.webSearchSupportDropped) { - this.deps.note(" [resume] Reusing web search selection: disabled."); + this.deps.note( + checkpointedValue + ? " [resume] Reusing checkpointed web search selection." + : " [resume] Reusing web search selection: disabled.", + ); } - return null; + return checkpointedValue ? Promise.resolve(checkpointedValue) : null; } private checkpointWebSearch( @@ -713,6 +952,10 @@ class SandboxStateFlow< const session = this.deps.updateSession((current) => { current.webSearchConfig = webSearchConfig as unknown as Session["webSearchConfig"]; current.sandboxPromptProgress.webSearch = true; + recordCheckpointWebSearch( + current, + webSearchConfig as unknown as SharedWebSearchConfig | null, + ); return current; }); return { ...state, session, webSearchConfig }; @@ -736,12 +979,33 @@ class SandboxStateFlow< } current.sandboxName = sandboxName; current.sandboxPromptProgress.sandboxName = true; + recordCheckpointSandboxIdentity( + current, + sandboxName, + current.agent ?? (this.options.agent as { name?: string } | null)?.name ?? "openclaw", + ); return current; }); if (messagingInvalidated) this.deps.clearPlanEnv(); return { ...state, session, sandboxName }; } + private recordSandboxIdentityForCreate( + state: SandboxStepState, + sandboxName: string, + ): SandboxStepState { + if (this.resumesSandboxPrompts) return state; + const session = this.deps.updateSession((current) => { + recordCheckpointSandboxIdentity( + current, + sandboxName, + current.agent ?? (this.options.agent as { name?: string } | null)?.name ?? "openclaw", + ); + return current; + }); + return { ...state, session }; + } + private checkpointMessaging( state: SandboxStepState, messaging: { plan: SandboxMessagingPlan | null; selectedChannels: string[] }, @@ -752,6 +1016,7 @@ class SandboxStateFlow< const session = this.deps.updateSession((current) => { current.messagingPlan = messaging.plan; current.sandboxPromptProgress.messaging = true; + recordCheckpointMessaging(current, messaging.plan); return current; }); return { @@ -765,8 +1030,17 @@ class SandboxStateFlow< sandboxName: string, enabledChannels: readonly string[], webSearchConfig: WebSearchConfig | null, + group: CheckpointEffectGroupName, + checkpoint: OnboardCheckpoint | null, ): Promise { if (!this.resumesSandboxPrompts || (!webSearchConfig && enabledChannels.length === 0)) return; + if ( + checkpoint && + planEffectGroupReplay(checkpoint, group, this.providerBindingsLive(checkpoint)).action === + "skip" + ) { + return; + } const registeredProviders = await this.deps.withGatewayRouteMutationLock( this.options.gatewayName, () => @@ -779,6 +1053,17 @@ class SandboxStateFlow< ); if (registeredProviders.length > 0) { this.deps.note(" ✓ Registered selected credentials with OpenShell for resume."); + this.deps.updateSession((current) => { + recordCheckpointBindings(current, { + registeredProviders, + }); + recordCheckpointEffectGroup( + current, + group, + registeredProviders.map((binding) => binding.name).join(","), + ); + return current; + }); } } @@ -786,13 +1071,19 @@ class SandboxStateFlow< state: SandboxStepState; resourceProfile: ResourceProfile | null; }> { + const checkpoint = state.session?.checkpoint; + const completedSelection = checkpoint + ? !isDecisionUnset(checkpoint.resourceProfile) + : state.session?.sandboxPromptProgress?.resourceProfile === true; if ( this.resumesSandboxPrompts && this.options.resume && - state.session?.sandboxPromptProgress?.resourceProfile === true && + completedSelection && !hasResourceProfileEnvOverride(this.options.env) ) { - const resourceProfile = state.session.resourceProfile as ResourceProfile | null; + const resourceProfile = ( + checkpoint ? decisionValue(checkpoint.resourceProfile) : state.session?.resourceProfile + ) as ResourceProfile | null; this.deps.note( resourceProfile ? " [resume] Reusing resource profile selection." @@ -806,6 +1097,7 @@ class SandboxStateFlow< const session = this.deps.updateSession((current) => { current.resourceProfile = resourceProfile as SessionResourceProfile | null; current.sandboxPromptProgress.resourceProfile = true; + recordCheckpointResourceProfile(current, resourceProfile as CheckpointResourceProfile | null); return current; }); return { state: { ...state, session }, resourceProfile }; @@ -883,6 +1175,12 @@ class SandboxStateFlow< ); const createAndRecord = async (): Promise> => { this.assertGatewayRouteCompatible(requestedSandboxName); + this.assertCheckpointBindingsStillLive(state); + this.assertCheckpointCreateInputsStillMatch( + state, + requestedSandboxName, + createIntent.resolved, + ); await this.deps.startRecordedStep("sandbox", { sandboxName: requestedSandboxName, provider: this.options.provider, @@ -939,7 +1237,7 @@ class SandboxStateFlow< }); // Finalization marks the default so a cancelled onboarding cannot leave a // partially configured sandbox selected as the default. - const completedSession = await this.deps.recordStepComplete( + await this.deps.recordStepComplete( "sandbox", this.deps.toSessionUpdates({ sandboxName, @@ -951,7 +1249,16 @@ class SandboxStateFlow< hermesToolGateways: effectiveHermesToolGateways, }), ); - return { ...state, sandboxName, session: completedSession }; + const recordedSession = this.deps.updateSession((current) => { + recordCheckpointEffectGroup( + current, + "sandbox_create", + this.currentSandboxCreateFingerprint(sandboxName, createIntent.resolved), + ); + recordCheckpointEffectGroup(current, "sandbox_register", sandboxName); + return current; + }); + return { ...state, sandboxName, session: recordedSession }; }; const withGatewayLock = () => this.deps.withGatewayRouteMutationLock(this.options.gatewayName, createAndRecord); @@ -988,6 +1295,7 @@ class SandboxStateFlow< if (!nextState.sandboxName) { nextState = this.checkpointSandboxName(nextState, requestedSandboxName); } + nextState = this.recordSandboxIdentityForCreate(nextState, requestedSandboxName); const webSearchConfig = await this.resolveWebSearchForCreation(nextState); const webSearchConfigChanged = nextState.webSearchConfigChanged || @@ -1007,6 +1315,8 @@ class SandboxStateFlow< requestedSandboxName, [], nextState.webSearchConfig, + "web_search_provider", + nextState.session?.checkpoint ?? null, ); const messaging = await reconcileSandboxMessaging({ resume: this.options.resume, @@ -1020,6 +1330,8 @@ class SandboxStateFlow< requestedSandboxName, nextState.selectedMessagingChannels, null, + "messaging_providers", + nextState.session?.checkpoint ?? null, ); return this.createAndRecordSandbox(nextState, requestedSandboxName, messaging.plan, decision); } diff --git a/src/lib/onboard/session-bootstrap.test.ts b/src/lib/onboard/session-bootstrap.test.ts index 244765fb07..91eb44d3f8 100644 --- a/src/lib/onboard/session-bootstrap.test.ts +++ b/src/lib/onboard/session-bootstrap.test.ts @@ -3,6 +3,12 @@ import { describe, expect, it, vi } from "vitest"; +import { decisionSelected, decisionUnset } from "../state/onboard-checkpoint-decision"; +import { + CHECKPOINT_SCHEMA_VERSION, + type CheckpointLoadResult, + type OnboardCheckpoint, +} from "../state/onboard-checkpoint-types"; import { createSession, type Session, type SessionRecoveryReceipt } from "../state/onboard-session"; import type { ResumeConfigConflict } from "./resume-config"; import { type OnboardSessionBootstrapDeps, prepareOnboardSession } from "./session-bootstrap"; @@ -53,6 +59,7 @@ function createDeps( exitProcess: vi.fn((code: number) => { throw new ExitError(code); }) as (code: number) => never, + resolveResumeCheckpoint: vi.fn((): CheckpointLoadResult => ({ status: "none" })), ...overrides, }; return { deps, getSession: () => session }; @@ -364,6 +371,91 @@ describe("prepareOnboardSession", () => { expect(deps.exitProcess).not.toHaveBeenCalled(); }); + it("recovers a non-OpenClaw checkpointed sandbox name after a crash before the legacy field was written (#7022)", async () => { + const session = createSession({ agent: "hermes", sandboxName: null }); + const checkpoint: OnboardCheckpoint = { + schemaVersion: CHECKPOINT_SCHEMA_VERSION, + sessionId: session.sessionId, + machineState: "sandbox", + updatedAt: "2026-01-01T00:00:00.000Z", + sandboxIdentity: decisionSelected({ name: "hermes-box", agent: "hermes" }), + webSearch: decisionUnset(), + messaging: decisionUnset(), + resourceProfile: decisionUnset(), + effectGroups: {}, + bindings: { credentialEnvs: [], registeredProviders: [] }, + }; + session.checkpoint = checkpoint; + const { deps } = createDeps(session, { + resolveResumeCheckpoint: vi.fn( + (): CheckpointLoadResult => ({ status: "loaded", checkpoint }), + ), + }); + + const result = await prepareOnboardSession( + { + resume: true, + fresh: false, + requestedFromDockerfile: null, + requestedSandboxName: null, + cannotPrompt: true, + nonInteractive: true, + }, + deps, + ); + + expect(deps.error).not.toHaveBeenCalledWith( + " Cannot resume non-interactive onboard: the previous run was interrupted before sandbox creation completed,", + ); + expect(deps.exitProcess).not.toHaveBeenCalled(); + expect(result.session?.checkpoint?.sandboxIdentity).toEqual( + decisionSelected({ name: "hermes-box", agent: "hermes" }), + ); + }); + + it("does not let a stale legacy checkpointed-name marker override an unset checkpoint identity (#7022)", async () => { + const session = createSession({ + sandboxName: "stale-box", + sandboxPromptProgress: { + sandboxName: true, + webSearch: false, + messaging: false, + resourceProfile: false, + }, + }); + session.checkpoint = { + schemaVersion: CHECKPOINT_SCHEMA_VERSION, + sessionId: session.sessionId, + machineState: "sandbox", + updatedAt: "2026-01-01T00:00:00.000Z", + sandboxIdentity: decisionUnset(), + webSearch: decisionUnset(), + messaging: decisionUnset(), + resourceProfile: decisionUnset(), + effectGroups: {}, + bindings: { credentialEnvs: [], registeredProviders: [] }, + }; + const { deps } = createDeps(session); + + await expect( + prepareOnboardSession( + { + resume: true, + fresh: false, + requestedFromDockerfile: null, + requestedSandboxName: null, + cannotPrompt: true, + nonInteractive: true, + }, + deps, + ), + ).rejects.toThrow(ExitError); + + expect(deps.error).toHaveBeenCalledWith( + " so no sandbox name was recorded. Re-run with --name (or set NEMOCLAW_SANDBOX_NAME).", + ); + }); + it("allows interactive resume to prompt when no sandbox name was recorded", async () => { const { deps } = createDeps(createSession({ sandboxName: null })); diff --git a/src/lib/onboard/session-bootstrap.ts b/src/lib/onboard/session-bootstrap.ts index d67abbabf4..090b708cb8 100644 --- a/src/lib/onboard/session-bootstrap.ts +++ b/src/lib/onboard/session-bootstrap.ts @@ -1,8 +1,13 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import { isDecisionSelected } from "../state/onboard-checkpoint-decision"; +import { loadResumeCheckpoint } from "../state/onboard-checkpoint-migrate"; +import type { CheckpointLoadResult } from "../state/onboard-checkpoint-types"; import type { Session } from "../state/onboard-session"; import { DEFAULT_TOOL_DISCLOSURE, type ToolDisclosure } from "../tool-disclosure"; +import { recordCheckpointSandboxIdentity } from "./checkpoint-record"; +import { checkpointProvesSandboxStepComplete } from "./checkpoint-replay"; import type { ResumeConfigConflict } from "./resume-config"; import type { StationExpressResumeIntent } from "./station-express-resume"; @@ -46,6 +51,7 @@ export interface OnboardSessionBootstrapDeps { cliName(): string; error(message: string): void; exitProcess(code: number): never; + resolveResumeCheckpoint(): CheckpointLoadResult; } export interface OnboardSessionBootstrapResult { @@ -53,6 +59,8 @@ export interface OnboardSessionBootstrapResult { fromDockerfile: string | null; } +export const defaultResolveResumeCheckpoint: () => CheckpointLoadResult = loadResumeCheckpoint; + export function checkpointSandboxName( sandboxName: string, agent: { name?: string } | null, @@ -62,6 +70,11 @@ export function checkpointSandboxName( updateSession((current) => { current.sandboxName = sandboxName; current.sandboxPromptProgress.sandboxName = true; + recordCheckpointSandboxIdentity( + current, + sandboxName, + current.agent ?? agent?.name ?? "openclaw", + ); return current; }); } @@ -71,8 +84,16 @@ export function getCheckpointedSandboxName( agent: { name?: string } | null, session: Session | null, ): string | null { - if (!resume || (agent?.name && agent.name !== "openclaw")) return null; - return session?.sandboxPromptProgress?.sandboxName === true ? session.sandboxName : null; + if (!resume) return null; + if (session?.checkpoint) { + return isDecisionSelected(session.checkpoint.sandboxIdentity) + ? session.checkpoint.sandboxIdentity.value.name + : null; + } + return (!agent?.name || agent.name === "openclaw") && + session?.sandboxPromptProgress?.sandboxName === true + ? session.sandboxName + : null; } function mode(nonInteractive: boolean): "non-interactive" | "interactive" { @@ -87,6 +108,43 @@ function reportMissingResumeSession(deps: OnboardSessionBootstrapDeps): never { deps.exitProcess(1); } +function reportUnsupportedResumeCheckpoint( + foundVersion: number, + deps: OnboardSessionBootstrapDeps, +): never { + deps.error( + ` This onboarding session was written by a newer NemoClaw (checkpoint schema v${foundVersion}).`, + ); + deps.error( + " Resuming it with this version could create a second sandbox or drop recorded decisions.", + ); + deps.error(` Upgrade NemoClaw to resume it, or start fresh: ${deps.cliName()} onboard`); + deps.exitProcess(1); +} + +function reportCorruptResumeCheckpoint(deps: OnboardSessionBootstrapDeps): never { + deps.error(" The onboarding resume checkpoint is unreadable and cannot be safely continued."); + deps.error(` Start fresh: ${deps.cliName()} onboard`); + deps.exitProcess(1); +} + +function guardResumeCheckpoint(deps: OnboardSessionBootstrapDeps): void { + const result = deps.resolveResumeCheckpoint(); + if (result?.status === "unsupported_future") { + reportUnsupportedResumeCheckpoint(result.foundVersion, deps); + } + if (result?.status === "corrupt") { + reportCorruptResumeCheckpoint(deps); + } + if (result?.status === "migrated") { + const migratedCheckpoint = result.checkpoint; + deps.updateSession((current) => { + current.checkpoint = migratedCheckpoint; + return current; + }); + } +} + function reportResumeConflict( conflict: ResumeConfigConflict, deps: OnboardSessionBootstrapDeps, @@ -148,13 +206,19 @@ function assertRecoverableResumeSandboxName( input: OnboardSessionBootstrapInput, deps: OnboardSessionBootstrapDeps, ): void { - const sandboxStepCompleted = session?.steps?.sandbox?.status === "complete"; - const sandboxNameCheckpointed = - (!session?.agent || session.agent === "openclaw") && - session?.sandboxPromptProgress?.sandboxName === true; + const checkpoint = session?.checkpoint ?? null; + const nameRecoverable = checkpoint + ? checkpointProvesSandboxStepComplete(session) || isDecisionSelected(checkpoint.sandboxIdentity) + : session?.steps?.sandbox?.status === "complete" || + ((!session?.agent || session.agent === "openclaw") && + session?.sandboxPromptProgress?.sandboxName === true); + const checkpointedSandboxName = + checkpoint && isDecisionSelected(checkpoint.sandboxIdentity) + ? checkpoint.sandboxIdentity.value.name + : null; const recoveredSandboxName = input.requestedSandboxName || - (sandboxStepCompleted || sandboxNameCheckpointed ? session?.sandboxName || null : null); + (nameRecoverable ? checkpointedSandboxName || session?.sandboxName || null : null); if (input.cannotPrompt && !recoveredSandboxName) { deps.error( " Cannot resume non-interactive onboard: the previous run was interrupted before sandbox creation completed,", @@ -175,6 +239,7 @@ async function prepareResumeSession( if (!session || session.resumable === false) { reportMissingResumeSession(deps); } + guardResumeCheckpoint(deps); const sessionFrom = session.metadata?.fromDockerfile || null; const fromDockerfile = input.requestedFromDockerfile @@ -240,3 +305,13 @@ export async function prepareOnboardSession( ): Promise { return input.resume ? prepareResumeSession(input, deps) : prepareFreshSession(input, deps); } + +export function prepareOnboardSessionValidated( + input: OnboardSessionBootstrapInput, + deps: Omit, +): Promise { + return prepareOnboardSession(input, { + ...deps, + resolveResumeCheckpoint: defaultResolveResumeCheckpoint, + }); +} diff --git a/src/lib/state/onboard-checkpoint-decision.ts b/src/lib/state/onboard-checkpoint-decision.ts new file mode 100644 index 0000000000..678d396e1b --- /dev/null +++ b/src/lib/state/onboard-checkpoint-decision.ts @@ -0,0 +1,73 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { CheckpointDecision } from "./onboard-checkpoint-types"; + +const UNSET: CheckpointDecision = { kind: "unset" }; +const DECLINED: CheckpointDecision = { kind: "declined" }; + +export function decisionUnset(): CheckpointDecision { + return UNSET; +} + +export function decisionDeclined(): CheckpointDecision { + return DECLINED; +} + +export function decisionSelected(value: T): CheckpointDecision { + return { kind: "selected", value }; +} + +export function isDecisionUnset(decision: CheckpointDecision): boolean { + return decision.kind === "unset"; +} + +export function isDecisionDeclined(decision: CheckpointDecision): boolean { + return decision.kind === "declined"; +} + +export function isDecisionSelected( + decision: CheckpointDecision, +): decision is { kind: "selected"; value: T } { + return decision.kind === "selected"; +} + +export function decisionValue(decision: CheckpointDecision): T | null { + return decision.kind === "selected" ? decision.value : null; +} + +export function decisionsEqual( + a: CheckpointDecision, + b: CheckpointDecision, + valuesEqual: (x: T, y: T) => boolean = Object.is, +): boolean { + if (a.kind !== b.kind) return false; + if (a.kind === "selected" && b.kind === "selected") return valuesEqual(a.value, b.value); + return true; +} + +export function decisionFromLegacyNullable( + completed: boolean, + rawValue: Raw | null | undefined, + parse: (raw: Raw) => Value | null, +): CheckpointDecision { + if (!completed) return decisionUnset(); + if (rawValue === null || rawValue === undefined) return decisionDeclined(); + const parsed = parse(rawValue); + return parsed === null ? decisionDeclined() : decisionSelected(parsed); +} + +export function parseCheckpointDecision( + raw: unknown, + 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(); + if (kind === "selected") { + const parsed = parseValue((raw as { value?: unknown }).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 new file mode 100644 index 0000000000..81de24c502 --- /dev/null +++ b/src/lib/state/onboard-checkpoint-migrate.test.ts @@ -0,0 +1,165 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; + +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { serializeCheckpoint } from "./onboard-checkpoint"; +import { decisionDeclined, decisionSelected, decisionUnset } from "./onboard-checkpoint-decision"; +import { + deriveCheckpointFromSession, + loadResumeCheckpoint, + resolveCheckpointForResume, +} from "./onboard-checkpoint-migrate"; +import { + CHECKPOINT_SCHEMA_VERSION, + type CheckpointLoadResult, + type OnboardCheckpoint, +} from "./onboard-checkpoint-types"; +import { createSession, normalizeSession, type Session } from "./onboard-session"; + +function rawJson(value: unknown): Record { + return JSON.parse(JSON.stringify(value)); +} + +function completedSession(overrides: Partial = {}): Session { + return createSession({ + sessionId: "sess-1", + agent: "openclaw", + sandboxName: "my-sandbox", + sandboxPromptProgress: { + sandboxName: true, + webSearch: true, + messaging: true, + resourceProfile: true, + }, + webSearchConfig: null, + messagingPlan: null, + resourceProfile: null, + credentialEnv: "OPENAI_API_KEY", + stagedCredentialProviders: ["web-search-openclaw"], + ...overrides, + }); +} + +describe("deriveCheckpointFromSession", () => { + it("maps completed prompts with explicit null to declined, not unset (#6227/#5783)", () => { + const checkpoint = deriveCheckpointFromSession(completedSession()); + expect(checkpoint.sandboxIdentity).toEqual( + decisionSelected({ name: "my-sandbox", agent: "openclaw" }), + ); + expect(checkpoint.webSearch).toEqual(decisionDeclined()); + expect(checkpoint.messaging).toEqual(decisionDeclined()); + expect(checkpoint.resourceProfile).toEqual(decisionDeclined()); + expect(checkpoint.bindings).toEqual({ + credentialEnvs: [], + registeredProviders: [], + }); + }); + + it("maps never-reached prompts to unset", () => { + const session = createSession({ sessionId: "sess-2", agent: "openclaw" }); + const checkpoint = deriveCheckpointFromSession(session); + expect(checkpoint.sandboxIdentity).toEqual(decisionUnset()); + expect(checkpoint.webSearch).toEqual(decisionUnset()); + expect(checkpoint.messaging).toEqual(decisionUnset()); + expect(checkpoint.resourceProfile).toEqual(decisionUnset()); + }); + + it("maps a concrete resource choice to selected", () => { + const session = completedSession({ + resourceProfile: { cpu: "2", memory: "4Gi" }, + }); + expect(deriveCheckpointFromSession(session).resourceProfile).toEqual( + decisionSelected({ cpu: "2", memory: "4Gi" }), + ); + }); +}); + +describe("resolveCheckpointForResume", () => { + const validCheckpoint: OnboardCheckpoint = { + schemaVersion: CHECKPOINT_SCHEMA_VERSION, + sessionId: "sess-1", + machineState: "sandbox", + updatedAt: "2026-01-01T00:00:00.000Z", + sandboxIdentity: decisionSelected({ name: "my-sandbox", agent: "openclaw" }), + webSearch: decisionUnset(), + messaging: decisionUnset(), + resourceProfile: decisionUnset(), + effectGroups: {}, + bindings: { credentialEnvs: [], registeredProviders: [] }, + }; + + it("returns loaded when the embedded checkpoint is valid", () => { + const raw = { + ...rawJson(completedSession()), + checkpoint: serializeCheckpoint(validCheckpoint), + }; + const result = resolveCheckpointForResume(raw as Record); + expect(result.status).toBe("loaded"); + }); + + it("fails safe on an unsupported future checkpoint version instead of a fresh start (#6228)", () => { + const raw = { ...rawJson(completedSession()), checkpoint: { schemaVersion: 99 } }; + expect(resolveCheckpointForResume(raw)).toEqual({ + status: "unsupported_future", + foundVersion: 99, + }); + }); + + it("migrates 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" }), + ); + }); + + it("reports a corrupt embedded checkpoint rather than migrating over it", () => { + const raw = { ...rawJson(completedSession()), checkpoint: { schemaVersion: 1 } }; + expect(resolveCheckpointForResume(raw)).toEqual({ status: "corrupt" }); + }); + + it("rejects a checkpoint copied from a different session's file instead of trusting it", () => { + const raw = { + ...rawJson(completedSession({ sessionId: "sess-2" })), + checkpoint: serializeCheckpoint(validCheckpoint), + }; + expect(resolveCheckpointForResume(raw)).toEqual({ status: "corrupt" }); + }); + + it("persists a recorded checkpoint through a normalize round-trip", () => { + const session = completedSession(); + const withCheckpoint = createSession({ + ...session, + checkpoint: deriveCheckpointFromSession(session), + }); + const reloaded = normalizeSession(rawJson(withCheckpoint) as never); + expect(reloaded?.checkpoint?.sandboxIdentity).toEqual( + decisionSelected({ name: "my-sandbox", agent: "openclaw" }), + ); + expect(reloaded?.checkpoint?.webSearch).toEqual(decisionDeclined()); + }); +}); + +describe("loadResumeCheckpoint", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("treats an unreadable or malformed session file as corrupt, not missing (#7022)", () => { + vi.spyOn(fs, "existsSync").mockReturnValue(true); + vi.spyOn(fs, "readFileSync").mockReturnValue("{not valid json"); + + expect(loadResumeCheckpoint()).toEqual({ status: "corrupt" }); + }); + + it("returns none only when the session file genuinely does not exist", () => { + vi.spyOn(fs, "existsSync").mockReturnValue(false); + + expect(loadResumeCheckpoint()).toEqual({ status: "none" }); + }); +}); diff --git a/src/lib/state/onboard-checkpoint-migrate.ts b/src/lib/state/onboard-checkpoint-migrate.ts new file mode 100644 index 0000000000..b8d404b8a1 --- /dev/null +++ b/src/lib/state/onboard-checkpoint-migrate.ts @@ -0,0 +1,125 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; + +import { isObjectRecord, type JsonValue } from "../core/json-types"; +import type { WebSearchConfig } from "../inference/web-search"; +import { + getActiveChannelIdsFromPlan, + getDisabledChannelIdsFromPlan, +} from "../messaging/plan-validation"; +import { inspectCheckpoint } from "./onboard-checkpoint"; +import { + decisionFromLegacyNullable, + decisionSelected, + decisionUnset, +} from "./onboard-checkpoint-decision"; +import { + CHECKPOINT_SCHEMA_VERSION, + type CheckpointDecision, + type CheckpointLoadResult, + type CheckpointMessagingSelection, + type CheckpointResourceProfile, + type CheckpointSandboxIdentity, + type OnboardCheckpoint, +} from "./onboard-checkpoint-types"; +import { normalizeSession, SESSION_FILE, type Session } from "./onboard-session"; + +function identityDecision(session: Session): CheckpointDecision { + const { sandboxName, agent } = session; + if ( + session.sandboxPromptProgress.sandboxName && + typeof sandboxName === "string" && + sandboxName.length > 0 && + typeof agent === "string" && + agent.length > 0 + ) { + return decisionSelected({ name: sandboxName, agent }); + } + return decisionUnset(); +} + +function webSearchDecision(session: Session): CheckpointDecision { + return decisionFromLegacyNullable( + session.sandboxPromptProgress.webSearch, + session.webSearchConfig, + (config) => config, + ); +} + +function messagingDecision(session: Session): CheckpointDecision { + return decisionFromLegacyNullable( + session.sandboxPromptProgress.messaging, + session.messagingPlan, + (plan) => ({ + selectedChannels: getActiveChannelIdsFromPlan(plan), + disabledChannels: getDisabledChannelIdsFromPlan(plan), + }), + ); +} + +function resourceDecision(session: Session): CheckpointDecision { + return decisionFromLegacyNullable( + session.sandboxPromptProgress.resourceProfile, + session.resourceProfile, + (profile) => ({ cpu: profile.cpu, memory: profile.memory }), + ); +} + +export function deriveCheckpointFromSession(session: Session): OnboardCheckpoint { + return { + schemaVersion: CHECKPOINT_SCHEMA_VERSION, + sessionId: session.sessionId, + machineState: session.machine.state, + updatedAt: session.updatedAt, + sandboxIdentity: identityDecision(session), + webSearch: webSearchDecision(session), + messaging: messagingDecision(session), + resourceProfile: resourceDecision(session), + effectGroups: {}, + bindings: { + // Provider/inference resume owns and revalidates the primary inference + // binding before the sandbox phase. This ledger covers only external + // effects created inside the sandbox phase. + credentialEnvs: [], + registeredProviders: [], + }, + }; +} + +export function resolveCheckpointForResume(rawSession: unknown): CheckpointLoadResult { + if (!isObjectRecord(rawSession)) return { status: "none" }; + + const inspected = inspectCheckpoint(rawSession.checkpoint); + if (inspected.status === "unsupported_future" || inspected.status === "corrupt") { + return inspected; + } + + const session = normalizeSession(rawSession as JsonValue); + if (!session) return { status: "none" }; + + if (inspected.status === "loaded") { + // A checkpoint copied from another session's file would otherwise supply + // identity, bindings, and effect receipts for the wrong onboarding run. + if (inspected.checkpoint.sessionId !== session.sessionId) return { status: "corrupt" }; + return inspected; + } + + return { + status: "migrated", + checkpoint: deriveCheckpointFromSession(session), + fromVersion: 0, + }; +} + +export function loadResumeCheckpoint(): CheckpointLoadResult { + if (!fs.existsSync(SESSION_FILE)) return { status: "none" }; + let raw: unknown; + try { + raw = JSON.parse(fs.readFileSync(SESSION_FILE, "utf-8")); + } catch { + return { status: "corrupt" }; + } + return resolveCheckpointForResume(raw); +} diff --git a/src/lib/state/onboard-checkpoint-types.ts b/src/lib/state/onboard-checkpoint-types.ts new file mode 100644 index 0000000000..21af3f595e --- /dev/null +++ b/src/lib/state/onboard-checkpoint-types.ts @@ -0,0 +1,77 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { WebSearchConfig } from "../inference/web-search"; +import type { OnboardMachineState } from "../onboard/machine/types"; + +export const CHECKPOINT_SCHEMA_VERSION = 1 as const; + +export type CheckpointSchemaVersion = typeof CHECKPOINT_SCHEMA_VERSION; + +export type CheckpointDecision = + | { readonly kind: "unset" } + | { readonly kind: "declined" } + | { readonly kind: "selected"; readonly value: T }; + +export interface CheckpointSandboxIdentity { + readonly name: string; + readonly agent: string; +} + +export interface CheckpointResourceProfile { + readonly cpu: string; + readonly memory: string; +} + +export interface CheckpointMessagingSelection { + readonly selectedChannels: readonly string[]; + readonly disabledChannels: readonly string[]; +} + +export type CheckpointEffectGroupName = + | "web_search_provider" + | "messaging_providers" + | "sandbox_create" + | "sandbox_register"; + +export interface CheckpointEffectGroupRecord { + readonly completedAt: string; + readonly fingerprint: string; +} + +export interface CheckpointProviderBinding { + readonly name: string; + readonly type: string; + readonly credentialEnv: string; +} + +export interface CheckpointBindings { + readonly credentialEnvs: readonly string[]; + readonly registeredProviders: readonly CheckpointProviderBinding[]; +} + +export interface OnboardCheckpoint { + readonly schemaVersion: CheckpointSchemaVersion; + readonly sessionId: string; + readonly machineState: OnboardMachineState; + readonly updatedAt: string; + readonly sandboxIdentity: CheckpointDecision; + readonly webSearch: CheckpointDecision; + readonly messaging: CheckpointDecision; + readonly resourceProfile: CheckpointDecision; + readonly effectGroups: Readonly< + Partial> + >; + readonly bindings: CheckpointBindings; +} + +export type CheckpointLoadResult = + | { readonly status: "none" } + | { readonly status: "loaded"; readonly checkpoint: OnboardCheckpoint } + | { + readonly status: "migrated"; + readonly checkpoint: OnboardCheckpoint; + readonly fromVersion: number; + } + | { 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 new file mode 100644 index 0000000000..c87811a175 --- /dev/null +++ b/src/lib/state/onboard-checkpoint.test.ts @@ -0,0 +1,149 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; + +import { inspectCheckpoint, serializeCheckpoint } from "./onboard-checkpoint"; +import { + decisionDeclined, + decisionFromLegacyNullable, + decisionSelected, + decisionsEqual, + decisionUnset, + isDecisionDeclined, + isDecisionSelected, + isDecisionUnset, +} from "./onboard-checkpoint-decision"; +import { CHECKPOINT_SCHEMA_VERSION, type OnboardCheckpoint } from "./onboard-checkpoint-types"; + +const ISO = "2026-01-01T00:00:00.000Z"; + +function baseCheckpoint(overrides: Partial = {}): OnboardCheckpoint { + return { + schemaVersion: CHECKPOINT_SCHEMA_VERSION, + sessionId: "s1", + machineState: "sandbox", + updatedAt: ISO, + sandboxIdentity: decisionSelected({ name: "my-sandbox", agent: "openclaw" }), + webSearch: decisionUnset(), + messaging: decisionUnset(), + resourceProfile: decisionDeclined(), + effectGroups: { sandbox_create: { completedAt: ISO, fingerprint: "fp-create" } }, + bindings: { + credentialEnvs: ["OPENAI_API_KEY"], + registeredProviders: [ + { name: "web-search-p", type: "brave", credentialEnv: "BRAVE_API_KEY" }, + ], + }, + ...overrides, + }; +} + +describe("checkpoint decision tri-state", () => { + it("distinguishes unset, declined, and selected", () => { + expect(isDecisionUnset(decisionUnset())).toBe(true); + expect(isDecisionDeclined(decisionDeclined())).toBe(true); + const selected = decisionSelected("v"); + expect(isDecisionSelected(selected)).toBe(true); + expect(selected.kind === "selected" && selected.value).toBe("v"); + }); + + it("collapses legacy null using the completion marker (#6227/#5783)", () => { + const parse = (raw: string): string | null => (raw.length > 0 ? raw : null); + // never reached -> unset + expect(decisionFromLegacyNullable(false, null, parse)).toEqual(decisionUnset()); + expect(decisionFromLegacyNullable(false, "x", parse)).toEqual(decisionUnset()); + // completed with an explicit null -> declined + expect(decisionFromLegacyNullable(true, null, parse)).toEqual(decisionDeclined()); + // completed with a valid value -> selected + expect(decisionFromLegacyNullable(true, "value", parse)).toEqual(decisionSelected("value")); + // completed with an invalid value -> declined, never a false selection + expect(decisionFromLegacyNullable(true, "", parse)).toEqual(decisionDeclined()); + }); + + it("compares decisions by kind and value", () => { + expect(decisionsEqual(decisionUnset(), decisionUnset())).toBe(true); + expect(decisionsEqual(decisionUnset(), decisionDeclined())).toBe(false); + expect(decisionsEqual(decisionSelected("a"), decisionSelected("a"))).toBe(true); + expect(decisionsEqual(decisionSelected("a"), decisionSelected("b"))).toBe(false); + }); +}); + +describe("checkpoint schema inspection", () => { + it("returns none for absent payloads", () => { + expect(inspectCheckpoint(undefined)).toEqual({ status: "none" }); + expect(inspectCheckpoint(null)).toEqual({ status: "none" }); + }); + + it("fails safe on an unknown future schema version instead of treating it as fresh (#6228)", () => { + const result = inspectCheckpoint({ + ...serializeCheckpoint(baseCheckpoint()), + schemaVersion: 99, + }); + expect(result).toEqual({ status: "unsupported_future", foundVersion: 99 }); + }); + + it("treats malformed version or payload as corrupt, not missing", () => { + expect(inspectCheckpoint({ schemaVersion: 0 })).toEqual({ status: "corrupt" }); + expect(inspectCheckpoint({ schemaVersion: "1" })).toEqual({ status: "corrupt" }); + expect(inspectCheckpoint("nope")).toEqual({ status: "corrupt" }); + expect(inspectCheckpoint({ schemaVersion: CHECKPOINT_SCHEMA_VERSION })).toEqual({ + status: "corrupt", + }); + }); + + it("loads and round-trips a valid v1 checkpoint", () => { + const checkpoint = baseCheckpoint(); + const result = inspectCheckpoint(serializeCheckpoint(checkpoint)); + expect(result).toEqual({ status: "loaded", checkpoint }); + }); + + it("rejects a checkpoint whose sandbox identity value is malformed", () => { + const checkpoint = serializeCheckpoint(baseCheckpoint()); + (checkpoint as Record).sandboxIdentity = { + kind: "selected", + value: { name: "Invalid Name", agent: "openclaw" }, + }; + expect(inspectCheckpoint(checkpoint)).toEqual({ status: "corrupt" }); + }); + + it("rejects a malformed effect group record instead of silently dropping it", () => { + const checkpoint = serializeCheckpoint(baseCheckpoint()); + (checkpoint as Record).effectGroups = { + sandbox_create: { completedAt: ISO, fingerprint: 42 }, + }; + expect(inspectCheckpoint(checkpoint)).toEqual({ status: "corrupt" }); + }); + + it("rejects a non-object effect groups container instead of defaulting to empty", () => { + const checkpoint = serializeCheckpoint(baseCheckpoint()); + (checkpoint as Record).effectGroups = "not-an-object"; + expect(inspectCheckpoint(checkpoint)).toEqual({ status: "corrupt" }); + }); + + it("rejects non-string entries inside checkpoint bindings instead of silently dropping them", () => { + const checkpoint = serializeCheckpoint(baseCheckpoint()); + (checkpoint as Record).bindings = { + credentialEnvs: ["OPENAI_API_KEY", 42], + registeredProviders: [ + { name: "web-search-p", type: "brave", credentialEnv: "BRAVE_API_KEY" }, + ], + }; + expect(inspectCheckpoint(checkpoint)).toEqual({ status: "corrupt" }); + }); + + it("rejects a provider binding missing its type or credential environment instead of silently dropping it", () => { + const checkpoint = serializeCheckpoint(baseCheckpoint()); + (checkpoint as Record).bindings = { + credentialEnvs: ["OPENAI_API_KEY"], + registeredProviders: [{ name: "web-search-p", type: "brave" }], + }; + expect(inspectCheckpoint(checkpoint)).toEqual({ status: "corrupt" }); + }); + + it("rejects a non-object bindings container instead of defaulting to empty", () => { + const checkpoint = serializeCheckpoint(baseCheckpoint()); + (checkpoint as Record).bindings = "not-an-object"; + expect(inspectCheckpoint(checkpoint)).toEqual({ status: "corrupt" }); + }); +}); diff --git a/src/lib/state/onboard-checkpoint.ts b/src/lib/state/onboard-checkpoint.ts new file mode 100644 index 0000000000..4055876603 --- /dev/null +++ b/src/lib/state/onboard-checkpoint.ts @@ -0,0 +1,201 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { isObjectRecord } from "../core/json-types"; +import { normalizeWebSearchConfig, type WebSearchConfig } from "../inference/web-search"; +import { NAME_MAX_LENGTH, NAME_VALID_PATTERN } from "../name-validation"; +import { isOnboardMachineState } from "../onboard/machine/transitions"; +import { parseCheckpointDecision } from "./onboard-checkpoint-decision"; +import { + CHECKPOINT_SCHEMA_VERSION, + type CheckpointBindings, + type CheckpointDecision, + type CheckpointEffectGroupName, + type CheckpointEffectGroupRecord, + type CheckpointLoadResult, + type CheckpointMessagingSelection, + type CheckpointProviderBinding, + type CheckpointResourceProfile, + type CheckpointSandboxIdentity, + type OnboardCheckpoint, +} from "./onboard-checkpoint-types"; + +const EFFECT_GROUP_NAMES: readonly CheckpointEffectGroupName[] = [ + "web_search_provider", + "messaging_providers", + "sandbox_create", + "sandbox_register", +]; + +function readString(value: unknown): string | null { + return typeof value === "string" ? value : null; +} + +function readStringArray(value: unknown): string[] | null { + if (!Array.isArray(value)) return null; + const entries: string[] = []; + for (const entry of value) { + if (typeof entry !== "string") return null; + entries.push(entry); + } + return entries; +} + +function readCanonicalIsoTimestamp(value: unknown): string | null { + if (typeof value !== "string") return null; + try { + return new Date(value).toISOString() === value ? value : null; + } catch { + return null; + } +} + +function parseSandboxIdentityValue(value: unknown): CheckpointSandboxIdentity | null { + if (!isObjectRecord(value)) return null; + const name = readString(value.name); + const agent = readString(value.agent); + if (name === null || agent === null || agent.length === 0) return null; + if (name.length > NAME_MAX_LENGTH || !NAME_VALID_PATTERN.test(name)) return null; + return { name, agent }; +} + +function parseResourceProfileValue(value: unknown): CheckpointResourceProfile | null { + if (!isObjectRecord(value)) 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; + return normalizeWebSearchConfig(value as Partial); +} + +function parseMessagingValue(value: unknown): CheckpointMessagingSelection | null { + if (!isObjectRecord(value)) return null; + const selectedChannels = readStringArray(value.selectedChannels); + const disabledChannels = readStringArray(value.disabledChannels); + if (selectedChannels === null || disabledChannels === null) return null; + return { selectedChannels, disabledChannels }; +} + +function parseEffectGroupRecord(value: unknown): CheckpointEffectGroupRecord | null { + if (!isObjectRecord(value)) return null; + const completedAt = readCanonicalIsoTimestamp(value.completedAt); + const fingerprint = readString(value.fingerprint); + if (completedAt === null || fingerprint === null || fingerprint.length === 0) return null; + return { completedAt, fingerprint }; +} + +function parseEffectGroups( + value: unknown, +): Partial> | null { + if (!isObjectRecord(value)) return null; + const groups: Partial> = {}; + for (const name of EFFECT_GROUP_NAMES) { + const raw = value[name]; + if (raw === undefined) continue; + const record = parseEffectGroupRecord(raw); + if (!record) return null; + groups[name] = record; + } + return groups; +} + +function parseProviderBinding(value: unknown): CheckpointProviderBinding | null { + if (!isObjectRecord(value)) return null; + const name = readString(value.name); + const type = readString(value.type); + const credentialEnv = readString(value.credentialEnv); + if (!name || !type || !credentialEnv) return null; + return { name, type, credentialEnv }; +} + +function parseProviderBindings(value: unknown): CheckpointProviderBinding[] | null { + if (!Array.isArray(value)) return null; + const bindings: CheckpointProviderBinding[] = []; + for (const entry of value) { + const binding = parseProviderBinding(entry); + if (!binding) return null; + bindings.push(binding); + } + return bindings; +} + +function parseBindings(value: unknown): CheckpointBindings | null { + if (!isObjectRecord(value)) return null; + const credentialEnvs = readStringArray(value.credentialEnvs); + const registeredProviders = parseProviderBindings(value.registeredProviders); + if (credentialEnvs === null || registeredProviders === null) return null; + return { credentialEnvs, registeredProviders }; +} + +function requireDecision( + raw: unknown, + parseValue: (value: unknown) => T | null, +): CheckpointDecision | null { + return parseCheckpointDecision(raw, parseValue); +} + +function parseCurrentSchema(value: Record): OnboardCheckpoint | 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 sandboxIdentity = requireDecision(value.sandboxIdentity, parseSandboxIdentityValue); + const webSearch = requireDecision(value.webSearch, parseWebSearchValue); + const messaging = requireDecision(value.messaging, parseMessagingValue); + const resourceProfile = requireDecision(value.resourceProfile, parseResourceProfileValue); + const effectGroups = parseEffectGroups(value.effectGroups); + const bindings = parseBindings(value.bindings); + if (!sandboxIdentity || !webSearch || !messaging || !resourceProfile) return null; + if (!effectGroups || !bindings) return null; + + return { + schemaVersion: CHECKPOINT_SCHEMA_VERSION, + sessionId, + machineState, + updatedAt, + sandboxIdentity, + webSearch, + messaging, + resourceProfile, + effectGroups, + bindings, + }; +} + +export function inspectCheckpoint(raw: unknown): CheckpointLoadResult { + if (raw === undefined || raw === null) return { status: "none" }; + if (!isObjectRecord(raw)) return { status: "corrupt" }; + + const version = raw.schemaVersion; + if (typeof version !== "number" || !Number.isInteger(version) || version < 1) { + return { status: "corrupt" }; + } + if (version > CHECKPOINT_SCHEMA_VERSION) { + return { status: "unsupported_future", foundVersion: version }; + } + if (version === CHECKPOINT_SCHEMA_VERSION) { + const checkpoint = parseCurrentSchema(raw); + return checkpoint ? { status: "loaded", checkpoint } : { status: "corrupt" }; + } + return { status: "corrupt" }; +} + +export function serializeCheckpoint(checkpoint: OnboardCheckpoint): Record { + return { + schemaVersion: checkpoint.schemaVersion, + sessionId: checkpoint.sessionId, + machineState: checkpoint.machineState, + updatedAt: checkpoint.updatedAt, + sandboxIdentity: checkpoint.sandboxIdentity, + webSearch: checkpoint.webSearch, + messaging: checkpoint.messaging, + resourceProfile: checkpoint.resourceProfile, + effectGroups: checkpoint.effectGroups, + bindings: checkpoint.bindings, + }; +} diff --git a/src/lib/state/onboard-session-station-express.test.ts b/src/lib/state/onboard-session-station-express.test.ts index 07bcd35b90..da06002703 100644 --- a/src/lib/state/onboard-session-station-express.test.ts +++ b/src/lib/state/onboard-session-station-express.test.ts @@ -37,6 +37,7 @@ function requireLoadedSession( async function realBootstrapDeps(): Promise { const { applySessionRecovery } = await import("../onboard/session-recovery"); const { getResumeConfigConflicts } = await import("../onboard/resume-config"); + const { defaultResolveResumeCheckpoint } = await import("../onboard/session-bootstrap"); return { loadSession: session.loadSession, clearSession: session.clearSession, @@ -47,6 +48,7 @@ async function realBootstrapDeps(): Promise { setOnboardBrandingAgent: vi.fn(), getResumeConfigConflicts, recordResumeConflict: vi.fn(async () => undefined), + resolveResumeCheckpoint: defaultResolveResumeCheckpoint, resolvePath: path.resolve, cliName: () => "nemoclaw", error: vi.fn(), diff --git a/src/lib/state/onboard-session.ts b/src/lib/state/onboard-session.ts index fcdde2556a..f182f4a206 100644 --- a/src/lib/state/onboard-session.ts +++ b/src/lib/state/onboard-session.ts @@ -39,6 +39,8 @@ import { type StationExpressResumeIntent, } from "../onboard/station-express-resume"; import { redactSensitiveText, redactUrl } from "../security/redact"; +import { inspectCheckpoint, serializeCheckpoint } from "./onboard-checkpoint"; +import type { OnboardCheckpoint } from "./onboard-checkpoint-types"; import { assignSafeToolDisclosureUpdate, normalizeSessionToolDisclosure, @@ -206,6 +208,7 @@ export interface Session { wechatConfig: WechatConfig | null; metadata: SessionMetadata; machine: OnboardMachineSnapshot; + checkpoint: OnboardCheckpoint | null; steps: Record; } @@ -530,6 +533,11 @@ function parseMachineSnapshot( }; } +function parseStoredCheckpoint(value: unknown): OnboardCheckpoint | null { + const inspected = inspectCheckpoint(value); + return inspected.status === "loaded" ? inspected.checkpoint : null; +} + function parseLockInfo(value: SessionJsonValue | undefined): LockInfo | null { if (!isObject(value) || typeof value.pid !== "number") return null; return { @@ -685,6 +693,7 @@ export function createSession(overrides: Partial = {}): Session { machine: parseMachineSnapshot(overrides.machine as SessionJsonValue | undefined, sessionId) ?? createMachineSnapshot("init", startedAt), + checkpoint: parseStoredCheckpoint(overrides.checkpoint), steps, }; preserveInvalidSessionToolDisclosure(overrides, session); @@ -750,6 +759,7 @@ export function normalizeSession(data: Session | SessionJsonValue | undefined): lastCompletedStep: readString(data.lastCompletedStep), failure: sanitizeFailure(isObject(data.failure) ? data.failure : null), metadata: parseSessionMetadata(data.metadata), + checkpoint: data.checkpoint as unknown as OnboardCheckpoint | null, }); normalized.resumable = data.resumable !== false; normalized.status = readString(data.status) ?? normalized.status; @@ -821,6 +831,7 @@ function serializeSessionForDisk(session: Session): Record { messagingPlan: session.messagingPlan ? compactSandboxMessagingPlanForPersistence(session.messagingPlan) : session.messagingPlan, + checkpoint: session.checkpoint ? serializeCheckpoint(session.checkpoint) : null, }; }