Skip to content
Open
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
1 change: 0 additions & 1 deletion src/lib/onboard/machine/core-flow-phases.ts
Original file line number Diff line number Diff line change
Expand Up @@ -271,7 +271,6 @@ export function createSandboxOnboardFlowPhase<
context: mergeSandboxCreatedContext(context, {
session: sandboxStateResult.session,
sandboxName: sandboxStateResult.sandboxName,
recreateJournalHandoff: Boolean(options.recreateJournalTargetIntentFingerprint),
webSearchConfig: sandboxStateResult.webSearchConfig,
webSearchConfigChanged: sandboxStateResult.webSearchConfigChanged,
hermesToolGateways: sandboxStateResult.hermesToolGateways,
Expand Down
2 changes: 0 additions & 2 deletions src/lib/onboard/machine/final-flow-phases.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,6 @@ export function createFinalOnboardFlowPhases<
? options.finalization.webSearchProvider(context.webSearchConfig)
: null,
portableProfileSelected: context.session?.checkpoint?.profile.value === "portable",
recreateJournalHandoff: context.recreateJournalHandoff,
deps: { ...finalizationDeps, revalidatePolicyRequirements },
});
return { result: finalizationResult.stateResult };
Expand All @@ -164,7 +163,6 @@ export function createFinalOnboardFlowPhases<
? options.finalization.webSearchProvider(context.webSearchConfig)
: null,
portableProfileSelected: context.session?.checkpoint?.profile.value === "portable",
recreateJournalHandoff: context.recreateJournalHandoff,
deps: {
...finalizationDeps,
revalidatePolicyRequirements: revalidationFor(context),
Expand Down
2 changes: 0 additions & 2 deletions src/lib/onboard/machine/flow-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import type { OnboardStateHandlerResult } from "./runner";
export interface OnboardFlowContext<Agent = unknown, Gpu = unknown, SandboxGpuConfig = unknown> {
resume: boolean;
fresh: boolean;
recreateJournalHandoff?: boolean;
session: Session | null;
agent: Agent;
recordedSandboxName: string | null;
Expand Down Expand Up @@ -90,7 +89,6 @@ export interface ProviderModelSelectedContextUpdate {
export interface SandboxCreatedContextUpdate {
session: Session | null;
sandboxName: string;
recreateJournalHandoff?: boolean;
webSearchConfig: WebSearchConfig | null;
webSearchConfigChanged: boolean;
hermesToolGateways: string[];
Expand Down
16 changes: 1 addition & 15 deletions src/lib/onboard/machine/handlers/finalization.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -664,7 +664,7 @@ describe("finalization handlers", () => {
expect(calls.reportReadiness).toHaveBeenCalledWith(false);
});

it("settles ordinary OpenClaw pairing after recovery and before verification (#9844)", async () => {
it("settles ordinary OpenClaw pairing after recovery and before verification (#9844, #10479)", async () => {
const { deps, calls } = createDeps();

await runFinalizationHandlers(baseOptions(deps));
Expand All @@ -681,20 +681,6 @@ describe("finalization handlers", () => {
);
});

it("does not settle ordinary OpenClaw pairing during an inner rebuild handoff (#9844)", async () => {
const { deps, calls } = createDeps();

const result = await runFinalizationHandlers({
...baseOptions(deps),
recreateJournalHandoff: true,
});

expect(result.stateResult.type).toBe("complete");
expect(calls.settleOrdinaryPairing).not.toHaveBeenCalled();
expect(calls.ensureAgentDashboard).toHaveBeenCalledWith("my-assistant", null);
expect(calls.verify).toHaveBeenCalledOnce();
});

it("does not run OpenClaw pairing settlement for Hermes (#9844)", async () => {
const { deps, calls } = createDeps();
const agent = { name: "hermes" };
Expand Down
19 changes: 10 additions & 9 deletions src/lib/onboard/machine/handlers/finalization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,6 @@ export interface FinalizationStateOptions<Agent, VerifyChain, VerificationResult
webSearchEnabled: boolean;
webSearchProvider: WebSearchVerifyProvider | null;
portableProfileSelected?: boolean;
recreateJournalHandoff?: boolean;
deps: {
ensureAgentDashboardForward(
sandboxName: string,
Expand Down Expand Up @@ -185,7 +184,6 @@ export async function handleFinalizationState<Agent, VerifyChain, VerificationRe
sandboxName,
agent,
portableProfileSelected,
recreateJournalHandoff,
stagedLegacyKeys,
migratedLegacyKeys,
deps,
Expand All @@ -201,10 +199,12 @@ export async function handleFinalizationState<Agent, VerifyChain, VerificationRe
portableProfileSelected,
deps.readRegistryAgent,
);
// Rebuild recreates the container from the image, which wipes the
// machine-local pairing state (`identity` and `devices` are declared
// `backup: false` and removed on destroy), so the pairing gate must run on
// rebuild handoff exactly as on fresh onboarding (#10479).
const ordinaryOpenClawPairingRequired =
portableAgent === "ordinary" &&
selectedAgentName(agent) === "openclaw" &&
recreateJournalHandoff !== true;
portableAgent === "ordinary" && selectedAgentName(agent) === "openclaw";
const revalidate = (operation: string) => deps.revalidatePolicyRequirements?.(operation);

// Reaching finalization means the policy-preset step was confirmed, so it is
Expand Down Expand Up @@ -276,7 +276,6 @@ export async function handlePostVerifyState<Agent, VerifyChain, VerificationResu
webSearchEnabled,
webSearchProvider,
portableProfileSelected,
recreateJournalHandoff,
deps,
}: FinalizationStateOptions<
Agent,
Expand All @@ -290,10 +289,12 @@ export async function handlePostVerifyState<Agent, VerifyChain, VerificationResu
portableProfileSelected,
deps.readRegistryAgent,
);
// Rebuild recreates the container from the image, which wipes the
// machine-local pairing state (`identity` and `devices` are declared
// `backup: false` and removed on destroy), so the pairing gate must run on
// rebuild handoff exactly as on fresh onboarding (#10479).
const ordinaryOpenClawPairingRequired =
portableAgent === "ordinary" &&
selectedAgentName(agent) === "openclaw" &&
recreateJournalHandoff !== true;
portableAgent === "ordinary" && selectedAgentName(agent) === "openclaw";
const revalidate = (operation: string) => deps.revalidatePolicyRequirements?.(operation);

let verificationDiagnostics: string[] = [];
Expand Down
204 changes: 148 additions & 56 deletions src/lib/onboard/machine/rebuild-pairing-handoff.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,36 +3,32 @@

import { beforeEach, describe, expect, it, vi } from "vitest";

import type { SessionUpdates } from "../../state/onboard-session";
import type { FinalizationStateOptions } from "./handlers/finalization";

const mocks = vi.hoisted(() => ({
handleSandboxState: vi.fn(),
handleFinalizationState: vi.fn(),
handlePostVerifyState: vi.fn(),
}));

vi.mock("./handlers/sandbox", async (importOriginal) => ({
...(await importOriginal<typeof import("./handlers/sandbox")>()),
handleSandboxState: mocks.handleSandboxState,
}));

vi.mock("./handlers/finalization", async (importOriginal) => ({
...(await importOriginal<typeof import("./handlers/finalization")>()),
handleFinalizationState: mocks.handleFinalizationState,
handlePostVerifyState: mocks.handlePostVerifyState,
}));

import { createSandboxOnboardFlowPhase } from "./core-flow-phases";
import { createFinalOnboardFlowPhases } from "./final-flow-phases";
import type { OnboardFlowContext } from "./flow-context";
import { advanceTo, branchTo, completeOnboardMachine } from "./result";
import { branchTo } from "./result";
import { createSession } from "../../state/onboard-session";

function context(
recreateJournalHandoff?: boolean,
): OnboardFlowContext<null, null, Record<string, never>> {
type Agent = { name: string } | null;
type VerifyChain = { port: number };
type VerificationResult = { ok: boolean };

function context(): OnboardFlowContext<Agent, null, Record<string, never>> {
return {
resume: true,
fresh: false,
recreateJournalHandoff,
session: createSession(),
agent: null,
recordedSandboxName: "alpha",
Expand All @@ -58,6 +54,72 @@ function context(
};
}

/**
* Dependencies for the real finalization handlers used by the rebuild handoff
* test. The settlement and verification spies let the flow test prove the
* public path: a journaled rebuild handoff must settle ordinary OpenClaw
* pairing and finish settlement before deployment verification.
*/
function finalizationDeps(
calls: ReturnType<typeof createFinalizationCalls>["calls"],
): FinalizationStateOptions<Agent, VerifyChain, VerificationResult>["deps"] {
return {
ensureAgentDashboardForward: calls.ensureAgentDashboard,
persistDashboardPort: calls.persistDashboardPort,
setDefaultSandbox: calls.setDefaultSandbox,
toSessionUpdates: (updates: Record<string, unknown>) => updates as SessionUpdates,
removeLegacyCredentialsFile: calls.removeLegacy,
cleanupStaleHostFiles: calls.cleanupHost,
checkAndRecoverSandboxProcesses: calls.recoverProcesses,
settleOrdinaryOpenClawPairing: calls.settleOrdinaryPairing,
ordinaryOpenClawPairingIncompleteMessage: calls.ordinaryPairingIncompleteMessage,
readRegistryAgent: calls.readRegistryAgent,
settlePortablePairing: calls.settlePortablePairing,
portablePairingIncompleteMessage: calls.portablePairingIncompleteMessage,
getChatUiUrl: calls.getChatUiUrl,
buildVerifyChain: calls.buildChain,
verifyDeployment: calls.verify,
formatVerificationDiagnostics: calls.diagnostics,
verifyWebSearchInsideSandbox: calls.verifyWebSearch,
printDashboard: calls.dashboard,
isDeploymentHealthy: calls.isHealthy,
reportDeploymentReadiness: calls.reportReadiness,
error: calls.error,
log: calls.log,
};
}

function createFinalizationCalls() {
const calls = {
ensureAgentDashboard: vi.fn(() => 18789),
persistDashboardPort: vi.fn(),
setDefaultSandbox: vi.fn(),
removeLegacy: vi.fn(),
cleanupHost: vi.fn(),
recoverProcesses: vi.fn(),
settleOrdinaryPairing: vi.fn(async () => ({ kind: "settled" as const })),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
ordinaryPairingIncompleteMessage: vi.fn(
() => "OpenClaw onboarding is incomplete; resume onboarding.",
),
readRegistryAgent: vi.fn(() => "openclaw"),
settlePortablePairing: vi.fn(async () => ({ kind: "settled" as const })),
portablePairingIncompleteMessage: vi.fn(
() => "Portable onboarding is incomplete; resume onboarding.",
),
getChatUiUrl: vi.fn(() => "http://127.0.0.1:18789"),
buildChain: vi.fn(() => ({ port: 18789 })),
verify: vi.fn(async () => ({ ok: true })),
diagnostics: vi.fn(() => []),
verifyWebSearch: vi.fn(() => true),
dashboard: vi.fn(),
isHealthy: vi.fn(() => true),
reportReadiness: vi.fn(),
error: vi.fn(),
log: vi.fn(),
};
return { calls };
}

describe("rebuild pairing handoff", () => {
beforeEach(() => {
mocks.handleSandboxState.mockReset().mockResolvedValue({
Expand All @@ -70,23 +132,11 @@ describe("rebuild pairing handoff", () => {
session: createSession(),
stateResult: branchTo("openclaw", { metadata: { state: "sandbox" } }),
});
mocks.handleFinalizationState.mockReset().mockResolvedValue({
stateResult: advanceTo("post_verify", { metadata: { state: "finalizing" } }),
unmigratedLegacyKeys: [],
});
mocks.handlePostVerifyState.mockReset().mockResolvedValue({
stateResult: completeOnboardMachine({}, { metadata: { state: "post_verify" } }),
verificationDiagnostics: [],
deploymentHealthy: true,
});
});

it.each([
{ fingerprint: "intent-1", expected: true },
{ fingerprint: null, expected: false },
])(
"maps journal fingerprint $fingerprint to handoff=$expected (#9844)",
async ({ fingerprint, expected }) => {
it.each([{ fingerprint: "intent-1" }, { fingerprint: null }])(
"keeps routing journal fingerprint $fingerprint to the sandbox resume decision (#9844)",
async ({ fingerprint }) => {
const phase = createSandboxOnboardFlowPhase({
gatewayName: "nemoclaw",
recreateJournalTargetIntentFingerprint: fingerprint,
Expand All @@ -99,41 +149,83 @@ describe("rebuild pairing handoff", () => {
deps: {} as never,
});

const result = await phase.run(context());
await phase.run(context());

expect(result.context.recreateJournalHandoff).toBe(expected);
expect(mocks.handleSandboxState).toHaveBeenCalledWith(
expect.objectContaining({ recreateJournalTargetIntentFingerprint: fingerprint }),
);
},
);

it.each([true, false])(
"passes handoff=%s from final-flow context to both final handlers (#9844)",
async (recreateJournalHandoff) => {
const phases = createFinalOnboardFlowPhases({
branchState: "openclaw",
agentSetupDeps: {} as never,
policiesDeps: {} as never,
finalization: {
stagedLegacyKeys: [],
migratedLegacyKeys: new Set(),
webSearchEnabled: () => false,
webSearchProvider: () => "brave",
},
finalizationDeps: {} as never,
});
const finalContext = context(recreateJournalHandoff);
it("settles ordinary OpenClaw pairing before deployment verification on a rebuild handoff (#10479)", async () => {
// Container recreation wipes the machine-local pairing state
// (identity/devices are `backup: false` and removed on destroy), so a
// rebuild handoff must reach finalization exactly like fresh onboarding:
// the real final handlers settle ordinary OpenClaw pairing before the
// deployment probe.
const sandboxPhase = createSandboxOnboardFlowPhase({
gatewayName: "nemoclaw",
recreateJournalTargetIntentFingerprint: "intent-1",
resumeAgentChanged: false,
endpointProvenance: { getSandboxRegistryEntry: () => null },
recreateSandbox: () => true,
controlUiPort: null,
rootDir: "/repo",
env: {},
deps: {} as never,
});
const sandboxResult = await sandboxPhase.run(context());
const { calls } = createFinalizationCalls();

await phases[2].run(finalContext);
await phases[3].run(finalContext);
// Hold pairing settlement open until the test proves the deployment probe
// waits for it: `verifyDeployment` must not start while pairing is still
// pending. A suppress-if-rebuild regression would let verification run
// immediately, so this ordering is the observable contract under test.
const events: string[] = [];
let releasePairing!: () => void;
const pairingGate = new Promise<void>((resolve) => {
releasePairing = resolve;
});
calls.settleOrdinaryPairing.mockImplementation(async () => {
events.push("pairing-started");
await pairingGate;
events.push("pairing-settled");
return { kind: "settled" as const };
});
calls.verify.mockImplementation(async () => {
events.push("verify");
return { ok: true };
});

expect(mocks.handleFinalizationState).toHaveBeenCalledWith(
expect.objectContaining({ recreateJournalHandoff }),
);
expect(mocks.handlePostVerifyState).toHaveBeenCalledWith(
expect.objectContaining({ recreateJournalHandoff }),
);
},
);
const phases = createFinalOnboardFlowPhases({
branchState: "openclaw",
agentSetupDeps: {
persistDashboardPort: calls.persistDashboardPort,
} as never,
policiesDeps: {} as never,
finalization: {
stagedLegacyKeys: [],
migratedLegacyKeys: new Set(),
webSearchEnabled: () => false,
webSearchProvider: () => "brave",
},
finalizationDeps: finalizationDeps(calls),
});

await phases[2].run(sandboxResult.context);
const postVerifyRun = phases[3].run(sandboxResult.context);

await vi.waitFor(() => {
expect(events).toContain("pairing-started");
});
expect(events).not.toContain("verify");
releasePairing();
await postVerifyRun;

expect(calls.settleOrdinaryPairing).toHaveBeenCalledExactlyOnceWith("alpha");
expect(calls.settleOrdinaryPairing.mock.invocationCallOrder[0]).toBeGreaterThan(
calls.recoverProcesses.mock.invocationCallOrder[0],
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
expect(events).toEqual(["pairing-started", "pairing-settled", "verify"]);
});
});