Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 23 additions & 2 deletions src/lib/actions/sandbox/launch-readiness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,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;
Expand Down Expand Up @@ -931,19 +932,23 @@ function resolvePortablePairingTarget(
*/
export async function settlePortableOpenClawPairing(
sandboxName: string,
options: { readonly portableRequired?: boolean } = {},
options: {
readonly portableRequired?: boolean;
readonly onboardingExpectedAgent?: "openclaw";
} = {},
deps: LaunchReadinessDeps = {},
): Promise<PortableOpenClawPairingSettlementResult> {
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;
const runProducer = deps.runPortablePairingProducer ?? runPortableOpenClawPairingRequestProducer;
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" &&
Expand All @@ -954,6 +959,22 @@ export async function settlePortableOpenClawPairing(
}

const firstReceipt = classifyReceipt(sandboxName);
if (
firstEntry?.agent === null &&
options.portableRequired === true &&
options.onboardingExpectedAgent === "openclaw" &&
firstReceipt.kind === "current" &&
firstEntry.policyPresetsFinalized === true &&
firstEntry.lifecycleGeneration === firstReceipt.registryGeneration
) {
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" };
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,74 @@ 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<SandboxEntry>) => {
entry = { ...entry, ...updates };
return true;
});
const scope = settlementDeps({
getSandbox: vi.fn(() => entry),
updateSandbox,
});

await expect(
settlePortableOpenClawPairing(
"alpha",
{ portableRequired: true, onboardingExpectedAgent: "openclaw" },
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("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, onboardingExpectedAgent: "openclaw" },
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, onboardingExpectedAgent: "openclaw" },
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" })),
Expand Down
2 changes: 1 addition & 1 deletion src/lib/onboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { RuntimeProviderSelectionError } from "../runtime-provider/access";
import {
classifyPortableLifecycleReceipt,
type PortableLifecycleReceiptClassification,
} from "./portable-runtime-receipt-readiness";

export type PortableLifecycleReceiptClassifier = (
sandboxName: string,
deps?: { readonly env?: NodeJS.ProcessEnv },
) => PortableLifecycleReceiptClassification;

/** Require the durable identity that authorizes an explicit Portable OpenClaw registry agent. */
export function requirePortableOpenClawRegistryIdentity(
sandboxName: string,
lifecycleGeneration: string | undefined,
env: NodeJS.ProcessEnv,
classifyReceipt: PortableLifecycleReceiptClassifier = classifyPortableLifecycleReceipt,
): "openclaw" {
const receipt = classifyReceipt(sandboxName, { env });
if (
receipt.kind !== "current" ||
!lifecycleGeneration ||
lifecycleGeneration !== receipt.registryGeneration
) {
throw new RuntimeProviderSelectionError(
"Portable OpenClaw registration requires a current lifecycle receipt that matches the registry generation.",
);
}
return "openclaw";
}
5 changes: 4 additions & 1 deletion src/lib/onboard/machine/finalization-deps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,10 @@ export const finalizationHandlerDeps = {
},
settlePortablePairing(
name: string,
options: { readonly portableRequired: true },
options: {
readonly portableRequired: true;
readonly onboardingExpectedAgent: "openclaw";
},
): ReturnType<
(typeof import("../../actions/sandbox/launch-readiness"))["settlePortableOpenClawPairing"]
> {
Expand Down
1 change: 1 addition & 0 deletions src/lib/onboard/machine/handlers/finalization.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,7 @@ describe("finalization handlers", () => {
expect(calls.autoPairScopeApproval).not.toHaveBeenCalled();
expect(calls.settlePortablePairing).toHaveBeenCalledExactlyOnceWith("my-assistant", {
portableRequired: true,
onboardingExpectedAgent: "openclaw",
});
});

Expand Down
10 changes: 8 additions & 2 deletions src/lib/onboard/machine/handlers/finalization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,10 @@ export interface FinalizationStateOptions<Agent, VerifyChain, VerificationResult
readRegistryAgent(sandboxName: string): string | null;
settlePortablePairing(
sandboxName: string,
options: { readonly portableRequired: true },
options: {
readonly portableRequired: true;
readonly onboardingExpectedAgent: "openclaw";
},
): Promise<PortableOpenClawPairingSettlementResult>;
portablePairingIncompleteMessage(
sandboxName: string,
Expand Down Expand Up @@ -279,7 +282,10 @@ export async function handlePostVerifyState<Agent, VerifyChain, VerificationResu
if (portableAgent !== "ordinary") {
const pairing =
portableAgent === "strict-openclaw"
? await deps.settlePortablePairing(sandboxName, { portableRequired: true })
? await deps.settlePortablePairing(sandboxName, {
portableRequired: true,
onboardingExpectedAgent: "openclaw",
})
: ({
kind: "incomplete",
reason: "portable-runtime-identity-invalid",
Expand Down
29 changes: 29 additions & 0 deletions src/lib/onboard/machine/handlers/sandbox.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,35 @@ describe("handleSandboxState", () => {
expect(result.session?.checkpoint?.messaging).toEqual(decisionDeclined());
});

it("preserves an explicitly registered Portable OpenClaw identity after creation (#9207)", async () => {
const { deps, calls } = createDeps({
getSandboxRegistryEntry: (name) => ({
name,
agent: "openclaw",
provider: "provider",
model: "model",
endpointUrl: null,
preferredInferenceApi: "openai-completions",
lifecycleGeneration: "generation-1",
webSearchEnabled: false,
toolDisclosure: "progressive",
fromDockerfile: null,
hermesAuthMethod: null,
}),
});

await handleSandboxState({ ...baseOptions(deps), fresh: true });

expect(calls.updateSandbox).toHaveBeenCalledWith(
"my-assistant",
expect.objectContaining({
agent: "openclaw",
model: "model",
provider: "provider",
}),
);
});

it("records credential-provider bindings and the resource-profile decision in the checkpoint (#7022)", async () => {
const { deps } = createDeps({
configureWebSearch: vi.fn(async () => ({ fetchEnabled: true as const })),
Expand Down
6 changes: 6 additions & 0 deletions src/lib/onboard/machine/handlers/sandbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1918,6 +1918,12 @@ class SandboxStateFlow<
// 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 registeredAgent = this.deps.getSandboxRegistryEntry(sandboxName)?.agent;
// Registration may persist explicit OpenClaw only after it validates a
// Portable lifecycle receipt. Preserve that authority; never infer it here.
if (agentRegistryFields.agent === null && registeredAgent === "openclaw") {
agentRegistryFields.agent = registeredAgent;
}
// Preserve the validated route and credential env-var name, never a credential value.
this.deps.updateSandboxRegistry(sandboxName, {
model: this.options.model,
Expand Down
82 changes: 82 additions & 0 deletions src/lib/onboard/sandbox-registration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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,
});
Expand All @@ -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", () => {
Expand Down
Loading
Loading