Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
2 changes: 1 addition & 1 deletion ci/source-architecture-budget.json
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
11 changes: 5 additions & 6 deletions src/lib/onboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2485,6 +2485,7 @@ async function createSandboxWithBaseImageResolution(
request: managedStartupRootApplyRequest,
intendedWorkloadArgv: intendedSandboxStartupCommand,
});
const createdSandboxLifecycle = sandboxRecreateTransaction.createCreatedSandboxLifecycle(recreateRuntime, { sandboxName, gatewayName: GATEWAY_NAME }, getSandboxRecreateObservation);
const {
createResult,
runtimePatch,
Expand All @@ -2506,7 +2507,7 @@ async function createSandboxWithBaseImageResolution(
createArgv,
sandboxEnv,
sandboxStartupCommand,
lifecycleGeneration: recreateRuntime.targetGeneration,
lifecycleGeneration: createdSandboxLifecycle.generation,
prebuild,
restoreBackupPath,
terminalAgent: agentDefs.isTerminalAgent(agent),
Expand Down Expand Up @@ -2578,7 +2579,7 @@ async function createSandboxWithBaseImageResolution(
resolveSandboxImageTagFromCreateOutput,
});
const sandboxRuntimeFields = getSandboxRuntimeRegistryFields(effectiveSandboxGpuConfig);
recreateRuntime.recordCreated();
const pinnedLifecycleRegistration = createdSandboxLifecycle.capture(lifecycleRegistrationFields);
finalizeCreatedSandbox(
{
sandboxName,
Expand All @@ -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,
Expand All @@ -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,
Expand Down
21 changes: 20 additions & 1 deletion src/lib/onboard/sandbox-gpu-create-flow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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(() => {
Expand Down
110 changes: 110 additions & 0 deletions src/lib/onboard/sandbox-recreate-transaction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,18 +23,21 @@ import {
advanceSandboxRecreateTransaction,
assertSandboxRecreateSourceProof,
beginSandboxRecreateTransaction,
captureCreatedSandboxLifecycleRegistration,
clearCompletedSandboxRecreateTransaction,
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";
Expand All @@ -49,6 +52,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",
Expand Down Expand Up @@ -887,3 +891,109 @@ describe("journal-bound source proof", () => {
).toThrow(/reports no OpenShell Id/);
});
});

describe("created sandbox lifecycle registration", () => {
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);
});
});
150 changes: 150 additions & 0 deletions src/lib/onboard/sandbox-recreate-transaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,156 @@ export interface SandboxRecreateObservation {
readonly liveIdentityFingerprint: string | null;
}

export type CreatedSandboxLifecycleRegistration = Required<
Pick<SandboxEntry, "lifecycleGeneration" | "lifecycleLiveIdentityFingerprint">
>;

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<SandboxEntry, "lifecycleGeneration">,
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<SandboxEntry, "lifecycleGeneration">,
): 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) => {
runtime.recordCreated();
return selectCreatedSandboxLifecycleRegistration(
target.sandboxName,
captureCreatedSandboxLifecycleRegistration(
target,
generation,
lifecycleRegistrationFields,
observe,
),
runtime.targetGeneration,
runtime.registrationFields,
);
},
revalidate: (registration) =>
revalidateCreatedSandboxLifecycleRegistration(target, registration, observe),
};
}

export interface SandboxRecreateSourceProof {
readonly transactionId: string;
readonly sandboxName: string;
Expand Down
Loading
Loading