diff --git a/ci/source-architecture-budget.json b/ci/source-architecture-budget.json index c6d2f0527af..7394b419845 100644 --- a/ci/source-architecture-budget.json +++ b/ci/source-architecture-budget.json @@ -56,7 +56,7 @@ }, "allowedCycles": [], "maxRootFiles": { - "src/lib/onboard": 309, + "src/lib/onboard": 308, "src/lib/actions": 19, "src/lib/actions/sandbox": 183, "src/lib/state": 38, diff --git a/ci/test-file-size-budget.json b/ci/test-file-size-budget.json index b93d7dab2c6..0637753b3e3 100644 --- a/ci/test-file-size-budget.json +++ b/ci/test-file-size-budget.json @@ -8,7 +8,7 @@ "test/generate-openclaw-config.test.ts": 1915, "test/install-preflight.test.ts": 3310, "test/nemoclaw-start.test.ts": 4790, - "test/onboard-messaging.test.ts": 2035, + "test/onboard-messaging.test.ts": 2033, "test/onboard-selection.test.ts": 4178 } } diff --git a/src/lib/actions/sandbox/snapshot-restore-test-fixture.ts b/src/lib/actions/sandbox/snapshot-restore-test-fixture.ts index 6e5b401f159..500b1dc4e6e 100644 --- a/src/lib/actions/sandbox/snapshot-restore-test-fixture.ts +++ b/src/lib/actions/sandbox/snapshot-restore-test-fixture.ts @@ -74,10 +74,19 @@ export function openshellResponses( args: string[], responses: Record, ): OpenshellCaptureResult { - const result = responses[`${args[0] ?? ""} ${args[1] ?? ""}`] ?? { - status: 0, - output: "", - }; + const command = `${args[0] ?? ""} ${args[1] ?? ""}`; + const sandboxName = String(args.at(-1) ?? "sandbox"); + const result = + responses[command] ?? + (command === "sandbox get" + ? { + status: 0, + output: `Name: ${sandboxName}\nId: ${sandboxName}-live-id\nPhase: Ready\n`, + } + : { + status: 0, + output: "", + }); return captureOpenshellStreams(args, result); } diff --git a/src/lib/actions/sandbox/snapshot.ts b/src/lib/actions/sandbox/snapshot.ts index 362d16a6ee8..4024b81d077 100644 --- a/src/lib/actions/sandbox/snapshot.ts +++ b/src/lib/actions/sandbox/snapshot.ts @@ -86,6 +86,8 @@ import { } from "./sandbox-gateway-routing"; import { backupSandboxStateWithManagedAuthority, + createSnapshotCloneLifecycle, + fingerprintSandboxLiveIdentity, confirmSandboxRuntimeRestore, type PreparedSandboxRuntimeRestore, prepareManagedSnapshotProfileRestore, @@ -398,6 +400,26 @@ async function autoCreateSandboxFromSource( dashboardEnvArgs: readonly string[], dstHermesApiPort: number | null, ): Promise { + const cloneLifecycle = createSnapshotCloneLifecycle( + dstName, + sourceGatewayName, + (sandboxName, gatewayName) => { + const get = captureOpenshell(["sandbox", "get", "-g", gatewayName, sandboxName], { + ignoreError: true, + }); + const list = captureOpenshell(["sandbox", "list", "-g", gatewayName], { + ignoreError: true, + }); + return { + state: + get.status === 0 && list.status === 0 && isSandboxReady(list.output || "", sandboxName) + ? ("ready" as const) + : ("not_ready" as const), + liveIdentityFingerprint: + get.status === 0 ? fingerprintSandboxLiveIdentity(get.output || "") : null, + }; + }, + ); const openshellBin = getOpenshellBinary(); const sourceObservabilityEnabled = (srcEntry as { observabilityEnabled?: boolean }).observabilityEnabled === true; @@ -433,7 +455,7 @@ async function autoCreateSandboxFromSource( initialPhase: "create", // Wait until the sandbox actually reaches Ready state, not just appears in the list. readyCheck: () => { - const list = captureOpenshell(["sandbox", "list"], { + const list = captureOpenshell(["sandbox", "list", "-g", sourceGatewayName], { ignoreError: true, }); if (list.status !== 0) return false; @@ -449,11 +471,14 @@ async function autoCreateSandboxFromSource( } // Double-check Ready after stream exit. - const verify = captureOpenshell(["sandbox", "list"], { ignoreError: true }); + const verify = captureOpenshell(["sandbox", "list", "-g", sourceGatewayName], { + ignoreError: true, + }); if (verify.status !== 0 || !isSandboxReady(verify.output || "", dstName)) { console.error(` Sandbox '${dstName}' did not reach Ready state after create.`); snapshotExit(1); } + const lifecycleRegistration = cloneLifecycle.capture(); // DNS proxy is only meaningful for the kubernetes driver (matches onboard.ts). const dnsScript = path.join(ROOT, "scripts", "setup-dns-proxy.sh"); @@ -496,6 +521,7 @@ async function autoCreateSandboxFromSource( // stop/start, recovery, and later snapshots can address its gateway. gatewayName: sourceGatewayName, gatewayPort: sourceGatewayPort, + ...cloneLifecycle.revalidate(lifecycleRegistration), }); const sourceAgent = (srcEntry as SandboxEntry).agent || "openclaw"; diff --git a/src/lib/actions/sandbox/snapshot/clone-lifecycle.ts b/src/lib/actions/sandbox/snapshot/clone-lifecycle.ts new file mode 100644 index 00000000000..fa300abec61 --- /dev/null +++ b/src/lib/actions/sandbox/snapshot/clone-lifecycle.ts @@ -0,0 +1,33 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { randomUUID } from "node:crypto"; + +import { + captureCreatedSandboxLifecycleRegistration, + fingerprintSandboxLiveIdentity, + revalidateCreatedSandboxLifecycleRegistration, + type SandboxRecreateObservation, +} from "../../../onboard/sandbox-recreate-transaction"; + +export { fingerprintSandboxLiveIdentity }; + +export function createSnapshotCloneLifecycle( + sandboxName: string, + gatewayName: string, + observe: (sandboxName: string, gatewayName: string) => SandboxRecreateObservation, +) { + const lifecycleGeneration = randomUUID(); + const target = { sandboxName, gatewayName }; + return { + capture: () => + captureCreatedSandboxLifecycleRegistration( + target, + lifecycleGeneration, + { lifecycleGeneration }, + observe, + ), + revalidate: (registration: ReturnType) => + revalidateCreatedSandboxLifecycleRegistration(target, registration, observe), + }; +} diff --git a/src/lib/actions/sandbox/snapshot/dependencies.ts b/src/lib/actions/sandbox/snapshot/dependencies.ts index 957d60dd625..ff9050b2209 100644 --- a/src/lib/actions/sandbox/snapshot/dependencies.ts +++ b/src/lib/actions/sandbox/snapshot/dependencies.ts @@ -12,6 +12,7 @@ export type { PrepareManagedWorkloadCloneHandoffInput, } from "../../../onboard/workload/clone"; export { backupSandboxStateWithManagedAuthority } from "./backup-authority"; +export { createSnapshotCloneLifecycle, fingerprintSandboxLiveIdentity } from "./clone-lifecycle"; export type { ManagedCloneProviderBinding, ManagedCloneProviderCleanupResult, diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 168fb755ee2..1dc9a84ea12 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -2485,6 +2485,7 @@ async function createSandboxWithBaseImageResolution( request: managedStartupRootApplyRequest, intendedWorkloadArgv: intendedSandboxStartupCommand, }); + const createdSandboxLifecycle = sandboxRecreateTransaction.createCreatedSandboxLifecycle(recreateRuntime, { sandboxName, gatewayName: GATEWAY_NAME }, getSandboxRecreateObservation); const { createResult, runtimePatch, @@ -2506,7 +2507,7 @@ async function createSandboxWithBaseImageResolution( createArgv, sandboxEnv, sandboxStartupCommand, - lifecycleGeneration: recreateRuntime.targetGeneration, + lifecycleGeneration: createdSandboxLifecycle.generation, prebuild, restoreBackupPath, terminalAgent: agentDefs.isTerminalAgent(agent), @@ -2578,7 +2579,7 @@ async function createSandboxWithBaseImageResolution( resolveSandboxImageTagFromCreateOutput, }); const sandboxRuntimeFields = getSandboxRuntimeRegistryFields(effectiveSandboxGpuConfig); - recreateRuntime.recordCreated(); + const pinnedLifecycleRegistration = createdSandboxLifecycle.capture(lifecycleRegistrationFields); finalizeCreatedSandbox( { sandboxName, @@ -2602,8 +2603,7 @@ async function createSandboxWithBaseImageResolution( note, error: console.error, exitProcess: (code) => process.exit(code), - register: (openclawImagePluginInstalls) => - sandboxRegistration.registerCreatedSandbox({ + register: (openclawImagePluginInstalls) => sandboxRegistration.registerCreatedSandbox({ sandboxName, inferenceSelection: sandboxRegistration.selection(sandboxName, provider, model, preferredInferenceApi, createIntent?.endpointSource ?? null), runtimeFields: sandboxRuntimeFields, @@ -2624,8 +2624,7 @@ async function createSandboxWithBaseImageResolution( hermesDashboardState: finalHermesDashboardState, hermesApiPort: hermesApiPortReservationScope.effectivePort, dashboardPort: actualDashboardPort, - ...lifecycleRegistrationFields, - ...recreateRuntime.registrationFields, + ...createdSandboxLifecycle.revalidate(pinnedLifecycleRegistration), gatewayName: GATEWAY_NAME, gatewayPort: GATEWAY_PORT, hostMounts: resolvedCreateIntent.hostMounts, diff --git a/src/lib/onboard/onboard-recreate-journal.test.ts b/src/lib/onboard/onboard-recreate-journal.test.ts index 04a218be278..f25603a7d2b 100644 --- a/src/lib/onboard/onboard-recreate-journal.test.ts +++ b/src/lib/onboard/onboard-recreate-journal.test.ts @@ -20,6 +20,7 @@ vi.mock("./gateway-teardown-authority", () => ({ import type { Session } from "../state/onboard-session"; import * as onboardSession from "../state/onboard-session"; import * as registry from "../state/registry"; +import { fingerprintSandboxRecreateValue } from "./sandbox-recreate-transaction"; import { fingerprintOnboardRecreateTargetIntent, type OnboardRecreateTargetIntent, @@ -74,6 +75,8 @@ describe("non-resumed replacement target fingerprint (#7735)", () => { }); const SANDBOX_ID = "sbx-71c9a4e08b"; +const SANDBOX_FINGERPRINT = fingerprintSandboxRecreateValue(SANDBOX_ID); +const REPLACEMENT_FINGERPRINT = fingerprintSandboxRecreateValue("sbx-2f80d5a613"); const NON_DEFAULT_TARGET = { sandboxName: "alpha", @@ -219,7 +222,7 @@ describe("non-resumed onboard replacement journal (#7735)", () => { runtime.confirmDeleted(); runtime.advance("creating"); mocks.captureOpenshell.mockReturnValue(livePresentProbe()); - runtime.recordCreated(); + runtime.recordCreated({ state: "ready", liveIdentityFingerprint: SANDBOX_FINGERPRINT }); runtime.complete(); @@ -233,7 +236,7 @@ describe("non-resumed onboard replacement journal (#7735)", () => { first.confirmDeleted(); first.advance("creating"); mocks.captureOpenshell.mockReturnValue(replacementProbe()); - first.recordCreated(); + first.recordCreated({ state: "ready", liveIdentityFingerprint: REPLACEMENT_FINGERPRINT }); first.advance("registry_committing"); vi.spyOn(registry, "getSandbox").mockReturnValue({ name: "alpha", @@ -257,7 +260,7 @@ describe("non-resumed onboard replacement journal (#7735)", () => { first.confirmDeleted(); first.advance("creating"); mocks.captureOpenshell.mockReturnValue(replacementProbe()); - first.recordCreated(); + first.recordCreated({ state: "ready", liveIdentityFingerprint: REPLACEMENT_FINGERPRINT }); first.advance("completed"); vi.spyOn(registry, "getSandbox").mockReturnValue({ name: "alpha", diff --git a/src/lib/onboard/sandbox-gpu-create-flow.test.ts b/src/lib/onboard/sandbox-gpu-create-flow.test.ts index 922afe3e17b..f640f20ce7f 100644 --- a/src/lib/onboard/sandbox-gpu-create-flow.test.ts +++ b/src/lib/onboard/sandbox-gpu-create-flow.test.ts @@ -707,7 +707,7 @@ describe("runSandboxGpuCreateFlow native failure and readiness", () => { expect(mocks.enforceDockerGpuPatchPreserveNetwork).not.toHaveBeenCalled(); }); - it("configures the portable lifecycle after sandbox creation succeeds (#8441)", async () => { + it("uses the provided lifecycle generation for portable setup and registration (#8942)", async () => { const input = createInput(); input.lifecycleGeneration = "current-generation"; const deps = createDeps(); @@ -730,6 +730,25 @@ describe("runSandboxGpuCreateFlow native failure and readiness", () => { ); }); + it("preserves the provided lifecycle generation when portable setup is unavailable (#8942)", async () => { + const input = createInput(); + input.lifecycleGeneration = "fresh-generation"; + const deps = createDeps(); + deps.installPortableDemoLifecycle = vi.fn(() => null); + + const result = await runSandboxGpuCreateFlow(input, deps); + + expect(result.lifecycleRegistrationFields).toEqual({ + lifecycleGeneration: "fresh-generation", + }); + expect(deps.installPortableDemoLifecycle).toHaveBeenCalledWith( + input.sandboxName, + input.sandboxStartupCommand, + process.env, + { registryGeneration: "fresh-generation" }, + ); + }); + it("keeps a created sandbox when portable lifecycle setup fails (#8441)", async () => { const deps = createDeps(); deps.installPortableDemoLifecycle = vi.fn(() => { diff --git a/src/lib/onboard/sandbox-recreate-transaction.test.ts b/src/lib/onboard/sandbox-recreate-transaction.test.ts index 6e2727a9049..c7bc3ac63bb 100644 --- a/src/lib/onboard/sandbox-recreate-transaction.test.ts +++ b/src/lib/onboard/sandbox-recreate-transaction.test.ts @@ -23,18 +23,22 @@ import { advanceSandboxRecreateTransaction, assertSandboxRecreateSourceProof, beginSandboxRecreateTransaction, + captureCreatedSandboxLifecycleRegistration, clearCompletedSandboxRecreateTransaction, + createCreatedSandboxLifecycle, createSandboxRecreateRuntime, fingerprintSandboxLiveIdentity, fingerprintSandboxRecreateValue, fingerprintSandboxRegistryEntry, matchingSandboxRecreateTransaction, planSandboxRecreateRecovery, + revalidateCreatedSandboxLifecycleRegistration, retireReplacedSandboxWorkload, type SandboxRecreateObservation, SandboxRecreateSourceMismatchError, sandboxRecreateSourceProof, sandboxRecreateSourceWorkloadEntry, + selectCreatedSandboxLifecycleRegistration, selectedGatewayForSandboxRecreate, } from "./sandbox-recreate-transaction"; import { nativeArtifactWorkloadReceiptFixture } from "./workload/native-artifact-test-fixture"; @@ -49,6 +53,7 @@ const TARGET_INTENT = fingerprintSandboxRecreateValue({ agent: "openclaw", provider: "nvidia", }); +const CREATED_TARGET = { sandboxName: "alpha", gatewayName: "owner-gateway" }; const SOURCE_ENTRY: SandboxEntry = { name: "alpha", agent: "openclaw", @@ -111,6 +116,48 @@ function transactionAt( }; } +function creatingLifecycleFixture() { + const session = createSession({ sandboxName: "alpha" }); + beginSandboxRecreateTransaction( + session, + beginInput({ state: "ready", liveIdentityFingerprint: SOURCE_ID }), + ); + let observation: SandboxRecreateObservation = { + state: "ready", + liveIdentityFingerprint: SOURCE_ID, + }; + const runtime = createSandboxRecreateRuntime( + { + loadSession: () => session, + updateSession: (mutator) => { + mutator(session); + return session; + }, + }, + { + id: TX_ID, + targetGeneration: TARGET_GENERATION, + targetIntentFingerprint: TARGET_INTENT, + }, + "alpha", + "nemoclaw-31818", + SOURCE_ENTRY, + () => observation, + () => undefined, + ); + runtime.advance("deleting"); + observation = { state: "missing", liveIdentityFingerprint: null }; + runtime.confirmDeleted(); + runtime.advance("creating"); + return { + lifecycle: createCreatedSandboxLifecycle(runtime, CREATED_TARGET, () => observation), + session, + setObservation: (next: SandboxRecreateObservation) => { + observation = next; + }, + }; +} + describe("sandbox recreate journal", () => { it("binds a secret-free transaction to a non-default gateway before deletion (#6492)", () => { const session = createSession({ sandboxName: "alpha", agent: "openclaw" }); @@ -340,7 +387,7 @@ describe("sandbox recreate journal", () => { runtime.confirmDeleted(); runtime.advance("creating"); observation = { state: "ready", liveIdentityFingerprint: TARGET_ID }; - runtime.recordCreated(); + runtime.recordCreated(observation); expect(runtime).toMatchObject({ acceptedTarget: false, @@ -357,6 +404,53 @@ describe("sandbox recreate journal", () => { }); }); + it("rejects a malformed identity at the lower runtime boundary (#8942)", () => { + const session = createSession({ sandboxName: "alpha" }); + beginSandboxRecreateTransaction( + session, + beginInput({ state: "ready", liveIdentityFingerprint: SOURCE_ID }), + ); + let observation: SandboxRecreateObservation = { + state: "ready", + liveIdentityFingerprint: SOURCE_ID, + }; + const runtime = createSandboxRecreateRuntime( + { + loadSession: () => session, + updateSession: (mutator) => { + mutator(session); + return session; + }, + }, + { + id: TX_ID, + targetGeneration: TARGET_GENERATION, + targetIntentFingerprint: TARGET_INTENT, + }, + "alpha", + "nemoclaw-31818", + SOURCE_ENTRY, + () => observation, + () => undefined, + ); + + runtime.advance("deleting"); + observation = { state: "missing", liveIdentityFingerprint: null }; + runtime.confirmDeleted(); + runtime.advance("creating"); + + expect(() => + runtime.recordCreated({ + state: "ready", + liveIdentityFingerprint: "not-a-fingerprint", + }), + ).toThrow(/stable OpenShell Id/u); + expect(session.checkpoint?.sandboxRecreate).toMatchObject({ + phase: "creating", + targetLiveIdentityFingerprint: null, + }); + }); + it("proves the journaled source at the delete edge before onboarding removes it", () => { const session = createSession({ sandboxName: "alpha" }); beginSandboxRecreateTransaction( @@ -521,7 +615,7 @@ describe("sandbox recreate journal", () => { expect(restart().acceptedTarget).toBe(false); observation = { state: "ready", liveIdentityFingerprint: TARGET_ID }; - runtime.recordCreated(); + runtime.recordCreated(observation); expect(() => restart()).toThrow(/registration did not commit/i); registryEntry = { @@ -887,3 +981,167 @@ describe("journal-bound source proof", () => { ).toThrow(/reports no OpenShell Id/); }); }); + +describe("created sandbox lifecycle registration", () => { + it.each([ + ["not Ready", { state: "not_ready" as const, liveIdentityFingerprint: null }, /Ready/u], + [ + "malformed", + { state: "ready" as const, liveIdentityFingerprint: "not-a-fingerprint" }, + /valid live identity/u, + ], + ])("does not journal a %s replacement before validation (#8942)", (_label, invalid, expected) => { + const fixture = creatingLifecycleFixture(); + fixture.setObservation(invalid); + + expect(() => + fixture.lifecycle.capture({ lifecycleGeneration: TARGET_GENERATION }), + ).toThrow(expected); + expect(fixture.session.checkpoint?.sandboxRecreate).toMatchObject({ + phase: "creating", + targetLiveIdentityFingerprint: null, + }); + + fixture.setObservation({ state: "ready", liveIdentityFingerprint: TARGET_ID }); + const captured = fixture.lifecycle.capture({ lifecycleGeneration: TARGET_GENERATION }); + expect(captured).toEqual({ + lifecycleGeneration: TARGET_GENERATION, + lifecycleLiveIdentityFingerprint: TARGET_ID, + }); + expect(fixture.session.checkpoint?.sandboxRecreate).toMatchObject({ + phase: "creating", + targetLiveIdentityFingerprint: null, + }); + expect(fixture.lifecycle.revalidate(captured)).toEqual(captured); + expect(fixture.session.checkpoint?.sandboxRecreate).toMatchObject({ + phase: "created", + targetLiveIdentityFingerprint: TARGET_ID, + }); + }); + + it("does not journal identity drift before registry publication (#8942)", () => { + const fixture = creatingLifecycleFixture(); + fixture.setObservation({ state: "ready", liveIdentityFingerprint: TARGET_ID }); + const registration = fixture.lifecycle.capture({ + lifecycleGeneration: TARGET_GENERATION, + }); + + fixture.setObservation({ state: "ready", liveIdentityFingerprint: FOREIGN_ID }); + expect(() => fixture.lifecycle.revalidate(registration)).toThrow(/identity changed/u); + expect(fixture.session.checkpoint?.sandboxRecreate).toMatchObject({ + phase: "creating", + targetLiveIdentityFingerprint: null, + }); + + fixture.setObservation({ state: "ready", liveIdentityFingerprint: TARGET_ID }); + expect(fixture.lifecycle.revalidate(registration)).toEqual(registration); + expect(fixture.session.checkpoint?.sandboxRecreate).toMatchObject({ + phase: "created", + targetLiveIdentityFingerprint: TARGET_ID, + }); + }); + + it("captures the Ready identity only from the owning gateway", () => { + const observe = vi.fn((_sandboxName: string, gatewayName: string) => + gatewayName === CREATED_TARGET.gatewayName + ? { state: "ready" as const, liveIdentityFingerprint: TARGET_ID } + : { state: "ready" as const, liveIdentityFingerprint: FOREIGN_ID }, + ); + + expect( + captureCreatedSandboxLifecycleRegistration( + CREATED_TARGET, + TARGET_GENERATION, + { lifecycleGeneration: TARGET_GENERATION }, + observe, + ), + ).toEqual({ + lifecycleGeneration: TARGET_GENERATION, + lifecycleLiveIdentityFingerprint: TARGET_ID, + }); + expect(observe).toHaveBeenCalledExactlyOnceWith("alpha", "owner-gateway"); + }); + + it("rejects lifecycle setup generation drift before observing the sandbox", () => { + const observe = vi.fn(); + + expect(() => + captureCreatedSandboxLifecycleRegistration( + CREATED_TARGET, + TARGET_GENERATION, + { lifecycleGeneration: "33333333-3333-4333-8333-333333333333" }, + observe, + ), + ).toThrow(/lifecycle setup did not preserve its generation/u); + expect(observe).not.toHaveBeenCalled(); + }); + + it.each([ + ["missing", { state: "missing" as const, liveIdentityFingerprint: null }, /Ready/u], + ["not Ready", { state: "not_ready" as const, liveIdentityFingerprint: null }, /Ready/u], + [ + "missing identity", + { state: "ready" as const, liveIdentityFingerprint: null }, + /valid live identity/u, + ], + [ + "malformed identity", + { state: "ready" as const, liveIdentityFingerprint: "not-a-fingerprint" }, + /valid live identity/u, + ], + ])("rejects a %s final observation from the owning gateway", (_label, observation, expected) => { + expect(() => + revalidateCreatedSandboxLifecycleRegistration( + CREATED_TARGET, + { + lifecycleGeneration: TARGET_GENERATION, + lifecycleLiveIdentityFingerprint: TARGET_ID, + }, + () => observation, + ), + ).toThrow(expected); + }); + + it("rejects an identity change before registry publication", () => { + expect(() => + revalidateCreatedSandboxLifecycleRegistration( + CREATED_TARGET, + { + lifecycleGeneration: TARGET_GENERATION, + lifecycleLiveIdentityFingerprint: TARGET_ID, + }, + () => ({ state: "ready", liveIdentityFingerprint: FOREIGN_ID }), + ), + ).toThrow(/identity changed/u); + }); + + it("keeps the recreate transaction authoritative", () => { + const observed = { + lifecycleGeneration: TARGET_GENERATION, + lifecycleLiveIdentityFingerprint: TARGET_ID, + }; + + expect( + selectCreatedSandboxLifecycleRegistration( + "alpha", + observed, + TARGET_GENERATION, + observed, + ), + ).toEqual(observed); + expect(() => + selectCreatedSandboxLifecycleRegistration( + "alpha", + observed, + "33333333-3333-4333-8333-333333333333", + observed, + ), + ).toThrow(/recreate transaction no longer matches/u); + expect(() => + selectCreatedSandboxLifecycleRegistration("alpha", observed, TARGET_GENERATION, { + ...observed, + lifecycleLiveIdentityFingerprint: FOREIGN_ID, + }), + ).toThrow(/recreate transaction no longer matches/u); + }); +}); diff --git a/src/lib/onboard/sandbox-recreate-transaction.ts b/src/lib/onboard/sandbox-recreate-transaction.ts index 1a5fa26e1f3..4320d646e60 100644 --- a/src/lib/onboard/sandbox-recreate-transaction.ts +++ b/src/lib/onboard/sandbox-recreate-transaction.ts @@ -215,6 +215,164 @@ export interface SandboxRecreateObservation { readonly liveIdentityFingerprint: string | null; } +export type CreatedSandboxLifecycleRegistration = Required< + Pick +>; + +export interface CreatedSandboxLifecycleTarget { + readonly sandboxName: string; + readonly gatewayName: string; +} + +type ObserveCreatedSandbox = ( + sandboxName: string, + gatewayName: string, +) => SandboxRecreateObservation; + +function requireLifecycleGeneration(sandboxName: string, lifecycleGeneration: string): void { + if ( + lifecycleGeneration.length === 0 || + lifecycleGeneration.length > 512 || + lifecycleGeneration.trim() !== lifecycleGeneration + ) { + throw new Error( + `Cannot register sandbox '${sandboxName}': its lifecycle generation is invalid.`, + ); + } +} + +function requireReadyIdentity( + target: CreatedSandboxLifecycleTarget, + observation: SandboxRecreateObservation, +): string { + if (observation.state !== "ready") { + throw new Error( + `Cannot register sandbox '${target.sandboxName}': its owning gateway did not report it Ready.`, + ); + } + const fingerprint = observation.liveIdentityFingerprint; + if (!fingerprint || !/^[0-9a-f]{64}$/u.test(fingerprint)) { + throw new Error( + `Cannot register sandbox '${target.sandboxName}': its owning gateway did not report a valid live identity.`, + ); + } + return fingerprint; +} + +/** Pin the Ready sandbox identity observed from its owning gateway after creation. */ +export function captureCreatedSandboxLifecycleRegistration( + target: CreatedSandboxLifecycleTarget, + lifecycleGeneration: string, + lifecycleRegistrationFields: Pick, + observe: ObserveCreatedSandbox, +): CreatedSandboxLifecycleRegistration { + requireLifecycleGeneration(target.sandboxName, lifecycleGeneration); + if (lifecycleRegistrationFields.lifecycleGeneration !== lifecycleGeneration) { + throw new Error( + `Cannot register sandbox '${target.sandboxName}': lifecycle setup did not preserve its generation.`, + ); + } + return { + lifecycleGeneration, + lifecycleLiveIdentityFingerprint: requireReadyIdentity( + target, + observe(target.sandboxName, target.gatewayName), + ), + }; +} + +/** Preserve the recreate journal as the authority for replacement registration. */ +export function selectCreatedSandboxLifecycleRegistration( + sandboxName: string, + observed: CreatedSandboxLifecycleRegistration, + recreateTargetGeneration: string | undefined, + recreateRegistration: Pick< + SandboxEntry, + "lifecycleGeneration" | "lifecycleLiveIdentityFingerprint" + >, +): CreatedSandboxLifecycleRegistration { + if (!recreateTargetGeneration) return observed; + if ( + recreateTargetGeneration !== observed.lifecycleGeneration || + recreateRegistration.lifecycleGeneration !== observed.lifecycleGeneration || + recreateRegistration.lifecycleLiveIdentityFingerprint !== + observed.lifecycleLiveIdentityFingerprint + ) { + throw new Error( + `Cannot register sandbox '${sandboxName}': its recreate transaction no longer matches the created sandbox.`, + ); + } + return { + lifecycleGeneration: recreateTargetGeneration, + lifecycleLiveIdentityFingerprint: recreateRegistration.lifecycleLiveIdentityFingerprint, + }; +} + +/** Re-observe the owner-scoped identity immediately before registry publication. */ +export function revalidateCreatedSandboxLifecycleRegistration( + target: CreatedSandboxLifecycleTarget, + registration: CreatedSandboxLifecycleRegistration, + observe: ObserveCreatedSandbox, +): CreatedSandboxLifecycleRegistration { + requireLifecycleGeneration(target.sandboxName, registration.lifecycleGeneration); + const liveIdentityFingerprint = requireReadyIdentity( + target, + observe(target.sandboxName, target.gatewayName), + ); + if (liveIdentityFingerprint !== registration.lifecycleLiveIdentityFingerprint) { + throw new Error( + `Cannot register sandbox '${target.sandboxName}': its live identity changed before registry publication.`, + ); + } + return registration; +} + +export interface CreatedSandboxLifecycle { + readonly generation: string; + capture( + lifecycleRegistrationFields: Pick, + ): CreatedSandboxLifecycleRegistration; + revalidate( + registration: CreatedSandboxLifecycleRegistration, + ): CreatedSandboxLifecycleRegistration; +} + +/** Coordinate sandbox setup and registry publication on one lifecycle generation. */ +export function createCreatedSandboxLifecycle( + runtime: SandboxRecreateRuntime, + target: CreatedSandboxLifecycleTarget, + observe: ObserveCreatedSandbox, +): CreatedSandboxLifecycle { + const generation = runtime.targetGeneration ?? randomUUID(); + return { + generation, + capture: (lifecycleRegistrationFields) => + captureCreatedSandboxLifecycleRegistration( + target, + generation, + lifecycleRegistrationFields, + observe, + ), + revalidate: (registration) => { + const verified = revalidateCreatedSandboxLifecycleRegistration( + target, + registration, + observe, + ); + runtime.recordCreated({ + state: "ready", + liveIdentityFingerprint: verified.lifecycleLiveIdentityFingerprint, + }); + return selectCreatedSandboxLifecycleRegistration( + target.sandboxName, + verified, + runtime.targetGeneration, + runtime.registrationFields, + ); + }, + }; +} + export interface SandboxRecreateSourceProof { readonly transactionId: string; readonly sandboxName: string; @@ -430,7 +588,11 @@ export function recordSandboxRecreateTargetCreated( observation: SandboxRecreateObservation, now = new Date().toISOString(), ): CheckpointSandboxRecreateTransaction { - if (observation.state !== "ready" || !observation.liveIdentityFingerprint) { + if ( + observation.state !== "ready" || + !observation.liveIdentityFingerprint || + !/^[0-9a-f]{64}$/u.test(observation.liveIdentityFingerprint) + ) { throw new Error("The journaled replacement must be ready with a stable OpenShell Id."); } const checkpoint = baseCheckpoint(session); @@ -623,7 +785,7 @@ export interface SandboxRecreateRuntime { advance(phase: CheckpointSandboxRecreatePhase): void; beginDelete(): SandboxRecreateSourcePresence; confirmDeleted(): void; - recordCreated(): void; + recordCreated(observation: SandboxRecreateObservation): void; } const NO_SANDBOX_RECREATE: SandboxRecreateRuntime = { @@ -641,7 +803,7 @@ const NO_SANDBOX_RECREATE: SandboxRecreateRuntime = { ); }, confirmDeleted: () => undefined, - recordCreated: () => undefined, + recordCreated: (_observation) => undefined, }; export function createSandboxRecreateRuntime( @@ -722,8 +884,7 @@ export function createSandboxRecreateRuntime( } advance("deleted"); }, - recordCreated: () => { - const observation = observe(sandboxName, transaction.gatewayName); + recordCreated: (observation) => { sessionStore.updateSession((current) => { targetLiveIdentityFingerprint = recordSandboxRecreateTargetCreated( current, diff --git a/src/lib/onboard/sandbox-registration.test.ts b/src/lib/onboard/sandbox-registration.test.ts index a653f958365..b9a6a9733b1 100644 --- a/src/lib/onboard/sandbox-registration.test.ts +++ b/src/lib/onboard/sandbox-registration.test.ts @@ -502,6 +502,48 @@ describe("selection", () => { }); describe("registerCreatedSandbox", () => { + it("persists lifecycle identity for a non-OpenClaw agent", () => { + const agentDefs = requireDist("../agent/defs.js") as typeof import("../agent/defs"); + const registerSandbox = vi.fn(); + + const entry = registerCreatedSandbox({ + sandboxName: "hermes-box", + inferenceSelection: { + model: "kimi", + provider: "hermes-provider", + endpointUrl: null, + credentialEnv: null, + preferredInferenceApi: null, + compatibleEndpointReasoning: null, + compatibleEndpointReasoningEffort: null, + nimContainer: null, + }, + runtimeFields, + agent: agentDefs.loadAgent("hermes"), + agentVersionKnown: true, + imageTag: null, + appliedPolicies: [], + plannedMessagingState: undefined, + hermesToolGateways: [], + hermesDashboardState: { enabled: false, config: null }, + hermesApiPort: 8642, + dashboardPort: 0, + lifecycleGeneration: "22222222-2222-4222-8222-222222222222", + lifecycleLiveIdentityFingerprint: "d".repeat(64), + gatewayName: "owner-gateway", + gatewayPort: 8080, + registerSandbox, + }); + + expect(entry).toMatchObject({ + agent: "hermes", + lifecycleGeneration: "22222222-2222-4222-8222-222222222222", + lifecycleLiveIdentityFingerprint: "d".repeat(64), + gatewayName: "owner-gateway", + }); + expect(registerSandbox).toHaveBeenCalledExactlyOnceWith(entry); + }); + it("passes the built entry to the supplied registry writer", () => { const registerSandbox = vi.fn(); diff --git a/test/helpers/managed-image-buildless-e2e.ts b/test/helpers/managed-image-buildless-e2e.ts index 3274ce9842e..77784303f79 100644 --- a/test/helpers/managed-image-buildless-e2e.ts +++ b/test/helpers/managed-image-buildless-e2e.ts @@ -519,6 +519,9 @@ function writeRuntimeStubs(fakeBin: string, dockerLog: string): void { 'if [ "${1:-}" = "--version" ] || [ "${1:-}" = "-V" ]; then', ' printf "%s\\n" "openshell 0.0.96"', "fi", + 'if [ "${1:-}" = "sandbox" ] && [ "${2:-}" = "get" ]; then', + ' printf "Sandbox:\\n\\n Id: fixture-managed-sandbox\\n Name: %s\\n Phase: Ready\\n" "${!#}"', + "fi", "exit 0", "", ].join("\n"), diff --git a/test/helpers/onboard-openshell-fixture.ts b/test/helpers/onboard-openshell-fixture.ts index b80ebe6edf8..9508afbf988 100644 --- a/test/helpers/onboard-openshell-fixture.ts +++ b/test/helpers/onboard-openshell-fixture.ts @@ -8,10 +8,16 @@ function writeExecutable(target: string, contents: string): void { fs.writeFileSync(target, contents, { mode: 0o755 }); } -export function writeOkOpenshell(fakeBin: string): void { +export function writeOkOpenshell( + fakeBin: string, + options: { readySandboxGet?: boolean } = {}, +): void { + const sandboxGet = options.readySandboxGet + ? 'if [ "${1:-}" = sandbox ] && [ "${2:-}" = get ]; then printf "Sandbox:\\n\\n Id: fixture-created-sandbox\\n Name: %s\\n Phase: Ready\\n" "${!#}"; fi\n' + : ""; writeExecutable( path.join(fakeBin, "openshell"), - '#!/usr/bin/env bash\nif [ "${1:-}" = sandbox ] && [ "${2:-}" = ssh-config ]; then printf "Host openshell-%s.default\\n HostName 127.0.0.1\\n User sandbox\\n" "${3:-sandbox}"; fi\nexit 0\n', + `#!/usr/bin/env bash\n${sandboxGet}if [ "\${1:-}" = sandbox ] && [ "\${2:-}" = ssh-config ]; then printf "Host openshell-%s.default\\n HostName 127.0.0.1\\n User sandbox\\n" "\${3:-sandbox}"; fi\nexit 0\n`, ); writeExecutable( path.join(fakeBin, "ssh"), diff --git a/test/onboard-custom-dockerfile.test.ts b/test/onboard-custom-dockerfile.test.ts index 1a3c464cc87..555ee3c6e7c 100644 --- a/test/onboard-custom-dockerfile.test.ts +++ b/test/onboard-custom-dockerfile.test.ts @@ -185,7 +185,7 @@ describe("onboard custom Dockerfile", () => { fs.writeFileSync(path.join(customBuildDir, "credentials.json"), "{}"); fs.mkdirSync(fakeBin, { recursive: true }); - writeOkOpenshell(fakeBin); + writeOkOpenshell(fakeBin, { readySandboxGet: true }); const customDockerfilePath = JSON.stringify(path.join(customBuildDir, "Dockerfile")); diff --git a/test/onboard-extra-provider-reconciliation.test.ts b/test/onboard-extra-provider-reconciliation.test.ts index 31d89be9a5f..35f33c2c4fe 100644 --- a/test/onboard-extra-provider-reconciliation.test.ts +++ b/test/onboard-extra-provider-reconciliation.test.ts @@ -40,7 +40,7 @@ describe("onboard extra-provider reconciliation", () => { ); fs.mkdirSync(fakeBin, { recursive: true }); - writeOkOpenshell(fakeBin); + writeOkOpenshell(fakeBin, { readySandboxGet: true }); const script = String.raw` const runner = require(${runnerPath}); diff --git a/test/onboard-messaging.test.ts b/test/onboard-messaging.test.ts index 0e29d382031..ba306e9032b 100644 --- a/test/onboard-messaging.test.ts +++ b/test/onboard-messaging.test.ts @@ -66,7 +66,7 @@ describe("onboard messaging", () => { ); fs.mkdirSync(fakeBin, { recursive: true }); - writeOkOpenshell(fakeBin); + writeOkOpenshell(fakeBin, { readySandboxGet: true }); const script = String.raw` const runner = require(${runnerPath}); @@ -315,9 +315,7 @@ const { createSandbox, setupMessagingChannels } = require(${onboardPath}); fs.mkdirSync(fakeBin, { recursive: true }); fs.mkdirSync(customBuildDir, { recursive: true }); fs.writeFileSync(customDockerfilePath, "FROM scratch\nARG NEMOCLAW_MESSAGING_PLAN_B64=\nARG NEMOCLAW_TOOL_DISCLOSURE=progressive\nENV NEMOCLAW_TOOL_DISCLOSURE=${NEMOCLAW_TOOL_DISCLOSURE}\n"); - fs.writeFileSync(path.join(fakeBin, "openshell"), "#!/usr/bin/env bash\nexit 0\n", { - mode: 0o755, - }); + writeOkOpenshell(fakeBin, { readySandboxGet: true }); const script = String.raw` const runner = require(${runnerPath}); @@ -501,7 +499,7 @@ const { createSandbox } = require(${onboardPath}); const messagingPlanB64 = encodeMessagingPlanForChannels(["discord", "slack"]); fs.mkdirSync(fakeBin, { recursive: true }); - writeOkOpenshell(fakeBin); + writeOkOpenshell(fakeBin, { readySandboxGet: true }); const script = String.raw` const runner = require(${runnerPath}); @@ -661,7 +659,7 @@ const { createSandbox } = require(${onboardPath}); const messagingPlanB64 = encodeMessagingPlanForChannels(["telegram"], ["telegram"]); fs.mkdirSync(fakeBin, { recursive: true }); - writeOkOpenshell(fakeBin); + writeOkOpenshell(fakeBin, { readySandboxGet: true }); const script = String.raw` const runner = require(${runnerPath}); @@ -814,7 +812,7 @@ const { createSandbox } = require(${onboardPath}); const messagingPlanB64 = encodeMessagingPlanForChannels(["whatsapp"]); fs.mkdirSync(fakeBin, { recursive: true }); - writeOkOpenshell(fakeBin); + writeOkOpenshell(fakeBin, { readySandboxGet: true }); const script = String.raw` const runner = require(${runnerPath}); @@ -964,7 +962,7 @@ const { createSandbox } = require(${onboardPath}); const messagingPlanB64 = encodeMessagingPlanForChannels(["whatsapp"], ["whatsapp"]); fs.mkdirSync(fakeBin, { recursive: true }); - writeOkOpenshell(fakeBin); + writeOkOpenshell(fakeBin, { readySandboxGet: true }); const script = String.raw` const runner = require(${runnerPath}); @@ -1286,7 +1284,7 @@ const { createSandbox } = require(${onboardPath}); ); fs.mkdirSync(fakeBin, { recursive: true }); - writeOkOpenshell(fakeBin); + writeOkOpenshell(fakeBin, { readySandboxGet: true }); const script = String.raw` const runner = require(${runnerPath}); @@ -1417,7 +1415,7 @@ const { createSandbox } = require(${onboardPath}); ); fs.mkdirSync(fakeBin, { recursive: true }); - writeOkOpenshell(fakeBin); + writeOkOpenshell(fakeBin, { readySandboxGet: true }); const script = String.raw` const runner = require(${runnerPath}); diff --git a/test/onboard-prepared-build-context.test.ts b/test/onboard-prepared-build-context.test.ts index 084101d9eda..82e5b83e9c2 100644 --- a/test/onboard-prepared-build-context.test.ts +++ b/test/onboard-prepared-build-context.test.ts @@ -34,9 +34,11 @@ function runPreparedContextScenario(scenario: PreparedContextScenario): Prepared fs.mkdirSync(fakeBin, { recursive: true }); fs.mkdirSync(preparedBuildCtx, { recursive: true }); - fs.writeFileSync(path.join(fakeBin, "openshell"), "#!/usr/bin/env bash\nexit 0\n", { - mode: 0o755, - }); + fs.writeFileSync( + path.join(fakeBin, "openshell"), + '#!/usr/bin/env bash\nif [ "${1:-}" = sandbox ] && [ "${2:-}" = get ]; then printf "Sandbox:\\n\\n Id: fixture-prepared-sandbox\\n Name: %s\\n Phase: Ready\\n" "${!#}"; fi\nexit 0\n', + { mode: 0o755 }, + ); fs.writeFileSync( path.join(preparedBuildCtx, "Dockerfile"), ["FROM scratch", `ARG NEMOCLAW_BUILD_ID=${buildId}`, 'CMD ["/bin/true"]', ""].join("\n"), diff --git a/test/onboard-sandbox-build.test.ts b/test/onboard-sandbox-build.test.ts index 62b05c567be..c63d75b4140 100644 --- a/test/onboard-sandbox-build.test.ts +++ b/test/onboard-sandbox-build.test.ts @@ -40,7 +40,7 @@ describe("onboard helpers", () => { ); fs.mkdirSync(fakeBin, { recursive: true }); - writeOkOpenshell(fakeBin); + writeOkOpenshell(fakeBin, { readySandboxGet: true }); const script = String.raw` const runner = require(${runnerPath}); @@ -207,7 +207,7 @@ const { createSandbox } = require(${onboardPath}); ); fs.mkdirSync(fakeBin, { recursive: true }); - writeOkOpenshell(fakeBin); + writeOkOpenshell(fakeBin, { readySandboxGet: true }); const script = String.raw` const fs = require("node:fs"); @@ -411,7 +411,7 @@ const { createSandbox } = require(${onboardPath}); const platformPath = JSON.stringify(path.join(repoRoot, "src", "lib", "platform.ts")); fs.mkdirSync(fakeBin, { recursive: true }); - writeOkOpenshell(fakeBin); + writeOkOpenshell(fakeBin, { readySandboxGet: true }); const script = String.raw` const fs = require("node:fs"); @@ -566,7 +566,7 @@ const { createSandbox } = require(${onboardPath}); ); fs.mkdirSync(fakeBin, { recursive: true }); - writeOkOpenshell(fakeBin); + writeOkOpenshell(fakeBin, { readySandboxGet: true }); const script = String.raw` const runner = require(${runnerPath}); @@ -664,7 +664,7 @@ const { createSandbox } = require(${onboardPath}); ); fs.mkdirSync(fakeBin, { recursive: true }); - writeOkOpenshell(fakeBin); + writeOkOpenshell(fakeBin, { readySandboxGet: true }); const script = String.raw` const runner = require(${runnerPath}); diff --git a/test/onboard-sandbox-recreation.test.ts b/test/onboard-sandbox-recreation.test.ts index 9b37c7ce711..de141aa980d 100644 --- a/test/onboard-sandbox-recreation.test.ts +++ b/test/onboard-sandbox-recreation.test.ts @@ -1202,7 +1202,7 @@ const { createSandbox } = require(${onboardPath}); ); assert.ok(result.stdout.includes("not ready"), "should mention sandbox is not ready"); }); - it("waits for the create stream to close after the sandbox is Ready", { + it("registers a fresh create only after owner-scoped identity confirmation (#8942)", { timeout: 20000, }, async () => { const repoRoot = path.join(import.meta.dirname, ".."); @@ -1245,8 +1245,11 @@ dockerExec.dockerSpawn = () => { const fs = require("node:fs"); const commands = []; +const lifecycleObservationCommands = []; let sandboxListCalls = 0; let dockerPsCalls = 0; +let sandboxCreated = false; +let registeredSandbox = null; const keepAlive = setInterval(() => {}, 1000); runner.run = (command, opts = {}) => { _deleted = _deleted || _n(command).includes("sandbox delete"); @@ -1254,11 +1257,16 @@ runner.run = (command, opts = {}) => { return { status: 0 }; }; runner.runCapture = (command) => { + if (_n(command).includes("sandbox get") || _n(command).includes("sandbox list")) { + lifecycleObservationCommands.push(_n(command)); + } if (_n(command).startsWith("docker ps -a --no-trunc ")) { dockerPsCalls += 1; if (dockerPsCalls === 1) return "a".repeat(64); } - if (_n(command).includes("sandbox get") && _n(command).includes("my-assistant")) return ""; + if (_n(command).includes("sandbox get") && _n(command).includes("my-assistant")) { + return sandboxCreated ? ["my-assistant", "Id: sbx-fresh-create"].join(String.fromCharCode(10)) : ""; + } if (_n(command).includes("sandbox list")) { sandboxListCalls += 1; return sandboxListCalls >= 2 ? "my-assistant Ready" : "my-assistant Pending"; @@ -1270,7 +1278,7 @@ runner.runCapture = (command) => { if (_n(command).includes("forward list")) return "my-assistant 127.0.0.1 18789 12345 running"; return ""; }; -registry.registerSandbox = () => true; +registry.registerSandbox = (entry) => { registeredSandbox = entry; return true; }; registry.updateSandbox = () => true; registry.setDefault = () => true; registry.removeSandbox = () => true; @@ -1290,6 +1298,7 @@ process.kill = (pid, signal) => { }; childProcess.spawn = (...args) => { + sandboxCreated = true; _deleted = false; const child = new EventEmitter(); child.stdout = new EventEmitter(); @@ -1335,6 +1344,8 @@ const { createSandbox } = require(${onboardPath}); unrefCalls: createCommand.child.unrefCalls, stdoutDestroyCalls: createCommand.child.stdout.destroyCalls, stderrDestroyCalls: createCommand.child.stderr.destroyCalls, + lifecycleObservationCommands, + registeredSandbox, })); clearInterval(keepAlive); })().catch((error) => { @@ -1367,5 +1378,22 @@ const { createSandbox } = require(${onboardPath}); assert.equal(payload.unrefCalls, 0); assert.equal(payload.stdoutDestroyCalls, 0); assert.equal(payload.stderrDestroyCalls, 0); + assert.match(payload.registeredSandbox.lifecycleGeneration, /^[0-9a-f-]{36}$/u); + assert.equal( + payload.registeredSandbox.lifecycleLiveIdentityFingerprint, + createHash("sha256").update("sbx-fresh-create").digest("hex"), + ); + const ownerScopedObservations = payload.lifecycleObservationCommands.filter( + (command: string) => command.includes("-g nemoclaw"), + ); + assert.equal(ownerScopedObservations.length, 4); + assert.ok( + ownerScopedObservations.every( + (command: string) => + command.includes("sandbox get -g nemoclaw my-assistant") || + command.includes("sandbox list -g nemoclaw"), + ), + `fresh identity observations must remain scoped to the owning gateway: ${JSON.stringify(ownerScopedObservations)}`, + ); }); }); diff --git a/test/onboard-terminal-dashboard.test.ts b/test/onboard-terminal-dashboard.test.ts index 56b5ec0c333..0b7f5527e59 100644 --- a/test/onboard-terminal-dashboard.test.ts +++ b/test/onboard-terminal-dashboard.test.ts @@ -43,7 +43,10 @@ function runTerminalDashboardScenario(scenario: "create" | "reuse") { ); fs.mkdirSync(fakeBin, { recursive: true }); - writeExecutable(path.join(fakeBin, "openshell"), "#!/usr/bin/env bash\nexit 0\n"); + writeExecutable( + path.join(fakeBin, "openshell"), + '#!/usr/bin/env bash\nif [ "${1:-}" = sandbox ] && [ "${2:-}" = get ]; then printf "Sandbox:\\n\\n Id: fixture-terminal-sandbox\\n Name: %s\\n Phase: Ready\\n" "${!#}"; fi\nexit 0\n', + ); const script = String.raw` const fs = require("node:fs"); diff --git a/test/shellquote-sandbox.test.ts b/test/shellquote-sandbox.test.ts index 9a513571c3b..5b4738a76d5 100644 --- a/test/shellquote-sandbox.test.ts +++ b/test/shellquote-sandbox.test.ts @@ -68,7 +68,7 @@ describe("sandboxName command hardening in onboard.js", () => { const streamPath = sourceModule("sandbox", "create-stream.ts"); fs.mkdirSync(fakeBin, { recursive: true }); - writeOkOpenshell(fakeBin); + writeOkOpenshell(fakeBin, { readySandboxGet: true }); fs.writeFileSync( scriptPath, String.raw` diff --git a/test/snapshot-gateway-guard.test.ts b/test/snapshot-gateway-guard.test.ts index 6d89d5b9a87..836940aa033 100644 --- a/test/snapshot-gateway-guard.test.ts +++ b/test/snapshot-gateway-guard.test.ts @@ -6,6 +6,7 @@ // `openshell sandbox list` lies and returns exit 0 with stale data. import { type ChildProcess, execSync, spawn, spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; @@ -89,6 +90,38 @@ function writeSandboxRegistry( ); } +function writeEmptyOpenClawSnapshot(home: string, name: string): void { + const backupPath = path.join( + home, + ".nemoclaw", + "rebuild-backups", + "alpha", + "2026-08-13T00-00-00-000Z", + ); + fs.mkdirSync(backupPath, { recursive: true, mode: 0o700 }); + fs.writeFileSync( + path.join(backupPath, "rebuild-manifest.json"), + JSON.stringify({ + version: 1, + sandboxName: "alpha", + timestamp: "2026-08-13T00:00:00.000Z", + agentType: "openclaw", + agentVersion: null, + expectedVersion: null, + stateDirs: [], + failedBackupDirs: [], + stateFiles: [], + dir: "/sandbox/.openclaw", + backupPath, + blueprintDigest: null, + policyPresets: [], + customPolicies: [], + name, + }), + { mode: 0o600 }, + ); +} + function startReachableForward(port: number): void { const listener = 'const net=require("node:net");' + @@ -181,6 +214,7 @@ function makeHealthyVmGatewayEnv(prefix: string): Record { function makeVmRestoreToEnv( prefix: string, entry: Record = { imageTag: "openshell/sandbox-from:fast-path-test" }, + cloneIdentity = "fixture-clone-1", ): Record { const home = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); const localBin = path.join(home, "bin"); @@ -192,6 +226,7 @@ function makeVmRestoreToEnv( ...entry, }); startReachableForward(dashboardPort); + writeEmptyOpenClawSnapshot(home, "baseline"); const cloneReadyMarker = path.join(home, "clone-1-ready"); const cloneRunningMarker = path.join(home, "clone-1-running"); @@ -200,8 +235,8 @@ function makeVmRestoreToEnv( writeExecutable(path.join(localBin, "openshell"), [ 'case "$1 $2" in', ' "gateway info") printf "Gateway Info\\n\\nGateway: nemoclaw\\nGateway endpoint: https://127.0.0.1:8080/\\n"; exit 0 ;;', - ' "sandbox get") printf "{\\"name\\":\\"%s\\"}\\n" "$3"; exit 0 ;;', - ` "sandbox list") if [ -f ${JSON.stringify(cloneReadyMarker)} ]; then printf "NAME STATUS\\nalpha Ready\\nclone-1 Ready\\n"; else printf "NAME STATUS\\nalpha Ready\\n"; fi; exit 0 ;;`, + ` "sandbox get") [ "$3 $4" = "-g nemoclaw" ] || exit 91; for sandbox_ref in "$@"; do :; done; printf "Name: %s\\nId: %s\\nPhase: Ready\\n" "$sandbox_ref" ${JSON.stringify(cloneIdentity)}; exit 0 ;;`, + ` "sandbox list") if [ -n "\${3:-}" ] && [ "$3 $4" != "-g nemoclaw" ]; then exit 91; fi; if [ -f ${JSON.stringify(cloneReadyMarker)} ]; then printf "NAME STATUS\\nalpha Ready\\nclone-1 Ready\\n"; else printf "NAME STATUS\\nalpha Ready\\n"; fi; exit 0 ;;`, ' "sandbox exec")', ' case "$*" in', ' *"__NEMOCLAW_SANDBOX_EXEC_STARTED__"*) printf "__NEMOCLAW_SANDBOX_EXEC_STARTED__\\nRUNNING\\n"; exit 0 ;;', @@ -303,12 +338,12 @@ describe("snapshot VM-driver gateway guard", () => { // `snapshot restore --to ` on VM driver must use the registered // imageTag, not the legacy `docker exec ... kubectl` probe. - it("snapshot restore --to uses registered imageTag and restarts the VM gateway before pairing verification", () => { - const env = makeVmRestoreToEnv("nemoclaw-snap-vm-gw-restore-to-"); - - const seed = runCli("alpha snapshot create --name baseline", env); - expect(seed.code).toBe(0); - expect(seed.out).toContain("Snapshot v1 name=baseline created"); + it("snapshot restore --to records a fresh clone lifecycle identity before pairing verification (#8942)", () => { + const env = makeVmRestoreToEnv("nemoclaw-snap-vm-gw-restore-to-", { + imageTag: "openshell/sandbox-from:fast-path-test", + lifecycleGeneration: "source-generation", + lifecycleLiveIdentityFingerprint: "a".repeat(64), + }); const r = runCli("alpha snapshot restore baseline --to clone-1", env); expect(r.code, r.out).toBe(0); @@ -318,6 +353,40 @@ describe("snapshot VM-driver gateway guard", () => { expect(fs.readFileSync(path.join(env.HOME, "gateway-lifecycle.log"), "utf8")).toBe( "restart clone-1\nrestart clone-1\n", ); + const registryState = JSON.parse( + fs.readFileSync(path.join(env.HOME, ".nemoclaw", "sandboxes.json"), "utf8"), + ); + expect(registryState.sandboxes["clone-1"]).toMatchObject({ + lifecycleGeneration: expect.stringMatching( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u, + ), + lifecycleLiveIdentityFingerprint: createHash("sha256") + .update("fixture-clone-1") + .digest("hex"), + }); + expect(registryState.sandboxes["clone-1"].lifecycleGeneration).not.toBe( + "source-generation", + ); + }, 15000); + + it("snapshot restore --to rejects a malformed clone identity before registration (#8942)", () => { + const env = makeVmRestoreToEnv( + "nemoclaw-snap-vm-gw-restore-to-malformed-identity-", + { + imageTag: "openshell/sandbox-from:fast-path-test", + lifecycleGeneration: "source-generation", + lifecycleLiveIdentityFingerprint: "a".repeat(64), + }, + "", + ); + + const r = runCli("alpha snapshot restore baseline --to clone-1", env); + expect(r.code, r.out).toBe(1); + expect(r.out).toContain("valid live identity"); + const registryState = JSON.parse( + fs.readFileSync(path.join(env.HOME, ".nemoclaw", "sandboxes.json"), "utf8"), + ); + expect(registryState.sandboxes["clone-1"]).toBeUndefined(); }, 15000); it("snapshot restore --to fails closed for VM-driver entries missing imageTag", () => { @@ -325,9 +394,6 @@ describe("snapshot VM-driver gateway guard", () => { imageTag: null, }); - const seed = runCli("alpha snapshot create --name baseline", env); - expect(seed.code).toBe(0); - const r = runCli("alpha snapshot restore baseline --to clone-1", env); expect(r.code).toBe(1); expect(r.out).toContain("Cannot resolve image"); diff --git a/test/support/connect-flow-test-harness.ts b/test/support/connect-flow-test-harness.ts index bae208eaadb..c5bf17699ff 100644 --- a/test/support/connect-flow-test-harness.ts +++ b/test/support/connect-flow-test-harness.ts @@ -11,9 +11,9 @@ import type { WslDetectionOptions } from "../../src/lib/platform"; import type { ConfigObject } from "../../src/lib/security/credential-filter"; import type { SandboxEntry } from "../../src/lib/state/registry"; -type ConnectSandbox = typeof import("../../src/lib/actions/sandbox/connect")["connectSandbox"]; +type ConnectSandbox = (typeof import("../../src/lib/actions/sandbox/connect"))["connectSandbox"]; type GatewayRouteMutationLock = - typeof import("../../src/lib/inference/gateway-route-mutation-lock")["withGatewayRouteMutationLock"]; + (typeof import("../../src/lib/inference/gateway-route-mutation-lock"))["withGatewayRouteMutationLock"]; type LaunchReadinessPublicationResult = import("../../src/lib/actions/sandbox/launch-readiness").LaunchReadinessPublicationResult; @@ -275,15 +275,17 @@ export function createConnectHarness(options: ConnectHarnessOptions = {}): Conne const probeOllamaAuthProxyHealthSpy = vi .spyOn(ollamaProxy, "probeOllamaAuthProxyHealth") .mockReturnValue({ ok: true }); - const realIsWsl = platform.isWsl as (opts?: WslDetectionOptions) => boolean; - // Pin the platform gate for every isWsl consumer the harness loads: isWsl - // answers false off Linux before it reads WSL_DISTRO_NAME, so a case that - // stubs that variable cannot reach the WSL route on a macOS contributor - // machine. With the gate pinned, the stubbed environment decides, on every - // host, and a caller's own options still win over the pin (#8868). - vi.spyOn(platform, "isWsl").mockImplementation((...args: unknown[]) => - realIsWsl({ platform: "linux", ...((args[0] as WslDetectionOptions | undefined) ?? {}) }), - ); + if (typeof options.isWsl !== "boolean") { + const realIsWsl = platform.isWsl as (opts?: WslDetectionOptions) => boolean; + // Pin the platform gate for every isWsl consumer the harness loads: isWsl + // answers false off Linux before it reads WSL_DISTRO_NAME, so a case that + // stubs that variable cannot reach the WSL route on a macOS contributor + // machine. With the gate pinned, the stubbed environment decides, on every + // host, and a caller's own options still win over the pin (#8868). + vi.spyOn(platform, "isWsl").mockImplementation((...args: unknown[]) => + realIsWsl({ platform: "linux", ...((args[0] as WslDetectionOptions | undefined) ?? {}) }), + ); + } const primaryRegistryEntry: SandboxEntry = { name: "alpha", agent: options.agentName ?? "openclaw",