diff --git a/src/lib/onboard/lifecycle-contracts.md b/src/lib/onboard/lifecycle-contracts.md index 43ce8193712..f8e1c1dcd5c 100644 --- a/src/lib/onboard/lifecycle-contracts.md +++ b/src/lib/onboard/lifecycle-contracts.md @@ -240,7 +240,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 helpers record step-progress bookkeeping and context updates accepted by `filterSafeUpdates`. `OnboardRuntime` owns machine transitions, terminal state, and machine events. Explicit session recovery and the process-exit failure backstop are separate recovery boundaries. 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. Provider-effect replay requires the receipt provider set to match the providers selected by the current web search configuration or messaging plan. Each persisted and live provider name, provider type, and credential key must match before the handler skips registration. After a successful replay, the handler replaces obsolete bindings owned by that effect group before sandbox creation and preserves bindings owned by the other provider effect group. 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. | +| Progress and recovery | `lastStepStarted`, `lastCompletedStep`, `failure`, `steps`, `machine`, `sandboxPromptProgress`, `stagedCredentialProviders`, `checkpoint` | Step helpers record step-progress bookkeeping and context updates accepted by `filterSafeUpdates`. `OnboardRuntime` owns machine transitions, terminal state, and machine events. Explicit session recovery and the process-exit failure backstop are separate recovery boundaries. 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 recreate journal handed to this run by the driver that owns the replacement — matching sandbox name and target-intent fingerprint, and past the delete boundary at `deleted` — is the equivalent ownership proof for a replacement that reset the session and can no longer read a host credential, and it stays paired with the same exact live binding check. A journal merely resident in the session is not that proof, because nothing binds it to this run: one survives a failed attempt, and one is opened straight at `deleted` when the sandbox is already missing. Provider-effect replay requires the receipt provider set to match the providers selected by the current web search configuration or messaging plan. Each persisted and live provider name, provider type, and credential key must match before the handler skips registration. After a successful replay, the handler replaces obsolete bindings owned by that effect group before sandbox creation and preserves bindings owned by the other provider effect group. 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. | diff --git a/src/lib/onboard/machine/handlers/sandbox-rebuild-web-search-reuse.test.ts b/src/lib/onboard/machine/handlers/sandbox-rebuild-web-search-reuse.test.ts new file mode 100644 index 00000000000..a608079157a --- /dev/null +++ b/src/lib/onboard/machine/handlers/sandbox-rebuild-web-search-reuse.test.ts @@ -0,0 +1,236 @@ +// 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 CheckpointSandboxRecreatePhase, + type CheckpointSandboxRecreateTransaction, + 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([]); + +const SANDBOX_NAME = "brave-rebuild"; +const PROVIDER_NAME = `${SANDBOX_NAME}-brave-search`; +const AT = "2026-01-01T00:00:00.000Z"; +const TARGET_INTENT_FINGERPRINT = "target-intent"; + +function recreateTransaction( + overrides: Partial = {}, +): CheckpointSandboxRecreateTransaction { + return { + version: 1, + id: "recreate-1", + revision: 1, + sandboxName: SANDBOX_NAME, + gatewayName: "nemoclaw", + gatewayPort: 8080, + sourceRegistryFingerprint: "source-registry", + sourceLiveIdentityFingerprint: null, + sourceWorkload: null, + targetIntentFingerprint: TARGET_INTENT_FINGERPRINT, + targetGeneration: "target-generation", + targetLiveIdentityFingerprint: null, + phase: "deleted", + startedAt: AT, + updatedAt: AT, + ...overrides, + }; +} + +function rebuiltCheckpoint( + sandboxRecreate: CheckpointSandboxRecreateTransaction | null, +): OnboardCheckpoint { + return { + schemaVersion: CHECKPOINT_SCHEMA_VERSION, + sessionId: "sess-1", + machineState: "sandbox", + updatedAt: AT, + sandboxIdentity: decisionSelected({ + name: SANDBOX_NAME, + agent: "openclaw", + }), + webSearch: decisionUnset(), + messaging: decisionUnset(), + resourceProfile: decisionUnset(), + gatewayAuthority: decisionSelected({ + gatewayName: "nemoclaw", + gatewayPort: 8080, + mode: "nemoclaw-managed", + source: "standalone", + endpoint: null, + stateDir: null, + supervisor: null, + requiredCapabilities: [], + }), + effectGroups: {}, + bindings: { credentialEnvs: [], registeredProviders: [] }, + sandboxRecreate, + }; +} + +/** + * The session `rebuild` hands to `onboard --resume`: it resets the session and + * derives a fresh checkpoint after destroying the sandbox, so no staged + * credential receipt survives and the recreate journal is the only ownership + * record left (#8717). + */ +function rebuiltSession( + sandboxRecreate: CheckpointSandboxRecreateTransaction | null, + stagedCredentialProviders: string[] = [], +): Session { + const session = createSession({ + sessionId: "sess-1", + agent: "openclaw", + sandboxName: SANDBOX_NAME, + stagedCredentialProviders, + }); + session.checkpoint = rebuiltCheckpoint(sandboxRecreate); + return session; +} + +function recreateWebSearch( + session: Session, + overrides: { + env?: NodeJS.ProcessEnv; + recreateJournalTargetIntentFingerprint?: string | null; + providerMatchesGatewayCredential?: ( + name: string, + type: string, + credentialEnv: string, + ) => boolean; + } = {}, +) { + const { deps, calls } = createDeps( + { + getSandboxReuseState: () => "missing", + providerMatchesGatewayCredential: + overrides.providerMatchesGatewayCredential ?? + ((name, type, credentialEnv) => + name === PROVIDER_NAME && type === "brave" && credentialEnv === "BRAVE_API_KEY"), + }, + session, + ); + const run = handleSandboxState({ + ...baseOptions(deps, session), + resume: true, + sandboxName: SANDBOX_NAME, + webSearchConfig: { fetchEnabled: true, provider: "brave" }, + env: overrides.env ?? {}, + // `rebuild` hands the journaled fingerprint to the recreate it drives. + recreateJournalTargetIntentFingerprint: + overrides.recreateJournalTargetIntentFingerprint === undefined + ? TARGET_INTENT_FINGERPRINT + : overrides.recreateJournalTargetIntentFingerprint, + }); + return { run, calls }; +} + +describe("rebuild web-search credential reuse", () => { + it("reuses the registered gateway credential when the recreate journal owns the deleted sandbox (#8717)", async () => { + const { run, calls } = recreateWebSearch(rebuiltSession(recreateTransaction())); + + await run; + + expect(calls.validateBrave).not.toHaveBeenCalled(); + expect(calls.note).toHaveBeenCalledWith( + " [resume] Reusing Brave Search credential registered with OpenShell.", + ); + expect(calls.createSandbox).toHaveBeenCalledTimes(1); + }); + + it.each([ + "planned", + "deleting", + ])("revalidates while the recreate journal is still at phase %s", async (phase) => { + const { run, calls } = recreateWebSearch(rebuiltSession(recreateTransaction({ phase }))); + + await run; + + expect(calls.validateBrave).toHaveBeenCalledTimes(1); + }); + + it("never reuses on a recreate journal that names a different sandbox", async () => { + const { run, calls } = recreateWebSearch( + rebuiltSession(recreateTransaction({ sandboxName: "other-sandbox" })), + ); + + await expect(run).rejects.toThrow("has a different recreate transaction in progress"); + + expect(calls.validateBrave).toHaveBeenCalledTimes(1); + expect(calls.createSandbox).not.toHaveBeenCalled(); + }); + + it("revalidates when no recreate journal and no staged receipt vouch for the provider", async () => { + const { run, calls } = recreateWebSearch(rebuiltSession(null)); + + await run; + + expect(calls.validateBrave).toHaveBeenCalledTimes(1); + }); + + it("never lets journal ownership alone stand in for a matching gateway binding", async () => { + const { run, calls } = recreateWebSearch(rebuiltSession(recreateTransaction()), { + providerMatchesGatewayCredential: () => false, + }); + + await expect(run).rejects.toThrow("exit 1"); + + expect(calls.validateBrave).toHaveBeenCalledTimes(1); + expect(calls.error).toHaveBeenCalledWith( + " OpenShell did not retain the selected credential bindings.", + ); + }); + + it("never reuses on a resident journal that this run was not handed", async () => { + const { run, calls } = recreateWebSearch(rebuiltSession(recreateTransaction()), { + recreateJournalTargetIntentFingerprint: null, + }); + + await expect(run).rejects.toThrow("has a different recreate transaction in progress"); + + expect(calls.validateBrave).toHaveBeenCalledTimes(1); + expect(calls.createSandbox).not.toHaveBeenCalled(); + }); + + it("never reuses on a handed-off journal whose target intent no longer matches", async () => { + const { run, calls } = recreateWebSearch( + rebuiltSession(recreateTransaction({ targetIntentFingerprint: "stale-intent" })), + ); + + await expect(run).rejects.toThrow("has a different recreate transaction in progress"); + + expect(calls.validateBrave).toHaveBeenCalledTimes(1); + expect(calls.createSandbox).not.toHaveBeenCalled(); + }); + + it("lets a host credential beat every reuse path", async () => { + const { run, calls } = recreateWebSearch(rebuiltSession(recreateTransaction()), { + env: { BRAVE_API_KEY: "host-key" }, + }); + + await run; + + expect(calls.validateBrave).toHaveBeenCalledTimes(1); + }); + + it("keeps reusing a staged receipt without any recreate journal", async () => { + const { run, calls } = recreateWebSearch(rebuiltSession(null, [PROVIDER_NAME])); + + await run; + + expect(calls.validateBrave).not.toHaveBeenCalled(); + expect(calls.createSandbox).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/lib/onboard/machine/handlers/sandbox.ts b/src/lib/onboard/machine/handlers/sandbox.ts index 7e4f8cec14e..9f56af7edcd 100644 --- a/src/lib/onboard/machine/handlers/sandbox.ts +++ b/src/lib/onboard/machine/handlers/sandbox.ts @@ -92,6 +92,7 @@ import { type ReplacedSandboxWorkloadCleanupResult, retireReplacedSandboxWorkload as retireReplacedSandboxWorkloadDefault, type SandboxRecreateObservation, + sandboxRecreatePhaseReached, sandboxRecreateSourceWorkloadEntry, selectedGatewayForSandboxRecreate, } from "../../sandbox-recreate-transaction"; @@ -1106,6 +1107,46 @@ class SandboxStateFlow< } } + /** + * Durable-ownership evidence that lets recreate reuse the web-search + * credential already registered with this sandbox's OpenShell gateway + * provider instead of revalidating a host credential. + * + * A staged receipt proves this session registered the provider itself, which + * is the evidence an interrupted onboard resumes against. `rebuild` can never + * present one: it resets the session and derives a fresh checkpoint before it + * calls `onboard --resume`, so `stagedCredentialProviders` is empty by the + * time recreate resolves web search. The recreate journal it hands off carries + * that ownership claim instead — the same claim the rebuild preflight + * (`canReuseGatewayWebSearchCredential`) and `messaging-prep` already accept + * for this binding (#7097). + * + * A journal merely resident in the session is not enough, because nothing + * binds it to this run: one survives a failed attempt, and + * `beginSandboxRecreateTransaction` opens one straight at `deleted` when the + * sandbox is already missing. What is checked is therefore that the journal + * was handed to this run by the driver that owns the replacement, names this + * sandbox, and has passed the delete boundary — the state in which the source + * is gone and no host key can be read. Both forms stay paired with the exact + * live gateway binding check, so neither can reuse a provider bound to + * anything but this sandbox (#8717). + */ + private ownsGatewayWebSearchProvider( + state: SandboxStepState, + providerName: string, + ): boolean { + if (state.session?.stagedCredentialProviders.includes(providerName)) return true; + const handoff = this.options.recreateJournalTargetIntentFingerprint; + const recreate = state.session?.checkpoint?.sandboxRecreate; + return Boolean( + handoff && + recreate && + recreate.sandboxName === state.sandboxName && + recreate.targetIntentFingerprint === handoff && + sandboxRecreatePhaseReached(recreate.phase, "deleted"), + ); + } + private async resolveWebSearchForCreation( state: SandboxStepState, ): Promise { @@ -1121,9 +1162,7 @@ class SandboxStateFlow< this.options.resume && state.sandboxName && !localCredential && - state.session?.stagedCredentialProviders.includes( - `${state.sandboxName}-${provider}-search`, - ) && + this.ownsGatewayWebSearchProvider(state, `${state.sandboxName}-${provider}-search`) && this.deps.providerMatchesGatewayCredential( `${state.sandboxName}-${provider}-search`, provider,