diff --git a/src/lib/actions/sandbox/launch-readiness.ts b/src/lib/actions/sandbox/launch-readiness.ts index 39a625cd30..cad3a124e2 100644 --- a/src/lib/actions/sandbox/launch-readiness.ts +++ b/src/lib/actions/sandbox/launch-readiness.ts @@ -14,6 +14,7 @@ import { parseServingProfileProvenance } from "../../inference/serving/profile-p import { resolveGatewayName } from "../../onboard/gateway-binding"; import { classifyPortableLifecycleReceipt, + portableLifecycleReceiptMatchesGeneration, type PortableLifecycleReceiptClassification, } from "../../onboard/experimental/portable-runtime-receipt-readiness"; import { @@ -104,6 +105,7 @@ export type LaunchReadinessDecision = export interface LaunchReadinessDeps extends LaunchReadinessHealthDeps { checkMutationAuthority?: typeof checkLaunchReadinessMutationAuthority; getSandbox?: typeof registry.getSandbox; + updateSandbox?: typeof registry.updateSandbox; observeSandbox?: SandboxRecreateObserver; readLease?: typeof readLaunchReadinessLease; fenceLease?: typeof fenceLaunchReadinessLease; @@ -931,11 +933,14 @@ function resolvePortablePairingTarget( */ export async function settlePortableOpenClawPairing( sandboxName: string, - options: { readonly portableRequired?: boolean } = {}, + options: { + readonly portableRequired?: boolean; + } = {}, deps: LaunchReadinessDeps = {}, ): Promise { const classifyReceipt = deps.classifyPortableLifecycleReceipt ?? classifyPortableLifecycleReceipt; const getSandbox = deps.getSandbox ?? registry.getSandbox; + const updateSandbox = deps.updateSandbox ?? registry.updateSandbox; const withSandboxLock = deps.withSandboxLock ?? withSandboxMutationLock; const withGatewayLock = deps.withGatewayLock ?? withGatewayRouteMutationLock; const observePairing = deps.observeOpenClawPairingSettlement ?? observeOpenClawPairingSettlement; @@ -943,7 +948,7 @@ export async function settlePortableOpenClawPairing( const runApproval = deps.runPortablePairingApproval ?? runPortableOpenClawPairingApproval; return withSandboxLock(sandboxName, async () => { - const firstEntry = getSandbox(sandboxName); + let firstEntry = getSandbox(sandboxName); if ( firstEntry?.agent !== "openclaw" && typeof firstEntry?.agent === "string" && @@ -954,6 +959,20 @@ export async function settlePortableOpenClawPairing( } const firstReceipt = classifyReceipt(sandboxName); + if ( + firstEntry?.agent === null && + options.portableRequired === true && + firstEntry.policyPresetsFinalized === true && + portableLifecycleReceiptMatchesGeneration(firstReceipt, firstEntry.lifecycleGeneration) + ) { + if (!updateSandbox(sandboxName, { agent: "openclaw" })) { + return incompletePortablePairing("portable-runtime-identity-invalid"); + } + firstEntry = getSandbox(sandboxName); + if (firstEntry?.agent !== "openclaw") { + return incompletePortablePairing("portable-runtime-identity-invalid"); + } + } if (firstEntry?.agent !== "openclaw") { if (firstReceipt.kind === "absent" && !options.portableRequired) { return { kind: "not-portable" }; diff --git a/src/lib/actions/sandbox/launch-readiness/portable-openclaw-pairing-settlement.test.ts b/src/lib/actions/sandbox/launch-readiness/portable-openclaw-pairing-settlement.test.ts index be58e2bc37..f03829d720 100644 --- a/src/lib/actions/sandbox/launch-readiness/portable-openclaw-pairing-settlement.test.ts +++ b/src/lib/actions/sandbox/launch-readiness/portable-openclaw-pairing-settlement.test.ts @@ -210,6 +210,85 @@ describe("Portable OpenClaw pairing settlement", () => { expect(scope.runApproval).not.toHaveBeenCalled(); }); + it("repairs an authority-matched legacy OpenClaw row only for onboarding finalization (#9207)", async () => { + let entry: SandboxEntry = { ...ENTRY, agent: null }; + const updateSandbox = vi.fn((_name: string, updates: Partial) => { + entry = { ...entry, ...updates }; + return true; + }); + const scope = settlementDeps({ + getSandbox: vi.fn(() => entry), + updateSandbox, + }); + + await expect( + settlePortableOpenClawPairing("alpha", { portableRequired: true }, scope.deps), + ).resolves.toEqual({ kind: "settled" }); + expect(updateSandbox).toHaveBeenCalledExactlyOnceWith("alpha", { agent: "openclaw" }); + expect(scope.observePairing).toHaveBeenCalledOnce(); + expect(scope.runProducer).not.toHaveBeenCalled(); + expect(scope.runApproval).not.toHaveBeenCalled(); + }); + + it.each<[string, boolean]>([ + ["registry update fails", false], + ["registry readback remains unchanged", true], + ])("fails closed when legacy OpenClaw repair %s (#9207)", async (_label, updateResult) => { + const updateSandbox = vi.fn(() => updateResult); + const scope = settlementDeps({ + getSandbox: vi.fn(() => ({ ...ENTRY, agent: null })), + updateSandbox, + }); + + await expect( + settlePortableOpenClawPairing("alpha", { portableRequired: true }, scope.deps), + ).resolves.toEqual({ + kind: "incomplete", + reason: "portable-runtime-identity-invalid", + }); + expect(updateSandbox).toHaveBeenCalledExactlyOnceWith("alpha", { agent: "openclaw" }); + expect(scope.calls).toEqual(["sandbox-lock"]); + expect(scope.observePairing).not.toHaveBeenCalled(); + expect(scope.runProducer).not.toHaveBeenCalled(); + expect(scope.runApproval).not.toHaveBeenCalled(); + }); + + it("does not repair a legacy OpenClaw row without exact receipt and policy authority (#9207)", async () => { + const updateSandbox = vi.fn(() => true); + const scope = settlementDeps({ + getSandbox: vi.fn(() => ({ + ...ENTRY, + agent: null, + lifecycleGeneration: "generation-2", + })), + updateSandbox, + }); + + await expect( + settlePortableOpenClawPairing("alpha", { portableRequired: true }, scope.deps), + ).resolves.toEqual({ + kind: "incomplete", + reason: "portable-runtime-identity-invalid", + }); + expect(updateSandbox).not.toHaveBeenCalled(); + expect(scope.observePairing).not.toHaveBeenCalled(); + expect(scope.runProducer).not.toHaveBeenCalled(); + expect(scope.runApproval).not.toHaveBeenCalled(); + }); + + it("never rewrites Portable Hermes when OpenClaw finalization is requested (#9207)", async () => { + const updateSandbox = vi.fn(() => true); + const scope = settlementDeps({ + getSandbox: vi.fn(() => ({ ...ENTRY, agent: "hermes" })), + updateSandbox, + }); + + await expect( + settlePortableOpenClawPairing("alpha", { portableRequired: true }, scope.deps), + ).resolves.toEqual({ kind: "not-portable" }); + expect(updateSandbox).not.toHaveBeenCalled(); + }); + it("rejects a receipt from another registry generation before observing or writing (#9207)", async () => { const scope = settlementDeps({ getSandbox: vi.fn(() => ({ ...ENTRY, lifecycleGeneration: "generation-2" })), diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index cfa36d88af..ee7f018940 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -2044,7 +2044,7 @@ async function createSandboxWithBaseImageResolution( inferenceSelection: sandboxRegistration.selection(sandboxName, provider, model, preferredInferenceApi, createIntent?.endpointSource ?? null), runtimeFields: sandboxRuntimeFields, agent, - agentVersionKnown: !fromDockerfile, + agentVersionKnown: !fromDockerfile, portableLifecycle: sandboxGpuCreateFlow.resolvePortableLifecycleMode(agent, process.env), imageTag: resolvedImageTag, workload: workloadReceipt, openclawImagePluginInstalls, diff --git a/src/lib/onboard/experimental/portable-runtime-receipt-readiness.ts b/src/lib/onboard/experimental/portable-runtime-receipt-readiness.ts index e9ad90c805..f066204e56 100644 --- a/src/lib/onboard/experimental/portable-runtime-receipt-readiness.ts +++ b/src/lib/onboard/experimental/portable-runtime-receipt-readiness.ts @@ -50,6 +50,18 @@ export type PortableLifecycleReceiptClassification = } | { readonly kind: "invalid-or-legacy" }; +/** Prove that a current Portable receipt owns the selected registry generation. */ +export function portableLifecycleReceiptMatchesGeneration( + receipt: PortableLifecycleReceiptClassification, + lifecycleGeneration: string | undefined, +): receipt is Extract { + return ( + receipt.kind === "current" && + typeof lifecycleGeneration === "string" && + lifecycleGeneration === receipt.registryGeneration + ); +} + function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } diff --git a/src/lib/onboard/machine/finalization-deps.ts b/src/lib/onboard/machine/finalization-deps.ts index 8a4f94593b..9c2a633db8 100644 --- a/src/lib/onboard/machine/finalization-deps.ts +++ b/src/lib/onboard/machine/finalization-deps.ts @@ -54,7 +54,9 @@ export const finalizationHandlerDeps = { }, settlePortablePairing( name: string, - options: { readonly portableRequired: true }, + options: { + readonly portableRequired: true; + }, ): ReturnType< (typeof import("../../actions/sandbox/launch-readiness"))["settlePortableOpenClawPairing"] > { diff --git a/src/lib/onboard/machine/handlers/finalization.test.ts b/src/lib/onboard/machine/handlers/finalization.test.ts index eb57926cbc..887403c3eb 100644 --- a/src/lib/onboard/machine/handlers/finalization.test.ts +++ b/src/lib/onboard/machine/handlers/finalization.test.ts @@ -186,6 +186,24 @@ describe("finalization handlers", () => { }); }); + it("uses strict Portable settlement for the default-null OpenClaw resume state (#9200)", async () => { + const { deps, calls } = createDeps({ readRegistryAgent: vi.fn(() => null) }); + const options = { + ...baseOptions(deps), + agent: null, + portableProfileSelected: true, + }; + + const result = await runFinalizationHandlers(options); + + expect(result.stateResult.type).toBe("complete"); + expect(calls.warmupScopeUpgrade).not.toHaveBeenCalled(); + expect(calls.autoPairScopeApproval).not.toHaveBeenCalled(); + expect(calls.settlePortablePairing).toHaveBeenCalledExactlyOnceWith("my-assistant", { + portableRequired: true, + }); + }); + it("fails selected Portable OpenClaw closed before ordinary writers when registry identity is invalid (#9207)", async () => { const { deps, calls } = createDeps({ settlePortablePairing: vi.fn(async () => ({ diff --git a/src/lib/onboard/machine/handlers/finalization.ts b/src/lib/onboard/machine/handlers/finalization.ts index d705195160..e7fe8bccba 100644 --- a/src/lib/onboard/machine/handlers/finalization.ts +++ b/src/lib/onboard/machine/handlers/finalization.ts @@ -65,14 +65,13 @@ export interface FinalizationStateOptions; portablePairingIncompleteMessage( sandboxName: string, - reason: Extract< - PortableOpenClawPairingSettlementResult, - { kind: "incomplete" } - >["reason"], + reason: Extract["reason"], ): string; getChatUiUrl(): string; /** @@ -138,7 +137,10 @@ function portableAgentDisposition( readRegistryAgent: (sandboxName: string) => string | null, ): PortableAgentDisposition { if (portableProfileSelected !== true) return "ordinary"; - const selectedAgent = (agent as { readonly name?: unknown } | null)?.name; + // The onboarding model represents the default OpenClaw selection as null. + // Keep malformed/unknown objects invalid; only the canonical null sentinel + // receives default-OpenClaw semantics. + const selectedAgent = agent === null ? "openclaw" : (agent as { readonly name?: unknown })?.name; if (selectedAgent === "openclaw") return "strict-openclaw"; if ( typeof selectedAgent === "string" && @@ -279,16 +281,16 @@ export async function handlePostVerifyState { "my-assistant", expect.objectContaining({ model: "model", provider: "provider" }), ); + expect( + calls.updateSandbox.mock.calls.some( + ([sandboxName, patch]) => + sandboxName === "my-assistant" && Object.prototype.hasOwnProperty.call(patch, "agent"), + ), + ).toBe(false); // Default-marking is deferred to finalization (#4614) — the sandbox step must not set it. expect(calls.complete).toHaveBeenCalledWith( "sandbox", @@ -274,33 +280,33 @@ describe("handleSandboxState", () => { expect(session.observabilityRequestedExplicitly).toBe(false); }); - it.each([ - "openclaw", - "hermes", - ])("requires an explicit observability disable when switching DCode to %s", async (agentName) => { - const session = createSession({ - agent: "langchain-deepagents-code", - observabilityEnabled: true, - }); - const { deps, calls } = createDeps({ - getSandboxRegistryEntry: (name: string) => dcodeRegistryEntry(name, true), - updateSession: vi.fn((mutator: (value: Session) => Session | void) => { - return mutator(session) ?? session; - }), - }); + it.each(["openclaw", "hermes"])( + "requires an explicit observability disable when switching DCode to %s", + async (agentName) => { + const session = createSession({ + agent: "langchain-deepagents-code", + observabilityEnabled: true, + }); + const { deps, calls } = createDeps({ + getSandboxRegistryEntry: (name: string) => dcodeRegistryEntry(name, true), + updateSession: vi.fn((mutator: (value: Session) => Session | void) => { + return mutator(session) ?? session; + }), + }); - await expect( - handleSandboxState({ - ...baseOptions(deps, session), - agent: { name: agentName }, - sandboxName: "saved", - }), - ).rejects.toThrow("exit 1"); + await expect( + handleSandboxState({ + ...baseOptions(deps, session), + agent: { name: agentName }, + sandboxName: "saved", + }), + ).rejects.toThrow("exit 1"); - expect(calls.error).toHaveBeenCalledWith(expect.stringContaining("--no-observability")); - expect(calls.createSandbox).not.toHaveBeenCalled(); - expect(session.observabilityEnabled).toBe(true); - }); + expect(calls.error).toHaveBeenCalledWith(expect.stringContaining("--no-observability")); + expect(calls.createSandbox).not.toHaveBeenCalled(); + expect(session.observabilityEnabled).toBe(true); + }, + ); it("requires an explicit disable when resumed session state has observability enabled", async () => { const session = createSession({ @@ -377,78 +383,78 @@ describe("handleSandboxState", () => { it.each([ { recorded: true, requested: false }, { recorded: false, requested: true }, - ])("gives current explicit observability=$requested precedence on resume", async ({ - recorded, - requested, - }) => { - const session = createSession({ - sandboxName: "saved", - observabilityEnabled: recorded, - observabilityRequestedExplicitly: true, - }); - session.steps.sandbox.status = "complete"; - const { deps, calls } = createDeps({ - getSandboxReuseState: () => "ready", - getSandboxRegistryEntry: (name: string) => dcodeRegistryEntry(name, recorded), - updateSession: vi.fn((mutator: (value: Session) => Session | void) => { - return mutator(session) ?? session; - }), - }); + ])( + "gives current explicit observability=$requested precedence on resume", + async ({ recorded, requested }) => { + const session = createSession({ + sandboxName: "saved", + observabilityEnabled: recorded, + observabilityRequestedExplicitly: true, + }); + session.steps.sandbox.status = "complete"; + const { deps, calls } = createDeps({ + getSandboxReuseState: () => "ready", + getSandboxRegistryEntry: (name: string) => dcodeRegistryEntry(name, recorded), + updateSession: vi.fn((mutator: (value: Session) => Session | void) => { + return mutator(session) ?? session; + }), + }); - await handleSandboxState({ - ...baseOptions(deps, session), - agent: { name: "langchain-deepagents-code" }, - resume: true, - sandboxName: "saved", - requestedObservabilityEnabled: requested, - }); + await handleSandboxState({ + ...baseOptions(deps, session), + agent: { name: "langchain-deepagents-code" }, + resume: true, + sandboxName: "saved", + requestedObservabilityEnabled: requested, + }); - expect(calls.createSandbox.mock.calls[0]?.at(-1)).toMatchObject({ - recreate: true, - observabilityEnabled: requested, - }); - expect(calls.note).toHaveBeenCalledWith( - " [resume] Observability configuration changed; recreating sandbox.", - ); - expect(session.observabilityEnabled).toBe(requested); - expect(session.observabilityRequestedExplicitly).toBe(true); - }); + expect(calls.createSandbox.mock.calls[0]?.at(-1)).toMatchObject({ + recreate: true, + observabilityEnabled: requested, + }); + expect(calls.note).toHaveBeenCalledWith( + " [resume] Observability configuration changed; recreating sandbox.", + ); + expect(session.observabilityEnabled).toBe(requested); + expect(session.observabilityRequestedExplicitly).toBe(true); + }, + ); it.each([ { recorded: false, requested: true }, { recorded: true, requested: false }, - ])("preserves interrupted explicit observability=$requested over registry=$recorded", async ({ - recorded, - requested, - }) => { - const session = createSession({ - sandboxName: "saved", - observabilityEnabled: requested, - observabilityRequestedExplicitly: true, - }); - session.steps.sandbox.status = "complete"; - const { deps, calls } = createDeps({ - getSandboxReuseState: () => "ready", - getSandboxRegistryEntry: (name: string) => dcodeRegistryEntry(name, recorded), - updateSession: vi.fn((mutator: (value: Session) => Session | void) => { - return mutator(session) ?? session; - }), - }); + ])( + "preserves interrupted explicit observability=$requested over registry=$recorded", + async ({ recorded, requested }) => { + const session = createSession({ + sandboxName: "saved", + observabilityEnabled: requested, + observabilityRequestedExplicitly: true, + }); + session.steps.sandbox.status = "complete"; + const { deps, calls } = createDeps({ + getSandboxReuseState: () => "ready", + getSandboxRegistryEntry: (name: string) => dcodeRegistryEntry(name, recorded), + updateSession: vi.fn((mutator: (value: Session) => Session | void) => { + return mutator(session) ?? session; + }), + }); - await handleSandboxState({ - ...baseOptions(deps, session), - agent: { name: "langchain-deepagents-code" }, - resume: true, - sandboxName: "saved", - }); + await handleSandboxState({ + ...baseOptions(deps, session), + agent: { name: "langchain-deepagents-code" }, + resume: true, + sandboxName: "saved", + }); - expect(calls.createSandbox.mock.calls[0]?.at(-1)).toMatchObject({ - recreate: true, - observabilityEnabled: requested, - }); - expect(session.observabilityEnabled).toBe(requested); - expect(session.observabilityRequestedExplicitly).toBe(true); - }); + expect(calls.createSandbox.mock.calls[0]?.at(-1)).toMatchObject({ + recreate: true, + observabilityEnabled: requested, + }); + expect(session.observabilityEnabled).toBe(requested); + expect(session.observabilityRequestedExplicitly).toBe(true); + }, + ); it("does not treat an interrupted omitted request as an explicit disable", async () => { const session = createSession({ diff --git a/src/lib/onboard/machine/handlers/sandbox.ts b/src/lib/onboard/machine/handlers/sandbox.ts index 810041afea..0dc7ffee98 100644 --- a/src/lib/onboard/machine/handlers/sandbox.ts +++ b/src/lib/onboard/machine/handlers/sandbox.ts @@ -1219,10 +1219,10 @@ class SandboxStateFlow< const recreate = state.session?.checkpoint?.sandboxRecreate; return Boolean( handoff && - recreate && - recreate.sandboxName === state.sandboxName && - recreate.targetIntentFingerprint === handoff && - sandboxRecreatePhaseReached(recreate.phase, "deleted"), + recreate && + recreate.sandboxName === state.sandboxName && + recreate.targetIntentFingerprint === handoff && + sandboxRecreatePhaseReached(recreate.phase, "deleted"), ); } @@ -1916,8 +1916,11 @@ 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 } = - this.deps.getSandboxAgentRegistryFields(this.options.agent, !this.options.fromDockerfile); + const { + nemoclawVersion: _builtFingerprint, + agent: _registeredAgent, + ...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, { model: this.options.model, diff --git a/src/lib/onboard/sandbox-registration.test.ts b/src/lib/onboard/sandbox-registration.test.ts index b1a341447b..3a9bc092a2 100644 --- a/src/lib/onboard/sandbox-registration.test.ts +++ b/src/lib/onboard/sandbox-registration.test.ts @@ -614,9 +614,87 @@ describe("selection", () => { }); describe("registerCreatedSandbox", () => { + const runtimeAuthority = { + schemaVersion: 1 as const, + kind: "podman" as const, + ownership: "current-user" as const, + uid: 1001, + homeDir: "/home/test", + configHome: "/home/test/.config", + runtimeDir: "/run/user/1001", + socketPath: "/run/user/1001/podman/podman.sock", + }; + + it("persists explicit OpenClaw identity for a matching Portable lifecycle receipt (#9207)", () => { + const registerSandbox = vi.fn(); + const env = { NEMOCLAW_EXPERIMENTAL_PROFILE: "portable" }; + const classifyPortableLifecycleReceipt = vi.fn(() => ({ + kind: "current" as const, + registryGeneration: "generation-1", + runtimeAuthority, + })); + + const entry = registerCreatedSandbox({ + ...createdRegistryEntryInput({ lifecycleGeneration: "generation-1" }), + portableLifecycle: true, + environment: env, + classifyPortableLifecycleReceipt, + registerSandbox, + }); + + expect(entry.agent).toBe("openclaw"); + expect(classifyPortableLifecycleReceipt).toHaveBeenCalledExactlyOnceWith("demo", { env }); + expect(registerSandbox).toHaveBeenCalledExactlyOnceWith(entry); + }); + + it.each([ + ["missing", { kind: "absent" as const }, "generation-1"], + ["legacy", { kind: "invalid-or-legacy" as const }, "generation-1"], + [ + "different generation", + { + kind: "current" as const, + registryGeneration: "generation-2", + runtimeAuthority, + }, + "generation-1", + ], + ])( + "rejects a Portable OpenClaw %s receipt before registry mutation (#9207)", + (_label, receipt, lifecycleGeneration) => { + const registerSandbox = vi.fn(); + + expect(() => + registerCreatedSandbox({ + ...createdRegistryEntryInput({ lifecycleGeneration }), + portableLifecycle: true, + environment: { NEMOCLAW_EXPERIMENTAL_PROFILE: "portable" }, + classifyPortableLifecycleReceipt: () => receipt, + registerSandbox, + }), + ).toThrow(/requires a current lifecycle receipt that matches the registry generation/u); + expect(registerSandbox).not.toHaveBeenCalled(); + }, + ); + + it("keeps ordinary OpenClaw registration agent-neutral (#9207)", () => { + const classifyPortableLifecycleReceipt = vi.fn(); + + const entry = registerCreatedSandbox({ + ...createdRegistryEntryInput({ lifecycleGeneration: "generation-1" }), + environment: {}, + classifyPortableLifecycleReceipt, + registerSandbox: vi.fn(), + }); + + expect(entry.agent).toBeNull(); + expect(classifyPortableLifecycleReceipt).not.toHaveBeenCalled(); + }); + 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 classifyPortableLifecycleReceipt = vi.fn(); const entry = registerCreatedSandbox({ sandboxName: "hermes-box", @@ -643,6 +721,8 @@ describe("registerCreatedSandbox", () => { lifecycleGeneration: "22222222-2222-4222-8222-222222222222", lifecycleLiveIdentityFingerprint: "d".repeat(64), gatewayName: "owner-gateway", + environment: { NEMOCLAW_EXPERIMENTAL_PROFILE: "portable" }, + classifyPortableLifecycleReceipt, gatewayPort: 8080, registerSandbox, }); @@ -654,6 +734,8 @@ describe("registerCreatedSandbox", () => { gatewayName: "owner-gateway", }); expect(registerSandbox).toHaveBeenCalledExactlyOnceWith(entry); + expect(entry.agent).toBe("hermes"); + expect(classifyPortableLifecycleReceipt).not.toHaveBeenCalled(); }); it("passes the built entry to the supplied registry writer", () => { diff --git a/src/lib/onboard/sandbox-registration.ts b/src/lib/onboard/sandbox-registration.ts index 2d4128484d..a0a16ca7eb 100644 --- a/src/lib/onboard/sandbox-registration.ts +++ b/src/lib/onboard/sandbox-registration.ts @@ -39,6 +39,10 @@ import { requireRuntimeProviderMutationAuthority, } from "./runtime-provider/access"; import { getRequestedSandboxAgentName, getSandboxAgentRegistryFields } from "./sandbox-agent"; +import { + classifyPortableLifecycleReceipt, + portableLifecycleReceiptMatchesGeneration, +} from "./experimental/portable-runtime-receipt-readiness"; export type CreatedSandboxRuntimeFields = Pick< SandboxEntry, @@ -93,6 +97,9 @@ export interface CreatedSandboxRegistryEntryInput { } export interface CreatedSandboxRegistrationInput extends CreatedSandboxRegistryEntryInput { + portableLifecycle?: boolean; + environment?: NodeJS.ProcessEnv; + classifyPortableLifecycleReceipt?: typeof classifyPortableLifecycleReceipt; registerSandbox?(entry: SandboxEntry): void; runtimeProviders?: RuntimeProviderBundleRegistry; } @@ -322,6 +329,23 @@ export function registerCreatedSandbox(input: CreatedSandboxRegistrationInput): ? {} : { hostLocalInferenceProvenance: pendingHostLocalInferenceProvenance }), }); + if (input.portableLifecycle === true) { + if (getRequestedSandboxAgentName(input.agent) !== "openclaw") { + throw new RuntimeProviderSelectionError( + "Portable lifecycle registration requires the OpenClaw agent.", + ); + } + const receipt = (input.classifyPortableLifecycleReceipt ?? classifyPortableLifecycleReceipt)( + input.sandboxName, + { env: input.environment ?? process.env }, + ); + if (!portableLifecycleReceiptMatchesGeneration(receipt, input.lifecycleGeneration)) { + throw new RuntimeProviderSelectionError( + "Portable OpenClaw registration requires a current lifecycle receipt that matches the registry generation.", + ); + } + entry.agent = "openclaw"; + } const provider = requireRuntimeProviderBundleForSandbox( entry, input.runtimeProviders ?? CURRENT_RUNTIME_PROVIDER_BUNDLES,