Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
23 changes: 21 additions & 2 deletions src/lib/actions/sandbox/launch-readiness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -931,19 +933,22 @@ function resolvePortablePairingTarget(
*/
export async function settlePortableOpenClawPairing(
sandboxName: string,
options: { readonly portableRequired?: boolean } = {},
options: {
readonly portableRequired?: boolean;
} = {},
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,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" };
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<SandboxEntry>) => {
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" })),
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
12 changes: 12 additions & 0 deletions src/lib/onboard/experimental/portable-runtime-receipt-readiness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<PortableLifecycleReceiptClassification, { readonly kind: "current" }> {
return (
receipt.kind === "current" &&
typeof lifecycleGeneration === "string" &&
lifecycleGeneration === receipt.registryGeneration
);
}

function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
Expand Down
4 changes: 3 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,9 @@ export const finalizationHandlerDeps = {
},
settlePortablePairing(
name: string,
options: { readonly portableRequired: true },
options: {
readonly portableRequired: true;
},
): ReturnType<
(typeof import("../../actions/sandbox/launch-readiness"))["settlePortableOpenClawPairing"]
> {
Expand Down
18 changes: 18 additions & 0 deletions src/lib/onboard/machine/handlers/finalization.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => ({
Expand Down
22 changes: 12 additions & 10 deletions src/lib/onboard/machine/handlers/finalization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,14 +65,13 @@ export interface FinalizationStateOptions<Agent, VerifyChain, VerificationResult
readRegistryAgent(sandboxName: string): string | null;
settlePortablePairing(
sandboxName: string,
options: { readonly portableRequired: true },
options: {
readonly portableRequired: true;
},
): Promise<PortableOpenClawPairingSettlementResult>;
portablePairingIncompleteMessage(
sandboxName: string,
reason: Extract<
PortableOpenClawPairingSettlementResult,
{ kind: "incomplete" }
>["reason"],
reason: Extract<PortableOpenClawPairingSettlementResult, { kind: "incomplete" }>["reason"],
): string;
getChatUiUrl(): string;
/**
Expand Down Expand Up @@ -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" &&
Expand Down Expand Up @@ -279,16 +281,16 @@ 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,
})
: ({
kind: "incomplete",
reason: "portable-runtime-identity-invalid",
} as const);
if (pairing.kind !== "settled") {
const reason =
pairing.kind === "incomplete"
? pairing.reason
: "portable-runtime-identity-invalid";
pairing.kind === "incomplete" ? pairing.reason : "portable-runtime-identity-invalid";
const message = deps.portablePairingIncompleteMessage(sandboxName, reason);
deps.error(` ${message}`);
deps.reportDeploymentReadiness(false);
Expand Down
Loading
Loading