From c02a9c37ac9e2b43976aa6004982940e3823bd7f Mon Sep 17 00:00:00 2001 From: Tinson Lai Date: Thu, 16 Jul 2026 13:10:02 +0000 Subject: [PATCH 01/29] refactor(onboard): add versioned resume checkpoint with tri-state decisions Add a dedicated, secret-free onboarding checkpoint with an explicit schemaVersion, migration from legacy sessions, and fail-closed handling of an unknown future version. Model sandbox name, web search, messaging, and resource choices as explicit unset/declined/selected decisions instead of overloading null, capture durable sandbox identity before create, and add the resumable-replay and stale-binding revalidation decision layer. Wire the resume-entry fail-safe and durable-identity capture into the live flow; consuming replay decisions at the create executor and recording effect groups at every apply boundary remain follow-ups. Signed-off-by: Tinson Lai --- src/lib/onboard.ts | 2 + src/lib/onboard/checkpoint-record.ts | 46 +++++ src/lib/onboard/checkpoint-replay.test.ts | 149 ++++++++++++++++ src/lib/onboard/checkpoint-replay.ts | 55 ++++++ .../onboard/checkpoint-resume-guard.test.ts | 114 ++++++++++++ src/lib/onboard/checkpoint-revalidate.ts | 47 +++++ src/lib/onboard/lifecycle-contracts.md | 8 +- src/lib/onboard/session-bootstrap.ts | 39 ++++ src/lib/state/onboard-checkpoint-decision.ts | 73 ++++++++ .../state/onboard-checkpoint-migrate.test.ts | 132 ++++++++++++++ src/lib/state/onboard-checkpoint-migrate.ts | 107 +++++++++++ src/lib/state/onboard-checkpoint-types.ts | 67 +++++++ src/lib/state/onboard-checkpoint.test.ts | 107 +++++++++++ src/lib/state/onboard-checkpoint.ts | 166 ++++++++++++++++++ src/lib/state/onboard-session.ts | 11 ++ 15 files changed, 1120 insertions(+), 3 deletions(-) create mode 100644 src/lib/onboard/checkpoint-record.ts create mode 100644 src/lib/onboard/checkpoint-replay.test.ts create mode 100644 src/lib/onboard/checkpoint-replay.ts create mode 100644 src/lib/onboard/checkpoint-resume-guard.test.ts create mode 100644 src/lib/onboard/checkpoint-revalidate.ts create mode 100644 src/lib/state/onboard-checkpoint-decision.ts create mode 100644 src/lib/state/onboard-checkpoint-migrate.test.ts create mode 100644 src/lib/state/onboard-checkpoint-migrate.ts create mode 100644 src/lib/state/onboard-checkpoint-types.ts create mode 100644 src/lib/state/onboard-checkpoint.test.ts create mode 100644 src/lib/state/onboard-checkpoint.ts diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 035d7686d5c..9c3cbccfe75 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -358,6 +358,7 @@ const { resolveSandboxImageTagFromCreateOutput } = require("./domain/sandbox/image-tag") as typeof import("./domain/sandbox/image-tag"); const nim: typeof import("./inference/nim") = require("./inference/nim"); const onboardSession: typeof import("./state/onboard-session") = require("./state/onboard-session"); +const onboardCheckpointMigrate: typeof import("./state/onboard-checkpoint-migrate") = require("./state/onboard-checkpoint-migrate"); const { registerIncompleteOnboardExitHandlerForSession, }: typeof import("./onboard/onboard-exit-handler") = require("./onboard/onboard-exit-handler"); @@ -4164,6 +4165,7 @@ async function runOnboard(opts: OnboardOptions = {}): Promise { cliName, error: (message) => console.error(message), exitProcess: (code) => process.exit(code), + resolveResumeCheckpoint: onboardCheckpointMigrate.loadResumeCheckpoint, }, ); await onboardRuntimeBoundary.recordOnboardStarted(resume); diff --git a/src/lib/onboard/checkpoint-record.ts b/src/lib/onboard/checkpoint-record.ts new file mode 100644 index 00000000000..afd4c5fa0e7 --- /dev/null +++ b/src/lib/onboard/checkpoint-record.ts @@ -0,0 +1,46 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { decisionSelected } from "../state/onboard-checkpoint-decision"; +import { deriveCheckpointFromSession } from "../state/onboard-checkpoint-migrate"; +import type { + CheckpointEffectGroupName, + 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 }, + }, + }; +} diff --git a/src/lib/onboard/checkpoint-replay.test.ts b/src/lib/onboard/checkpoint-replay.test.ts new file mode 100644 index 00000000000..84d7169488d --- /dev/null +++ b/src/lib/onboard/checkpoint-replay.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 { 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" }, + }); + }); +}); + +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"); + const gone = planSandboxCreateReplay(cp, { liveSandboxExists: false }); + expect(gone.action).toBe("create"); + if (gone.action === "create") { + expect(gone.identity.name).toBe("my-sandbox"); + } + }); +}); + +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: ["p1"] }, + }); + 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: ["p1"] }, + }); + 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 00000000000..5ef3e540cb5 --- /dev/null +++ b/src/lib/onboard/checkpoint-replay.ts @@ -0,0 +1,55 @@ +// 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 type { + CheckpointEffectGroupName, + CheckpointSandboxIdentity, + OnboardCheckpoint, +} from "../state/onboard-checkpoint-types"; + +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; + const created = Boolean(checkpoint.effectGroups.sandbox_create); + if (created && 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 00000000000..4c7c4af18b7 --- /dev/null +++ b/src/lib/onboard/checkpoint-resume-guard.test.ts @@ -0,0 +1,114 @@ +// 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); + }, + ...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("is backward compatible when no checkpoint resolver is supplied", async () => { + const deps = makeDeps({ + resolveResumeCheckpoint: undefined, + getResumeConfigConflicts: () => { + throw new Error("PAST_GUARD"); + }, + }); + await expect(prepareOnboardSession(resumeInput, deps)).rejects.toThrow("PAST_GUARD"); + }); +}); diff --git a/src/lib/onboard/checkpoint-revalidate.ts b/src/lib/onboard/checkpoint-revalidate.ts new file mode 100644 index 00000000000..891e9c62b80 --- /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), + ); + 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/lifecycle-contracts.md b/src/lib/onboard/lifecycle-contracts.md index 7ca770dbf39..f620b31697a 100644 --- a/src/lib/onboard/lifecycle-contracts.md +++ b/src/lib/onboard/lifecycle-contracts.md @@ -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 bindings, serialized alongside the session under its own `schemaVersion` with fail-closed handling of an unknown future version. | | 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`; `planSandboxCreateReplay` reuses a surviving sandbox and never recreates under a new name. Consuming the decision at the live create executor is a follow-up (#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/session-bootstrap.ts b/src/lib/onboard/session-bootstrap.ts index ca776f71810..e7e81835d94 100644 --- a/src/lib/onboard/session-bootstrap.ts +++ b/src/lib/onboard/session-bootstrap.ts @@ -1,8 +1,10 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +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 type { ResumeConfigConflict } from "./resume-config"; export interface OnboardSessionBootstrapInput { @@ -44,6 +46,7 @@ export interface OnboardSessionBootstrapDeps { cliName(): string; error(message: string): void; exitProcess(code: number): never; + resolveResumeCheckpoint?: () => CheckpointLoadResult; } export interface OnboardSessionBootstrapResult { @@ -60,6 +63,11 @@ export function checkpointSandboxName( updateSession((current) => { current.sandboxName = sandboxName; current.sandboxPromptProgress.sandboxName = true; + recordCheckpointSandboxIdentity( + current, + sandboxName, + current.agent ?? agent?.name ?? "openclaw", + ); return current; }); } @@ -85,6 +93,36 @@ 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); + } +} + function reportResumeConflict( conflict: ResumeConfigConflict, deps: OnboardSessionBootstrapDeps, @@ -173,6 +211,7 @@ async function prepareResumeSession( if (!session || session.resumable === false) { reportMissingResumeSession(deps); } + guardResumeCheckpoint(deps); const sessionFrom = session.metadata?.fromDockerfile || null; const fromDockerfile = input.requestedFromDockerfile diff --git a/src/lib/state/onboard-checkpoint-decision.ts b/src/lib/state/onboard-checkpoint-decision.ts new file mode 100644 index 00000000000..678d396e1b4 --- /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 00000000000..86507c955d4 --- /dev/null +++ b/src/lib/state/onboard-checkpoint-migrate.test.ts @@ -0,0 +1,132 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; + +import { serializeCheckpoint } from "./onboard-checkpoint"; +import { decisionDeclined, decisionSelected, decisionUnset } from "./onboard-checkpoint-decision"; +import { + deriveCheckpointFromSession, + resolveCheckpointForResume, +} from "./onboard-checkpoint-migrate"; +import { CHECKPOINT_SCHEMA_VERSION, 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: ["OPENAI_API_KEY"], + registeredProviders: ["web-search-openclaw"], + }); + }); + + 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"); + if (result.status === "migrated") { + expect(result.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("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()); + }); +}); diff --git a/src/lib/state/onboard-checkpoint-migrate.ts b/src/lib/state/onboard-checkpoint-migrate.ts new file mode 100644 index 00000000000..1d82c2bdb14 --- /dev/null +++ b/src/lib/state/onboard-checkpoint-migrate.ts @@ -0,0 +1,107 @@ +// 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 type { SandboxMessagingPlan } from "../messaging/manifest"; +import { inspectCheckpoint } from "./onboard-checkpoint"; +import { + decisionFromLegacyNullable, + decisionSelected, + decisionUnset, +} from "./onboard-checkpoint-decision"; +import { + CHECKPOINT_SCHEMA_VERSION, + type CheckpointDecision, + type CheckpointLoadResult, + 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) => 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: { + credentialEnvs: session.credentialEnv ? [session.credentialEnv] : [], + registeredProviders: [...session.stagedCredentialProviders], + }, + }; +} + +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; + } + if (inspected.status === "loaded") return inspected; + + const session = normalizeSession(rawSession as JsonValue); + if (!session) return { status: "none" }; + return { + status: "migrated", + checkpoint: deriveCheckpointFromSession(session), + fromVersion: 0, + }; +} + +export function loadResumeCheckpoint(): CheckpointLoadResult { + try { + if (!fs.existsSync(SESSION_FILE)) return { status: "none" }; + const raw = JSON.parse(fs.readFileSync(SESSION_FILE, "utf-8")); + return resolveCheckpointForResume(raw); + } catch { + return { status: "none" }; + } +} diff --git a/src/lib/state/onboard-checkpoint-types.ts b/src/lib/state/onboard-checkpoint-types.ts new file mode 100644 index 00000000000..b4b57bac87b --- /dev/null +++ b/src/lib/state/onboard-checkpoint-types.ts @@ -0,0 +1,67 @@ +// 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 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 type CheckpointEffectGroupName = + | "web_search_provider" + | "messaging_providers" + | "sandbox_create" + | "sandbox_register"; + +export interface CheckpointEffectGroupRecord { + readonly completedAt: string; + readonly fingerprint: string; +} + +export interface CheckpointBindings { + readonly credentialEnvs: readonly string[]; + readonly registeredProviders: readonly string[]; +} + +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 00000000000..3526fd3904a --- /dev/null +++ b/src/lib/state/onboard-checkpoint.test.ts @@ -0,0 +1,107 @@ +// 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: ["web-search-p"] }, + ...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.status).toBe("loaded"); + if (result.status === "loaded") { + expect(result.checkpoint).toEqual(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" }); + }); +}); diff --git a/src/lib/state/onboard-checkpoint.ts b/src/lib/state/onboard-checkpoint.ts new file mode 100644 index 00000000000..b73f16eb16b --- /dev/null +++ b/src/lib/state/onboard-checkpoint.ts @@ -0,0 +1,166 @@ +// 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 type { SandboxMessagingPlan } from "../messaging/manifest"; +import { parseSandboxMessagingPlan } from "../messaging/plan-validation"; +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 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[] { + if (!Array.isArray(value)) return []; + return value.filter((entry): entry is string => typeof entry === "string"); +} + +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): SandboxMessagingPlan | null { + return parseSandboxMessagingPlan(value); +} + +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> { + const groups: Partial> = {}; + if (!isObjectRecord(value)) return groups; + for (const name of EFFECT_GROUP_NAMES) { + const record = parseEffectGroupRecord(value[name]); + if (record) groups[name] = record; + } + return groups; +} + +function parseBindings(value: unknown): CheckpointBindings { + if (!isObjectRecord(value)) return { credentialEnvs: [], registeredProviders: [] }; + return { + credentialEnvs: readStringArray(value.credentialEnvs), + registeredProviders: readStringArray(value.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); + if (!sandboxIdentity || !webSearch || !messaging || !resourceProfile) return null; + + return { + schemaVersion: CHECKPOINT_SCHEMA_VERSION, + sessionId, + machineState, + updatedAt, + sandboxIdentity, + webSearch, + messaging, + resourceProfile, + effectGroups: parseEffectGroups(value.effectGroups), + bindings: parseBindings(value.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.ts b/src/lib/state/onboard-session.ts index be7589bbdc3..24f2122d509 100644 --- a/src/lib/state/onboard-session.ts +++ b/src/lib/state/onboard-session.ts @@ -31,6 +31,8 @@ import { } from "../onboard/machine/transitions"; import type { OnboardMachineState, OnboardNonTerminalMachineState } from "../onboard/machine/types"; import { redactSensitiveText, redactUrl } from "../security/redact"; +import { inspectCheckpoint, serializeCheckpoint } from "./onboard-checkpoint"; +import type { OnboardCheckpoint } from "./onboard-checkpoint-types"; import { assignSafeToolDisclosureUpdate, normalizeSessionToolDisclosure, @@ -194,6 +196,7 @@ export interface Session { wechatConfig: WechatConfig | null; metadata: SessionMetadata; machine: OnboardMachineSnapshot; + checkpoint: OnboardCheckpoint | null; steps: Record; } @@ -516,6 +519,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 { @@ -665,6 +673,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); @@ -709,6 +718,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; @@ -747,6 +757,7 @@ function serializeSessionForDisk(session: Session): Record { messagingPlan: session.messagingPlan ? compactSandboxMessagingPlanForPersistence(session.messagingPlan) : session.messagingPlan, + checkpoint: session.checkpoint ? serializeCheckpoint(session.checkpoint) : null, }; } From 5142e9a6cbba8319ff7e2c16b1cc80fbd82d5f81 Mon Sep 17 00:00:00 2001 From: Tinson Lai Date: Thu, 16 Jul 2026 16:20:42 +0000 Subject: [PATCH 02/29] fix(onboard): wire checkpoint replay into the live sandbox executor Give recordCheckpointEffectGroup, revalidateCheckpointBindings, and planSandboxCreateReplay real production call sites instead of leaving them covered only by unit tests. A "create" resume decision only means the sandbox step was never marked complete; it does not check whether a previous run already executed the destructive create effect before crashing. The sandbox handler now disambiguates that narrow window using the durable checkpoint identity and effect-group receipt, revalidates recorded bindings before any mutation, and reuses a surviving sandbox or recreates only under its recorded identity, never a new one. Record the sandbox_create effect group at its real success boundary, and capture durable sandbox identity from the same FSM path that names the sandbox, not only the bootstrap resume guard. Move the checkpoint resolver default into session-bootstrap.ts so the production wiring lives under src/lib/onboard/, keeping src/lib/onboard.ts net-neutral. Signed-off-by: Tinson Lai --- src/lib/onboard.ts | 2 - .../sandbox-checkpoint-crash-recovery.test.ts | 114 ++++++++++++++++++ src/lib/onboard/machine/handlers/sandbox.ts | 73 ++++++++++- src/lib/onboard/session-bootstrap.ts | 3 +- 4 files changed, 185 insertions(+), 7 deletions(-) create mode 100644 src/lib/onboard/machine/handlers/sandbox-checkpoint-crash-recovery.test.ts diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 9c3cbccfe75..035d7686d5c 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -358,7 +358,6 @@ const { resolveSandboxImageTagFromCreateOutput } = require("./domain/sandbox/image-tag") as typeof import("./domain/sandbox/image-tag"); const nim: typeof import("./inference/nim") = require("./inference/nim"); const onboardSession: typeof import("./state/onboard-session") = require("./state/onboard-session"); -const onboardCheckpointMigrate: typeof import("./state/onboard-checkpoint-migrate") = require("./state/onboard-checkpoint-migrate"); const { registerIncompleteOnboardExitHandlerForSession, }: typeof import("./onboard/onboard-exit-handler") = require("./onboard/onboard-exit-handler"); @@ -4165,7 +4164,6 @@ async function runOnboard(opts: OnboardOptions = {}): Promise { cliName, error: (message) => console.error(message), exitProcess: (code) => process.exit(code), - resolveResumeCheckpoint: onboardCheckpointMigrate.loadResumeCheckpoint, }, ); await onboardRuntimeBoundary.recordOnboardStarted(resume); 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 00000000000..0161f2bc972 --- /dev/null +++ b/src/lib/onboard/machine/handlers/sandbox-checkpoint-crash-recovery.test.ts @@ -0,0 +1,114 @@ +// 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 } from "../../../state/onboard-session"; +import { detectMessagingChannelsFromEnv } from "../../messaging-channel-setup"; +import { handleSandboxState } from "./sandbox"; +import { baseOptions, createDeps } from "./sandbox-test-fixtures"; + +vi.mock("../../messaging-channel-setup", () => ({ + detectMessagingChannelsFromEnv: vi.fn(() => []), +})); + +vi.mocked(detectMessagingChannelsFromEnv).mockReturnValue([]); + +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: "fp" }, + }, + bindings: { credentialEnvs: [], registeredProviders: [] }, + ...overrides, + }; +} + +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(); + }); +}); diff --git a/src/lib/onboard/machine/handlers/sandbox.ts b/src/lib/onboard/machine/handlers/sandbox.ts index 9f49313e08f..f27ab386c1f 100644 --- a/src/lib/onboard/machine/handlers/sandbox.ts +++ b/src/lib/onboard/machine/handlers/sandbox.ts @@ -17,6 +17,7 @@ import { webSearchProviderForConfig, } from "../../../inference/web-search"; import type { SandboxMessagingPlan } from "../../../messaging/manifest"; +import { isDecisionSelected } from "../../../state/onboard-checkpoint-decision"; import type { HermesAuthMethod, Session, @@ -26,6 +27,15 @@ import type { import type { SandboxEntry } from "../../../state/registry"; import { getSandboxEntryInference } from "../../../state/registry-entry-view"; import { toolDisclosureOrDefault } from "../../../tool-disclosure"; +import { + recordCheckpointEffectGroup, + recordCheckpointSandboxIdentity, +} from "../../checkpoint-record"; +import { 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 { @@ -500,7 +510,49 @@ 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; + if (!checkpoint?.effectGroups.sandbox_create) return decision; + if (!isDecisionSelected(checkpoint.sandboxIdentity)) return decision; + + const bindingCheck = revalidateCheckpointBindings(checkpoint, { + availableCredentialEnvs: new Set( + Object.keys(this.options.env).filter((name) => Boolean(this.options.env[name]?.trim())), + ), + liveRegisteredProviders: new Set(state.session?.stagedCredentialProviders ?? []), + }); + if (bindingCheck.status === "stale") { + 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); + } + + const replay = planSandboxCreateReplay(checkpoint, { + liveSandboxExists: sandboxReuseState === "ready", + }); + return replay.action === "reuse" ? { kind: "reuse" } : decision; } private applyObservabilityRequest( @@ -736,6 +788,11 @@ 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(); @@ -925,7 +982,7 @@ class SandboxStateFlow< ); // createSandbox() owns the build fingerprint. In particular, reusing an // image must not stamp it with the current version and hide build drift. - const { nemoclawVersion: _builtFingerprint, ...agentRegistryFields } = + const { nemoclawVersion: builtFingerprint, ...agentRegistryFields } = this.deps.getSandboxAgentRegistryFields(this.options.agent, !this.options.fromDockerfile); // Preserve the validated route and credential env-var name, never a credential value. this.deps.updateSandboxRegistry(sandboxName, { @@ -939,7 +996,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 +1008,15 @@ class SandboxStateFlow< hermesToolGateways: effectiveHermesToolGateways, }), ); - return { ...state, sandboxName, session: completedSession }; + const recordedSession = this.deps.updateSession((current) => { + recordCheckpointEffectGroup( + current, + "sandbox_create", + typeof builtFingerprint === "string" ? builtFingerprint : sandboxName, + ); + return current; + }); + return { ...state, sandboxName, session: recordedSession }; }; const withGatewayLock = () => this.deps.withGatewayRouteMutationLock(this.options.gatewayName, createAndRecord); diff --git a/src/lib/onboard/session-bootstrap.ts b/src/lib/onboard/session-bootstrap.ts index e7e81835d94..366a296b890 100644 --- a/src/lib/onboard/session-bootstrap.ts +++ b/src/lib/onboard/session-bootstrap.ts @@ -1,6 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +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"; @@ -114,7 +115,7 @@ function reportCorruptResumeCheckpoint(deps: OnboardSessionBootstrapDeps): never } function guardResumeCheckpoint(deps: OnboardSessionBootstrapDeps): void { - const result = deps.resolveResumeCheckpoint?.(); + const result = (deps.resolveResumeCheckpoint ?? loadResumeCheckpoint)(); if (result?.status === "unsupported_future") { reportUnsupportedResumeCheckpoint(result.foundVersion, deps); } From b7053f321f87fb04d48bdb88b6fe7c37af30b46f Mon Sep 17 00:00:00 2001 From: Tinson Lai Date: Thu, 16 Jul 2026 17:12:14 +0000 Subject: [PATCH 03/29] test(onboard): keep changed checkpoint test bodies linear Signed-off-by: Tinson Lai --- src/lib/onboard/checkpoint-replay.test.ts | 9 ++++----- src/lib/state/onboard-checkpoint-migrate.test.ts | 15 +++++++++------ src/lib/state/onboard-checkpoint.test.ts | 5 +---- 3 files changed, 14 insertions(+), 15 deletions(-) diff --git a/src/lib/onboard/checkpoint-replay.test.ts b/src/lib/onboard/checkpoint-replay.test.ts index 84d7169488d..93619743f00 100644 --- a/src/lib/onboard/checkpoint-replay.test.ts +++ b/src/lib/onboard/checkpoint-replay.test.ts @@ -108,11 +108,10 @@ describe("crash-then-resume matrix proves at-most-once destructive create (#6228 effectGroups: { sandbox_create: { completedAt: ISO, fingerprint: "fp" } }, }); expect(planSandboxCreateReplay(cp, { liveSandboxExists: true }).action).toBe("reuse"); - const gone = planSandboxCreateReplay(cp, { liveSandboxExists: false }); - expect(gone.action).toBe("create"); - if (gone.action === "create") { - expect(gone.identity.name).toBe("my-sandbox"); - } + expect(planSandboxCreateReplay(cp, { liveSandboxExists: false })).toEqual({ + action: "create", + identity: { name: "my-sandbox", agent: "openclaw" }, + }); }); }); diff --git a/src/lib/state/onboard-checkpoint-migrate.test.ts b/src/lib/state/onboard-checkpoint-migrate.test.ts index 86507c955d4..caebde36b60 100644 --- a/src/lib/state/onboard-checkpoint-migrate.test.ts +++ b/src/lib/state/onboard-checkpoint-migrate.test.ts @@ -9,7 +9,11 @@ import { deriveCheckpointFromSession, resolveCheckpointForResume, } from "./onboard-checkpoint-migrate"; -import { CHECKPOINT_SCHEMA_VERSION, type OnboardCheckpoint } from "./onboard-checkpoint-types"; +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 { @@ -105,11 +109,10 @@ describe("resolveCheckpointForResume", () => { const raw = rawJson(completedSession()); const result = resolveCheckpointForResume(raw); expect(result.status).toBe("migrated"); - if (result.status === "migrated") { - expect(result.checkpoint.sandboxIdentity).toEqual( - decisionSelected({ name: "my-sandbox", agent: "openclaw" }), - ); - } + 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", () => { diff --git a/src/lib/state/onboard-checkpoint.test.ts b/src/lib/state/onboard-checkpoint.test.ts index 3526fd3904a..9a2bbd0aab3 100644 --- a/src/lib/state/onboard-checkpoint.test.ts +++ b/src/lib/state/onboard-checkpoint.test.ts @@ -90,10 +90,7 @@ describe("checkpoint schema inspection", () => { it("loads and round-trips a valid v1 checkpoint", () => { const checkpoint = baseCheckpoint(); const result = inspectCheckpoint(serializeCheckpoint(checkpoint)); - expect(result.status).toBe("loaded"); - if (result.status === "loaded") { - expect(result.checkpoint).toEqual(checkpoint); - } + expect(result).toEqual({ status: "loaded", checkpoint }); }); it("rejects a checkpoint whose sandbox identity value is malformed", () => { From 26de066e80b6761be01d7369373c6b02296d7684 Mon Sep 17 00:00:00 2001 From: Tinson Lai Date: Fri, 17 Jul 2026 05:48:54 +0000 Subject: [PATCH 04/29] fix(onboard): close checkpoint crash-recovery and validation gaps Address PR review advisor blockers and CodeRabbit findings on the versioned checkpoint work: reuse a live sandbox even when its create receipt was lost to a mid-create crash, bind reuse to the checkpoint's recorded identity, revalidate credential bindings against the live gateway registry instead of a stale session receipt, record sandbox identity on every creation path (not only resumable-prompt agents), treat checkpoint machine-state progress as resume authority alongside the legacy step status, bind a loaded checkpoint to its parent session, reject malformed checkpoint effect groups and bindings instead of silently dropping them, and make resume checkpoint validation a mandatory dependency with no compatibility bypass. Signed-off-by: Tinson Lai --- src/lib/onboard.ts | 5 + src/lib/onboard/checkpoint-replay.test.ts | 7 ++ src/lib/onboard/checkpoint-replay.ts | 3 +- .../onboard/checkpoint-resume-guard.test.ts | 7 +- .../onboard/machine/core-flow-phases.test.ts | 1 + .../sandbox-checkpoint-crash-recovery.test.ts | 92 ++++++++++++++++ .../machine/handlers/sandbox-test-fixtures.ts | 1 + .../onboard/machine/handlers/sandbox.test.ts | 41 +++++++ src/lib/onboard/machine/handlers/sandbox.ts | 104 ++++++++++++++---- src/lib/onboard/session-bootstrap.test.ts | 2 + src/lib/onboard/session-bootstrap.ts | 5 +- .../state/onboard-checkpoint-migrate.test.ts | 8 ++ src/lib/state/onboard-checkpoint-migrate.ts | 9 +- src/lib/state/onboard-checkpoint.test.ts | 29 +++++ src/lib/state/onboard-checkpoint.ts | 41 ++++--- 15 files changed, 313 insertions(+), 42 deletions(-) diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 035d7686d5c..e1ade967be0 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -358,6 +358,9 @@ const { resolveSandboxImageTagFromCreateOutput } = require("./domain/sandbox/image-tag") as typeof import("./domain/sandbox/image-tag"); const nim: typeof import("./inference/nim") = require("./inference/nim"); const onboardSession: typeof import("./state/onboard-session") = require("./state/onboard-session"); +const { + loadResumeCheckpoint, +}: typeof import("./state/onboard-checkpoint-migrate") = require("./state/onboard-checkpoint-migrate"); const { registerIncompleteOnboardExitHandlerForSession, }: typeof import("./onboard/onboard-exit-handler") = require("./onboard/onboard-exit-handler"); @@ -4164,6 +4167,7 @@ async function runOnboard(opts: OnboardOptions = {}): Promise { cliName, error: (message) => console.error(message), exitProcess: (code) => process.exit(code), + resolveResumeCheckpoint: loadResumeCheckpoint, }, ); await onboardRuntimeBoundary.recordOnboardStarted(resume); @@ -4482,6 +4486,7 @@ async function runOnboard(opts: OnboardOptions = {}): Promise { clearPlanEnv, getRegistrySandboxMessagingPlan, providerMatchesGatewayCredential, + providerExistsInGateway, stageSandboxCredentialProviders, promptValidatedSandboxName, selectResourceProfileForSandbox: () => diff --git a/src/lib/onboard/checkpoint-replay.test.ts b/src/lib/onboard/checkpoint-replay.test.ts index 93619743f00..91fcf03f40c 100644 --- a/src/lib/onboard/checkpoint-replay.test.ts +++ b/src/lib/onboard/checkpoint-replay.test.ts @@ -88,6 +88,13 @@ describe("planSandboxCreateReplay never opens a second sandbox (#5961)", () => { 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)", () => { diff --git a/src/lib/onboard/checkpoint-replay.ts b/src/lib/onboard/checkpoint-replay.ts index 5ef3e540cb5..d627b4d5605 100644 --- a/src/lib/onboard/checkpoint-replay.ts +++ b/src/lib/onboard/checkpoint-replay.ts @@ -47,8 +47,7 @@ export function planSandboxCreateReplay( return { action: "capture_identity_first" }; } const identity = checkpoint.sandboxIdentity.value; - const created = Boolean(checkpoint.effectGroups.sandbox_create); - if (created && observed.liveSandboxExists) { + 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 index 4c7c4af18b7..33b5a61f467 100644 --- a/src/lib/onboard/checkpoint-resume-guard.test.ts +++ b/src/lib/onboard/checkpoint-resume-guard.test.ts @@ -61,6 +61,7 @@ function makeDeps(overrides: Partial): OnboardSessi exitProcess: (code) => { throw new ExitError(code); }, + resolveResumeCheckpoint: (): CheckpointLoadResult => ({ status: "none" }), ...overrides, }; } @@ -102,13 +103,15 @@ describe("resume checkpoint fail-safe (#6228)", () => { await expect(prepareOnboardSession(resumeInput, deps)).rejects.toThrow("PAST_GUARD"); }); - it("is backward compatible when no checkpoint resolver is supplied", async () => { + 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: undefined, + resolveResumeCheckpoint, getResumeConfigConflicts: () => { throw new Error("PAST_GUARD"); }, }); await expect(prepareOnboardSession(resumeInput, deps)).rejects.toThrow("PAST_GUARD"); + expect(resolveResumeCheckpoint).toHaveBeenCalled(); }); }); diff --git a/src/lib/onboard/machine/core-flow-phases.test.ts b/src/lib/onboard/machine/core-flow-phases.test.ts index 52b0d150e4d..909bb36a92c 100644 --- a/src/lib/onboard/machine/core-flow-phases.test.ts +++ b/src/lib/onboard/machine/core-flow-phases.test.ts @@ -205,6 +205,7 @@ function createPhases( clearPlanEnv: vi.fn(), getRegistrySandboxMessagingPlan: () => null, providerMatchesGatewayCredential: () => false, + providerExistsInGateway: () => false, stageSandboxCredentialProviders: vi.fn(async () => []), promptValidatedSandboxName: vi.fn(async () => "my-sandbox"), selectResourceProfileForSandbox: vi.fn(async () => null), diff --git a/src/lib/onboard/machine/handlers/sandbox-checkpoint-crash-recovery.test.ts b/src/lib/onboard/machine/handlers/sandbox-checkpoint-crash-recovery.test.ts index 0161f2bc972..25a3ced2dfc 100644 --- a/src/lib/onboard/machine/handlers/sandbox-checkpoint-crash-recovery.test.ts +++ b/src/lib/onboard/machine/handlers/sandbox-checkpoint-crash-recovery.test.ts @@ -111,4 +111,96 @@ describe("sandbox crash-recovery replay (#5961, #6228)", () => { 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", + providerExistsInGateway: () => false, + }); + const session = sessionWithCheckpoint( + crashedCheckpoint({ + bindings: { credentialEnvs: [], registeredProviders: ["my-assistant-brave-search"] }, + }), + ); + + 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", + providerExistsInGateway: (name) => name === "my-assistant-brave-search", + }); + const session = sessionWithCheckpoint( + crashedCheckpoint({ + bindings: { credentialEnvs: [], registeredProviders: ["my-assistant-brave-search"] }, + }), + ); + + await handleSandboxState({ + ...baseOptions(deps, session), + resume: true, + sandboxName: "my-assistant", + }); + + expect(calls.createSandbox).not.toHaveBeenCalled(); + expect(calls.recordSkip).toHaveBeenCalled(); + }); + + 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" }), + ); + }); }); diff --git a/src/lib/onboard/machine/handlers/sandbox-test-fixtures.ts b/src/lib/onboard/machine/handlers/sandbox-test-fixtures.ts index b8a9795fb18..6e6ca98d9e8 100644 --- a/src/lib/onboard/machine/handlers/sandbox-test-fixtures.ts +++ b/src/lib/onboard/machine/handlers/sandbox-test-fixtures.ts @@ -226,6 +226,7 @@ export function createDeps( clearPlanEnv: calls.clearPlanEnv, getRegistrySandboxMessagingPlan: () => null, providerMatchesGatewayCredential: () => false, + providerExistsInGateway: () => false, stageSandboxCredentialProviders: calls.stageCredentialProviders, promptValidatedSandboxName: calls.promptName, selectResourceProfileForSandbox: calls.selectResourceProfile, diff --git a/src/lib/onboard/machine/handlers/sandbox.test.ts b/src/lib/onboard/machine/handlers/sandbox.test.ts index d74d038dcd7..4e78aa34d84 100644 --- a/src/lib/onboard/machine/handlers/sandbox.test.ts +++ b/src/lib/onboard/machine/handlers/sandbox.test.ts @@ -4,6 +4,8 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { hashCredential } from "../../../security/credential-hash"; +import { 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"; @@ -510,6 +512,45 @@ 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" }); + 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("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 f27ab386c1f..3c39570c52f 100644 --- a/src/lib/onboard/machine/handlers/sandbox.ts +++ b/src/lib/onboard/machine/handlers/sandbox.ts @@ -18,6 +18,10 @@ import { } from "../../../inference/web-search"; import type { SandboxMessagingPlan } from "../../../messaging/manifest"; import { isDecisionSelected } from "../../../state/onboard-checkpoint-decision"; +import type { + CheckpointSandboxIdentity, + OnboardCheckpoint, +} from "../../../state/onboard-checkpoint-types"; import type { HermesAuthMethod, Session, @@ -57,6 +61,7 @@ import type { SandboxCreateIntent as ResolvedSandboxCreateIntent } from "../../s import { withSandboxPhaseTrace } from "../../tracing"; import type { SandboxCreateIntent } from "../../types"; import { branchTo, type OnboardStateTransitionResult } from "../result"; +import { ONBOARD_MACHINE_STATES } from "../types"; import * as dcodeResume from "./sandbox-dcode-resume"; import { reconcileReusedSandboxMessaging, reconcileSandboxMessaging } from "./sandbox-messaging"; import { @@ -184,6 +189,7 @@ export interface SandboxStateOptions< clearPlanEnv(): void; getRegistrySandboxMessagingPlan(sandboxName: string): SandboxMessagingPlan | null; providerMatchesGatewayCredential(name: string, type: string, credentialEnv: string): boolean; + providerExistsInGateway(name: string, gatewayName: string): boolean; stageSandboxCredentialProviders(input: { sandboxName: string; enabledChannels: readonly string[]; @@ -342,6 +348,25 @@ function observabilityRequestValidationError( return null; } +function checkpointProvesSandboxStepComplete( + checkpoint: Session["checkpoint"] | undefined, +): boolean { + if (!checkpoint) return false; + const sandboxIndex = ONBOARD_MACHINE_STATES.indexOf("sandbox"); + const stateIndex = ONBOARD_MACHINE_STATES.indexOf(checkpoint.machineState); + return stateIndex > sandboxIndex; +} + +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, @@ -478,7 +503,9 @@ class SandboxStateFlow< const decision = decideSandboxResume({ resume: this.options.resume, resumeAgentChanged: this.options.resumeAgentChanged, - sandboxStepComplete: state.session?.steps?.sandbox?.status === "complete", + sandboxStepComplete: + state.session?.steps?.sandbox?.status === "complete" || + checkpointProvesSandboxStepComplete(state.session?.checkpoint), sandboxReuseState, inferenceRouteConfigChanged: hasHermesCompatibleAnthropicInferenceRouteDrift({ agentName: (this.options.agent as { name?: string } | null)?.name, @@ -530,29 +557,51 @@ class SandboxStateFlow< ): SandboxResumeDecision { if (decision.kind !== "create") return decision; const checkpoint = state.session?.checkpoint; - if (!checkpoint?.effectGroups.sandbox_create) return decision; - if (!isDecisionSelected(checkpoint.sandboxIdentity)) return decision; + 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 bindingCheck = revalidateCheckpointBindings(checkpoint, { - availableCredentialEnvs: new Set( - Object.keys(this.options.env).filter((name) => Boolean(this.options.env[name]?.trim())), - ), - liveRegisteredProviders: new Set(state.session?.stagedCredentialProviders ?? []), - }); - if (bindingCheck.status === "stale") { - 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); - } + 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" ? { kind: "reuse" } : decision; + return replay.action === "reuse" && replay.identity.name === state.sandboxName + ? { kind: "reuse" } + : decision; + } + + private checkpointBindingAvailability(checkpoint: OnboardCheckpoint): { + availableCredentialEnvs: ReadonlySet; + liveRegisteredProviders: ReadonlySet; + } { + return { + availableCredentialEnvs: new Set( + Object.keys(this.options.env).filter((name) => Boolean(this.options.env[name]?.trim())), + ), + liveRegisteredProviders: new Set( + checkpoint.bindings.registeredProviders.filter((name) => + this.deps.providerExistsInGateway(name, this.options.gatewayName), + ), + ), + }; + } + + 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 applyObservabilityRequest( @@ -799,6 +848,22 @@ class SandboxStateFlow< 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[] }, @@ -1053,6 +1118,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 || diff --git a/src/lib/onboard/session-bootstrap.test.ts b/src/lib/onboard/session-bootstrap.test.ts index 3b64d57f257..5126d0a57bd 100644 --- a/src/lib/onboard/session-bootstrap.test.ts +++ b/src/lib/onboard/session-bootstrap.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it, vi } from "vitest"; +import type { CheckpointLoadResult } 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 +54,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 }; diff --git a/src/lib/onboard/session-bootstrap.ts b/src/lib/onboard/session-bootstrap.ts index 366a296b890..1ef5e976c93 100644 --- a/src/lib/onboard/session-bootstrap.ts +++ b/src/lib/onboard/session-bootstrap.ts @@ -1,7 +1,6 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -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"; @@ -47,7 +46,7 @@ export interface OnboardSessionBootstrapDeps { cliName(): string; error(message: string): void; exitProcess(code: number): never; - resolveResumeCheckpoint?: () => CheckpointLoadResult; + resolveResumeCheckpoint(): CheckpointLoadResult; } export interface OnboardSessionBootstrapResult { @@ -115,7 +114,7 @@ function reportCorruptResumeCheckpoint(deps: OnboardSessionBootstrapDeps): never } function guardResumeCheckpoint(deps: OnboardSessionBootstrapDeps): void { - const result = (deps.resolveResumeCheckpoint ?? loadResumeCheckpoint)(); + const result = deps.resolveResumeCheckpoint(); if (result?.status === "unsupported_future") { reportUnsupportedResumeCheckpoint(result.foundVersion, deps); } diff --git a/src/lib/state/onboard-checkpoint-migrate.test.ts b/src/lib/state/onboard-checkpoint-migrate.test.ts index caebde36b60..7d9608d462e 100644 --- a/src/lib/state/onboard-checkpoint-migrate.test.ts +++ b/src/lib/state/onboard-checkpoint-migrate.test.ts @@ -120,6 +120,14 @@ describe("resolveCheckpointForResume", () => { 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({ diff --git a/src/lib/state/onboard-checkpoint-migrate.ts b/src/lib/state/onboard-checkpoint-migrate.ts index 1d82c2bdb14..d19fdac71a9 100644 --- a/src/lib/state/onboard-checkpoint-migrate.ts +++ b/src/lib/state/onboard-checkpoint-migrate.ts @@ -85,10 +85,17 @@ export function resolveCheckpointForResume(rawSession: unknown): CheckpointLoadR if (inspected.status === "unsupported_future" || inspected.status === "corrupt") { return inspected; } - if (inspected.status === "loaded") 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), diff --git a/src/lib/state/onboard-checkpoint.test.ts b/src/lib/state/onboard-checkpoint.test.ts index 9a2bbd0aab3..73a7e2bead1 100644 --- a/src/lib/state/onboard-checkpoint.test.ts +++ b/src/lib/state/onboard-checkpoint.test.ts @@ -101,4 +101,33 @@ describe("checkpoint schema inspection", () => { }; 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: ["web-search-p"], + }; + 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 index b73f16eb16b..9739e9bc1f6 100644 --- a/src/lib/state/onboard-checkpoint.ts +++ b/src/lib/state/onboard-checkpoint.ts @@ -31,9 +31,14 @@ function readString(value: unknown): string | null { return typeof value === "string" ? value : null; } -function readStringArray(value: unknown): string[] { - if (!Array.isArray(value)) return []; - return value.filter((entry): entry is string => typeof entry === "string"); +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 { @@ -80,22 +85,25 @@ function parseEffectGroupRecord(value: unknown): CheckpointEffectGroupRecord | n function parseEffectGroups( value: unknown, -): Partial> { +): Partial> | null { + if (!isObjectRecord(value)) return null; const groups: Partial> = {}; - if (!isObjectRecord(value)) return groups; for (const name of EFFECT_GROUP_NAMES) { - const record = parseEffectGroupRecord(value[name]); - if (record) groups[name] = record; + const raw = value[name]; + if (raw === undefined) continue; + const record = parseEffectGroupRecord(raw); + if (!record) return null; + groups[name] = record; } return groups; } -function parseBindings(value: unknown): CheckpointBindings { - if (!isObjectRecord(value)) return { credentialEnvs: [], registeredProviders: [] }; - return { - credentialEnvs: readStringArray(value.credentialEnvs), - registeredProviders: readStringArray(value.registeredProviders), - }; +function parseBindings(value: unknown): CheckpointBindings | null { + if (!isObjectRecord(value)) return null; + const credentialEnvs = readStringArray(value.credentialEnvs); + const registeredProviders = readStringArray(value.registeredProviders); + if (credentialEnvs === null || registeredProviders === null) return null; + return { credentialEnvs, registeredProviders }; } function requireDecision( @@ -116,7 +124,10 @@ function parseCurrentSchema(value: Record): OnboardCheckpoint | 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, @@ -127,8 +138,8 @@ function parseCurrentSchema(value: Record): OnboardCheckpoint | webSearch, messaging, resourceProfile, - effectGroups: parseEffectGroups(value.effectGroups), - bindings: parseBindings(value.bindings), + effectGroups, + bindings, }; } From 8c5cbc3296e8cf064c4b18fcb5bdea5aeabbe844 Mon Sep 17 00:00:00 2001 From: Tinson Lai Date: Fri, 17 Jul 2026 07:51:24 +0000 Subject: [PATCH 05/29] fix(onboard): keep the checkpoint resume wiring net-neutral for onboard.ts Wire the checkpoint-validation and live-provider-registry dependencies without growing the capped onboard.ts entrypoint: route the default checkpoint resolver through a small session-bootstrap helper referenced by name, and make the live gateway provider check an optional dependency that falls back to the prior staged-provider heuristic when unwired. Signed-off-by: Tinson Lai --- src/lib/onboard.ts | 7 +----- .../sandbox-checkpoint-crash-recovery.test.ts | 22 +++++++++++++++++++ src/lib/onboard/machine/handlers/sandbox.ts | 22 ++++++++++++------- src/lib/onboard/session-bootstrap.ts | 13 +++++++++++ 4 files changed, 50 insertions(+), 14 deletions(-) diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index e1ade967be0..620c6f9b870 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -358,9 +358,6 @@ const { resolveSandboxImageTagFromCreateOutput } = require("./domain/sandbox/image-tag") as typeof import("./domain/sandbox/image-tag"); const nim: typeof import("./inference/nim") = require("./inference/nim"); const onboardSession: typeof import("./state/onboard-session") = require("./state/onboard-session"); -const { - loadResumeCheckpoint, -}: typeof import("./state/onboard-checkpoint-migrate") = require("./state/onboard-checkpoint-migrate"); const { registerIncompleteOnboardExitHandlerForSession, }: typeof import("./onboard/onboard-exit-handler") = require("./onboard/onboard-exit-handler"); @@ -4140,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, @@ -4167,7 +4164,6 @@ async function runOnboard(opts: OnboardOptions = {}): Promise { cliName, error: (message) => console.error(message), exitProcess: (code) => process.exit(code), - resolveResumeCheckpoint: loadResumeCheckpoint, }, ); await onboardRuntimeBoundary.recordOnboardStarted(resume); @@ -4486,7 +4482,6 @@ async function runOnboard(opts: OnboardOptions = {}): Promise { clearPlanEnv, getRegistrySandboxMessagingPlan, providerMatchesGatewayCredential, - providerExistsInGateway, stageSandboxCredentialProviders, promptValidatedSandboxName, selectResourceProfileForSandbox: () => diff --git a/src/lib/onboard/machine/handlers/sandbox-checkpoint-crash-recovery.test.ts b/src/lib/onboard/machine/handlers/sandbox-checkpoint-crash-recovery.test.ts index 25a3ced2dfc..e5f09f55f5d 100644 --- a/src/lib/onboard/machine/handlers/sandbox-checkpoint-crash-recovery.test.ts +++ b/src/lib/onboard/machine/handlers/sandbox-checkpoint-crash-recovery.test.ts @@ -188,6 +188,28 @@ describe("sandbox crash-recovery replay (#5961, #6228)", () => { expect(calls.recordSkip).toHaveBeenCalled(); }); + it("falls back to the staged-provider receipt when no live gateway check is wired", async () => { + const { deps, calls } = createDeps({ + getSandboxReuseState: () => "ready", + providerExistsInGateway: undefined, + }); + const session = sessionWithCheckpoint( + crashedCheckpoint({ + bindings: { credentialEnvs: [], registeredProviders: ["my-assistant-brave-search"] }, + }), + ); + session.stagedCredentialProviders = ["my-assistant-brave-search"]; + + await handleSandboxState({ + ...baseOptions(deps, session), + resume: true, + sandboxName: "my-assistant", + }); + + expect(calls.createSandbox).not.toHaveBeenCalled(); + expect(calls.recordSkip).toHaveBeenCalled(); + }); + 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" }); diff --git a/src/lib/onboard/machine/handlers/sandbox.ts b/src/lib/onboard/machine/handlers/sandbox.ts index 3c39570c52f..35d102e79ac 100644 --- a/src/lib/onboard/machine/handlers/sandbox.ts +++ b/src/lib/onboard/machine/handlers/sandbox.ts @@ -189,7 +189,7 @@ export interface SandboxStateOptions< clearPlanEnv(): void; getRegistrySandboxMessagingPlan(sandboxName: string): SandboxMessagingPlan | null; providerMatchesGatewayCredential(name: string, type: string, credentialEnv: string): boolean; - providerExistsInGateway(name: string, gatewayName: string): boolean; + providerExistsInGateway?(name: string, gatewayName: string): boolean; stageSandboxCredentialProviders(input: { sandboxName: string; enabledChannels: readonly string[]; @@ -564,7 +564,7 @@ class SandboxStateFlow< const bindingCheck = revalidateCheckpointBindings( checkpoint, - this.checkpointBindingAvailability(checkpoint), + this.checkpointBindingAvailability(checkpoint, state), ); if (bindingCheck.status === "stale") return this.rejectStaleCheckpointBindings(bindingCheck); @@ -576,19 +576,25 @@ class SandboxStateFlow< : decision; } - private checkpointBindingAvailability(checkpoint: OnboardCheckpoint): { + private checkpointBindingAvailability( + checkpoint: OnboardCheckpoint, + state: SandboxStepState, + ): { availableCredentialEnvs: ReadonlySet; liveRegisteredProviders: ReadonlySet; } { + const providerExistsInGateway = this.deps.providerExistsInGateway; return { availableCredentialEnvs: new Set( Object.keys(this.options.env).filter((name) => Boolean(this.options.env[name]?.trim())), ), - liveRegisteredProviders: new Set( - checkpoint.bindings.registeredProviders.filter((name) => - this.deps.providerExistsInGateway(name, this.options.gatewayName), - ), - ), + liveRegisteredProviders: providerExistsInGateway + ? new Set( + checkpoint.bindings.registeredProviders.filter((name) => + providerExistsInGateway(name, this.options.gatewayName), + ), + ) + : new Set(state.session?.stagedCredentialProviders ?? []), }; } diff --git a/src/lib/onboard/session-bootstrap.ts b/src/lib/onboard/session-bootstrap.ts index 1ef5e976c93..6746e51b179 100644 --- a/src/lib/onboard/session-bootstrap.ts +++ b/src/lib/onboard/session-bootstrap.ts @@ -1,6 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +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"; @@ -54,6 +55,8 @@ export interface OnboardSessionBootstrapResult { fromDockerfile: string | null; } +export const defaultResolveResumeCheckpoint: () => CheckpointLoadResult = loadResumeCheckpoint; + export function checkpointSandboxName( sandboxName: string, agent: { name?: string } | null, @@ -276,3 +279,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, + }); +} From e43a56b71f1fa17ee7dc94ee80c95022fe449b6c Mon Sep 17 00:00:00 2001 From: Tinson Lai Date: Fri, 17 Jul 2026 09:15:24 +0000 Subject: [PATCH 06/29] fix(onboard): make the checkpoint the sole resume authority Extend the crash-recovery fix so the versioned checkpoint, not the legacy step and prompt-progress fields, decides resume behaviour whenever a checkpoint is present: the sandbox step-complete signal and the recoverable resume sandbox name now come exclusively from the checkpoint when one exists, with legacy fields used only as a migration fallback for sessions that never acquired one. A migrated legacy checkpoint is now attached to the session and persisted during the resume guard, so it is loaded rather than re-derived on every subsequent resume. Signed-off-by: Tinson Lai --- src/lib/onboard/checkpoint-replay.ts | 10 ++++ .../onboard/checkpoint-resume-guard.test.ts | 22 +++++++++ .../onboard/machine/handlers/sandbox.test.ts | 26 ++++++++++ src/lib/onboard/machine/handlers/sandbox.ts | 21 +++----- src/lib/onboard/session-bootstrap.test.ts | 49 ++++++++++++++++++- src/lib/onboard/session-bootstrap.ts | 28 ++++++++--- 6 files changed, 135 insertions(+), 21 deletions(-) diff --git a/src/lib/onboard/checkpoint-replay.ts b/src/lib/onboard/checkpoint-replay.ts index d627b4d5605..d07a3f5b39e 100644 --- a/src/lib/onboard/checkpoint-replay.ts +++ b/src/lib/onboard/checkpoint-replay.ts @@ -1,6 +1,7 @@ // 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 { isDecisionSelected } from "../state/onboard-checkpoint-decision"; import type { CheckpointEffectGroupName, @@ -8,6 +9,15 @@ import type { OnboardCheckpoint, } from "../state/onboard-checkpoint-types"; +export function checkpointProvesSandboxStepComplete( + checkpoint: OnboardCheckpoint | null | undefined, +): boolean { + if (!checkpoint) return false; + const sandboxIndex = ONBOARD_MACHINE_STATES.indexOf("sandbox"); + const stateIndex = ONBOARD_MACHINE_STATES.indexOf(checkpoint.machineState); + return stateIndex > sandboxIndex; +} + export type EffectGroupReplayReason = | "not_recorded" | "postcondition_failed" diff --git a/src/lib/onboard/checkpoint-resume-guard.test.ts b/src/lib/onboard/checkpoint-resume-guard.test.ts index 33b5a61f467..695fd9986fc 100644 --- a/src/lib/onboard/checkpoint-resume-guard.test.ts +++ b/src/lib/onboard/checkpoint-resume-guard.test.ts @@ -114,4 +114,26 @@ describe("resume checkpoint fail-safe (#6228)", () => { 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/machine/handlers/sandbox.test.ts b/src/lib/onboard/machine/handlers/sandbox.test.ts index 4e78aa34d84..30dcfda2d87 100644 --- a/src/lib/onboard/machine/handlers/sandbox.test.ts +++ b/src/lib/onboard/machine/handlers/sandbox.test.ts @@ -551,6 +551,32 @@ describe("handleSandboxState", () => { 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("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 35d102e79ac..42272f42d8a 100644 --- a/src/lib/onboard/machine/handlers/sandbox.ts +++ b/src/lib/onboard/machine/handlers/sandbox.ts @@ -35,7 +35,10 @@ import { recordCheckpointEffectGroup, recordCheckpointSandboxIdentity, } from "../../checkpoint-record"; -import { planSandboxCreateReplay } from "../../checkpoint-replay"; +import { + checkpointProvesSandboxStepComplete, + planSandboxCreateReplay, +} from "../../checkpoint-replay"; import { bindingRevalidationGuidance, revalidateCheckpointBindings, @@ -61,7 +64,6 @@ import type { SandboxCreateIntent as ResolvedSandboxCreateIntent } from "../../s import { withSandboxPhaseTrace } from "../../tracing"; import type { SandboxCreateIntent } from "../../types"; import { branchTo, type OnboardStateTransitionResult } from "../result"; -import { ONBOARD_MACHINE_STATES } from "../types"; import * as dcodeResume from "./sandbox-dcode-resume"; import { reconcileReusedSandboxMessaging, reconcileSandboxMessaging } from "./sandbox-messaging"; import { @@ -348,15 +350,6 @@ function observabilityRequestValidationError( return null; } -function checkpointProvesSandboxStepComplete( - checkpoint: Session["checkpoint"] | undefined, -): boolean { - if (!checkpoint) return false; - const sandboxIndex = ONBOARD_MACHINE_STATES.indexOf("sandbox"); - const stateIndex = ONBOARD_MACHINE_STATES.indexOf(checkpoint.machineState); - return stateIndex > sandboxIndex; -} - function checkpointIdentityForResumeTarget( checkpoint: OnboardCheckpoint, sandboxName: string | null, @@ -503,9 +496,9 @@ class SandboxStateFlow< const decision = decideSandboxResume({ resume: this.options.resume, resumeAgentChanged: this.options.resumeAgentChanged, - sandboxStepComplete: - state.session?.steps?.sandbox?.status === "complete" || - checkpointProvesSandboxStepComplete(state.session?.checkpoint), + sandboxStepComplete: state.session?.checkpoint + ? checkpointProvesSandboxStepComplete(state.session.checkpoint) + : state.session?.steps?.sandbox?.status === "complete", sandboxReuseState, inferenceRouteConfigChanged: hasHermesCompatibleAnthropicInferenceRouteDrift({ agentName: (this.options.agent as { name?: string } | null)?.name, diff --git a/src/lib/onboard/session-bootstrap.test.ts b/src/lib/onboard/session-bootstrap.test.ts index 5126d0a57bd..85912cd072b 100644 --- a/src/lib/onboard/session-bootstrap.test.ts +++ b/src/lib/onboard/session-bootstrap.test.ts @@ -3,7 +3,11 @@ import { describe, expect, it, vi } from "vitest"; -import type { CheckpointLoadResult } from "../state/onboard-checkpoint-types"; +import { decisionUnset } from "../state/onboard-checkpoint-decision"; +import { + CHECKPOINT_SCHEMA_VERSION, + type CheckpointLoadResult, +} 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"; @@ -340,6 +344,49 @@ describe("prepareOnboardSession", () => { expect(deps.exitProcess).not.toHaveBeenCalled(); }); + 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 6746e51b179..c28be01c009 100644 --- a/src/lib/onboard/session-bootstrap.ts +++ b/src/lib/onboard/session-bootstrap.ts @@ -1,11 +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"; export interface OnboardSessionBootstrapInput { @@ -81,6 +83,11 @@ export function getCheckpointedSandboxName( session: Session | null, ): string | null { if (!resume || (agent?.name && agent.name !== "openclaw")) return null; + if (session?.checkpoint) { + return isDecisionSelected(session.checkpoint.sandboxIdentity) + ? session.checkpoint.sandboxIdentity.value.name + : null; + } return session?.sandboxPromptProgress?.sandboxName === true ? session.sandboxName : null; } @@ -124,6 +131,13 @@ function guardResumeCheckpoint(deps: OnboardSessionBootstrapDeps): void { if (result?.status === "corrupt") { reportCorruptResumeCheckpoint(deps); } + if (result?.status === "migrated") { + const migratedCheckpoint = result.checkpoint; + deps.updateSession((current) => { + current.checkpoint = migratedCheckpoint; + return current; + }); + } } function reportResumeConflict( @@ -187,13 +201,15 @@ 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(checkpoint) || + isDecisionSelected(checkpoint.sandboxIdentity) + : session?.steps?.sandbox?.status === "complete" || + ((!session?.agent || session.agent === "openclaw") && + session?.sandboxPromptProgress?.sandboxName === true); const recoveredSandboxName = - input.requestedSandboxName || - (sandboxStepCompleted || sandboxNameCheckpointed ? session?.sandboxName || null : null); + input.requestedSandboxName || (nameRecoverable ? session?.sandboxName || null : null); if (input.cannotPrompt && !recoveredSandboxName) { deps.error( " Cannot resume non-interactive onboard: the previous run was interrupted before sandbox creation completed,", From 05c9903d7c7f0942c154a25651ff023ded429457 Mon Sep 17 00:00:00 2001 From: Tinson Lai Date: Fri, 17 Jul 2026 10:28:18 +0000 Subject: [PATCH 07/29] fix(onboard): keep the checkpoint machine state live and record every decision boundary Read the checkpoint-authority signal from the session's live machine state instead of a snapshot the checkpoint only captured while recording sandbox identity or effects, since that snapshot goes stale the moment the FSM advances past the sandbox step and would otherwise send a later-phase interruption back through recreation instead of reuse. Record the web-search, messaging, and resource-profile decisions plus credential-provider bindings into the checkpoint at their own write boundaries, alongside the existing sandbox identity and create-effect receipts, so resume can reconstruct and revalidate those choices instead of leaving the checkpoint's bindings and decisions empty after they have already taken effect. Also drop a test fixture default that made the optional live-provider-registry check always report nothing live, which caused stale-binding rejection for provider names that were never meant to be checked live in that scenario. Signed-off-by: Tinson Lai --- src/lib/onboard/checkpoint-record.ts | 63 ++++++++++++++++++- src/lib/onboard/checkpoint-replay.ts | 12 +++- .../machine/handlers/sandbox-test-fixtures.ts | 1 - .../onboard/machine/handlers/sandbox.test.ts | 30 ++++++++- src/lib/onboard/machine/handlers/sandbox.ts | 20 +++++- src/lib/onboard/session-bootstrap.ts | 3 +- 6 files changed, 119 insertions(+), 10 deletions(-) diff --git a/src/lib/onboard/checkpoint-record.ts b/src/lib/onboard/checkpoint-record.ts index afd4c5fa0e7..77028332fff 100644 --- a/src/lib/onboard/checkpoint-record.ts +++ b/src/lib/onboard/checkpoint-record.ts @@ -1,10 +1,13 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { decisionSelected } from "../state/onboard-checkpoint-decision"; +import type { WebSearchConfig } from "../inference/web-search"; +import type { SandboxMessagingPlan } from "../messaging/manifest"; +import { decisionDeclined, decisionSelected } from "../state/onboard-checkpoint-decision"; import { deriveCheckpointFromSession } from "../state/onboard-checkpoint-migrate"; import type { CheckpointEffectGroupName, + CheckpointResourceProfile, OnboardCheckpoint, } from "../state/onboard-checkpoint-types"; import type { Session } from "../state/onboard-session"; @@ -44,3 +47,61 @@ export function recordCheckpointEffectGroup( }, }; } + +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(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: { credentialEnv?: string | null; registeredProviders?: readonly string[] }, +): void { + const base = baseCheckpoint(session); + const credentialEnvs = additions.credentialEnv + ? [...new Set([...base.bindings.credentialEnvs, additions.credentialEnv])] + : base.bindings.credentialEnvs; + const registeredProviders = additions.registeredProviders + ? [...new Set([...base.bindings.registeredProviders, ...additions.registeredProviders])] + : 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.ts b/src/lib/onboard/checkpoint-replay.ts index d07a3f5b39e..fa303e1726b 100644 --- a/src/lib/onboard/checkpoint-replay.ts +++ b/src/lib/onboard/checkpoint-replay.ts @@ -2,6 +2,7 @@ // 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, @@ -9,12 +10,17 @@ import type { OnboardCheckpoint, } from "../state/onboard-checkpoint-types"; +export interface CheckpointedMachineSession { + readonly checkpoint: OnboardCheckpoint | null; + readonly machine: { readonly state: OnboardMachineState }; +} + export function checkpointProvesSandboxStepComplete( - checkpoint: OnboardCheckpoint | null | undefined, + session: CheckpointedMachineSession | null | undefined, ): boolean { - if (!checkpoint) return false; + if (!session?.checkpoint) return false; const sandboxIndex = ONBOARD_MACHINE_STATES.indexOf("sandbox"); - const stateIndex = ONBOARD_MACHINE_STATES.indexOf(checkpoint.machineState); + const stateIndex = ONBOARD_MACHINE_STATES.indexOf(session.machine.state); return stateIndex > sandboxIndex; } diff --git a/src/lib/onboard/machine/handlers/sandbox-test-fixtures.ts b/src/lib/onboard/machine/handlers/sandbox-test-fixtures.ts index 6e6ca98d9e8..b8a9795fb18 100644 --- a/src/lib/onboard/machine/handlers/sandbox-test-fixtures.ts +++ b/src/lib/onboard/machine/handlers/sandbox-test-fixtures.ts @@ -226,7 +226,6 @@ export function createDeps( clearPlanEnv: calls.clearPlanEnv, getRegistrySandboxMessagingPlan: () => null, providerMatchesGatewayCredential: () => false, - providerExistsInGateway: () => false, stageSandboxCredentialProviders: calls.stageCredentialProviders, promptValidatedSandboxName: calls.promptName, selectResourceProfileForSandbox: calls.selectResourceProfile, diff --git a/src/lib/onboard/machine/handlers/sandbox.test.ts b/src/lib/onboard/machine/handlers/sandbox.test.ts index 30dcfda2d87..b8bf90c18b4 100644 --- a/src/lib/onboard/machine/handlers/sandbox.test.ts +++ b/src/lib/onboard/machine/handlers/sandbox.test.ts @@ -4,7 +4,11 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { hashCredential } from "../../../security/credential-hash"; -import { decisionUnset } from "../../../state/onboard-checkpoint-decision"; +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"; @@ -104,6 +108,25 @@ 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 () => ["my-assistant-brave-search"]), + selectResourceProfileForSandbox: vi.fn(async () => ({ cpu: "2", memory: "4Gi" })), + }); + + const result = await handleSandboxState(baseOptions(deps)); + + expect(result.session?.checkpoint?.bindings.registeredProviders).toEqual([ + "my-assistant-brave-search", + ]); + expect(result.session?.checkpoint?.resourceProfile).toEqual( + decisionSelected({ cpu: "2", memory: "4Gi" }), + ); }); it("does not auto-enable web search from ambient credentials during authoritative rebuild", async () => { @@ -513,7 +536,10 @@ describe("handleSandboxState", () => { }); 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" }); + const session = createSession({ + sandboxName: "saved", + machine: { version: 1, state: "agent_setup", stateEnteredAt: null, revision: 1 }, + }); session.checkpoint = { schemaVersion: CHECKPOINT_SCHEMA_VERSION, sessionId: session.sessionId, diff --git a/src/lib/onboard/machine/handlers/sandbox.ts b/src/lib/onboard/machine/handlers/sandbox.ts index 42272f42d8a..96e10840825 100644 --- a/src/lib/onboard/machine/handlers/sandbox.ts +++ b/src/lib/onboard/machine/handlers/sandbox.ts @@ -19,6 +19,7 @@ import { import type { SandboxMessagingPlan } from "../../../messaging/manifest"; import { isDecisionSelected } from "../../../state/onboard-checkpoint-decision"; import type { + CheckpointResourceProfile, CheckpointSandboxIdentity, OnboardCheckpoint, } from "../../../state/onboard-checkpoint-types"; @@ -32,8 +33,12 @@ 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, @@ -497,7 +502,7 @@ class SandboxStateFlow< resume: this.options.resume, resumeAgentChanged: this.options.resumeAgentChanged, sandboxStepComplete: state.session?.checkpoint - ? checkpointProvesSandboxStepComplete(state.session.checkpoint) + ? checkpointProvesSandboxStepComplete(state.session) : state.session?.steps?.sandbox?.status === "complete", sandboxReuseState, inferenceRouteConfigChanged: hasHermesCompatibleAnthropicInferenceRouteDrift({ @@ -813,6 +818,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 }; @@ -873,6 +882,7 @@ class SandboxStateFlow< const session = this.deps.updateSession((current) => { current.messagingPlan = messaging.plan; current.sandboxPromptProgress.messaging = true; + recordCheckpointMessaging(current, messaging.plan); return current; }); return { @@ -900,6 +910,13 @@ class SandboxStateFlow< ); if (registeredProviders.length > 0) { this.deps.note(" ✓ Registered selected credentials with OpenShell for resume."); + this.deps.updateSession((current) => { + recordCheckpointBindings(current, { + credentialEnv: this.options.credentialEnv, + registeredProviders, + }); + return current; + }); } } @@ -927,6 +944,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 }; diff --git a/src/lib/onboard/session-bootstrap.ts b/src/lib/onboard/session-bootstrap.ts index c28be01c009..2840fed0f42 100644 --- a/src/lib/onboard/session-bootstrap.ts +++ b/src/lib/onboard/session-bootstrap.ts @@ -203,8 +203,7 @@ function assertRecoverableResumeSandboxName( ): void { const checkpoint = session?.checkpoint ?? null; const nameRecoverable = checkpoint - ? checkpointProvesSandboxStepComplete(checkpoint) || - isDecisionSelected(checkpoint.sandboxIdentity) + ? checkpointProvesSandboxStepComplete(session) || isDecisionSelected(checkpoint.sandboxIdentity) : session?.steps?.sandbox?.status === "complete" || ((!session?.agent || session.agent === "openclaw") && session?.sandboxPromptProgress?.sandboxName === true); From c614f386baf4e3833417854d80d1f4188af17570 Mon Sep 17 00:00:00 2001 From: Tinson Lai Date: Fri, 17 Jul 2026 13:11:17 +0000 Subject: [PATCH 08/29] fix(onboard): wire effect-group replay and revalidate build/policy drift before reuse Route the credential-provider registration boundary through the declared effect-group replay contract instead of leaving it unused: record a web_search_provider or messaging_providers receipt after a successful registration, and skip re-registering only when that receipt exists and every bound provider is confirmed live in the gateway, so an interrupted run reruns registration whenever the live postcondition cannot be confirmed. Record a sandbox_register receipt alongside sandbox_create once the registry entry is written. Extend the sandbox-create effect-group fingerprint to cover both the built agent version and the resolved policy tier, and revalidate it before a crash-recovery reuse decision: a recorded fingerprint that no longer matches what the current run would build is treated as drift and rejected with guidance to pass --recreate-sandbox, rather than silently reusing a sandbox built from different inputs. Signed-off-by: Tinson Lai --- .../sandbox-checkpoint-crash-recovery.test.ts | 27 ++++++++- .../onboard/machine/handlers/sandbox.test.ts | 36 +++++++++++ src/lib/onboard/machine/handlers/sandbox.ts | 60 ++++++++++++++++++- 3 files changed, 121 insertions(+), 2 deletions(-) diff --git a/src/lib/onboard/machine/handlers/sandbox-checkpoint-crash-recovery.test.ts b/src/lib/onboard/machine/handlers/sandbox-checkpoint-crash-recovery.test.ts index e5f09f55f5d..4faefa7d4a7 100644 --- a/src/lib/onboard/machine/handlers/sandbox-checkpoint-crash-recovery.test.ts +++ b/src/lib/onboard/machine/handlers/sandbox-checkpoint-crash-recovery.test.ts @@ -30,7 +30,10 @@ function crashedCheckpoint(overrides: Partial = {}): OnboardC messaging: decisionUnset(), resourceProfile: decisionUnset(), effectGroups: { - sandbox_create: { completedAt: "2026-01-01T00:00:00.000Z", fingerprint: "fp" }, + sandbox_create: { + completedAt: "2026-01-01T00:00:00.000Z", + fingerprint: "my-assistant|default", + }, }, bindings: { credentialEnvs: [], registeredProviders: [] }, ...overrides, @@ -225,4 +228,26 @@ describe("sandbox crash-recovery replay (#5961, #6228)", () => { decisionSelected({ name: "my-assistant", agent: "hermes" }), ); }); + + 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"); + }); }); diff --git a/src/lib/onboard/machine/handlers/sandbox.test.ts b/src/lib/onboard/machine/handlers/sandbox.test.ts index b8bf90c18b4..7fdba887d0c 100644 --- a/src/lib/onboard/machine/handlers/sandbox.test.ts +++ b/src/lib/onboard/machine/handlers/sandbox.test.ts @@ -129,6 +129,42 @@ describe("handleSandboxState", () => { ); }); + 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: ["my-assistant-brave-search"] }, + }; + const updateSession = vi.fn((mutator: (value: typeof session) => void) => { + mutator(session); + return session; + }); + const stageSandboxCredentialProviders = vi.fn(async () => ["my-assistant-brave-search"]); + const { deps } = createDeps({ + updateSession, + configureWebSearch: vi.fn(async () => ({ fetchEnabled: true as const })), + stageSandboxCredentialProviders, + providerExistsInGateway: (name) => name === "my-assistant-brave-search", + }); + + 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 () => { const configureWebSearch = vi.fn(async () => ({ fetchEnabled: true as const })); const { deps, calls } = createDeps({ configureWebSearch }); diff --git a/src/lib/onboard/machine/handlers/sandbox.ts b/src/lib/onboard/machine/handlers/sandbox.ts index 96e10840825..fab8b7bf819 100644 --- a/src/lib/onboard/machine/handlers/sandbox.ts +++ b/src/lib/onboard/machine/handlers/sandbox.ts @@ -19,6 +19,7 @@ import { import type { SandboxMessagingPlan } from "../../../messaging/manifest"; import { isDecisionSelected } from "../../../state/onboard-checkpoint-decision"; import type { + CheckpointEffectGroupName, CheckpointResourceProfile, CheckpointSandboxIdentity, OnboardCheckpoint, @@ -42,6 +43,7 @@ import { } from "../../checkpoint-record"; import { checkpointProvesSandboxStepComplete, + planEffectGroupReplay, planSandboxCreateReplay, } from "../../checkpoint-replay"; import { @@ -560,6 +562,14 @@ class SandboxStateFlow< checkpoint && checkpointIdentityForResumeTarget(checkpoint, state.sandboxName, agentName); if (!checkpoint || !identity) return decision; + const recordedFingerprint = checkpoint.effectGroups.sandbox_create?.fingerprint; + if ( + recordedFingerprint && + recordedFingerprint !== this.currentSandboxCreateFingerprint(identity.name) + ) { + return this.rejectDriftedCheckpointFingerprint(identity.name); + } + const bindingCheck = revalidateCheckpointBindings( checkpoint, this.checkpointBindingAvailability(checkpoint, state), @@ -574,6 +584,36 @@ class SandboxStateFlow< : decision; } + private currentSandboxCreateFingerprint(sandboxName: string): string { + const { nemoclawVersion: builtFingerprint } = this.deps.getSandboxAgentRegistryFields( + this.options.agent, + !this.options.fromDockerfile, + ); + const policyFingerprint = this.options.authoritativePolicyTier ?? "default"; + return [ + typeof builtFingerprint === "string" ? builtFingerprint : sandboxName, + policyFingerprint, + ].join("|"); + } + + 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 { + const providerExistsInGateway = this.deps.providerExistsInGateway; + if (!providerExistsInGateway || checkpoint.bindings.registeredProviders.length === 0) { + return false; + } + return checkpoint.bindings.registeredProviders.every((name) => + providerExistsInGateway(name, this.options.gatewayName), + ); + } + private checkpointBindingAvailability( checkpoint: OnboardCheckpoint, state: SandboxStepState, @@ -896,8 +936,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, () => @@ -915,6 +964,7 @@ class SandboxStateFlow< credentialEnv: this.options.credentialEnv, registeredProviders, }); + recordCheckpointEffectGroup(current, group, registeredProviders.join(",")); return current; }); } @@ -1094,8 +1144,12 @@ class SandboxStateFlow< recordCheckpointEffectGroup( current, "sandbox_create", - typeof builtFingerprint === "string" ? builtFingerprint : sandboxName, + [ + typeof builtFingerprint === "string" ? builtFingerprint : sandboxName, + this.options.authoritativePolicyTier ?? "default", + ].join("|"), ); + recordCheckpointEffectGroup(current, "sandbox_register", sandboxName); return current; }); return { ...state, sandboxName, session: recordedSession }; @@ -1155,6 +1209,8 @@ class SandboxStateFlow< requestedSandboxName, [], nextState.webSearchConfig, + "web_search_provider", + nextState.session?.checkpoint ?? null, ); const messaging = await reconcileSandboxMessaging({ resume: this.options.resume, @@ -1168,6 +1224,8 @@ class SandboxStateFlow< requestedSandboxName, nextState.selectedMessagingChannels, null, + "messaging_providers", + nextState.session?.checkpoint ?? null, ); return this.createAndRecordSandbox(nextState, requestedSandboxName, messaging.plan, decision); } From f4ded2750e228b069b99b9be64e7b47b30af0df2 Mon Sep 17 00:00:00 2001 From: Tinson Lai Date: Fri, 17 Jul 2026 14:49:45 +0000 Subject: [PATCH 09/29] fix(onboard): revalidate provider bindings by type and credential env, not name alone Signed-off-by: Tinson Lai --- src/lib/onboard/checkpoint-record.ts | 14 +- src/lib/onboard/checkpoint-replay.test.ts | 10 +- src/lib/onboard/checkpoint-revalidate.ts | 6 +- .../credential-provider-registration.test.ts | 9 +- .../credential-provider-registration.ts | 10 +- .../onboard/machine/core-flow-phases.test.ts | 1 - .../sandbox-checkpoint-crash-recovery.test.ts | 144 ++++++++++++++++-- .../sandbox-create-intent-boundary.test.ts | 9 +- .../machine/handlers/sandbox-test-fixtures.ts | 3 +- .../onboard/machine/handlers/sandbox.test.ts | 22 ++- src/lib/onboard/machine/handlers/sandbox.ts | 41 ++--- .../state/onboard-checkpoint-migrate.test.ts | 2 +- src/lib/state/onboard-checkpoint-migrate.ts | 2 +- src/lib/state/onboard-checkpoint-types.ts | 8 +- src/lib/state/onboard-checkpoint.test.ts | 20 ++- src/lib/state/onboard-checkpoint.ts | 23 ++- 16 files changed, 261 insertions(+), 63 deletions(-) diff --git a/src/lib/onboard/checkpoint-record.ts b/src/lib/onboard/checkpoint-record.ts index 77028332fff..f271a4a0a83 100644 --- a/src/lib/onboard/checkpoint-record.ts +++ b/src/lib/onboard/checkpoint-record.ts @@ -7,6 +7,7 @@ import { decisionDeclined, decisionSelected } from "../state/onboard-checkpoint- import { deriveCheckpointFromSession } from "../state/onboard-checkpoint-migrate"; import type { CheckpointEffectGroupName, + CheckpointProviderBinding, CheckpointResourceProfile, OnboardCheckpoint, } from "../state/onboard-checkpoint-types"; @@ -89,14 +90,23 @@ export function recordCheckpointResourceProfile( export function recordCheckpointBindings( session: Session, - additions: { credentialEnv?: string | null; registeredProviders?: readonly string[] }, + additions: { + credentialEnv?: string | null; + registeredProviders?: readonly CheckpointProviderBinding[]; + }, ): void { const base = baseCheckpoint(session); const credentialEnvs = additions.credentialEnv ? [...new Set([...base.bindings.credentialEnvs, additions.credentialEnv])] : base.bindings.credentialEnvs; const registeredProviders = additions.registeredProviders - ? [...new Set([...base.bindings.registeredProviders, ...additions.registeredProviders])] + ? [ + ...new Map( + [...base.bindings.registeredProviders, ...additions.registeredProviders].map( + (binding) => [binding.name, binding], + ), + ).values(), + ] : base.bindings.registeredProviders; session.checkpoint = { ...base, diff --git a/src/lib/onboard/checkpoint-replay.test.ts b/src/lib/onboard/checkpoint-replay.test.ts index 91fcf03f40c..f741a2f92a9 100644 --- a/src/lib/onboard/checkpoint-replay.test.ts +++ b/src/lib/onboard/checkpoint-replay.test.ts @@ -125,7 +125,10 @@ describe("crash-then-resume matrix proves at-most-once destructive create (#6228 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: ["p1"] }, + bindings: { + credentialEnvs: ["OPENAI_API_KEY"], + registeredProviders: [{ name: "p1", type: "generic", credentialEnv: "P1_API_KEY" }], + }, }); const result = revalidateCheckpointBindings(cp, { availableCredentialEnvs: new Set(["OPENAI_API_KEY"]), @@ -137,7 +140,10 @@ describe("revalidateCheckpointBindings fails closed without leaking values (#622 it("fails closed on a stale binding and reports only names, never values", () => { const cp = checkpoint({ - bindings: { credentialEnvs: ["OPENAI_API_KEY"], registeredProviders: ["p1"] }, + bindings: { + credentialEnvs: ["OPENAI_API_KEY"], + registeredProviders: [{ name: "p1", type: "generic", credentialEnv: "P1_API_KEY" }], + }, }); const result = revalidateCheckpointBindings(cp, { availableCredentialEnvs: new Set(), diff --git a/src/lib/onboard/checkpoint-revalidate.ts b/src/lib/onboard/checkpoint-revalidate.ts index 891e9c62b80..62ce522d4c4 100644 --- a/src/lib/onboard/checkpoint-revalidate.ts +++ b/src/lib/onboard/checkpoint-revalidate.ts @@ -23,9 +23,9 @@ export function revalidateCheckpointBindings( const missingCredentialEnvs = checkpoint.bindings.credentialEnvs.filter( (env) => !available.availableCredentialEnvs.has(env), ); - const missingProviders = checkpoint.bindings.registeredProviders.filter( - (provider) => !available.liveRegisteredProviders.has(provider), - ); + 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" }; } diff --git a/src/lib/onboard/credential-provider-registration.test.ts b/src/lib/onboard/credential-provider-registration.test.ts index 11adfd474c8..7bf73e36e63 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 2318e1deeda..eab370c66b3 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/machine/core-flow-phases.test.ts b/src/lib/onboard/machine/core-flow-phases.test.ts index 909bb36a92c..52b0d150e4d 100644 --- a/src/lib/onboard/machine/core-flow-phases.test.ts +++ b/src/lib/onboard/machine/core-flow-phases.test.ts @@ -205,7 +205,6 @@ function createPhases( clearPlanEnv: vi.fn(), getRegistrySandboxMessagingPlan: () => null, providerMatchesGatewayCredential: () => false, - providerExistsInGateway: () => false, stageSandboxCredentialProviders: vi.fn(async () => []), promptValidatedSandboxName: vi.fn(async () => "my-sandbox"), selectResourceProfileForSandbox: vi.fn(async () => null), diff --git a/src/lib/onboard/machine/handlers/sandbox-checkpoint-crash-recovery.test.ts b/src/lib/onboard/machine/handlers/sandbox-checkpoint-crash-recovery.test.ts index 4faefa7d4a7..d19d95b8a28 100644 --- a/src/lib/onboard/machine/handlers/sandbox-checkpoint-crash-recovery.test.ts +++ b/src/lib/onboard/machine/handlers/sandbox-checkpoint-crash-recovery.test.ts @@ -11,7 +11,7 @@ import { import { createSession, type Session } from "../../../state/onboard-session"; import { detectMessagingChannelsFromEnv } from "../../messaging-channel-setup"; import { handleSandboxState } from "./sandbox"; -import { baseOptions, createDeps } from "./sandbox-test-fixtures"; +import { baseOptions, createDeps, makeMinimalPlan } from "./sandbox-test-fixtures"; vi.mock("../../messaging-channel-setup", () => ({ detectMessagingChannelsFromEnv: vi.fn(() => []), @@ -150,11 +150,16 @@ describe("sandbox crash-recovery replay (#5961, #6228)", () => { it("rejects reuse when a checkpointed provider is no longer live-registered with the gateway", async () => { const { deps, calls } = createDeps({ getSandboxReuseState: () => "missing", - providerExistsInGateway: () => false, + providerMatchesGatewayCredential: () => false, }); const session = sessionWithCheckpoint( crashedCheckpoint({ - bindings: { credentialEnvs: [], registeredProviders: ["my-assistant-brave-search"] }, + bindings: { + credentialEnvs: [], + registeredProviders: [ + { name: "my-assistant-brave-search", type: "brave", credentialEnv: "BRAVE_API_KEY" }, + ], + }, }), ); @@ -173,11 +178,19 @@ describe("sandbox crash-recovery replay (#5961, #6228)", () => { it("accepts a checkpointed provider that is still live-registered with the gateway", async () => { const { deps, calls } = createDeps({ getSandboxReuseState: () => "ready", - providerExistsInGateway: (name) => name === "my-assistant-brave-search", + providerMatchesGatewayCredential: (name, type, credentialEnv) => + name === "my-assistant-brave-search" && + type === "brave" && + credentialEnv === "BRAVE_API_KEY", }); const session = sessionWithCheckpoint( crashedCheckpoint({ - bindings: { credentialEnvs: [], registeredProviders: ["my-assistant-brave-search"] }, + bindings: { + credentialEnvs: [], + registeredProviders: [ + { name: "my-assistant-brave-search", type: "brave", credentialEnv: "BRAVE_API_KEY" }, + ], + }, }), ); @@ -191,26 +204,35 @@ describe("sandbox crash-recovery replay (#5961, #6228)", () => { expect(calls.recordSkip).toHaveBeenCalled(); }); - it("falls back to the staged-provider receipt when no live gateway check is wired", async () => { + it("rejects reuse when a checkpointed provider name exists live under a different type or credential environment (#7022)", async () => { const { deps, calls } = createDeps({ - getSandboxReuseState: () => "ready", - providerExistsInGateway: undefined, + getSandboxReuseState: () => "missing", + providerMatchesGatewayCredential: (name, type, credentialEnv) => + name === "my-assistant-brave-search" && + type === "generic" && + credentialEnv === "OTHER_API_KEY", }); const session = sessionWithCheckpoint( crashedCheckpoint({ - bindings: { credentialEnvs: [], registeredProviders: ["my-assistant-brave-search"] }, + bindings: { + credentialEnvs: [], + registeredProviders: [ + { name: "my-assistant-brave-search", type: "brave", credentialEnv: "BRAVE_API_KEY" }, + ], + }, }), ); - session.stagedCredentialProviders = ["my-assistant-brave-search"]; - await handleSandboxState({ - ...baseOptions(deps, session), - resume: true, - sandboxName: "my-assistant", - }); + await expect( + handleSandboxState({ + ...baseOptions(deps, session), + resume: true, + sandboxName: "my-assistant", + }), + ).rejects.toThrow("exit 1"); expect(calls.createSandbox).not.toHaveBeenCalled(); - expect(calls.recordSkip).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 () => { @@ -229,6 +251,96 @@ describe("sandbox crash-recovery replay (#5961, #6228)", () => { ); }); + it("safely replays web-search provider registration after a crash between staging and its checkpoint receipt (#7022)", async () => { + const stageSandboxCredentialProviders = vi + .fn() + .mockRejectedValueOnce(new Error("gateway connection dropped mid-registration")) + .mockResolvedValueOnce([ + { name: "my-assistant-brave-search", type: "brave", credentialEnv: "BRAVE_API_KEY" }, + ]); + const providerMatchesGatewayCredential = vi.fn( + (name: string, type: string, credentialEnv: string) => + name === "my-assistant-brave-search" && + type === "brave" && + credentialEnv === "BRAVE_API_KEY", + ); + const { deps, getSession } = createDeps({ + getSandboxReuseState: () => "missing", + configureWebSearch: vi.fn(async () => ({ fetchEnabled: true as const })), + stageSandboxCredentialProviders, + providerMatchesGatewayCredential, + }); + + await expect(handleSandboxState({ ...baseOptions(deps), 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); + 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("safely replays messaging provider registration after a crash between staging and its checkpoint receipt (#7022)", async () => { + const stageSandboxCredentialProviders = vi + .fn() + .mockRejectedValueOnce(new Error("gateway connection dropped mid-registration")) + .mockResolvedValueOnce([ + { + name: "my-assistant-discord-bridge", + type: "generic", + credentialEnv: "DISCORD_BOT_TOKEN", + }, + ]); + const providerMatchesGatewayCredential = vi.fn( + (name: string, type: string, credentialEnv: string) => + name === "my-assistant-discord-bridge" && + type === "generic" && + credentialEnv === "DISCORD_BOT_TOKEN", + ); + const messagingPlan = makeMinimalPlan("my-assistant", "openclaw", ["discord"]); + const { deps, getSession } = createDeps({ + getSandboxReuseState: () => "missing", + readMessagingPlanFromEnv: () => messagingPlan, + stageSandboxCredentialProviders, + providerMatchesGatewayCredential, + }); + + await expect(handleSandboxState({ ...baseOptions(deps), 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); + 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( 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 8fc910432dc..96e6dbd1945 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-test-fixtures.ts b/src/lib/onboard/machine/handlers/sandbox-test-fixtures.ts index b8a9795fb18..7e1150c2a89 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"; @@ -113,7 +114,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 7fdba887d0c..5f13ad60767 100644 --- a/src/lib/onboard/machine/handlers/sandbox.test.ts +++ b/src/lib/onboard/machine/handlers/sandbox.test.ts @@ -115,14 +115,16 @@ describe("handleSandboxState", () => { 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 () => ["my-assistant-brave-search"]), + stageSandboxCredentialProviders: vi.fn(async () => [ + { 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([ - "my-assistant-brave-search", + { name: "my-assistant-brave-search", type: "brave", credentialEnv: "BRAVE_API_KEY" }, ]); expect(result.session?.checkpoint?.resourceProfile).toEqual( decisionSelected({ cpu: "2", memory: "4Gi" }), @@ -146,18 +148,28 @@ describe("handleSandboxState", () => { fingerprint: "my-assistant-brave-search", }, }, - bindings: { credentialEnvs: [], registeredProviders: ["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 () => ["my-assistant-brave-search"]); + 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, - providerExistsInGateway: (name) => name === "my-assistant-brave-search", + providerMatchesGatewayCredential: (name, type, credentialEnv) => + name === "my-assistant-brave-search" && + type === "brave" && + credentialEnv === "BRAVE_API_KEY", }); await handleSandboxState({ ...baseOptions(deps, session), sandboxName: "my-assistant" }); diff --git a/src/lib/onboard/machine/handlers/sandbox.ts b/src/lib/onboard/machine/handlers/sandbox.ts index fab8b7bf819..5b53231ee93 100644 --- a/src/lib/onboard/machine/handlers/sandbox.ts +++ b/src/lib/onboard/machine/handlers/sandbox.ts @@ -20,6 +20,7 @@ import type { SandboxMessagingPlan } from "../../../messaging/manifest"; import { isDecisionSelected } from "../../../state/onboard-checkpoint-decision"; import type { CheckpointEffectGroupName, + CheckpointProviderBinding, CheckpointResourceProfile, CheckpointSandboxIdentity, OnboardCheckpoint, @@ -198,13 +199,12 @@ export interface SandboxStateOptions< clearPlanEnv(): void; getRegistrySandboxMessagingPlan(sandboxName: string): SandboxMessagingPlan | null; providerMatchesGatewayCredential(name: string, type: string, credentialEnv: string): boolean; - providerExistsInGateway?(name: string, gatewayName: string): boolean; stageSandboxCredentialProviders(input: { sandboxName: string; enabledChannels: readonly string[]; webSearchConfig: WebSearchConfig | null; agent: Agent; - }): Promise; + }): Promise; promptValidatedSandboxName(agent: Agent): Promise; selectResourceProfileForSandbox(): Promise; stopStaleDashboardListenersForSandbox(sandboxes: unknown[], sandboxName: string): void; @@ -572,7 +572,7 @@ class SandboxStateFlow< const bindingCheck = revalidateCheckpointBindings( checkpoint, - this.checkpointBindingAvailability(checkpoint, state), + this.checkpointBindingAvailability(checkpoint), ); if (bindingCheck.status === "stale") return this.rejectStaleCheckpointBindings(bindingCheck); @@ -605,34 +605,31 @@ class SandboxStateFlow< } private providerBindingsLive(checkpoint: OnboardCheckpoint): boolean { - const providerExistsInGateway = this.deps.providerExistsInGateway; - if (!providerExistsInGateway || checkpoint.bindings.registeredProviders.length === 0) { - return false; - } - return checkpoint.bindings.registeredProviders.every((name) => - providerExistsInGateway(name, this.options.gatewayName), + 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, - state: SandboxStepState, - ): { + private checkpointBindingAvailability(checkpoint: OnboardCheckpoint): { availableCredentialEnvs: ReadonlySet; liveRegisteredProviders: ReadonlySet; } { - const providerExistsInGateway = this.deps.providerExistsInGateway; return { availableCredentialEnvs: new Set( Object.keys(this.options.env).filter((name) => Boolean(this.options.env[name]?.trim())), ), - liveRegisteredProviders: providerExistsInGateway - ? new Set( - checkpoint.bindings.registeredProviders.filter((name) => - providerExistsInGateway(name, this.options.gatewayName), + liveRegisteredProviders: new Set( + checkpoint.bindings.registeredProviders + .filter((binding) => + this.deps.providerMatchesGatewayCredential( + binding.name, + binding.type, + binding.credentialEnv, ), ) - : new Set(state.session?.stagedCredentialProviders ?? []), + .map((binding) => binding.name), + ), }; } @@ -964,7 +961,11 @@ class SandboxStateFlow< credentialEnv: this.options.credentialEnv, registeredProviders, }); - recordCheckpointEffectGroup(current, group, registeredProviders.join(",")); + recordCheckpointEffectGroup( + current, + group, + registeredProviders.map((binding) => binding.name).join(","), + ); return current; }); } diff --git a/src/lib/state/onboard-checkpoint-migrate.test.ts b/src/lib/state/onboard-checkpoint-migrate.test.ts index 7d9608d462e..6428c2a6575 100644 --- a/src/lib/state/onboard-checkpoint-migrate.test.ts +++ b/src/lib/state/onboard-checkpoint-migrate.test.ts @@ -51,7 +51,7 @@ describe("deriveCheckpointFromSession", () => { expect(checkpoint.resourceProfile).toEqual(decisionDeclined()); expect(checkpoint.bindings).toEqual({ credentialEnvs: ["OPENAI_API_KEY"], - registeredProviders: ["web-search-openclaw"], + registeredProviders: [], }); }); diff --git a/src/lib/state/onboard-checkpoint-migrate.ts b/src/lib/state/onboard-checkpoint-migrate.ts index d19fdac71a9..b2d51e6c3cf 100644 --- a/src/lib/state/onboard-checkpoint-migrate.ts +++ b/src/lib/state/onboard-checkpoint-migrate.ts @@ -73,7 +73,7 @@ export function deriveCheckpointFromSession(session: Session): OnboardCheckpoint effectGroups: {}, bindings: { credentialEnvs: session.credentialEnv ? [session.credentialEnv] : [], - registeredProviders: [...session.stagedCredentialProviders], + registeredProviders: [], }, }; } diff --git a/src/lib/state/onboard-checkpoint-types.ts b/src/lib/state/onboard-checkpoint-types.ts index b4b57bac87b..9e0a333d474 100644 --- a/src/lib/state/onboard-checkpoint-types.ts +++ b/src/lib/state/onboard-checkpoint-types.ts @@ -35,9 +35,15 @@ export interface CheckpointEffectGroupRecord { 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 string[]; + readonly registeredProviders: readonly CheckpointProviderBinding[]; } export interface OnboardCheckpoint { diff --git a/src/lib/state/onboard-checkpoint.test.ts b/src/lib/state/onboard-checkpoint.test.ts index 73a7e2bead1..c87811a1757 100644 --- a/src/lib/state/onboard-checkpoint.test.ts +++ b/src/lib/state/onboard-checkpoint.test.ts @@ -29,7 +29,12 @@ function baseCheckpoint(overrides: Partial = {}): OnboardChec messaging: decisionUnset(), resourceProfile: decisionDeclined(), effectGroups: { sandbox_create: { completedAt: ISO, fingerprint: "fp-create" } }, - bindings: { credentialEnvs: ["OPENAI_API_KEY"], registeredProviders: ["web-search-p"] }, + bindings: { + credentialEnvs: ["OPENAI_API_KEY"], + registeredProviders: [ + { name: "web-search-p", type: "brave", credentialEnv: "BRAVE_API_KEY" }, + ], + }, ...overrides, }; } @@ -120,7 +125,18 @@ describe("checkpoint schema inspection", () => { const checkpoint = serializeCheckpoint(baseCheckpoint()); (checkpoint as Record).bindings = { credentialEnvs: ["OPENAI_API_KEY", 42], - registeredProviders: ["web-search-p"], + 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" }); }); diff --git a/src/lib/state/onboard-checkpoint.ts b/src/lib/state/onboard-checkpoint.ts index 9739e9bc1f6..9b35bb5c8ab 100644 --- a/src/lib/state/onboard-checkpoint.ts +++ b/src/lib/state/onboard-checkpoint.ts @@ -15,6 +15,7 @@ import { type CheckpointEffectGroupName, type CheckpointEffectGroupRecord, type CheckpointLoadResult, + type CheckpointProviderBinding, type CheckpointResourceProfile, type CheckpointSandboxIdentity, type OnboardCheckpoint, @@ -98,10 +99,30 @@ function parseEffectGroups( 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 = readStringArray(value.registeredProviders); + const registeredProviders = parseProviderBindings(value.registeredProviders); if (credentialEnvs === null || registeredProviders === null) return null; return { credentialEnvs, registeredProviders }; } From 62c2ac79b1f058881bd9e613fd5ed116e08b5819 Mon Sep 17 00:00:00 2001 From: Tinson Lai Date: Fri, 17 Jul 2026 15:57:04 +0000 Subject: [PATCH 10/29] fix(onboard): make the checkpoint the resume authority for sandbox decisions Signed-off-by: Tinson Lai --- src/lib/onboard/checkpoint-record.ts | 11 ++- .../sandbox-checkpoint-crash-recovery.test.ts | 43 ++++++++++ .../handlers/sandbox-messaging.test.ts | 71 +++++++++++++++- .../machine/handlers/sandbox-messaging.ts | 49 +++++++++-- .../onboard/machine/handlers/sandbox.test.ts | 82 +++++++++++++++++++ src/lib/onboard/machine/handlers/sandbox.ts | 32 ++++++-- src/lib/state/onboard-checkpoint-migrate.ts | 13 ++- src/lib/state/onboard-checkpoint-types.ts | 8 +- src/lib/state/onboard-checkpoint.ts | 11 ++- 9 files changed, 298 insertions(+), 22 deletions(-) diff --git a/src/lib/onboard/checkpoint-record.ts b/src/lib/onboard/checkpoint-record.ts index f271a4a0a83..a7b74b7b786 100644 --- a/src/lib/onboard/checkpoint-record.ts +++ b/src/lib/onboard/checkpoint-record.ts @@ -3,6 +3,10 @@ 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 { @@ -71,7 +75,12 @@ export function recordCheckpointMessaging( ...base, machineState: session.machine.state, updatedAt: new Date().toISOString(), - messaging: messagingPlan ? decisionSelected(messagingPlan) : decisionDeclined(), + messaging: messagingPlan + ? decisionSelected({ + selectedChannels: getActiveChannelIdsFromPlan(messagingPlan), + disabledChannels: getDisabledChannelIdsFromPlan(messagingPlan), + }) + : decisionDeclined(), }; } diff --git a/src/lib/onboard/machine/handlers/sandbox-checkpoint-crash-recovery.test.ts b/src/lib/onboard/machine/handlers/sandbox-checkpoint-crash-recovery.test.ts index d19d95b8a28..a907d475be1 100644 --- a/src/lib/onboard/machine/handlers/sandbox-checkpoint-crash-recovery.test.ts +++ b/src/lib/onboard/machine/handlers/sandbox-checkpoint-crash-recovery.test.ts @@ -362,4 +362,47 @@ describe("sandbox crash-recovery replay (#5961, #6228)", () => { expect(calls.createSandbox).not.toHaveBeenCalled(); expect(calls.error.mock.calls.flat().join("\n")).toContain("--recreate-sandbox"); }); + + it("resumes a non-interactive onboarding attempt that crashed after create succeeded but before its completion receipt (#7022)", async () => { + 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" }); + + 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(); + }); }); diff --git a/src/lib/onboard/machine/handlers/sandbox-messaging.test.ts b/src/lib/onboard/machine/handlers/sandbox-messaging.test.ts index 4ed783da655..dd6e940a231 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,6 +159,27 @@ 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(), @@ -346,4 +372,47 @@ 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]); + 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(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 e973e9c5b6a..d4e383f6dc9 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,35 @@ 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[], + envPlan: SandboxMessagingPlan | null, + 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."); + const registryPlan = options.deps.getRegistrySandboxMessagingPlan(options.sandboxName); + return selectionFromRecordedChannels([...checkpointedChannels], envPlan, registryPlan, options); +} + async function selectionFromCompletedMessagingCheckpoint( envPlan: SandboxMessagingPlan | null, options: ReconcileSandboxMessagingOptions, @@ -271,6 +307,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, envPlan, options); + } if (!durablePlan) { options.deps.clearPlanEnv(); options.deps.showMessagingStage?.(); @@ -338,11 +378,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.ts b/src/lib/onboard/machine/handlers/sandbox.test.ts index 5f13ad60767..6a17e363405 100644 --- a/src/lib/onboard/machine/handlers/sandbox.test.ts +++ b/src/lib/onboard/machine/handlers/sandbox.test.ts @@ -651,6 +651,88 @@ describe("handleSandboxState", () => { 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: false, + 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: 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: 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 5b53231ee93..8df90d03820 100644 --- a/src/lib/onboard/machine/handlers/sandbox.ts +++ b/src/lib/onboard/machine/handlers/sandbox.ts @@ -17,7 +17,11 @@ import { webSearchProviderForConfig, } from "../../../inference/web-search"; import type { SandboxMessagingPlan } from "../../../messaging/manifest"; -import { isDecisionSelected } from "../../../state/onboard-checkpoint-decision"; +import { + decisionValue, + isDecisionSelected, + isDecisionUnset, +} from "../../../state/onboard-checkpoint-decision"; import type { CheckpointEffectGroupName, CheckpointProviderBinding, @@ -830,10 +834,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, @@ -841,10 +848,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( @@ -975,13 +989,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." diff --git a/src/lib/state/onboard-checkpoint-migrate.ts b/src/lib/state/onboard-checkpoint-migrate.ts index b2d51e6c3cf..c126f51315e 100644 --- a/src/lib/state/onboard-checkpoint-migrate.ts +++ b/src/lib/state/onboard-checkpoint-migrate.ts @@ -5,7 +5,10 @@ import fs from "node:fs"; import { isObjectRecord, type JsonValue } from "../core/json-types"; import type { WebSearchConfig } from "../inference/web-search"; -import type { SandboxMessagingPlan } from "../messaging/manifest"; +import { + getActiveChannelIdsFromPlan, + getDisabledChannelIdsFromPlan, +} from "../messaging/plan-validation"; import { inspectCheckpoint } from "./onboard-checkpoint"; import { decisionFromLegacyNullable, @@ -16,6 +19,7 @@ import { CHECKPOINT_SCHEMA_VERSION, type CheckpointDecision, type CheckpointLoadResult, + type CheckpointMessagingSelection, type CheckpointResourceProfile, type CheckpointSandboxIdentity, type OnboardCheckpoint, @@ -44,11 +48,14 @@ function webSearchDecision(session: Session): CheckpointDecision { +function messagingDecision(session: Session): CheckpointDecision { return decisionFromLegacyNullable( session.sandboxPromptProgress.messaging, session.messagingPlan, - (plan) => plan, + (plan) => ({ + selectedChannels: getActiveChannelIdsFromPlan(plan), + disabledChannels: getDisabledChannelIdsFromPlan(plan), + }), ); } diff --git a/src/lib/state/onboard-checkpoint-types.ts b/src/lib/state/onboard-checkpoint-types.ts index 9e0a333d474..21af3f595e5 100644 --- a/src/lib/state/onboard-checkpoint-types.ts +++ b/src/lib/state/onboard-checkpoint-types.ts @@ -2,7 +2,6 @@ // SPDX-License-Identifier: Apache-2.0 import type { WebSearchConfig } from "../inference/web-search"; -import type { SandboxMessagingPlan } from "../messaging/manifest"; import type { OnboardMachineState } from "../onboard/machine/types"; export const CHECKPOINT_SCHEMA_VERSION = 1 as const; @@ -24,6 +23,11 @@ export interface CheckpointResourceProfile { readonly memory: string; } +export interface CheckpointMessagingSelection { + readonly selectedChannels: readonly string[]; + readonly disabledChannels: readonly string[]; +} + export type CheckpointEffectGroupName = | "web_search_provider" | "messaging_providers" @@ -53,7 +57,7 @@ export interface OnboardCheckpoint { readonly updatedAt: string; readonly sandboxIdentity: CheckpointDecision; readonly webSearch: CheckpointDecision; - readonly messaging: CheckpointDecision; + readonly messaging: CheckpointDecision; readonly resourceProfile: CheckpointDecision; readonly effectGroups: Readonly< Partial> diff --git a/src/lib/state/onboard-checkpoint.ts b/src/lib/state/onboard-checkpoint.ts index 9b35bb5c8ab..40558766034 100644 --- a/src/lib/state/onboard-checkpoint.ts +++ b/src/lib/state/onboard-checkpoint.ts @@ -3,8 +3,6 @@ import { isObjectRecord } from "../core/json-types"; import { normalizeWebSearchConfig, type WebSearchConfig } from "../inference/web-search"; -import type { SandboxMessagingPlan } from "../messaging/manifest"; -import { parseSandboxMessagingPlan } from "../messaging/plan-validation"; import { NAME_MAX_LENGTH, NAME_VALID_PATTERN } from "../name-validation"; import { isOnboardMachineState } from "../onboard/machine/transitions"; import { parseCheckpointDecision } from "./onboard-checkpoint-decision"; @@ -15,6 +13,7 @@ import { type CheckpointEffectGroupName, type CheckpointEffectGroupRecord, type CheckpointLoadResult, + type CheckpointMessagingSelection, type CheckpointProviderBinding, type CheckpointResourceProfile, type CheckpointSandboxIdentity, @@ -72,8 +71,12 @@ function parseWebSearchValue(value: unknown): WebSearchConfig | null { return normalizeWebSearchConfig(value as Partial); } -function parseMessagingValue(value: unknown): SandboxMessagingPlan | null { - return parseSandboxMessagingPlan(value); +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 { From ab9d50931ab8e220e0c873e42e53e009a5bdd7d4 Mon Sep 17 00:00:00 2001 From: Tinson Lai Date: Fri, 17 Jul 2026 16:23:19 +0000 Subject: [PATCH 11/29] fix(onboard): address CodeRabbit findings on checkpoint resume authority Signed-off-by: Tinson Lai --- .../sandbox-checkpoint-crash-recovery.test.ts | 122 ++++++++++++++---- .../handlers/sandbox-messaging.test.ts | 18 ++- .../machine/handlers/sandbox-messaging.ts | 6 +- .../onboard/machine/handlers/sandbox.test.ts | 4 +- .../state/onboard-checkpoint-migrate.test.ts | 24 +++- src/lib/state/onboard-checkpoint-migrate.ts | 9 +- 6 files changed, 144 insertions(+), 39 deletions(-) diff --git a/src/lib/onboard/machine/handlers/sandbox-checkpoint-crash-recovery.test.ts b/src/lib/onboard/machine/handlers/sandbox-checkpoint-crash-recovery.test.ts index a907d475be1..23106944d3d 100644 --- a/src/lib/onboard/machine/handlers/sandbox-checkpoint-crash-recovery.test.ts +++ b/src/lib/onboard/machine/handlers/sandbox-checkpoint-crash-recovery.test.ts @@ -3,6 +3,11 @@ import { describe, expect, it, vi } from "vitest"; +import { + type CredentialProviderRegistrationDeps, + createCredentialProviderRegistration, +} from "../../credential-provider-registration"; +import type { MessagingTokenDef } from "../../messaging-prep"; import { decisionSelected, decisionUnset } from "../../../state/onboard-checkpoint-decision"; import { CHECKPOINT_SCHEMA_VERSION, @@ -40,6 +45,78 @@ function crashedCheckpoint(overrides: Partial = {}): OnboardC }; } +type StubbedRunOpenshellResult = { status: number; stdout: string; stderr: string }; + +function fakeGatewayRunOpenshell() { + const createdProviders = new Map(); + const runOpenshell = vi.fn((args: string[]): StubbedRunOpenshellResult => { + if (args[0] === "provider" && args[1] === "get") { + const provider = createdProviders.get(args[args.length - 1]); + if (!provider) return { status: 1, stdout: "", stderr: "not found" }; + return { + status: 0, + stdout: [ + `Name: ${args[args.length - 1]}`, + `Type: ${provider.type}`, + `Credential keys: ${provider.credentialEnv}`, + "Config keys: ", + ].join("\n"), + stderr: "", + }; + } + if (args[0] === "provider" && args[1] === "create") { + createdProviders.set(args[args.indexOf("--name") + 1], { + type: args[args.indexOf("--type") + 1] ?? "generic", + credentialEnv: args[args.indexOf("--credential") + 1] ?? "", + }); + return { status: 0, stdout: "", stderr: "" }; + } + if (args[0] === "provider" && args[1] === "update") { + return { status: 0, stdout: "", stderr: "" }; + } + return { status: 0, stdout: "", stderr: "" }; + }); + 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; + return vi.fn( + async (input: { + sandboxName: string; + enabledChannels: readonly string[]; + webSearchConfig: unknown; + agent: unknown; + }) => { + const staged = await registration.stageSandboxCredentialProviders( + input as never, + async () => ({ messagingTokenDefs: tokenDefs }), + ); + if (crashPending) { + crashPending = false; + throw new Error("gateway connection dropped mid-registration"); + } + return staged; + }, + ); +} + function sessionWithCheckpoint(checkpoint: OnboardCheckpoint): Session { const session = createSession({ sessionId: "sess-1", @@ -251,24 +328,22 @@ describe("sandbox crash-recovery replay (#5961, #6228)", () => { ); }); - it("safely replays web-search provider registration after a crash between staging and its checkpoint receipt (#7022)", async () => { - const stageSandboxCredentialProviders = vi - .fn() - .mockRejectedValueOnce(new Error("gateway connection dropped mid-registration")) - .mockResolvedValueOnce([ - { name: "my-assistant-brave-search", type: "brave", credentialEnv: "BRAVE_API_KEY" }, - ]); - const providerMatchesGatewayCredential = vi.fn( - (name: string, type: string, credentialEnv: string) => - name === "my-assistant-brave-search" && - type === "brave" && - credentialEnv === "BRAVE_API_KEY", + it("safely replays web-search provider registration through the real adapter after a crash between staging and its checkpoint receipt (#7022)", async () => { + const stageSandboxCredentialProviders = realStageSandboxCredentialProviders( + [ + { + name: "my-assistant-brave-search", + envKey: "BRAVE_API_KEY", + token: "brave-secret", + providerType: "brave", + }, + ], + true, ); const { deps, getSession } = createDeps({ getSandboxReuseState: () => "missing", configureWebSearch: vi.fn(async () => ({ fetchEnabled: true as const })), stageSandboxCredentialProviders, - providerMatchesGatewayCredential, }); await expect(handleSandboxState({ ...baseOptions(deps), resume: false })).rejects.toThrow( @@ -294,29 +369,22 @@ describe("sandbox crash-recovery replay (#5961, #6228)", () => { ]); }); - it("safely replays messaging provider registration after a crash between staging and its checkpoint receipt (#7022)", async () => { - const stageSandboxCredentialProviders = vi - .fn() - .mockRejectedValueOnce(new Error("gateway connection dropped mid-registration")) - .mockResolvedValueOnce([ + it("safely replays messaging provider registration through the real adapter after a crash between staging and its checkpoint receipt (#7022)", async () => { + const stageSandboxCredentialProviders = realStageSandboxCredentialProviders( + [ { name: "my-assistant-discord-bridge", - type: "generic", - credentialEnv: "DISCORD_BOT_TOKEN", + envKey: "DISCORD_BOT_TOKEN", + token: "discord-secret", }, - ]); - const providerMatchesGatewayCredential = vi.fn( - (name: string, type: string, credentialEnv: string) => - name === "my-assistant-discord-bridge" && - type === "generic" && - credentialEnv === "DISCORD_BOT_TOKEN", + ], + true, ); const messagingPlan = makeMinimalPlan("my-assistant", "openclaw", ["discord"]); const { deps, getSession } = createDeps({ getSandboxReuseState: () => "missing", readMessagingPlanFromEnv: () => messagingPlan, stageSandboxCredentialProviders, - providerMatchesGatewayCredential, }); await expect(handleSandboxState({ ...baseOptions(deps), resume: false })).rejects.toThrow( diff --git a/src/lib/onboard/machine/handlers/sandbox-messaging.test.ts b/src/lib/onboard/machine/handlers/sandbox-messaging.test.ts index dd6e940a231..84a7912dc4a 100644 --- a/src/lib/onboard/machine/handlers/sandbox-messaging.test.ts +++ b/src/lib/onboard/machine/handlers/sandbox-messaging.test.ts @@ -185,7 +185,14 @@ function reconcileDeps(plans: readonly (SandboxMessagingPlan | null)[]) { 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) @@ -400,6 +407,9 @@ describe("reconcileSandboxMessaging completed checkpoint credentials", () => { 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({ @@ -413,6 +423,12 @@ describe("reconcileSandboxMessaging completed checkpoint credentials", () => { 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 d4e383f6dc9..6be3fa92586 100644 --- a/src/lib/onboard/machine/handlers/sandbox-messaging.ts +++ b/src/lib/onboard/machine/handlers/sandbox-messaging.ts @@ -285,7 +285,6 @@ function divergedCheckpointChannels( async function selectionFromDivergedMessagingCheckpoint( checkpointedChannels: readonly string[], - envPlan: SandboxMessagingPlan | null, options: ReconcileSandboxMessagingOptions, ): Promise { if (checkpointedChannels.length === 0) { @@ -295,8 +294,7 @@ async function selectionFromDivergedMessagingCheckpoint( return { plan: null, selectedChannels: [] }; } options.deps.note(" [resume] Reconciling messaging selection with the recorded checkpoint."); - const registryPlan = options.deps.getRegistrySandboxMessagingPlan(options.sandboxName); - return selectionFromRecordedChannels([...checkpointedChannels], envPlan, registryPlan, options); + return selectionFromMessagingSetup([...checkpointedChannels], options, true); } async function selectionFromCompletedMessagingCheckpoint( @@ -309,7 +307,7 @@ async function selectionFromCompletedMessagingCheckpoint( const durablePlan = options.session?.messagingPlan ?? null; const diverged = divergedCheckpointChannels(options.session, durablePlan); if (diverged) { - return selectionFromDivergedMessagingCheckpoint(diverged, envPlan, options); + return selectionFromDivergedMessagingCheckpoint(diverged, options); } if (!durablePlan) { options.deps.clearPlanEnv(); diff --git a/src/lib/onboard/machine/handlers/sandbox.test.ts b/src/lib/onboard/machine/handlers/sandbox.test.ts index 6a17e363405..ffdb3308a51 100644 --- a/src/lib/onboard/machine/handlers/sandbox.test.ts +++ b/src/lib/onboard/machine/handlers/sandbox.test.ts @@ -656,7 +656,7 @@ describe("handleSandboxState", () => { sandboxName: "saved", sandboxPromptProgress: { sandboxName: true, - webSearch: false, + webSearch: true, messaging: false, resourceProfile: false, }, @@ -699,7 +699,7 @@ describe("handleSandboxState", () => { sandboxName: true, webSearch: true, messaging: true, - resourceProfile: false, + resourceProfile: true, }, }); session.checkpoint = { diff --git a/src/lib/state/onboard-checkpoint-migrate.test.ts b/src/lib/state/onboard-checkpoint-migrate.test.ts index 6428c2a6575..ca3cd1732da 100644 --- a/src/lib/state/onboard-checkpoint-migrate.test.ts +++ b/src/lib/state/onboard-checkpoint-migrate.test.ts @@ -1,12 +1,15 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { describe, expect, it } from "vitest"; +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 { @@ -141,3 +144,22 @@ describe("resolveCheckpointForResume", () => { 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 index c126f51315e..79d548d8873 100644 --- a/src/lib/state/onboard-checkpoint-migrate.ts +++ b/src/lib/state/onboard-checkpoint-migrate.ts @@ -111,11 +111,12 @@ export function resolveCheckpointForResume(rawSession: unknown): CheckpointLoadR } export function loadResumeCheckpoint(): CheckpointLoadResult { + if (!fs.existsSync(SESSION_FILE)) return { status: "none" }; + let raw: unknown; try { - if (!fs.existsSync(SESSION_FILE)) return { status: "none" }; - const raw = JSON.parse(fs.readFileSync(SESSION_FILE, "utf-8")); - return resolveCheckpointForResume(raw); + raw = JSON.parse(fs.readFileSync(SESSION_FILE, "utf-8")); } catch { - return { status: "none" }; + return { status: "corrupt" }; } + return resolveCheckpointForResume(raw); } From 69c0081b995df8c65d470477b2e5f3f7f2524497 Mon Sep 17 00:00:00 2001 From: Tinson Lai Date: Fri, 17 Jul 2026 16:36:47 +0000 Subject: [PATCH 12/29] test(onboard): keep crash-recovery test bodies linear for the growth guardrail Signed-off-by: Tinson Lai --- .../sandbox-checkpoint-crash-recovery.test.ts | 75 +++++++++++-------- 1 file changed, 43 insertions(+), 32 deletions(-) diff --git a/src/lib/onboard/machine/handlers/sandbox-checkpoint-crash-recovery.test.ts b/src/lib/onboard/machine/handlers/sandbox-checkpoint-crash-recovery.test.ts index 23106944d3d..9ee68501e9f 100644 --- a/src/lib/onboard/machine/handlers/sandbox-checkpoint-crash-recovery.test.ts +++ b/src/lib/onboard/machine/handlers/sandbox-checkpoint-crash-recovery.test.ts @@ -47,35 +47,46 @@ function crashedCheckpoint(overrides: Partial = {}): OnboardC type StubbedRunOpenshellResult = { status: number; stdout: string; stderr: string }; +const OK_RESULT: StubbedRunOpenshellResult = { status: 0, stdout: "", stderr: "" }; + function fakeGatewayRunOpenshell() { const createdProviders = new Map(); - const runOpenshell = vi.fn((args: string[]): StubbedRunOpenshellResult => { - if (args[0] === "provider" && args[1] === "get") { - const provider = createdProviders.get(args[args.length - 1]); - if (!provider) return { status: 1, stdout: "", stderr: "not found" }; - return { - status: 0, - stdout: [ - `Name: ${args[args.length - 1]}`, - `Type: ${provider.type}`, - `Credential keys: ${provider.credentialEnv}`, - "Config keys: ", - ].join("\n"), - stderr: "", - }; - } - if (args[0] === "provider" && args[1] === "create") { - createdProviders.set(args[args.indexOf("--name") + 1], { - type: args[args.indexOf("--type") + 1] ?? "generic", - credentialEnv: args[args.indexOf("--credential") + 1] ?? "", - }); - return { status: 0, stdout: "", stderr: "" }; - } - if (args[0] === "provider" && args[1] === "update") { - return { status: 0, stdout: "", stderr: "" }; - } - return { status: 0, stdout: "", stderr: "" }; - }); + + 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 }; } @@ -108,11 +119,11 @@ function realStageSandboxCredentialProviders( input as never, async () => ({ messagingTokenDefs: tokenDefs }), ); - if (crashPending) { - crashPending = false; - throw new Error("gateway connection dropped mid-registration"); - } - return staged; + const shouldCrash = crashPending; + crashPending = false; + return shouldCrash + ? Promise.reject(new Error("gateway connection dropped mid-registration")) + : staged; }, ); } From c084f53408d4874f9079a40645f025e4037086d4 Mon Sep 17 00:00:00 2001 From: Tinson Lai Date: Sat, 18 Jul 2026 04:52:18 +0000 Subject: [PATCH 13/29] fix(onboard): recover the checkpointed sandbox name for non-OpenClaw resumes Signed-off-by: Tinson Lai --- src/lib/onboard/session-bootstrap.test.ts | 39 ++++++++++++++++++++++- src/lib/onboard/session-bootstrap.ts | 14 ++++++-- 2 files changed, 49 insertions(+), 4 deletions(-) diff --git a/src/lib/onboard/session-bootstrap.test.ts b/src/lib/onboard/session-bootstrap.test.ts index 85912cd072b..0b0f6108f5d 100644 --- a/src/lib/onboard/session-bootstrap.test.ts +++ b/src/lib/onboard/session-bootstrap.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it, vi } from "vitest"; -import { decisionUnset } from "../state/onboard-checkpoint-decision"; +import { decisionSelected, decisionUnset } from "../state/onboard-checkpoint-decision"; import { CHECKPOINT_SCHEMA_VERSION, type CheckpointLoadResult, @@ -344,6 +344,43 @@ 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 }); + session.checkpoint = { + 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: [] }, + }; + const { deps } = createDeps(session); + + 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", diff --git a/src/lib/onboard/session-bootstrap.ts b/src/lib/onboard/session-bootstrap.ts index 2840fed0f42..c371f8c8bc6 100644 --- a/src/lib/onboard/session-bootstrap.ts +++ b/src/lib/onboard/session-bootstrap.ts @@ -82,13 +82,16 @@ export function getCheckpointedSandboxName( agent: { name?: string } | null, session: Session | null, ): string | null { - if (!resume || (agent?.name && agent.name !== "openclaw")) return null; + if (!resume) return null; if (session?.checkpoint) { return isDecisionSelected(session.checkpoint.sandboxIdentity) ? session.checkpoint.sandboxIdentity.value.name : null; } - return session?.sandboxPromptProgress?.sandboxName === true ? session.sandboxName : null; + return (!agent?.name || agent.name === "openclaw") && + session?.sandboxPromptProgress?.sandboxName === true + ? session.sandboxName + : null; } function mode(nonInteractive: boolean): "non-interactive" | "interactive" { @@ -207,8 +210,13 @@ function assertRecoverableResumeSandboxName( : 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 || (nameRecoverable ? session?.sandboxName || null : null); + input.requestedSandboxName || + (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,", From 069f87e8ce1718f0fa201305a0125413dd8d17b2 Mon Sep 17 00:00:00 2001 From: Tinson Lai Date: Sat, 18 Jul 2026 05:15:49 +0000 Subject: [PATCH 14/29] test(state): wire the resume checkpoint resolver into the merged Station Express fixture Signed-off-by: Tinson Lai --- src/lib/state/onboard-session-station-express.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/lib/state/onboard-session-station-express.test.ts b/src/lib/state/onboard-session-station-express.test.ts index 07bcd35b908..da06002703d 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(), From 18b50f2ffd8b06f89db8efb7661719d99c2f7633 Mon Sep 17 00:00:00 2001 From: Tinson Lai Date: Sat, 18 Jul 2026 05:49:43 +0000 Subject: [PATCH 15/29] fix(onboard): revalidate checkpoint bindings at the locked destructive replay boundary Signed-off-by: Tinson Lai --- .../sandbox-checkpoint-crash-recovery.test.ts | 92 ++++++++++++++----- .../onboard/machine/handlers/sandbox.test.ts | 4 + src/lib/onboard/machine/handlers/sandbox.ts | 11 +++ 3 files changed, 85 insertions(+), 22 deletions(-) diff --git a/src/lib/onboard/machine/handlers/sandbox-checkpoint-crash-recovery.test.ts b/src/lib/onboard/machine/handlers/sandbox-checkpoint-crash-recovery.test.ts index 9ee68501e9f..82536022b37 100644 --- a/src/lib/onboard/machine/handlers/sandbox-checkpoint-crash-recovery.test.ts +++ b/src/lib/onboard/machine/handlers/sandbox-checkpoint-crash-recovery.test.ts @@ -108,7 +108,7 @@ function realStageSandboxCredentialProviders( persistMigratedLegacyKeys: vi.fn(), }); let crashPending = crashAfterFirstSuccess; - return vi.fn( + const stageSandboxCredentialProviders = vi.fn( async (input: { sandboxName: string; enabledChannels: readonly string[]; @@ -126,6 +126,10 @@ function realStageSandboxCredentialProviders( : staged; }, ); + return { + stageSandboxCredentialProviders, + providerMatchesGatewayCredential: registration.providerMatchesGatewayCredential, + }; } function sessionWithCheckpoint(checkpoint: OnboardCheckpoint): Session { @@ -340,21 +344,23 @@ describe("sandbox crash-recovery replay (#5961, #6228)", () => { }); it("safely replays web-search provider registration through the real adapter after a crash between staging and its checkpoint receipt (#7022)", async () => { - const stageSandboxCredentialProviders = realStageSandboxCredentialProviders( - [ - { - name: "my-assistant-brave-search", - envKey: "BRAVE_API_KEY", - token: "brave-secret", - providerType: "brave", - }, - ], - true, - ); + const { stageSandboxCredentialProviders, providerMatchesGatewayCredential } = + realStageSandboxCredentialProviders( + [ + { + name: "my-assistant-brave-search", + envKey: "BRAVE_API_KEY", + token: "brave-secret", + providerType: "brave", + }, + ], + true, + ); const { deps, getSession } = createDeps({ getSandboxReuseState: () => "missing", configureWebSearch: vi.fn(async () => ({ fetchEnabled: true as const })), stageSandboxCredentialProviders, + providerMatchesGatewayCredential, }); await expect(handleSandboxState({ ...baseOptions(deps), resume: false })).rejects.toThrow( @@ -381,21 +387,23 @@ describe("sandbox crash-recovery replay (#5961, #6228)", () => { }); it("safely replays messaging provider registration through the real adapter after a crash between staging and its checkpoint receipt (#7022)", async () => { - const stageSandboxCredentialProviders = realStageSandboxCredentialProviders( - [ - { - name: "my-assistant-discord-bridge", - envKey: "DISCORD_BOT_TOKEN", - token: "discord-secret", - }, - ], - true, - ); + const { stageSandboxCredentialProviders, providerMatchesGatewayCredential } = + realStageSandboxCredentialProviders( + [ + { + name: "my-assistant-discord-bridge", + envKey: "DISCORD_BOT_TOKEN", + token: "discord-secret", + }, + ], + true, + ); const messagingPlan = makeMinimalPlan("my-assistant", "openclaw", ["discord"]); const { deps, getSession } = createDeps({ getSandboxReuseState: () => "missing", readMessagingPlanFromEnv: () => messagingPlan, stageSandboxCredentialProviders, + providerMatchesGatewayCredential, }); await expect(handleSandboxState({ ...baseOptions(deps), resume: false })).rejects.toThrow( @@ -442,6 +450,46 @@ describe("sandbox crash-recovery replay (#5961, #6228)", () => { expect(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("resumes a non-interactive onboarding attempt that crashed after create succeeded but before its completion receipt (#7022)", async () => { const recordStepComplete = vi .fn() diff --git a/src/lib/onboard/machine/handlers/sandbox.test.ts b/src/lib/onboard/machine/handlers/sandbox.test.ts index ffdb3308a51..27fba83085d 100644 --- a/src/lib/onboard/machine/handlers/sandbox.test.ts +++ b/src/lib/onboard/machine/handlers/sandbox.test.ts @@ -118,6 +118,10 @@ describe("handleSandboxState", () => { 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" })), }); diff --git a/src/lib/onboard/machine/handlers/sandbox.ts b/src/lib/onboard/machine/handlers/sandbox.ts index 8df90d03820..28326a97f12 100644 --- a/src/lib/onboard/machine/handlers/sandbox.ts +++ b/src/lib/onboard/machine/handlers/sandbox.ts @@ -649,6 +649,16 @@ class SandboxStateFlow< 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( state: SandboxStepState, ): SandboxStepState { @@ -1093,6 +1103,7 @@ class SandboxStateFlow< ); const createAndRecord = async (): Promise> => { this.assertGatewayRouteCompatible(requestedSandboxName); + this.assertCheckpointBindingsStillLive(state); await this.deps.startRecordedStep("sandbox", { sandboxName: requestedSandboxName, provider: this.options.provider, From aa168a3e928f8b6124634722731daf8e88cf6b6a Mon Sep 17 00:00:00 2001 From: Tinson Lai Date: Sat, 18 Jul 2026 06:24:04 +0000 Subject: [PATCH 16/29] fix(onboard): fingerprint the full resolved package and policy input set for replay Signed-off-by: Tinson Lai --- .../sandbox-checkpoint-crash-recovery.test.ts | 32 ++++++++++++++++++- src/lib/onboard/machine/handlers/sandbox.ts | 13 +++++--- 2 files changed, 39 insertions(+), 6 deletions(-) diff --git a/src/lib/onboard/machine/handlers/sandbox-checkpoint-crash-recovery.test.ts b/src/lib/onboard/machine/handlers/sandbox-checkpoint-crash-recovery.test.ts index 82536022b37..f0c4f211bf1 100644 --- a/src/lib/onboard/machine/handlers/sandbox-checkpoint-crash-recovery.test.ts +++ b/src/lib/onboard/machine/handlers/sandbox-checkpoint-crash-recovery.test.ts @@ -24,6 +24,19 @@ vi.mock("../../messaging-channel-setup", () => ({ 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, @@ -37,7 +50,7 @@ function crashedCheckpoint(overrides: Partial = {}): OnboardC effectGroups: { sandbox_create: { completedAt: "2026-01-01T00:00:00.000Z", - fingerprint: "my-assistant|default", + fingerprint: defaultCreateFingerprint(), }, }, bindings: { credentialEnvs: [], registeredProviders: [] }, @@ -450,6 +463,23 @@ describe("sandbox crash-recovery replay (#5961, #6228)", () => { 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("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(() => { diff --git a/src/lib/onboard/machine/handlers/sandbox.ts b/src/lib/onboard/machine/handlers/sandbox.ts index 28326a97f12..abb01e0dc2e 100644 --- a/src/lib/onboard/machine/handlers/sandbox.ts +++ b/src/lib/onboard/machine/handlers/sandbox.ts @@ -597,6 +597,12 @@ class SandboxStateFlow< return [ 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("|"); } @@ -1146,7 +1152,7 @@ class SandboxStateFlow< ); // createSandbox() owns the build fingerprint. In particular, reusing an // image must not stamp it with the current version and hide build drift. - const { nemoclawVersion: builtFingerprint, ...agentRegistryFields } = + const { nemoclawVersion: _builtFingerprint, ...agentRegistryFields } = this.deps.getSandboxAgentRegistryFields(this.options.agent, !this.options.fromDockerfile); // Preserve the validated route and credential env-var name, never a credential value. this.deps.updateSandboxRegistry(sandboxName, { @@ -1176,10 +1182,7 @@ class SandboxStateFlow< recordCheckpointEffectGroup( current, "sandbox_create", - [ - typeof builtFingerprint === "string" ? builtFingerprint : sandboxName, - this.options.authoritativePolicyTier ?? "default", - ].join("|"), + this.currentSandboxCreateFingerprint(sandboxName), ); recordCheckpointEffectGroup(current, "sandbox_register", sandboxName); return current; From ae1278c21b3e0d1890179d175da8fd4f51d97e1a Mon Sep 17 00:00:00 2001 From: Tinson Lai Date: Sat, 18 Jul 2026 07:05:15 +0000 Subject: [PATCH 17/29] fix(onboard): revalidate the full resolved create intent at the locked replay boundary Signed-off-by: Tinson Lai --- .../sandbox-checkpoint-crash-recovery.test.ts | 44 +++++++++++++++++++ src/lib/onboard/machine/handlers/sandbox.ts | 31 +++++++++++-- 2 files changed, 71 insertions(+), 4 deletions(-) diff --git a/src/lib/onboard/machine/handlers/sandbox-checkpoint-crash-recovery.test.ts b/src/lib/onboard/machine/handlers/sandbox-checkpoint-crash-recovery.test.ts index f0c4f211bf1..8ee62b34b3a 100644 --- a/src/lib/onboard/machine/handlers/sandbox-checkpoint-crash-recovery.test.ts +++ b/src/lib/onboard/machine/handlers/sandbox-checkpoint-crash-recovery.test.ts @@ -480,6 +480,50 @@ describe("sandbox crash-recovery replay (#5961, #6228)", () => { expect(calls.error.mock.calls.flat().join("\n")).toContain("--recreate-sandbox"); }); + it("rejects reuse when a resolved createIntent contribution drifted 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 { 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: [], + }), + }); + + await expect( + handleSandboxState({ + ...baseOptions(resumeDeps, 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("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(() => { diff --git a/src/lib/onboard/machine/handlers/sandbox.ts b/src/lib/onboard/machine/handlers/sandbox.ts index abb01e0dc2e..4c5882dd8b2 100644 --- a/src/lib/onboard/machine/handlers/sandbox.ts +++ b/src/lib/onboard/machine/handlers/sandbox.ts @@ -567,9 +567,11 @@ class SandboxStateFlow< if (!checkpoint || !identity) return decision; const recordedFingerprint = checkpoint.effectGroups.sandbox_create?.fingerprint; + const currentLightFingerprint = this.currentSandboxCreateFingerprint(identity.name); if ( recordedFingerprint && - recordedFingerprint !== this.currentSandboxCreateFingerprint(identity.name) + recordedFingerprint !== currentLightFingerprint && + !recordedFingerprint.startsWith(`${currentLightFingerprint}|`) ) { return this.rejectDriftedCheckpointFingerprint(identity.name); } @@ -588,13 +590,16 @@ class SandboxStateFlow< : decision; } - private currentSandboxCreateFingerprint(sandboxName: string): string { + 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"; - return [ + const lightFingerprint = [ typeof builtFingerprint === "string" ? builtFingerprint : sandboxName, policyFingerprint, this.options.provider, @@ -604,6 +609,19 @@ class SandboxStateFlow< JSON.stringify(this.options.sandboxGpuConfig ?? null), [...this.options.hermesToolGateways].sort().join(","), ].join("|"); + return createIntent ? `${lightFingerprint}|${JSON.stringify(createIntent)}` : lightFingerprint; + } + + 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 { @@ -1110,6 +1128,11 @@ 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, @@ -1182,7 +1205,7 @@ class SandboxStateFlow< recordCheckpointEffectGroup( current, "sandbox_create", - this.currentSandboxCreateFingerprint(sandboxName), + this.currentSandboxCreateFingerprint(sandboxName, createIntent.resolved), ); recordCheckpointEffectGroup(current, "sandbox_register", sandboxName); return current; From 6ceafc5b10142544645859ed5d6eff5975ebd542 Mon Sep 17 00:00:00 2001 From: Tinson Lai Date: Sat, 18 Jul 2026 07:40:41 +0000 Subject: [PATCH 18/29] test(onboard): exercise real checkpoint resolution in the non-OpenClaw resume test Signed-off-by: Tinson Lai --- src/lib/onboard/session-bootstrap.test.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/lib/onboard/session-bootstrap.test.ts b/src/lib/onboard/session-bootstrap.test.ts index 8a94216a54e..91eb44d3f8e 100644 --- a/src/lib/onboard/session-bootstrap.test.ts +++ b/src/lib/onboard/session-bootstrap.test.ts @@ -7,6 +7,7 @@ import { decisionSelected, decisionUnset } from "../state/onboard-checkpoint-dec 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"; @@ -372,7 +373,7 @@ describe("prepareOnboardSession", () => { 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 }); - session.checkpoint = { + const checkpoint: OnboardCheckpoint = { schemaVersion: CHECKPOINT_SCHEMA_VERSION, sessionId: session.sessionId, machineState: "sandbox", @@ -384,7 +385,12 @@ describe("prepareOnboardSession", () => { effectGroups: {}, bindings: { credentialEnvs: [], registeredProviders: [] }, }; - const { deps } = createDeps(session); + session.checkpoint = checkpoint; + const { deps } = createDeps(session, { + resolveResumeCheckpoint: vi.fn( + (): CheckpointLoadResult => ({ status: "loaded", checkpoint }), + ), + }); const result = await prepareOnboardSession( { From 6b2a592130d2aec61101a7ce6110fcd7dcbd693f Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Sat, 18 Jul 2026 09:16:19 -0700 Subject: [PATCH 19/29] fix(onboard): backfill sandbox checkpoint receipts Signed-off-by: Carlos Villela --- .../sandbox-checkpoint-crash-recovery.test.ts | 83 +++++++++++++++++-- src/lib/onboard/machine/handlers/sandbox.ts | 40 ++++++++- 2 files changed, 117 insertions(+), 6 deletions(-) diff --git a/src/lib/onboard/machine/handlers/sandbox-checkpoint-crash-recovery.test.ts b/src/lib/onboard/machine/handlers/sandbox-checkpoint-crash-recovery.test.ts index 8ee62b34b3a..95971ea15ad 100644 --- a/src/lib/onboard/machine/handlers/sandbox-checkpoint-crash-recovery.test.ts +++ b/src/lib/onboard/machine/handlers/sandbox-checkpoint-crash-recovery.test.ts @@ -3,18 +3,18 @@ import { describe, expect, it, vi } from "vitest"; -import { - type CredentialProviderRegistrationDeps, - createCredentialProviderRegistration, -} from "../../credential-provider-registration"; -import type { MessagingTokenDef } from "../../messaging-prep"; 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 { + 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"; @@ -606,4 +606,77 @@ describe("sandbox crash-recovery replay (#5961, #6228)", () => { expect(resumeCalls.createSandbox).not.toHaveBeenCalled(); expect(resumeCalls.recordSkip).toHaveBeenCalled(); }); + + it("backfills effect receipts after a crash following sandbox registration (#7022)", async () => { + let persistedSession = createSession({ sessionId: "sess-1", agent: "openclaw" }); + let crashBeforeEffectReceipts = false; + const updateSession = vi.fn((mutator: (value: Session) => Session | void) => { + if (crashBeforeEffectReceipts) { + crashBeforeEffectReceipts = false; + throw new Error("process crashed after sandbox registration"); + } + persistedSession = mutator(persistedSession) ?? persistedSession; + return persistedSession; + }); + const recordStepComplete = vi.fn(async (_stepName: string, updates: Partial) => { + Object.assign(persistedSession, updates); + crashBeforeEffectReceipts = true; + 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.ts b/src/lib/onboard/machine/handlers/sandbox.ts index 4c5882dd8b2..daa37170113 100644 --- a/src/lib/onboard/machine/handlers/sandbox.ts +++ b/src/lib/onboard/machine/handlers/sandbox.ts @@ -798,14 +798,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); From cfdf1e8927fa768bb968f90bec913863000228c2 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Sat, 18 Jul 2026 09:18:18 -0700 Subject: [PATCH 20/29] test(onboard): match checkpoint update contract Signed-off-by: Carlos Villela --- .../handlers/sandbox-checkpoint-crash-recovery.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/lib/onboard/machine/handlers/sandbox-checkpoint-crash-recovery.test.ts b/src/lib/onboard/machine/handlers/sandbox-checkpoint-crash-recovery.test.ts index 95971ea15ad..b6f8cc9ccf0 100644 --- a/src/lib/onboard/machine/handlers/sandbox-checkpoint-crash-recovery.test.ts +++ b/src/lib/onboard/machine/handlers/sandbox-checkpoint-crash-recovery.test.ts @@ -8,7 +8,7 @@ import { CHECKPOINT_SCHEMA_VERSION, type OnboardCheckpoint, } from "../../../state/onboard-checkpoint-types"; -import { createSession, type Session } from "../../../state/onboard-session"; +import { createSession, type Session, type SessionUpdates } from "../../../state/onboard-session"; import { type CredentialProviderRegistrationDeps, createCredentialProviderRegistration, @@ -618,7 +618,7 @@ describe("sandbox crash-recovery replay (#5961, #6228)", () => { persistedSession = mutator(persistedSession) ?? persistedSession; return persistedSession; }); - const recordStepComplete = vi.fn(async (_stepName: string, updates: Partial) => { + const recordStepComplete = vi.fn(async (_stepName: string, updates: SessionUpdates) => { Object.assign(persistedSession, updates); crashBeforeEffectReceipts = true; return persistedSession; From c0dad7ed33c2143252fe55ebe86ba8ba7690001f Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Sat, 18 Jul 2026 09:26:07 -0700 Subject: [PATCH 21/29] test(onboard): keep crash recovery setup linear --- .../handlers/sandbox-checkpoint-crash-recovery.test.ts | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/src/lib/onboard/machine/handlers/sandbox-checkpoint-crash-recovery.test.ts b/src/lib/onboard/machine/handlers/sandbox-checkpoint-crash-recovery.test.ts index b6f8cc9ccf0..cd8aa858127 100644 --- a/src/lib/onboard/machine/handlers/sandbox-checkpoint-crash-recovery.test.ts +++ b/src/lib/onboard/machine/handlers/sandbox-checkpoint-crash-recovery.test.ts @@ -609,18 +609,15 @@ describe("sandbox crash-recovery replay (#5961, #6228)", () => { it("backfills effect receipts after a crash following sandbox registration (#7022)", async () => { let persistedSession = createSession({ sessionId: "sess-1", agent: "openclaw" }); - let crashBeforeEffectReceipts = false; const updateSession = vi.fn((mutator: (value: Session) => Session | void) => { - if (crashBeforeEffectReceipts) { - crashBeforeEffectReceipts = false; - throw new Error("process crashed after sandbox registration"); - } persistedSession = mutator(persistedSession) ?? persistedSession; return persistedSession; }); const recordStepComplete = vi.fn(async (_stepName: string, updates: SessionUpdates) => { Object.assign(persistedSession, updates); - crashBeforeEffectReceipts = true; + updateSession.mockImplementationOnce(() => { + throw new Error("process crashed after sandbox registration"); + }); return persistedSession; }); const firstRun = createDeps({ From 5e8654dc75e535544de9dc7d895b15b252ca6f8f Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Sat, 18 Jul 2026 09:44:31 -0700 Subject: [PATCH 22/29] fix(onboard): accept scrubbed registered credentials --- .../sandbox-checkpoint-crash-recovery.test.ts | 37 +++++++++++++++++++ src/lib/onboard/machine/handlers/sandbox.ts | 25 +++++++------ 2 files changed, 50 insertions(+), 12 deletions(-) diff --git a/src/lib/onboard/machine/handlers/sandbox-checkpoint-crash-recovery.test.ts b/src/lib/onboard/machine/handlers/sandbox-checkpoint-crash-recovery.test.ts index cd8aa858127..1fb50a7d7ea 100644 --- a/src/lib/onboard/machine/handlers/sandbox-checkpoint-crash-recovery.test.ts +++ b/src/lib/onboard/machine/handlers/sandbox-checkpoint-crash-recovery.test.ts @@ -564,6 +564,43 @@ describe("sandbox crash-recovery replay (#5961, #6228)", () => { 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("resumes a non-interactive onboarding attempt that crashed after create succeeded but before its completion receipt (#7022)", async () => { const recordStepComplete = vi .fn() diff --git a/src/lib/onboard/machine/handlers/sandbox.ts b/src/lib/onboard/machine/handlers/sandbox.ts index daa37170113..37115e59582 100644 --- a/src/lib/onboard/machine/handlers/sandbox.ts +++ b/src/lib/onboard/machine/handlers/sandbox.ts @@ -643,21 +643,22 @@ class SandboxStateFlow< 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())), - ), - liveRegisteredProviders: new Set( - checkpoint.bindings.registeredProviders - .filter((binding) => - this.deps.providerMatchesGatewayCredential( - binding.name, - binding.type, - binding.credentialEnv, - ), - ) - .map((binding) => binding.name), + [ + ...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)), }; } From 2621de767805dcdd52ba5dd5ae201d3682b9c67d Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Sat, 18 Jul 2026 10:05:49 -0700 Subject: [PATCH 23/29] docs(onboard): update live replay contract --- src/lib/onboard/lifecycle-contracts.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/onboard/lifecycle-contracts.md b/src/lib/onboard/lifecycle-contracts.md index f620b31697a..16d33e97b3d 100644 --- a/src/lib/onboard/lifecycle-contracts.md +++ b/src/lib/onboard/lifecycle-contracts.md @@ -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 | Durable identity and effect-group receipts now captured in the `checkpoint`; `planSandboxCreateReplay` reuses a surviving sandbox and never recreates under a new name. Consuming the decision at the live create executor is a follow-up (#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 | From f8837e38f67a60d75d24de8651f928a276d61638 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Sat, 18 Jul 2026 12:25:21 -0700 Subject: [PATCH 24/29] test(onboard): cover provider receipt loss by mode --- .../sandbox-checkpoint-crash-recovery.test.ts | 65 ++++++++++++------- .../machine/handlers/sandbox-test-fixtures.ts | 3 +- 2 files changed, 45 insertions(+), 23 deletions(-) diff --git a/src/lib/onboard/machine/handlers/sandbox-checkpoint-crash-recovery.test.ts b/src/lib/onboard/machine/handlers/sandbox-checkpoint-crash-recovery.test.ts index 1fb50a7d7ea..9717e38132f 100644 --- a/src/lib/onboard/machine/handlers/sandbox-checkpoint-crash-recovery.test.ts +++ b/src/lib/onboard/machine/handlers/sandbox-checkpoint-crash-recovery.test.ts @@ -142,6 +142,7 @@ function realStageSandboxCredentialProviders( return { stageSandboxCredentialProviders, providerMatchesGatewayCredential: registration.providerMatchesGatewayCredential, + runOpenshell, }; } @@ -356,8 +357,11 @@ describe("sandbox crash-recovery replay (#5961, #6228)", () => { ); }); - it("safely replays web-search provider registration through the real adapter after a crash between staging and its checkpoint receipt (#7022)", async () => { - const { stageSandboxCredentialProviders, providerMatchesGatewayCredential } = + 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( [ { @@ -369,17 +373,21 @@ describe("sandbox crash-recovery replay (#5961, #6228)", () => { ], true, ); - const { deps, getSession } = createDeps({ - getSandboxReuseState: () => "missing", - configureWebSearch: vi.fn(async () => ({ fetchEnabled: true as const })), - stageSandboxCredentialProviders, - providerMatchesGatewayCredential, - }); - - await expect(handleSandboxState({ ...baseOptions(deps), resume: false })).rejects.toThrow( - "gateway connection dropped mid-registration", + 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([]); @@ -392,6 +400,9 @@ describe("sandbox crash-recovery replay (#5961, #6228)", () => { }); 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([ @@ -399,8 +410,11 @@ describe("sandbox crash-recovery replay (#5961, #6228)", () => { ]); }); - it("safely replays messaging provider registration through the real adapter after a crash between staging and its checkpoint receipt (#7022)", async () => { - const { stageSandboxCredentialProviders, providerMatchesGatewayCredential } = + 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( [ { @@ -411,18 +425,22 @@ describe("sandbox crash-recovery replay (#5961, #6228)", () => { ], 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, - }); - - await expect(handleSandboxState({ ...baseOptions(deps), resume: false })).rejects.toThrow( - "gateway connection dropped mid-registration", + 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([]); @@ -434,6 +452,9 @@ describe("sandbox crash-recovery replay (#5961, #6228)", () => { }); 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([ diff --git a/src/lib/onboard/machine/handlers/sandbox-test-fixtures.ts b/src/lib/onboard/machine/handlers/sandbox-test-fixtures.ts index 7e1150c2a89..7bdbcdbd6b9 100644 --- a/src/lib/onboard/machine/handlers/sandbox-test-fixtures.ts +++ b/src/lib/onboard/machine/handlers/sandbox-test-fixtures.ts @@ -94,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(), From 0f2d39c11af665e5f47588ab162ffb41d9c2587c Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Sat, 18 Jul 2026 12:55:22 -0700 Subject: [PATCH 25/29] fix(onboard): scope checkpoint credential bindings Signed-off-by: Carlos Villela --- src/lib/onboard/checkpoint-record.ts | 10 +++++++--- src/lib/onboard/machine/handlers/sandbox.test.ts | 1 + src/lib/onboard/machine/handlers/sandbox.ts | 1 - src/lib/state/onboard-checkpoint-migrate.test.ts | 2 +- src/lib/state/onboard-checkpoint-migrate.ts | 5 ++++- 5 files changed, 13 insertions(+), 6 deletions(-) diff --git a/src/lib/onboard/checkpoint-record.ts b/src/lib/onboard/checkpoint-record.ts index a7b74b7b786..762898306a0 100644 --- a/src/lib/onboard/checkpoint-record.ts +++ b/src/lib/onboard/checkpoint-record.ts @@ -100,13 +100,17 @@ export function recordCheckpointResourceProfile( export function recordCheckpointBindings( session: Session, additions: { - credentialEnv?: string | null; registeredProviders?: readonly CheckpointProviderBinding[]; }, ): void { const base = baseCheckpoint(session); - const credentialEnvs = additions.credentialEnv - ? [...new Set([...base.bindings.credentialEnvs, additions.credentialEnv])] + const credentialEnvs = additions.registeredProviders + ? [ + ...new Set([ + ...base.bindings.credentialEnvs, + ...additions.registeredProviders.map((binding) => binding.credentialEnv), + ]), + ] : base.bindings.credentialEnvs; const registeredProviders = additions.registeredProviders ? [ diff --git a/src/lib/onboard/machine/handlers/sandbox.test.ts b/src/lib/onboard/machine/handlers/sandbox.test.ts index 27fba83085d..6a8de4d223d 100644 --- a/src/lib/onboard/machine/handlers/sandbox.test.ts +++ b/src/lib/onboard/machine/handlers/sandbox.test.ts @@ -130,6 +130,7 @@ describe("handleSandboxState", () => { 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" }), ); diff --git a/src/lib/onboard/machine/handlers/sandbox.ts b/src/lib/onboard/machine/handlers/sandbox.ts index 37115e59582..61152452569 100644 --- a/src/lib/onboard/machine/handlers/sandbox.ts +++ b/src/lib/onboard/machine/handlers/sandbox.ts @@ -1045,7 +1045,6 @@ class SandboxStateFlow< this.deps.note(" ✓ Registered selected credentials with OpenShell for resume."); this.deps.updateSession((current) => { recordCheckpointBindings(current, { - credentialEnv: this.options.credentialEnv, registeredProviders, }); recordCheckpointEffectGroup( diff --git a/src/lib/state/onboard-checkpoint-migrate.test.ts b/src/lib/state/onboard-checkpoint-migrate.test.ts index ca3cd1732da..81de24c5023 100644 --- a/src/lib/state/onboard-checkpoint-migrate.test.ts +++ b/src/lib/state/onboard-checkpoint-migrate.test.ts @@ -53,7 +53,7 @@ describe("deriveCheckpointFromSession", () => { expect(checkpoint.messaging).toEqual(decisionDeclined()); expect(checkpoint.resourceProfile).toEqual(decisionDeclined()); expect(checkpoint.bindings).toEqual({ - credentialEnvs: ["OPENAI_API_KEY"], + credentialEnvs: [], registeredProviders: [], }); }); diff --git a/src/lib/state/onboard-checkpoint-migrate.ts b/src/lib/state/onboard-checkpoint-migrate.ts index 79d548d8873..b8d404b8a19 100644 --- a/src/lib/state/onboard-checkpoint-migrate.ts +++ b/src/lib/state/onboard-checkpoint-migrate.ts @@ -79,7 +79,10 @@ export function deriveCheckpointFromSession(session: Session): OnboardCheckpoint resourceProfile: resourceDecision(session), effectGroups: {}, bindings: { - credentialEnvs: session.credentialEnv ? [session.credentialEnv] : [], + // 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: [], }, }; From 38fd79eb560d13c403b9134575e89b50cfa67e7d Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Sat, 18 Jul 2026 12:58:38 -0700 Subject: [PATCH 26/29] test(onboard): complete checkpoint crash matrix Signed-off-by: Carlos Villela --- .../sandbox-checkpoint-crash-recovery.test.ts | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/lib/onboard/machine/handlers/sandbox-checkpoint-crash-recovery.test.ts b/src/lib/onboard/machine/handlers/sandbox-checkpoint-crash-recovery.test.ts index 9717e38132f..9e419a7780e 100644 --- a/src/lib/onboard/machine/handlers/sandbox-checkpoint-crash-recovery.test.ts +++ b/src/lib/onboard/machine/handlers/sandbox-checkpoint-crash-recovery.test.ts @@ -622,7 +622,10 @@ describe("sandbox crash-recovery replay (#5961, #6228)", () => { expect(calls.createSandbox).toHaveBeenCalledTimes(1); }); - it("resumes a non-interactive onboarding attempt that crashed after create succeeded but before its completion receipt (#7022)", async () => { + 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")); @@ -630,7 +633,7 @@ describe("sandbox crash-recovery replay (#5961, #6228)", () => { getSandboxReuseState: () => "missing", recordStepComplete, }); - const session = createSession({ sessionId: "sess-1", agent: "openclaw" }); + const session = createSession({ sessionId: "sess-1", agent: "openclaw", mode }); await expect( handleSandboxState({ @@ -665,8 +668,11 @@ describe("sandbox crash-recovery replay (#5961, #6228)", () => { expect(resumeCalls.recordSkip).toHaveBeenCalled(); }); - it("backfills effect receipts after a crash following sandbox registration (#7022)", async () => { - let persistedSession = createSession({ sessionId: "sess-1", agent: "openclaw" }); + 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; From 728f9a767a21431489c8cb726f20f1fdcf3f7cc8 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Sat, 18 Jul 2026 13:02:06 -0700 Subject: [PATCH 27/29] docs(onboard): clarify checkpoint credential ownership --- src/lib/onboard/lifecycle-contracts.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/lib/onboard/lifecycle-contracts.md b/src/lib/onboard/lifecycle-contracts.md index 16d33e97b3d..0c2fb9f922b 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). | @@ -123,7 +123,7 @@ The schema and sanitation authority is `Session` plus `normalizeSession`/`filter | 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`, `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 bindings, serialized alongside the session under its own `schemaVersion` with fail-closed handling of an unknown future version. | +| 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. | From 78a19cffc4d5cce87b90c5f73615c7eb9ec4a11d Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Sat, 18 Jul 2026 13:24:38 -0700 Subject: [PATCH 28/29] fix(onboard): reconcile extra providers on resume --- .../sandbox-checkpoint-crash-recovery.test.ts | 45 ++++++++++++++++--- src/lib/onboard/machine/handlers/sandbox.ts | 12 ++++- 2 files changed, 51 insertions(+), 6 deletions(-) diff --git a/src/lib/onboard/machine/handlers/sandbox-checkpoint-crash-recovery.test.ts b/src/lib/onboard/machine/handlers/sandbox-checkpoint-crash-recovery.test.ts index 9e419a7780e..14428338f99 100644 --- a/src/lib/onboard/machine/handlers/sandbox-checkpoint-crash-recovery.test.ts +++ b/src/lib/onboard/machine/handlers/sandbox-checkpoint-crash-recovery.test.ts @@ -501,7 +501,7 @@ describe("sandbox crash-recovery replay (#5961, #6228)", () => { expect(calls.error.mock.calls.flat().join("\n")).toContain("--recreate-sandbox"); }); - it("rejects reuse when a resolved createIntent contribution drifted despite an unchanged light fingerprint (#7022)", async () => { + 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); @@ -529,20 +529,55 @@ describe("sandbox crash-recovery replay (#5961, #6228)", () => { updateSession, planRegisteredExtraProviders: () => ({ extraProviders: ["provider-b"], - staleExtraProviders: [], + 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(); + if (!defaultResolve) throw new Error("default create-intent resolver is unavailable"); + 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(resumeDeps, session), + ...baseOptions(resumedRun.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"); + 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 () => { diff --git a/src/lib/onboard/machine/handlers/sandbox.ts b/src/lib/onboard/machine/handlers/sandbox.ts index 61152452569..df33d057093 100644 --- a/src/lib/onboard/machine/handlers/sandbox.ts +++ b/src/lib/onboard/machine/handlers/sandbox.ts @@ -609,7 +609,17 @@ class SandboxStateFlow< JSON.stringify(this.options.sandboxGpuConfig ?? null), [...this.options.hermesToolGateways].sort().join(","), ].join("|"); - return createIntent ? `${lightFingerprint}|${JSON.stringify(createIntent)}` : lightFingerprint; + 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( From e267a742438eef5d6703608f30ae2b273742d8f1 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Sat, 18 Jul 2026 13:31:10 -0700 Subject: [PATCH 29/29] test(onboard): keep checkpoint setup linear Signed-off-by: Carlos Villela --- .../handlers/sandbox-checkpoint-crash-recovery.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/lib/onboard/machine/handlers/sandbox-checkpoint-crash-recovery.test.ts b/src/lib/onboard/machine/handlers/sandbox-checkpoint-crash-recovery.test.ts index 14428338f99..4d15d0fa1cc 100644 --- a/src/lib/onboard/machine/handlers/sandbox-checkpoint-crash-recovery.test.ts +++ b/src/lib/onboard/machine/handlers/sandbox-checkpoint-crash-recovery.test.ts @@ -559,9 +559,9 @@ describe("sandbox crash-recovery replay (#5961, #6228)", () => { const resumedRun = createDeps({ getSandboxReuseState: () => "missing", updateSession }); const defaultResolve = resumedRun.calls.resolveCreateIntent.getMockImplementation(); - if (!defaultResolve) throw new Error("default create-intent resolver is unavailable"); + expect(defaultResolve).toBeDefined(); resumedRun.calls.resolveCreateIntent.mockImplementation(async (input) => { - const resolved = await defaultResolve(input); + const resolved = await defaultResolve!(input); return { ...resolved, policy: { ...resolved.policy, basePolicyPath: "/repo/changed-policy.yaml" },