Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
9 changes: 6 additions & 3 deletions src/lib/onboard/onboard-recreate-journal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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();

Expand All @@ -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",
Expand All @@ -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",
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
201 changes: 199 additions & 2 deletions src/lib/onboard/sandbox-recreate-transaction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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",
Expand Down Expand Up @@ -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" });
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -521,7 +568,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 = {
Expand Down Expand Up @@ -887,3 +934,153 @@ 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", (_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 });
expect(fixture.lifecycle.capture({ lifecycleGeneration: TARGET_GENERATION })).toEqual({
lifecycleGeneration: TARGET_GENERATION,
lifecycleLiveIdentityFingerprint: TARGET_ID,
});
expect(fixture.session.checkpoint?.sandboxRecreate).toMatchObject({
phase: "created",
targetLiveIdentityFingerprint: TARGET_ID,
});
});

it("can revalidate the journaled identity after transient identity drift", () => {
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);

fixture.setObservation({ state: "ready", liveIdentityFingerprint: TARGET_ID });
expect(fixture.lifecycle.revalidate(registration)).toEqual(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);
});
});
Loading
Loading