diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 2946c46f29a..95248e8f540 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -451,19 +451,14 @@ const { wasSandboxDefault, restoreDefaultAfterRecreate, }: typeof import("./onboard/cancel-rollback") = require("./onboard/cancel-rollback"); -const { - handleAgentSetupState, -}: typeof import("./onboard/machine/handlers/agent-setup") = require("./onboard/machine/handlers/agent-setup"); -const { - handleFinalizationState, -}: typeof import("./onboard/machine/handlers/finalization") = require("./onboard/machine/handlers/finalization"); -const { - handlePoliciesState, -}: typeof import("./onboard/machine/handlers/policies") = require("./onboard/machine/handlers/policies"); const { createCoreOnboardFlowPhases, runCoreOnboardFlowSlice, }: typeof import("./onboard/machine/core-flow-phases") = require("./onboard/machine/core-flow-phases"); +const { + createFinalOnboardFlowPhases, + runFinalOnboardFlowSlice, +}: typeof import("./onboard/machine/final-flow-phases") = require("./onboard/machine/final-flow-phases"); const { createInitialOnboardFlowPhases, runInitialOnboardFlowSlice, @@ -6681,19 +6676,32 @@ async function onboard(opts: OnboardOptions = {}): Promise { const hermesToolGateways = coreContext.hermesToolGateways; const nimContainer = coreContext.nimContainer; let webSearchConfig = coreContext.webSearchConfig as WebSearchConfig | null; - selectedMessagingChannels = coreContext.selectedMessagingChannels; const webSearchSupported = coreContext.webSearchSupported; - const agentSetupResult = await handleAgentSetupState({ - agent, + const finalFlowContext: CoreOnboardFlowContext = { + ...coreContext, + session, sandboxName, model, provider, - resume, - session, + endpointUrl, + credentialEnv, hermesAuthMethod, hermesToolGateways, - deps: { + nimContainer, + webSearchConfig, + selectedMessagingChannels: coreContext.selectedMessagingChannels, + webSearchSupported, + }; + let liveFinalFlowContext = finalFlowContext; + + const [branchSetupPhase, policiesPhase, finalizationPhase] = createFinalOnboardFlowPhases< + CoreOnboardFlowContext, + import("./dashboard/contract").DashboardDeliveryChain, + import("./verify-deployment").VerifyDeploymentResult + >({ + branchState: agent ? "agent_setup" : "openclaw", + agentSetupDeps: { handleAgentSetup: agentOnboard.handleAgentSetup, agentSetupContext: () => ({ step, @@ -6708,7 +6716,8 @@ async function onboard(opts: OnboardOptions = {}): Promise { recordStepFailed, skippedStepMessage, }), - ensureAgentDashboardForward, + ensureAgentDashboardForward: (name, selectedAgent) => + selectedAgent ? ensureAgentDashboardForward(name, selectedAgent) : 0, recordStepSkipped, isOpenclawReady, skippedStepMessage, @@ -6720,31 +6729,12 @@ async function onboard(opts: OnboardOptions = {}): Promise { toSessionUpdates: (updates) => toSessionUpdates(updates as Parameters[0]), }, - }); - await recordStateResult(agentSetupResult.stateResult); - - const policiesResult = await handlePoliciesState({ - resume, - sandboxName, - provider, - model, - endpointUrl, - credentialEnv, - selectedMessagingChannels, - webSearchConfig, - webSearchSupported, - hermesToolGateways, - agent, - deps: { + policiesDeps: { loadSession: onboardSession.loadSession, getActiveSandbox: (name) => registry.getSandbox(name), mergePolicyMessagingChannels, verifyCompatibleEndpointSandboxSmoke: (options) => - verifyCompatibleEndpointSandboxSmoke({ - ...options, - runOpenshell, - redact, - }), + verifyCompatibleEndpointSandboxSmoke({ ...options, runOpenshell, redact }), preparePolicyPresetResumeSelection: (name, options) => preparePolicyPresetResumeSelection({ policies }, name, options), arePolicyPresetsApplied, @@ -6758,23 +6748,14 @@ async function onboard(opts: OnboardOptions = {}): Promise { toSessionUpdates(updates as Parameters[0]), persistAppliedPolicyPresets: policyPresetCarry.persistFinalizedPolicyPresets, }, - }); - await recordStateResult(policiesResult.stateResult); - sandboxCancelRollback.disarm(); // #4614: policies confirmed, past the cancellable window - - const finalizationResult = await handleFinalizationState({ - sandboxName, - model, - provider, - nimContainer, - agent, - hermesAuthMethod, - hermesToolGateways, - stagedLegacyKeys, - migratedLegacyKeys, - webSearchEnabled: braveProviderProfile.shouldEnableBraveWebSearch(webSearchConfig), - deps: { - ensureAgentDashboardForward, + finalization: { + stagedLegacyKeys, + migratedLegacyKeys, + webSearchEnabled: (config) => braveProviderProfile.shouldEnableBraveWebSearch(config), + }, + finalizationDeps: { + ensureAgentDashboardForward: (name, selectedAgent) => + selectedAgent ? ensureAgentDashboardForward(name, selectedAgent) : 0, setDefaultSandbox: registry.setDefault, verifyWebSearchInsideSandbox, recordPostVerifyStarted, @@ -6788,7 +6769,8 @@ async function onboard(opts: OnboardOptions = {}): Promise { // biome-ignore format: keep src/lib/onboard.ts net-neutral for growth guardrail. buildChain({ chatUiUrl, isWsl: isWsl(), wslHostAddress: getWslHostAddress(), dashboardHealthEndpoint: agent?.dashboard.healthPath, gatewayPort: agent?.healthProbe.port, gatewayHealthEndpoint: agent?.healthProbe.url }), verifyDeployment: async (name, chain) => { - const verifyDeploymentModule: typeof import("./verify-deployment") = require("./verify-deployment"); + const verifyDeploymentModule: typeof import("./verify-deployment") = + require("./verify-deployment"); return verifyDeploymentModule.verifyDeployment(name, chain, { executeSandboxCommand: (sandbox: string, script: string) => executeSandboxCommandForVerification(sandbox, script), @@ -6810,13 +6792,14 @@ async function onboard(opts: OnboardOptions = {}): Promise { }, captureForwardList: () => runCaptureOpenshell(["forward", "list"], { ignoreError: true }) || null, - getMessagingChannels: () => selectedMessagingChannels || [], + getMessagingChannels: () => liveFinalFlowContext.selectedMessagingChannels || [], providerExistsInGateway: (providerName: string) => providerExistsInGateway(providerName), }); }, formatVerificationDiagnostics: (result) => { - const verifyDeploymentModule: typeof import("./verify-deployment") = require("./verify-deployment"); + const verifyDeploymentModule: typeof import("./verify-deployment") = + require("./verify-deployment"); return verifyDeploymentModule.formatVerificationDiagnostics(result); }, printDashboard, @@ -6824,7 +6807,20 @@ async function onboard(opts: OnboardOptions = {}): Promise { log: (message) => console.log(message), }, }); - await recordStateResult(finalizationResult.stateResult); + + await runFinalOnboardFlowSlice({ + context: finalFlowContext, + runtime: onboardRuntimeBoundary.getRuntime(), + phases: [branchSetupPhase, policiesPhase, finalizationPhase], + resume, + recordStateResult, + afterPoliciesResultApplied: () => { + sandboxCancelRollback.disarm(); + }, + onContextUpdated: (context) => { + liveFinalFlowContext = context; + }, + }); traceCompleted = true; } finally { releaseOnboardLock(); diff --git a/src/lib/onboard/machine/final-flow-phases.runtime.test.ts b/src/lib/onboard/machine/final-flow-phases.runtime.test.ts new file mode 100644 index 00000000000..1e251a112a3 --- /dev/null +++ b/src/lib/onboard/machine/final-flow-phases.runtime.test.ts @@ -0,0 +1,264 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it, vi } from "vitest"; +import { + context, + createPhases, + createRuntimeHarness, + sessionAt, +} from "../../../../test/helpers/onboard-final-flow-phases"; +import { createSession } from "../../state/onboard-session"; +import { runFinalOnboardFlowSlice } from "./final-flow-phases"; + +describe("final onboard flow runtime boundary", () => { + it("uses the strict final runner for fresh OpenClaw sessions with a real runtime boundary", async () => { + const order: string[] = []; + const harness = createRuntimeHarness(sessionAt("openclaw")); + const recorders = harness.boundary.recorders(); + const phases = createPhases("openclaw", order, { + loadSession: harness.getSession, + recordStepSkipped: recorders.recordStepSkipped, + recordStateSkipped: recorders.recordStateSkipped, + startRecordedStep: recorders.startRecordedStep, + recordStepComplete: recorders.recordStepComplete, + recordPostVerifyStarted: recorders.recordPostVerifyStarted, + }); + const compatibilityRecorder = vi.fn(recorders.recordStateResultWithStepCompatibility); + + await runFinalOnboardFlowSlice({ + context: context({ session: harness.getSession() }), + runtime: harness.boundary.getRuntime(), + phases, + resume: false, + recordStateResult: compatibilityRecorder, + afterPoliciesResultApplied: () => { + order.push("disarm"); + }, + }); + + expect(compatibilityRecorder).not.toHaveBeenCalled(); + expect(order).toEqual(["openclaw", "policies", "disarm", "set-default", "verify"]); + expect(harness.getSession()).toMatchObject({ + status: "complete", + sandboxName: "my-sandbox", + provider: "nim", + model: "nvidia/test", + machine: { state: "complete" }, + }); + }); + + it.each([ + "policies", + "finalizing", + "post_verify", + ] as const)("keeps persisted %s sessions on the compatibility path with the real runtime boundary", async (initialState) => { + const order: string[] = []; + const harness = createRuntimeHarness(sessionAt(initialState)); + const recorders = harness.boundary.recorders(); + const phases = createPhases("openclaw", order, { + loadSession: harness.getSession, + recordStepSkipped: recorders.recordStepSkipped, + recordStateSkipped: recorders.recordStateSkipped, + startRecordedStep: recorders.startRecordedStep, + recordStepComplete: recorders.recordStepComplete, + recordPostVerifyStarted: recorders.recordPostVerifyStarted, + }); + const compatibilityRecorder = vi.fn(recorders.recordStateResultWithStepCompatibility); + + await runFinalOnboardFlowSlice({ + context: context({ session: harness.getSession() }), + runtime: harness.boundary.getRuntime(), + phases, + resume: false, + recordStateResult: compatibilityRecorder, + afterPoliciesResultApplied: () => { + order.push("disarm"); + }, + }); + + expect(compatibilityRecorder).toHaveBeenCalled(); + expect(order).toEqual(["openclaw", "policies", "disarm", "set-default", "verify"]); + expect(harness.getSession()).toMatchObject({ + status: "complete", + sandboxName: "my-sandbox", + provider: "nim", + model: "nvidia/test", + machine: { state: "complete" }, + }); + + const skippedTargets = harness.events + .filter((event) => event.type === "state.result.skipped") + .map((event) => event.metadata.targetState); + expect(skippedTargets).toContain("policies"); + if (initialState !== "policies") { + expect(skippedTargets).toContain("finalizing"); + } + }); + + it("uses the strict final runner for fresh agent sessions with a real runtime boundary", async () => { + const order: string[] = []; + const harness = createRuntimeHarness(sessionAt("agent_setup")); + const recorders = harness.boundary.recorders(); + const phases = createPhases("agent_setup", order, { + loadSession: harness.getSession, + recordStepSkipped: recorders.recordStepSkipped, + recordStateSkipped: recorders.recordStateSkipped, + startRecordedStep: recorders.startRecordedStep, + recordStepComplete: recorders.recordStepComplete, + recordPostVerifyStarted: recorders.recordPostVerifyStarted, + }); + const compatibilityRecorder = vi.fn(recorders.recordStateResultWithStepCompatibility); + + await runFinalOnboardFlowSlice({ + context: context({ agent: { name: "hermes" }, session: harness.getSession() }), + runtime: harness.boundary.getRuntime(), + phases, + resume: false, + recordStateResult: compatibilityRecorder, + afterPoliciesResultApplied: () => { + order.push("disarm"); + }, + }); + + expect(compatibilityRecorder).not.toHaveBeenCalled(); + expect(order).toEqual([ + "agent-setup", + "agent-forward", + "policies", + "disarm", + "set-default", + "agent-forward", + "verify", + ]); + expect(harness.getSession()).toMatchObject({ + status: "complete", + sandboxName: "my-sandbox", + provider: "nim", + model: "nvidia/test", + machine: { state: "complete" }, + }); + }); + + it("updates the live final context before strict final verification", async () => { + const order: string[] = []; + let liveChannels: string[] = []; + const harness = createRuntimeHarness(sessionAt("openclaw")); + const recorders = harness.boundary.recorders(); + const phases = createPhases("openclaw", order, { + loadSession: harness.getSession, + recordStepSkipped: recorders.recordStepSkipped, + recordStateSkipped: recorders.recordStateSkipped, + startRecordedStep: recorders.startRecordedStep, + recordStepComplete: recorders.recordStepComplete, + recordPostVerifyStarted: recorders.recordPostVerifyStarted, + mergePolicyMessagingChannels: () => ["slack", "discord"], + verifyDeployment: vi.fn(async () => { + order.push(`verify:${liveChannels.join(",")}`); + return { + healthy: true, + verification: { + gatewayReachable: true, + gatewayVersion: "test", + inferenceRouteWorking: true, + dashboardReachable: true, + messagingBridgesHealthy: true, + messagingRuntimeChannelsMissing: null, + messagingConfigChannelsMissing: null, + accessMethod: "localhost" as const, + }, + diagnostics: [], + }; + }), + }); + + await runFinalOnboardFlowSlice({ + context: context({ selectedMessagingChannels: ["slack"], session: harness.getSession() }), + runtime: harness.boundary.getRuntime(), + phases, + resume: false, + recordStateResult: vi.fn(), + afterPoliciesResultApplied: () => { + order.push("disarm"); + }, + onContextUpdated: (updatedContext) => { + liveChannels = updatedContext.selectedMessagingChannels; + }, + }); + + expect(order).toEqual([ + "openclaw", + "policies", + "disarm", + "set-default", + "verify:slack,discord", + ]); + }); + + it("keeps rollback armed when recording the policies FSM result fails", async () => { + const order: string[] = []; + const phases = createPhases("openclaw", order); + + await expect( + runFinalOnboardFlowSlice({ + context: context(), + runtime: { + session: async () => sessionAt("policies"), + applyResult: async () => createSession(), + }, + phases, + resume: false, + recordStateResult: async (result) => { + if (result.type === "transition" && result.next === "finalizing") { + throw new Error("recording failed"); + } + }, + afterPoliciesResultApplied: () => { + order.push("disarm"); + }, + }), + ).rejects.toThrow("recording failed"); + + expect(order).toEqual(["openclaw", "policies"]); + }); + + it("does not complete or print dashboard when strict final verification fails", async () => { + const order: string[] = []; + const harness = createRuntimeHarness(sessionAt("openclaw")); + const recorders = harness.boundary.recorders(); + const printDashboard = vi.fn(); + const phases = createPhases("openclaw", order, { + loadSession: harness.getSession, + recordStepSkipped: recorders.recordStepSkipped, + recordStateSkipped: recorders.recordStateSkipped, + startRecordedStep: recorders.startRecordedStep, + recordStepComplete: recorders.recordStepComplete, + recordPostVerifyStarted: recorders.recordPostVerifyStarted, + verifyDeployment: vi.fn(async () => { + order.push("verify"); + throw new Error("verification failed"); + }), + printDashboard, + }); + + await expect( + runFinalOnboardFlowSlice({ + context: context({ session: harness.getSession() }), + runtime: harness.boundary.getRuntime(), + phases, + resume: false, + recordStateResult: vi.fn(), + afterPoliciesResultApplied: () => { + order.push("disarm"); + }, + }), + ).rejects.toThrow("verification failed"); + + expect(order).toEqual(["openclaw", "policies", "disarm", "set-default", "verify"]); + expect(printDashboard).not.toHaveBeenCalled(); + expect(harness.getSession()).toMatchObject({ + status: "in_progress", + machine: { state: "post_verify" }, + }); + }); +}); diff --git a/src/lib/onboard/machine/final-flow-phases.test.ts b/src/lib/onboard/machine/final-flow-phases.test.ts new file mode 100644 index 00000000000..6542c5a041d --- /dev/null +++ b/src/lib/onboard/machine/final-flow-phases.test.ts @@ -0,0 +1,79 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it, vi } from "vitest"; +import { context, createPhases } from "../../../../test/helpers/onboard-final-flow-phases"; +import { createSession } from "../../state/onboard-session"; +import { runFinalOnboardFlowSlice } from "./final-flow-phases"; + +describe("final onboard flow phases", () => { + it("selects the requested branch setup state", () => { + expect(createPhases("openclaw")[0].state).toBe("openclaw"); + expect(createPhases("agent_setup")[0].state).toBe("agent_setup"); + }); + + it("runs policies before final verification", async () => { + const order: string[] = []; + const [branchPhase, policiesPhase, finalizationPhase] = createPhases("openclaw", order); + + const branchResult = await branchPhase.run(context()); + const policiesResult = await policiesPhase.run(branchResult.context); + await finalizationPhase.run(policiesResult.context); + + expect(order).toEqual(["openclaw", "policies", "set-default", "verify"]); + }); + + it("carries merged policy messaging channels into the final flow context", async () => { + const mergePolicyMessagingChannels = vi.fn(() => ["slack", "discord"]); + const [, policiesPhase] = createPhases("openclaw", [], { mergePolicyMessagingChannels }); + + const result = await policiesPhase.run(context({ selectedMessagingChannels: ["slack"] })); + + expect(mergePolicyMessagingChannels).toHaveBeenCalledWith(["slack"], [], undefined, undefined); + expect(result.context.selectedMessagingChannels).toEqual(["slack", "discord"]); + }); + + it("rejects final phases when required context is missing", async () => { + const [branchPhase, policiesPhase, finalizationPhase] = createPhases("openclaw"); + const incomplete = context({ sandboxName: null }); + + await expect(branchPhase.run(incomplete)).rejects.toThrow( + "Onboarding state is incomplete before agent setup.", + ); + await expect(policiesPhase.run(incomplete)).rejects.toThrow( + "Onboarding state is incomplete before policies.", + ); + await expect(finalizationPhase.run(incomplete)).rejects.toThrow( + "Onboarding state is incomplete before finalization.", + ); + }); + + it("records each phase result on the resume compatibility path", async () => { + const order: string[] = []; + const recorded: string[] = []; + const phases = createPhases("openclaw", order); + + await runFinalOnboardFlowSlice({ + context: context({ resume: true }), + runtime: { + session: async () => createSession(), + applyResult: async () => createSession(), + }, + phases, + resume: true, + recordStateResult: async (result) => { + if (result.type === "complete" || result.type === "failed") { + recorded.push(result.type); + } else { + recorded.push(result.next); + } + }, + afterPoliciesResultApplied: () => { + order.push("disarm"); + }, + }); + + expect(order).toEqual(["openclaw", "policies", "disarm", "set-default", "verify"]); + expect(recorded).toEqual(["policies", "finalizing", "complete"]); + }); +}); diff --git a/src/lib/onboard/machine/final-flow-phases.ts b/src/lib/onboard/machine/final-flow-phases.ts new file mode 100644 index 00000000000..9ea996171b8 --- /dev/null +++ b/src/lib/onboard/machine/final-flow-phases.ts @@ -0,0 +1,211 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { WebSearchConfig } from "../../inference/web-search"; +import type { OnboardFlowContext } from "./flow-context"; +import { + createAgentSetupPhase, + createFinalizationPhase, + createOpenclawSetupPhase, + createPoliciesPhase, +} from "./flow-phases/agent-policy-finalization"; +import { runFinalOnboardFlowSequence } from "./flow-slices"; +import { type AgentSetupStateOptions, handleAgentSetupState } from "./handlers/agent-setup"; +import { type FinalizationStateOptions, handleFinalizationState } from "./handlers/finalization"; +import { handlePoliciesState, type PoliciesStateOptions } from "./handlers/policies"; +import type { OnboardStateResult } from "./result"; +import type { OnboardMachineRunnerRuntime, OnboardStateHandlerResult } from "./runner"; +import type { OnboardSequencePhase } from "./sequence-runner"; + +export interface FinalOnboardFlowPhaseOptions< + Context extends OnboardFlowContext, + VerifyChain = unknown, + VerificationResult = unknown, +> { + branchState: "agent_setup" | "openclaw"; + agentSetupDeps: AgentSetupStateOptions["deps"]; + policiesDeps: PoliciesStateOptions["deps"]; + finalization: { + stagedLegacyKeys: readonly string[]; + migratedLegacyKeys: ReadonlySet; + webSearchEnabled(webSearchConfig: WebSearchConfig | null): boolean; + }; + finalizationDeps: FinalizationStateOptions< + Context["agent"], + VerifyChain, + VerificationResult + >["deps"]; +} + +function requireFinalContext( + context: Context, + stepName: string, +): asserts context is Context & { sandboxName: string; model: string; provider: string } { + if (!context.sandboxName || !context.model || !context.provider) { + throw new Error(`Onboarding state is incomplete before ${stepName}.`); + } +} + +export function createFinalOnboardFlowPhases< + Context extends OnboardFlowContext, + VerifyChain = unknown, + VerificationResult = unknown, +>( + options: FinalOnboardFlowPhaseOptions, +): [OnboardSequencePhase, OnboardSequencePhase, OnboardSequencePhase] { + const createBranchPhase = + options.branchState === "agent_setup" ? createAgentSetupPhase : createOpenclawSetupPhase; + const branchSetupPhase = createBranchPhase(async (context) => { + requireFinalContext(context, "agent setup"); + const agentSetupResult = await handleAgentSetupState({ + agent: context.agent, + sandboxName: context.sandboxName, + model: context.model, + provider: context.provider, + resume: context.resume, + session: context.session, + hermesAuthMethod: context.hermesAuthMethod, + hermesToolGateways: context.hermesToolGateways, + deps: options.agentSetupDeps, + }); + return { + context: { session: agentSetupResult.session } as Partial, + result: agentSetupResult.stateResult, + }; + }); + + const policiesPhase = createPoliciesPhase(async (context) => { + requireFinalContext(context, "policies"); + const policiesResult = await handlePoliciesState({ + resume: context.resume, + sandboxName: context.sandboxName, + provider: context.provider, + model: context.model, + endpointUrl: context.endpointUrl, + credentialEnv: context.credentialEnv, + selectedMessagingChannels: context.selectedMessagingChannels, + webSearchConfig: context.webSearchConfig, + webSearchSupported: context.webSearchSupported, + hermesToolGateways: context.hermesToolGateways, + agent: context.agent, + deps: options.policiesDeps, + }); + return { + context: { + session: policiesResult.session, + selectedMessagingChannels: policiesResult.selectedMessagingChannels, + } as Partial, + result: policiesResult.stateResult, + }; + }); + + const finalizationPhase = createFinalizationPhase(async (context) => { + requireFinalContext(context, "finalization"); + const finalizationResult = await handleFinalizationState({ + sandboxName: context.sandboxName, + model: context.model, + provider: context.provider, + nimContainer: context.nimContainer, + agent: context.agent, + hermesAuthMethod: context.hermesAuthMethod, + hermesToolGateways: context.hermesToolGateways, + stagedLegacyKeys: options.finalization.stagedLegacyKeys, + migratedLegacyKeys: options.finalization.migratedLegacyKeys, + webSearchEnabled: options.finalization.webSearchEnabled(context.webSearchConfig), + deps: options.finalizationDeps, + }); + return { result: finalizationResult.stateResult }; + }); + + return [branchSetupPhase, policiesPhase, finalizationPhase]; +} + +function stateResults(result: OnboardStateHandlerResult): readonly OnboardStateResult[] { + if (Array.isArray(result)) return result as readonly OnboardStateResult[]; + return [result as OnboardStateResult]; +} + +function isPoliciesAppliedResult(result: OnboardStateResult): boolean { + return ( + result.type === "transition" && + result.next === "finalizing" && + result.metadata?.state === "policies" + ); +} + +function withAfterPoliciesResultApplied( + runtime: OnboardMachineRunnerRuntime, + afterPoliciesResultApplied: (() => void) | undefined, +): OnboardMachineRunnerRuntime { + if (!afterPoliciesResultApplied) return runtime; + return { + session: runtime.session.bind(runtime), + async applyResult(result) { + const session = await runtime.applyResult(result); + if (isPoliciesAppliedResult(result)) afterPoliciesResultApplied(); + return session; + }, + }; +} + +function withContextObserver( + phases: readonly OnboardSequencePhase[], + onContextUpdated: ((context: Context) => void) | undefined, +): readonly OnboardSequencePhase[] { + if (!onContextUpdated) return phases; + return phases.map((phase) => ({ + ...phase, + async run(context) { + const result = await phase.run(context); + onContextUpdated(result.context); + return result; + }, + })); +} + +export async function runFinalOnboardFlowSlice(options: { + context: Context; + runtime: OnboardMachineRunnerRuntime; + phases: readonly OnboardSequencePhase[]; + resume: boolean; + recordStateResult(result: OnboardStateResult): Promise; + afterPoliciesResultApplied?(): void; + onContextUpdated?(context: Context): void; +}): Promise { + const finalRuntimeSession = await options.runtime.session(); + // Keep resume and ahead-state sessions on the compatibility path for now. + // The persisted invalid states for this slice are "policies", "finalizing", + // and "post_verify": a previous run may have advanced `session.machine` + // there via legacy step helpers, but resume still needs to re-run branch + // setup/readiness, policy reconciliation, and final verification. Those + // legacy helpers remain a second machine snapshot writer in + // OnboardRuntimeBoundary/recordStateResultWithStepCompatibility, so this + // slice cannot make those persisted states impossible at the source without + // changing the broader step persistence contract. Remove this fallback once + // final-phase repair checks are first-class resumable FSM states, or once + // legacy step helpers no longer advance `session.machine` and handler FSM + // results are the sole transition source. + if ( + !options.resume && + (finalRuntimeSession.machine.state === "openclaw" || + finalRuntimeSession.machine.state === "agent_setup") + ) { + await runFinalOnboardFlowSequence({ + context: options.context, + runtime: withAfterPoliciesResultApplied(options.runtime, options.afterPoliciesResultApplied), + phases: withContextObserver(options.phases, options.onContextUpdated), + }); + return; + } + + let context = options.context; + for (const phase of options.phases) { + const phaseResult = await phase.run(context); + for (const stateResult of stateResults(phaseResult.result)) { + await options.recordStateResult(stateResult); + if (isPoliciesAppliedResult(stateResult)) options.afterPoliciesResultApplied?.(); + } + context = phaseResult.context; + options.onContextUpdated?.(context); + } +} diff --git a/src/lib/onboard/machine/handlers/policies.ts b/src/lib/onboard/machine/handlers/policies.ts index a9e630ccac3..e1f16d57a0c 100644 --- a/src/lib/onboard/machine/handlers/policies.ts +++ b/src/lib/onboard/machine/handlers/policies.ts @@ -110,6 +110,7 @@ export interface PoliciesStateOptions { export interface PoliciesStateResult { session: Session | null; recordedMessagingChannels: string[]; + selectedMessagingChannels: string[]; appliedPolicyPresets: string[]; stateResult: OnboardStateTransitionResult; } @@ -249,6 +250,7 @@ export async function handlePoliciesState({ return { session, recordedMessagingChannels, + selectedMessagingChannels: policyMessagingChannels, appliedPolicyPresets, stateResult: advanceTo("finalizing", { metadata: { state: "policies", policyPresets: appliedPolicyPresets }, diff --git a/test/helpers/onboard-final-flow-phases.ts b/test/helpers/onboard-final-flow-phases.ts new file mode 100644 index 00000000000..7c3f996ec9e --- /dev/null +++ b/test/helpers/onboard-final-flow-phases.ts @@ -0,0 +1,289 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { vi } from "vitest"; +import type { DashboardDeliveryChain } from "../../src/lib/dashboard/contract"; +import type { OnboardMachineEvent } from "../../src/lib/onboard/machine/events"; +import { createFinalOnboardFlowPhases } from "../../src/lib/onboard/machine/final-flow-phases"; +import type { OnboardFlowContext } from "../../src/lib/onboard/machine/flow-context"; +import type { PoliciesStateOptions } from "../../src/lib/onboard/machine/handlers/policies"; +import { OnboardRuntime, type OnboardRuntimeDeps } from "../../src/lib/onboard/machine/runtime"; +import type { OnboardMachineState } from "../../src/lib/onboard/machine/types"; +import { OnboardRuntimeBoundary } from "../../src/lib/onboard/runtime-boundary"; +import { + createSession, + filterSafeUpdates, + MACHINE_SNAPSHOT_VERSION, + normalizeSession, + type Session, + type SessionUpdates, +} from "../../src/lib/state/onboard-session"; +import type { VerifyDeploymentResult } from "../../src/lib/verify-deployment"; + +export type Agent = { name: string }; +type WebSearchConfig = NonNullable; + +export type RecorderOverrides = { + loadSession?: () => Session | null; + updateSession?: (mutator: (session: Session) => Session | void) => Session; + recordStepSkipped?: (stepName: string) => Promise; + recordStateSkipped?: ( + state: OnboardMachineState, + metadata?: Record | null, + ) => Promise; + startRecordedStep?: ( + stepName: string, + updates?: { + sandboxName?: string | null; + provider?: string | null; + model?: string | null; + policyPresets?: string[] | null; + }, + ) => Promise; + recordStepComplete?: (stepName: string, updates?: SessionUpdates) => Promise; + recordPostVerifyStarted?: () => Promise; + mergePolicyMessagingChannels?: PoliciesStateOptions< + Agent | null, + WebSearchConfig + >["deps"]["mergePolicyMessagingChannels"]; + verifyDeployment?: ( + sandboxName: string, + chain: DashboardDeliveryChain, + ) => Promise; + printDashboard?: ( + sandboxName: string, + model: string, + provider: string, + nimContainer: string | null, + agent: Agent | null, + ) => void; +}; + +function cloneSession(session: Session): Session { + return normalizeSession(JSON.parse(JSON.stringify(session))) ?? session; +} + +function sessionWithUpdates(updates: SessionUpdates = {}): Session { + const session = createSession(); + Object.assign(session, updates); + if (updates.metadata) session.metadata = { ...session.metadata, ...updates.metadata }; + return session; +} + +export function sessionAt(state: OnboardMachineState): Session { + return createSession({ + sandboxName: "my-sandbox", + provider: "nim", + model: "nvidia/test", + machine: { + version: MACHINE_SNAPSHOT_VERSION, + state, + stateEnteredAt: "2026-06-10T00:00:00.000Z", + revision: 0, + }, + }); +} + +export function createRuntimeHarness(initialSession: Session) { + let session = cloneSession(initialSession); + const events: OnboardMachineEvent[] = []; + const updateSession = (mutator: (value: Session) => Session | void): Session => { + const current = cloneSession(session); + session = cloneSession(mutator(current) ?? current); + return cloneSession(session); + }; + const deps: OnboardRuntimeDeps = { + loadSession: () => cloneSession(session), + createSession, + saveSession: (next) => { + session = cloneSession(next); + return cloneSession(session); + }, + updateSession, + markStepStarted: () => cloneSession(session), + markStepComplete: (_stepName, updates: SessionUpdates = {}) => + updateSession((current) => Object.assign(current, filterSafeUpdates(updates))), + markStepCompleteRecordOnly: (_stepName, updates: SessionUpdates = {}) => + updateSession((current) => Object.assign(current, filterSafeUpdates(updates))), + markStepSkipped: () => cloneSession(session), + markStepFailed: () => cloneSession(session), + markStepFailedRecordOnly: () => cloneSession(session), + completeSession: (updates: SessionUpdates = {}) => + updateSession((current) => { + Object.assign(current, filterSafeUpdates(updates)); + current.status = "complete"; + current.resumable = false; + return current; + }), + filterSafeUpdates, + emitEvent: (event) => events.push(event), + now: () => "2026-06-10T00:00:00.000Z", + }; + const boundary = new OnboardRuntimeBoundary({ + toSessionUpdates: (updates: Record) => + filterSafeUpdates(updates as SessionUpdates) as SessionUpdates, + maybeForceE2eStepFailure: () => undefined, + createRuntime: () => new OnboardRuntime(deps), + }); + return { + boundary, + events, + getSession: () => cloneSession(session), + }; +} + +export function context( + patch: Partial> = {}, +): OnboardFlowContext { + return { + resume: false, + fresh: false, + session: createSession(), + agent: null, + recordedSandboxName: null, + requestedSandboxName: null, + sandboxName: "my-sandbox", + fromDockerfile: null, + model: "nvidia/test", + provider: "nim", + endpointUrl: "https://example.test/v1", + credentialEnv: "NVIDIA_API_KEY", + hermesAuthMethod: null, + hermesToolGateways: ["local"], + preferredInferenceApi: "chat", + nimContainer: "nim-test", + webSearchConfig: null, + webSearchSupported: true, + selectedMessagingChannels: ["slack"], + gpu: null, + sandboxGpuConfig: null, + gpuPassthrough: false, + ...patch, + }; +} + +export function createPhases( + branchState: "agent_setup" | "openclaw", + order: string[] = [], + recorders: RecorderOverrides = {}, +) { + return createFinalOnboardFlowPhases< + OnboardFlowContext, + DashboardDeliveryChain, + VerifyDeploymentResult + >({ + branchState, + agentSetupDeps: { + handleAgentSetup: vi.fn(async () => { + order.push("agent-setup"); + }), + agentSetupContext: () => ({}), + ensureAgentDashboardForward: vi.fn(() => { + order.push("agent-forward"); + return 45123; + }), + recordStepSkipped: recorders.recordStepSkipped ?? vi.fn(async () => createSession()), + isOpenclawReady: () => false, + skippedStepMessage: vi.fn(), + recordStateSkipped: recorders.recordStateSkipped ?? vi.fn(async () => createSession()), + startRecordedStep: recorders.startRecordedStep ?? vi.fn(async () => undefined), + setupOpenclaw: vi.fn(async () => { + order.push("openclaw"); + }), + syncNemoClawConfigInSandbox: vi.fn(), + recordStepComplete: + recorders.recordStepComplete ?? + vi.fn(async (_stepName: string, updates: SessionUpdates = {}) => + sessionWithUpdates(updates), + ), + toSessionUpdates: (updates) => updates as SessionUpdates, + }, + policiesDeps: { + loadSession: recorders.loadSession ?? (() => createSession()), + getActiveSandbox: () => null, + mergePolicyMessagingChannels: + recorders.mergePolicyMessagingChannels ?? ((selected) => selected), + verifyCompatibleEndpointSandboxSmoke: vi.fn(), + preparePolicyPresetResumeSelection: () => ({ + policyPresets: ["balanced"], + recordedPolicyPresetsNeedReconcile: false, + disabledMessagingPolicyPresetApplied: false, + }), + arePolicyPresetsApplied: () => false, + skippedStepMessage: vi.fn(), + recordStateSkipped: recorders.recordStateSkipped ?? vi.fn(async () => createSession()), + startRecordedStep: recorders.startRecordedStep ?? vi.fn(async () => undefined), + setupPoliciesWithSelection: vi.fn(async () => { + order.push("policies"); + return ["balanced"]; + }), + updateSession: + recorders.updateSession ?? vi.fn((mutator) => mutator(createSession()) ?? createSession()), + recordStepComplete: + recorders.recordStepComplete ?? + vi.fn(async (_stepName: string, updates: SessionUpdates = {}) => + sessionWithUpdates(updates), + ), + toSessionUpdates: (updates) => updates as SessionUpdates, + persistAppliedPolicyPresets: vi.fn(), + }, + finalization: { + stagedLegacyKeys: [], + migratedLegacyKeys: new Set(), + webSearchEnabled: () => false, + }, + finalizationDeps: { + ensureAgentDashboardForward: vi.fn(() => { + order.push("agent-forward"); + return 45123; + }), + setDefaultSandbox: vi.fn(() => { + order.push("set-default"); + }), + recordPostVerifyStarted: + recorders.recordPostVerifyStarted ?? vi.fn(async () => createSession()), + toSessionUpdates: (updates) => updates as NonNullable, + removeLegacyCredentialsFile: vi.fn(), + cleanupStaleHostFiles: vi.fn(), + checkAndRecoverSandboxProcesses: vi.fn(), + autoPairScopeApproval: vi.fn(), + getChatUiUrl: () => "http://127.0.0.1:45123", + buildVerifyChain: (): DashboardDeliveryChain => ({ + accessUrl: "http://127.0.0.1:45123", + corsOrigins: ["http://127.0.0.1:45123"], + forwardTarget: "45123", + healthEndpoint: "/health", + dashboardHealthEndpoint: "/health", + gatewayPort: 45124, + gatewayHealthEndpoint: "/health", + port: 45123, + bindAddress: "127.0.0.1", + shouldDisableDeviceAuth: false, + }), + verifyDeployment: + recorders.verifyDeployment ?? + vi.fn(async (): Promise => { + order.push("verify"); + return { + healthy: true, + verification: { + gatewayReachable: true, + gatewayVersion: "test", + inferenceRouteWorking: true, + dashboardReachable: true, + messagingBridgesHealthy: true, + messagingRuntimeChannelsMissing: null, + messagingConfigChannelsMissing: null, + accessMethod: "localhost" as const, + }, + diagnostics: [], + }; + }), + formatVerificationDiagnostics: () => [], + verifyWebSearchInsideSandbox: vi.fn(), + printDashboard: recorders.printDashboard ?? vi.fn(), + error: vi.fn(), + log: vi.fn(), + }, + }); +} diff --git a/test/repro-2666-silent-list-status.test.ts b/test/repro-2666-silent-list-status.test.ts index 35506b4c9be..6045f223bb9 100644 --- a/test/repro-2666-silent-list-status.test.ts +++ b/test/repro-2666-silent-list-status.test.ts @@ -24,13 +24,13 @@ import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - import { - type ListSandboxesCommandDeps, getSandboxInventory, + type ListSandboxesCommandDeps, renderSandboxInventoryText, } from "../dist/lib/inventory/index.js"; import { recoverRegistryEntriesWithFallback } from "../dist/lib/list-command-deps.js"; +import { testTimeoutOptions } from "./helpers/timeouts"; const CLI = path.join(import.meta.dirname, "..", "bin", "nemoclaw.js"); @@ -280,16 +280,20 @@ describe("#2666 — subprocess regression: simulated (container-stopped + foreig expect(code).toBe(0); }); - it("nemoclaw status never produces silent empty output when openshell is broken", () => { - const { code, stdout, stderr } = runCli(["my-assist", "status"]); - const combined = `${stdout}\n${stderr}`; - // Must include the sandbox header AND an actionable hint. - expect(combined.trim().length).toBeGreaterThan(0); - expect(combined).toContain("my-assist"); - // `status` must exit non-zero when the live gateway can't be verified - // — that's the contract a watchdog wrapping the command relies on. - expect(code).not.toBe(0); - }); + it( + "nemoclaw status never produces silent empty output when openshell is broken", + testTimeoutOptions(30_000), + () => { + const { code, stdout, stderr } = runCli(["my-assist", "status"]); + const combined = `${stdout}\n${stderr}`; + // Must include the sandbox header AND an actionable hint. + expect(combined.trim().length).toBeGreaterThan(0); + expect(combined).toContain("my-assist"); + // `status` must exit non-zero when the live gateway can't be verified + // — that's the contract a watchdog wrapping the command relies on. + expect(code).not.toBe(0); + }, + ); it("nemoclaw status prints the classifier header before gateway_unreachable_after_restart guidance", () => { writeFakeDocker([