From 9ee6cfab9b3fd24ff2ad22c06af41871832cebfb Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Tue, 19 May 2026 19:09:44 -0700 Subject: [PATCH 1/6] refactor(cli): add onboard FSM transition types --- src/lib/onboard/machine/transitions.test.ts | 164 ++++++++++++++++++++ src/lib/onboard/machine/transitions.ts | 107 +++++++++++++ src/lib/onboard/machine/types.ts | 101 ++++++++++++ 3 files changed, 372 insertions(+) create mode 100644 src/lib/onboard/machine/transitions.test.ts create mode 100644 src/lib/onboard/machine/transitions.ts create mode 100644 src/lib/onboard/machine/types.ts diff --git a/src/lib/onboard/machine/transitions.test.ts b/src/lib/onboard/machine/transitions.test.ts new file mode 100644 index 0000000000..875a0ec45a --- /dev/null +++ b/src/lib/onboard/machine/transitions.test.ts @@ -0,0 +1,164 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; + +import { + ONBOARD_MACHINE_EVENT_TYPES, + ONBOARD_MACHINE_STATES, + ONBOARD_NON_TERMINAL_MACHINE_STATES, +} from "./types"; +import { + assertValidOnboardMachineTransition, + canTransitionOnboardMachineState, + getNextOnboardMachineStates, + getOnboardMachineTransition, + InvalidOnboardMachineTransitionError, + isOnboardMachineState, + isTerminalOnboardMachineState, + ONBOARD_MACHINE_DIRECT_TRANSITIONS, + ONBOARD_MACHINE_NEXT_STATES, + ONBOARD_MACHINE_TRANSITIONS, +} from "./transitions"; + +const canonicalDirectTransitions = [ + ["init", "preflight", "advance"], + ["preflight", "gateway", "advance"], + ["gateway", "provider_selection", "advance"], + ["provider_selection", "inference", "advance"], + ["inference", "provider_selection", "retry"], + ["inference", "sandbox", "advance"], + ["sandbox", "openclaw", "branch"], + ["sandbox", "agent_setup", "branch"], + ["openclaw", "policies", "advance"], + ["agent_setup", "policies", "advance"], + ["policies", "finalizing", "advance"], + ["finalizing", "post_verify", "advance"], + ["post_verify", "complete", "advance"], +] as const; + +describe("onboard machine vocabulary", () => { + it("defines the initial coarse state vocabulary from issue #3802", () => { + expect(ONBOARD_MACHINE_STATES).toEqual([ + "init", + "preflight", + "gateway", + "provider_selection", + "inference", + "sandbox", + "agent_setup", + "openclaw", + "policies", + "finalizing", + "post_verify", + "complete", + "failed", + ]); + }); + + it("defines the initial observe-only event vocabulary from issue #3802", () => { + expect(ONBOARD_MACHINE_EVENT_TYPES).toEqual([ + "onboard.started", + "onboard.resumed", + "onboard.completed", + "onboard.failed", + "state.entered", + "state.exited", + "state.skipped", + "state.completed", + "state.failed", + "state.repair.started", + "state.repair.completed", + "state.repair.failed", + "context.updated", + "resume.conflict", + "hook.started", + "hook.completed", + "hook.failed", + ]); + }); + + it("recognizes valid machine state names", () => { + expect(isOnboardMachineState("preflight")).toBe(true); + expect(isOnboardMachineState("messaging")).toBe(false); + expect(isOnboardMachineState(null)).toBe(false); + }); +}); + +describe("onboard machine transitions", () => { + it("encodes the canonical direct transition graph", () => { + expect(ONBOARD_MACHINE_DIRECT_TRANSITIONS).toEqual( + canonicalDirectTransitions.map(([from, to, kind]) => ({ from, to, kind })), + ); + }); + + it("allows every non-terminal state to fail", () => { + for (const state of ONBOARD_NON_TERMINAL_MACHINE_STATES) { + expect(canTransitionOnboardMachineState(state, "failed")).toBe(true); + expect(getOnboardMachineTransition(state, "failed")?.kind).toBe("failure"); + } + }); + + it("keeps terminal states terminal", () => { + expect(isTerminalOnboardMachineState("complete")).toBe(true); + expect(isTerminalOnboardMachineState("failed")).toBe(true); + expect(getNextOnboardMachineStates("complete")).toEqual([]); + expect(getNextOnboardMachineStates("failed")).toEqual([]); + expect(canTransitionOnboardMachineState("complete", "failed")).toBe(false); + expect(canTransitionOnboardMachineState("failed", "init")).toBe(false); + }); + + it("exposes next states in deterministic order", () => { + expect(ONBOARD_MACHINE_NEXT_STATES).toEqual({ + init: ["preflight", "failed"], + preflight: ["gateway", "failed"], + gateway: ["provider_selection", "failed"], + provider_selection: ["inference", "failed"], + inference: ["provider_selection", "sandbox", "failed"], + sandbox: ["openclaw", "agent_setup", "failed"], + agent_setup: ["policies", "failed"], + openclaw: ["policies", "failed"], + policies: ["finalizing", "failed"], + finalizing: ["post_verify", "failed"], + post_verify: ["complete", "failed"], + complete: [], + failed: [], + }); + }); + + it("classifies retry and branch transitions", () => { + expect(assertValidOnboardMachineTransition("inference", "provider_selection")).toMatchObject({ + kind: "retry", + }); + expect(assertValidOnboardMachineTransition("sandbox", "openclaw")).toMatchObject({ + kind: "branch", + }); + expect(assertValidOnboardMachineTransition("sandbox", "agent_setup")).toMatchObject({ + kind: "branch", + }); + }); + + it("rejects transitions outside the graph", () => { + expect(() => assertValidOnboardMachineTransition("init", "sandbox")).toThrow( + InvalidOnboardMachineTransitionError, + ); + expect(() => assertValidOnboardMachineTransition("complete", "failed")).toThrow( + "complete -> failed", + ); + }); + + it("keeps the next-state map aligned with the transition list", () => { + for (const state of ONBOARD_MACHINE_STATES) { + expect( + ONBOARD_MACHINE_TRANSITIONS.filter((transition) => transition.from === state).map( + (transition) => transition.to, + ), + ).toEqual(getNextOnboardMachineStates(state)); + } + }); + + it("does not contain duplicate transition edges", () => { + const edges = ONBOARD_MACHINE_TRANSITIONS.map(({ from, to }) => `${from}->${to}`); + expect(new Set(edges).size).toBe(edges.length); + }); +}); diff --git a/src/lib/onboard/machine/transitions.ts b/src/lib/onboard/machine/transitions.ts new file mode 100644 index 0000000000..9f23e3895a --- /dev/null +++ b/src/lib/onboard/machine/transitions.ts @@ -0,0 +1,107 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { OnboardMachineState, OnboardMachineTransition } from "./types"; +import { + ONBOARD_MACHINE_STATES, + ONBOARD_NON_TERMINAL_MACHINE_STATES, + ONBOARD_TERMINAL_MACHINE_STATES, +} from "./types"; + +export const ONBOARD_MACHINE_NEXT_STATES = { + init: ["preflight", "failed"], + preflight: ["gateway", "failed"], + gateway: ["provider_selection", "failed"], + provider_selection: ["inference", "failed"], + inference: ["provider_selection", "sandbox", "failed"], + sandbox: ["openclaw", "agent_setup", "failed"], + agent_setup: ["policies", "failed"], + openclaw: ["policies", "failed"], + policies: ["finalizing", "failed"], + finalizing: ["post_verify", "failed"], + post_verify: ["complete", "failed"], + complete: [], + failed: [], +} as const satisfies Readonly>; + +export const ONBOARD_MACHINE_DIRECT_TRANSITIONS = [ + { from: "init", to: "preflight", kind: "advance" }, + { from: "preflight", to: "gateway", kind: "advance" }, + { from: "gateway", to: "provider_selection", kind: "advance" }, + { from: "provider_selection", to: "inference", kind: "advance" }, + { from: "inference", to: "provider_selection", kind: "retry" }, + { from: "inference", to: "sandbox", kind: "advance" }, + { from: "sandbox", to: "openclaw", kind: "branch" }, + { from: "sandbox", to: "agent_setup", kind: "branch" }, + { from: "openclaw", to: "policies", kind: "advance" }, + { from: "agent_setup", to: "policies", kind: "advance" }, + { from: "policies", to: "finalizing", kind: "advance" }, + { from: "finalizing", to: "post_verify", kind: "advance" }, + { from: "post_verify", to: "complete", kind: "advance" }, +] as const satisfies readonly OnboardMachineTransition[]; + +export const ONBOARD_MACHINE_FAILURE_TRANSITIONS = ONBOARD_NON_TERMINAL_MACHINE_STATES.map( + (from) => ({ from, to: "failed" as const, kind: "failure" as const }), +) satisfies readonly OnboardMachineTransition[]; + +export const ONBOARD_MACHINE_TRANSITIONS = [ + ...ONBOARD_MACHINE_DIRECT_TRANSITIONS, + ...ONBOARD_MACHINE_FAILURE_TRANSITIONS, +] as const satisfies readonly OnboardMachineTransition[]; + +export class InvalidOnboardMachineTransitionError extends Error { + readonly from: OnboardMachineState; + readonly to: OnboardMachineState; + + constructor(from: OnboardMachineState, to: OnboardMachineState) { + super(`Invalid onboarding machine transition: ${from} -> ${to}`); + this.name = "InvalidOnboardMachineTransitionError"; + this.from = from; + this.to = to; + } +} + +export function isOnboardMachineState(value: unknown): value is OnboardMachineState { + return typeof value === "string" && ONBOARD_MACHINE_STATES.includes(value as OnboardMachineState); +} + +export function isTerminalOnboardMachineState( + state: OnboardMachineState, +): state is "complete" | "failed" { + return ONBOARD_TERMINAL_MACHINE_STATES.includes(state as "complete" | "failed"); +} + +export function getNextOnboardMachineStates( + from: OnboardMachineState, +): readonly OnboardMachineState[] { + return ONBOARD_MACHINE_NEXT_STATES[from]; +} + +export function canTransitionOnboardMachineState( + from: OnboardMachineState, + to: OnboardMachineState, +): boolean { + return getNextOnboardMachineStates(from).includes(to); +} + +export function getOnboardMachineTransition( + from: OnboardMachineState, + to: OnboardMachineState, +): OnboardMachineTransition | null { + return ( + ONBOARD_MACHINE_TRANSITIONS.find( + (transition) => transition.from === from && transition.to === to, + ) ?? null + ); +} + +export function assertValidOnboardMachineTransition( + from: OnboardMachineState, + to: OnboardMachineState, +): OnboardMachineTransition { + const transition = getOnboardMachineTransition(from, to); + if (!transition) { + throw new InvalidOnboardMachineTransitionError(from, to); + } + return transition; +} diff --git a/src/lib/onboard/machine/types.ts b/src/lib/onboard/machine/types.ts new file mode 100644 index 0000000000..bbba7bd5f6 --- /dev/null +++ b/src/lib/onboard/machine/types.ts @@ -0,0 +1,101 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** + * Coarse onboarding finite-state-machine vocabulary. + * + * These types intentionally model only major step boundaries. Mid-operation + * resume inside gateway startup, sandbox creation, credential upserts, model + * probes, or policy application is out of scope for the initial FSM shell. + */ + +export const ONBOARD_MACHINE_STATES = [ + "init", + "preflight", + "gateway", + "provider_selection", + "inference", + "sandbox", + "agent_setup", + "openclaw", + "policies", + "finalizing", + "post_verify", + "complete", + "failed", +] as const; + +export type OnboardMachineState = (typeof ONBOARD_MACHINE_STATES)[number]; + +export const ONBOARD_TERMINAL_MACHINE_STATES = ["complete", "failed"] as const; + +export type OnboardTerminalMachineState = + (typeof ONBOARD_TERMINAL_MACHINE_STATES)[number]; + +export type OnboardNonTerminalMachineState = Exclude< + OnboardMachineState, + OnboardTerminalMachineState +>; + +export const ONBOARD_NON_TERMINAL_MACHINE_STATES: readonly OnboardNonTerminalMachineState[] = + ONBOARD_MACHINE_STATES.filter( + (state): state is OnboardNonTerminalMachineState => + !ONBOARD_TERMINAL_MACHINE_STATES.includes(state as OnboardTerminalMachineState), + ); + +export const ONBOARD_MACHINE_EVENT_TYPES = [ + "onboard.started", + "onboard.resumed", + "onboard.completed", + "onboard.failed", + "state.entered", + "state.exited", + "state.skipped", + "state.completed", + "state.failed", + "state.repair.started", + "state.repair.completed", + "state.repair.failed", + "context.updated", + "resume.conflict", + "hook.started", + "hook.completed", + "hook.failed", +] as const; + +export type OnboardMachineEventType = (typeof ONBOARD_MACHINE_EVENT_TYPES)[number]; + +export type OnboardMachineTransitionKind = + | "advance" + | "retry" + | "branch" + | "failure"; + +export interface OnboardMachineTransition { + from: OnboardMachineState; + to: OnboardMachineState; + kind: OnboardMachineTransitionKind; +} + +/** + * Stable, redacted context keys that machine events may expose. + * + * Do not add raw secrets or unredacted URLs here. Runtime-derived topology + * decisions such as Docker/WSL reachability, Ollama proxy necessity, or live + * gateway health should be recomputed during execution rather than stored as + * durable FSM context. + */ +export interface OnboardMachineContext { + agent?: string | null; + sandboxName?: string | null; + provider?: string | null; + model?: string | null; + endpointUrl?: string | null; + credentialEnv?: string | null; + preferredInferenceApi?: string | null; + hermesAuthMethod?: "oauth" | "api_key" | null; + hermesToolGateways?: string[] | null; + policyPresets?: string[] | null; + messagingChannels?: string[] | null; + gpuPassthrough?: boolean; +} From b9e4545e44066975dab7945a93b580b366ec82c2 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Tue, 19 May 2026 19:27:06 -0700 Subject: [PATCH 2/6] refactor(cli): emit onboard session machine events --- src/lib/onboard/machine/events.ts | 166 ++++++++++++++++++++++++++ src/lib/state/onboard-session.test.ts | 90 ++++++++++++++ src/lib/state/onboard-session.ts | 94 +++++++++++++-- 3 files changed, 343 insertions(+), 7 deletions(-) create mode 100644 src/lib/onboard/machine/events.ts diff --git a/src/lib/onboard/machine/events.ts b/src/lib/onboard/machine/events.ts new file mode 100644 index 0000000000..9a68d3f899 --- /dev/null +++ b/src/lib/onboard/machine/events.ts @@ -0,0 +1,166 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { JsonObject, JsonValue } from "../../core/json-types"; +import { redactSensitiveText, redactUrl } from "../../security/redact"; +import type { HermesAuthMethod, Session } from "../../state/onboard-session"; +import type { + OnboardMachineContext, + OnboardMachineEventType, + OnboardMachineState, +} from "./types"; + +export const ONBOARD_SESSION_STEP_TO_MACHINE_STATE = { + preflight: "preflight", + gateway: "gateway", + provider_selection: "provider_selection", + inference: "inference", + sandbox: "sandbox", + agent_setup: "agent_setup", + openclaw: "openclaw", + policies: "policies", +} as const satisfies Readonly>; + +export type OnboardSessionStepName = keyof typeof ONBOARD_SESSION_STEP_TO_MACHINE_STATE; + +export interface OnboardMachineEvent { + version: 1; + type: OnboardMachineEventType; + occurredAt: string; + sessionId: string | null; + state: OnboardMachineState | null; + step: OnboardSessionStepName | null; + context: OnboardMachineContext; + error: string | null; + metadata: JsonObject; +} + +export type OnboardMachineEventListener = (event: OnboardMachineEvent) => void; + +const listeners = new Set(); + +export function addOnboardMachineEventListener( + listener: OnboardMachineEventListener, +): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +export function clearOnboardMachineEventListeners(): void { + listeners.clear(); +} + +export function isOnboardSessionStepName(value: string): value is OnboardSessionStepName { + return Object.prototype.hasOwnProperty.call(ONBOARD_SESSION_STEP_TO_MACHINE_STATE, value); +} + +export function machineStateFromOnboardSessionStep( + stepName: string | null | undefined, +): OnboardMachineState | null { + if (!stepName || !isOnboardSessionStepName(stepName)) return null; + return ONBOARD_SESSION_STEP_TO_MACHINE_STATE[stepName]; +} + +function nullableString(value: unknown): string | null { + return typeof value === "string" ? value : null; +} + +function stringArray(value: unknown): string[] | null { + if (!Array.isArray(value)) return null; + return value.filter((entry): entry is string => typeof entry === "string"); +} + +function hermesAuthMethod(value: unknown): HermesAuthMethod | null { + return value === "oauth" || value === "api_key" ? value : null; +} + +function booleanValue(value: unknown): boolean | undefined { + return typeof value === "boolean" ? value : undefined; +} + +function sanitizeJsonValue(value: unknown): JsonValue { + if (typeof value === "string") return redactUrl(value) ?? redactSensitiveText(value) ?? ""; + if (typeof value === "number" && Number.isFinite(value)) return value; + if (typeof value === "boolean" || value === null) return value; + if (Array.isArray(value)) return value.map((entry) => sanitizeJsonValue(entry)); + if (typeof value !== "object" || value === null) return String(value); + + const result: JsonObject = {}; + for (const [key, entry] of Object.entries(value)) { + result[key] = sanitizeJsonValue(entry); + } + return result; +} + +export function sanitizeOnboardMachineEventMetadata( + metadata: Record | null | undefined, +): JsonObject { + if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) return {}; + const sanitized: JsonObject = {}; + for (const [key, value] of Object.entries(metadata)) { + sanitized[key] = sanitizeJsonValue(value); + } + return sanitized; +} + +export function buildOnboardMachineContext(session: Session): OnboardMachineContext { + const endpointUrl = redactUrl(session.endpointUrl); + return { + agent: nullableString(session.agent), + sandboxName: nullableString(session.sandboxName), + provider: nullableString(session.provider), + model: nullableString(session.model), + endpointUrl, + credentialEnv: nullableString(session.credentialEnv), + preferredInferenceApi: nullableString(session.preferredInferenceApi), + hermesAuthMethod: hermesAuthMethod(session.hermesAuthMethod), + hermesToolGateways: stringArray(session.hermesToolGateways), + policyPresets: stringArray(session.policyPresets), + messagingChannels: stringArray(session.messagingChannels), + gpuPassthrough: booleanValue(session.gpuPassthrough), + }; +} + +export function createOnboardMachineEvent({ + type, + session, + step, + state, + error = null, + metadata = {}, +}: { + type: OnboardMachineEventType; + session: Session; + step?: string | null; + state?: OnboardMachineState | null; + error?: string | null; + metadata?: Record | null; +}): OnboardMachineEvent { + const normalizedStep = step && isOnboardSessionStepName(step) ? step : null; + return { + version: 1, + type, + occurredAt: new Date().toISOString(), + sessionId: nullableString(session.sessionId), + state: state ?? machineStateFromOnboardSessionStep(normalizedStep), + step: normalizedStep, + context: buildOnboardMachineContext(session), + error: redactSensitiveText(error), + metadata: sanitizeOnboardMachineEventMetadata(metadata), + }; +} + +export function emitOnboardMachineEvent(event: OnboardMachineEvent): void { + if (listeners.size === 0) return; + for (const listener of listeners) { + try { + listener(event); + } catch { + // Event observers are diagnostics only. A broken observer must not + // change onboarding behavior; hook failure events are introduced by the + // later observe-only hook API. + } + } +} diff --git a/src/lib/state/onboard-session.test.ts b/src/lib/state/onboard-session.test.ts index b2c925858f..5ddd94908d 100644 --- a/src/lib/state/onboard-session.test.ts +++ b/src/lib/state/onboard-session.test.ts @@ -9,11 +9,15 @@ import { createRequire } from "node:module"; const require = createRequire(import.meta.url); const distPath = require.resolve("../../../dist/lib/state/onboard-session"); +const eventsDistPath = require.resolve("../../../dist/lib/onboard/machine/events"); const originalHome = process.env.HOME; type OnboardSessionModule = typeof import("../../../dist/lib/state/onboard-session"); +type OnboardMachineEventsModule = typeof import("../../../dist/lib/onboard/machine/events"); +type OnboardMachineEvent = import("../../../dist/lib/onboard/machine/events").OnboardMachineEvent; type LoadedSession = NonNullable>; type DebugSummary = NonNullable>; let session: OnboardSessionModule; +let machineEvents: OnboardMachineEventsModule; let tmpDir: string; function requireLoadedSession( @@ -44,13 +48,18 @@ beforeEach(() => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-session-")); process.env.HOME = tmpDir; delete require.cache[distPath]; + delete require.cache[eventsDistPath]; session = require("../../../dist/lib/state/onboard-session"); + machineEvents = require("../../../dist/lib/onboard/machine/events"); + machineEvents.clearOnboardMachineEventListeners(); session.clearSession(); session.releaseOnboardLock(); }); afterEach(() => { + machineEvents.clearOnboardMachineEventListeners(); delete require.cache[distPath]; + delete require.cache[eventsDistPath]; fs.rmSync(tmpDir, { recursive: true, force: true }); if (originalHome === undefined) { delete process.env.HOME; @@ -117,6 +126,87 @@ describe("onboard session", () => { expect(loaded.failure.message).toMatch(/Sandbox creation failed/); }); + it("emits redacted structured machine events for session step mutations", () => { + const emitted: OnboardMachineEvent[] = []; + machineEvents.addOnboardMachineEventListener((event) => emitted.push(event)); + + session.saveSession(session.createSession({ sessionId: "session-1" })); + session.markStepStarted("gateway"); + session.markStepComplete("gateway", { + sandboxName: "my-assistant", + endpointUrl: + "https://alice:super-secret-token@example.com/v1?token=super-secret-token&keep=yes#token=super-secret-token", + credentialEnv: "NVIDIA_API_KEY", + }); + session.markStepSkipped("openclaw"); + session.markStepFailed("sandbox", "NVIDIA_API_KEY=super-secret-token"); + session.completeSession({ provider: "ollama-local", credentialEnv: null }); + + expect(emitted.map((event) => event.type)).toEqual([ + "state.entered", + "context.updated", + "state.completed", + "state.skipped", + "state.failed", + "onboard.failed", + "context.updated", + "onboard.completed", + ]); + expect(emitted[0]).toMatchObject({ + version: 1, + sessionId: "session-1", + state: "gateway", + step: "gateway", + error: null, + }); + expect(emitted[1].context).toMatchObject({ + sandboxName: "my-assistant", + credentialEnv: "NVIDIA_API_KEY", + }); + expect(emitted[1].context.endpointUrl).toBe( + "https://example.com/v1?token=%3CREDACTED%3E&keep=yes", + ); + expect(emitted[1].metadata.fields).toEqual([ + "sandboxName", + "endpointUrl", + "credentialEnv", + ]); + expect(emitted[4]).toMatchObject({ + type: "state.failed", + state: "sandbox", + step: "sandbox", + error: "NVIDIA_API_KEY=", + }); + expect(emitted[5]).toMatchObject({ type: "onboard.failed", state: "failed" }); + expect(emitted.at(-1)).toMatchObject({ type: "onboard.completed", state: "complete" }); + expect(JSON.stringify(emitted)).not.toContain("super-secret-token"); + + const persisted = JSON.parse(fs.readFileSync(session.SESSION_FILE, "utf8")); + expect(persisted.events).toBeUndefined(); + }); + + it("keeps event observer failures from changing session mutation behavior", () => { + machineEvents.addOnboardMachineEventListener(() => { + throw new Error("observer failed"); + }); + + session.saveSession(session.createSession()); + expect(() => session.markStepStarted("preflight")).not.toThrow(); + + const loaded = requireLoadedSession(session.loadSession()); + expect(loaded.steps.preflight.status).toBe("in_progress"); + }); + + it("does not emit machine events for unknown session step names", () => { + const emitted: OnboardMachineEvent[] = []; + machineEvents.addOnboardMachineEventListener((event) => emitted.push(event)); + + session.saveSession(session.createSession()); + session.markStepStarted("not_a_real_step"); + + expect(emitted).toEqual([]); + }); + it("persists safe provider metadata without persisting secrets", () => { session.saveSession(session.createSession()); const unsafeProviderUpdate: Parameters[1] & { diff --git a/src/lib/state/onboard-session.ts b/src/lib/state/onboard-session.ts index f05c1116e8..7fe94d8096 100644 --- a/src/lib/state/onboard-session.ts +++ b/src/lib/state/onboard-session.ts @@ -18,6 +18,10 @@ import { sanitizeMessagingChannelConfig, type MessagingChannelConfig, } from "../messaging-channel-config"; +import { + createOnboardMachineEvent, + emitOnboardMachineEvent, +} from "../onboard/machine/events"; import { redactSensitiveText, redactUrl } from "../security/redact"; export const SESSION_VERSION = 1; @@ -883,7 +887,8 @@ export function updateSession(mutator: (session: Session) => Session | void): Se } export function markStepStarted(stepName: string): Session { - return updateSession((session) => { + let shouldEmit = false; + const updatedSession = updateSession((session) => { const step = session.steps[stepName]; if (!step) return session; step.status = "in_progress"; @@ -893,12 +898,21 @@ export function markStepStarted(stepName: string): Session { session.lastStepStarted = stepName; session.failure = null; session.status = "in_progress"; + shouldEmit = true; return session; }); + if (shouldEmit) { + emitOnboardMachineEvent( + createOnboardMachineEvent({ type: "state.entered", session: updatedSession, step: stepName }), + ); + } + return updatedSession; } export function markStepComplete(stepName: string, updates: SessionUpdates = {}): Session { - return updateSession((session) => { + const safeUpdates = filterSafeUpdates(updates); + let shouldEmit = false; + const updatedSession = updateSession((session) => { const step = session.steps[stepName]; if (!step) return session; step.status = "complete"; @@ -906,13 +920,31 @@ export function markStepComplete(stepName: string, updates: SessionUpdates = {}) step.error = null; session.lastCompletedStep = stepName; session.failure = null; - Object.assign(session, filterSafeUpdates(updates)); + Object.assign(session, safeUpdates); + shouldEmit = true; return session; }); + if (shouldEmit) { + if (Object.keys(safeUpdates).length > 0) { + emitOnboardMachineEvent( + createOnboardMachineEvent({ + type: "context.updated", + session: updatedSession, + step: stepName, + metadata: { fields: Object.keys(safeUpdates) }, + }), + ); + } + emitOnboardMachineEvent( + createOnboardMachineEvent({ type: "state.completed", session: updatedSession, step: stepName }), + ); + } + return updatedSession; } export function markStepSkipped(stepName: string): Session { - return updateSession((session) => { + let shouldEmit = false; + const updatedSession = updateSession((session) => { const step = session.steps[stepName]; if (!step) return session; if (step.status === "complete" || step.status === "failed") return session; @@ -920,12 +952,20 @@ export function markStepSkipped(stepName: string): Session { step.startedAt = null; step.completedAt = null; step.error = null; + shouldEmit = true; return session; }); + if (shouldEmit) { + emitOnboardMachineEvent( + createOnboardMachineEvent({ type: "state.skipped", session: updatedSession, step: stepName }), + ); + } + return updatedSession; } export function markStepFailed(stepName: string, message: string | null = null): Session { - return updateSession((session) => { + let shouldEmit = false; + const updatedSession = updateSession((session) => { const step = session.steps[stepName]; if (!step) return session; step.status = "failed"; @@ -937,18 +977,58 @@ export function markStepFailed(stepName: string, message: string | null = null): recordedAt: new Date().toISOString(), }); session.status = "failed"; + shouldEmit = true; return session; }); + if (shouldEmit) { + emitOnboardMachineEvent( + createOnboardMachineEvent({ + type: "state.failed", + session: updatedSession, + step: stepName, + error: message, + }), + ); + emitOnboardMachineEvent( + createOnboardMachineEvent({ + type: "onboard.failed", + session: updatedSession, + state: "failed", + step: stepName, + error: message, + }), + ); + } + return updatedSession; } export function completeSession(updates: SessionUpdates = {}): Session { - return updateSession((session) => { - Object.assign(session, filterSafeUpdates(updates)); + const safeUpdates = filterSafeUpdates(updates); + const updatedSession = updateSession((session) => { + Object.assign(session, safeUpdates); session.status = "complete"; session.resumable = false; session.failure = null; return session; }); + if (Object.keys(safeUpdates).length > 0) { + emitOnboardMachineEvent( + createOnboardMachineEvent({ + type: "context.updated", + session: updatedSession, + state: "complete", + metadata: { fields: Object.keys(safeUpdates) }, + }), + ); + } + emitOnboardMachineEvent( + createOnboardMachineEvent({ + type: "onboard.completed", + session: updatedSession, + state: "complete", + }), + ); + return updatedSession; } export function summarizeForDebug( From a3c9eb2c750c5f6b69376410560c5f5cc0e37d2b Mon Sep 17 00:00:00 2001 From: Julie Yaunches Date: Wed, 20 May 2026 10:40:50 -0400 Subject: [PATCH 3/6] refactor(cli): harden onboard FSM context --- src/lib/onboard/machine/transitions.ts | 28 +++++++++++--------------- src/lib/onboard/machine/types.ts | 2 +- 2 files changed, 13 insertions(+), 17 deletions(-) diff --git a/src/lib/onboard/machine/transitions.ts b/src/lib/onboard/machine/transitions.ts index 9f23e3895a..17d4cbdf66 100644 --- a/src/lib/onboard/machine/transitions.ts +++ b/src/lib/onboard/machine/transitions.ts @@ -8,22 +8,6 @@ import { ONBOARD_TERMINAL_MACHINE_STATES, } from "./types"; -export const ONBOARD_MACHINE_NEXT_STATES = { - init: ["preflight", "failed"], - preflight: ["gateway", "failed"], - gateway: ["provider_selection", "failed"], - provider_selection: ["inference", "failed"], - inference: ["provider_selection", "sandbox", "failed"], - sandbox: ["openclaw", "agent_setup", "failed"], - agent_setup: ["policies", "failed"], - openclaw: ["policies", "failed"], - policies: ["finalizing", "failed"], - finalizing: ["post_verify", "failed"], - post_verify: ["complete", "failed"], - complete: [], - failed: [], -} as const satisfies Readonly>; - export const ONBOARD_MACHINE_DIRECT_TRANSITIONS = [ { from: "init", to: "preflight", kind: "advance" }, { from: "preflight", to: "gateway", kind: "advance" }, @@ -49,6 +33,18 @@ export const ONBOARD_MACHINE_TRANSITIONS = [ ...ONBOARD_MACHINE_FAILURE_TRANSITIONS, ] as const satisfies readonly OnboardMachineTransition[]; +export const ONBOARD_MACHINE_NEXT_STATES: Readonly< + Record +> = ONBOARD_MACHINE_STATES.reduce( + (nextStates, state) => ({ + ...nextStates, + [state]: ONBOARD_MACHINE_TRANSITIONS.filter((transition) => transition.from === state).map( + (transition) => transition.to, + ), + }), + {} as Record, +); + export class InvalidOnboardMachineTransitionError extends Error { readonly from: OnboardMachineState; readonly to: OnboardMachineState; diff --git a/src/lib/onboard/machine/types.ts b/src/lib/onboard/machine/types.ts index bbba7bd5f6..e1dca21e72 100644 --- a/src/lib/onboard/machine/types.ts +++ b/src/lib/onboard/machine/types.ts @@ -90,7 +90,7 @@ export interface OnboardMachineContext { sandboxName?: string | null; provider?: string | null; model?: string | null; - endpointUrl?: string | null; + endpointOrigin?: string | null; credentialEnv?: string | null; preferredInferenceApi?: string | null; hermesAuthMethod?: "oauth" | "api_key" | null; From 22a7cd85ea6301d9f81d9332c35c7dfc7eb67cd9 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Wed, 20 May 2026 14:07:06 -0700 Subject: [PATCH 4/6] fix(cli): align onboard event context with FSM types --- src/lib/onboard/machine/events.ts | 14 +++++++++++--- src/lib/state/onboard-session.test.ts | 4 +--- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/src/lib/onboard/machine/events.ts b/src/lib/onboard/machine/events.ts index 9a68d3f899..f6b7dca47c 100644 --- a/src/lib/onboard/machine/events.ts +++ b/src/lib/onboard/machine/events.ts @@ -85,7 +85,7 @@ function sanitizeJsonValue(value: unknown): JsonValue { if (typeof value === "number" && Number.isFinite(value)) return value; if (typeof value === "boolean" || value === null) return value; if (Array.isArray(value)) return value.map((entry) => sanitizeJsonValue(entry)); - if (typeof value !== "object" || value === null) return String(value); + if (typeof value !== "object") return String(value); const result: JsonObject = {}; for (const [key, entry] of Object.entries(value)) { @@ -94,6 +94,15 @@ function sanitizeJsonValue(value: unknown): JsonValue { return result; } +function endpointOrigin(value: unknown): string | null { + if (typeof value !== "string" || value.trim() === "") return null; + try { + return new URL(value).origin; + } catch { + return null; + } +} + export function sanitizeOnboardMachineEventMetadata( metadata: Record | null | undefined, ): JsonObject { @@ -106,13 +115,12 @@ export function sanitizeOnboardMachineEventMetadata( } export function buildOnboardMachineContext(session: Session): OnboardMachineContext { - const endpointUrl = redactUrl(session.endpointUrl); return { agent: nullableString(session.agent), sandboxName: nullableString(session.sandboxName), provider: nullableString(session.provider), model: nullableString(session.model), - endpointUrl, + endpointOrigin: endpointOrigin(session.endpointUrl), credentialEnv: nullableString(session.credentialEnv), preferredInferenceApi: nullableString(session.preferredInferenceApi), hermesAuthMethod: hermesAuthMethod(session.hermesAuthMethod), diff --git a/src/lib/state/onboard-session.test.ts b/src/lib/state/onboard-session.test.ts index 5ddd94908d..f706018495 100644 --- a/src/lib/state/onboard-session.test.ts +++ b/src/lib/state/onboard-session.test.ts @@ -163,9 +163,7 @@ describe("onboard session", () => { sandboxName: "my-assistant", credentialEnv: "NVIDIA_API_KEY", }); - expect(emitted[1].context.endpointUrl).toBe( - "https://example.com/v1?token=%3CREDACTED%3E&keep=yes", - ); + expect(emitted[1].context.endpointOrigin).toBe("https://example.com"); expect(emitted[1].metadata.fields).toEqual([ "sandboxName", "endpointUrl", From b5072f8112e34ba5e6de5669f92e0df5139f45f6 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Wed, 20 May 2026 14:16:33 -0700 Subject: [PATCH 5/6] fix(cli): suppress duplicate onboard terminal events --- src/lib/state/onboard-session.test.ts | 17 +++++++++++++++++ src/lib/state/onboard-session.ts | 20 ++++++++++++-------- 2 files changed, 29 insertions(+), 8 deletions(-) diff --git a/src/lib/state/onboard-session.test.ts b/src/lib/state/onboard-session.test.ts index f706018495..825ad6937f 100644 --- a/src/lib/state/onboard-session.test.ts +++ b/src/lib/state/onboard-session.test.ts @@ -205,6 +205,23 @@ describe("onboard session", () => { expect(emitted).toEqual([]); }); + it("does not emit duplicate events for no-op skipped and completed transitions", () => { + const emitted: OnboardMachineEvent[] = []; + machineEvents.addOnboardMachineEventListener((event) => emitted.push(event)); + + session.saveSession(session.createSession({ sessionId: "session-1" })); + session.markStepSkipped("openclaw"); + session.markStepSkipped("openclaw"); + session.completeSession(); + session.completeSession(); + + expect(emitted.map((event) => event.type)).toEqual([ + "state.skipped", + "onboard.completed", + ]); + expect(emitted).toHaveLength(2); + }); + it("persists safe provider metadata without persisting secrets", () => { session.saveSession(session.createSession()); const unsafeProviderUpdate: Parameters[1] & { diff --git a/src/lib/state/onboard-session.ts b/src/lib/state/onboard-session.ts index 7fe94d8096..a74602db39 100644 --- a/src/lib/state/onboard-session.ts +++ b/src/lib/state/onboard-session.ts @@ -947,7 +947,7 @@ export function markStepSkipped(stepName: string): Session { const updatedSession = updateSession((session) => { const step = session.steps[stepName]; if (!step) return session; - if (step.status === "complete" || step.status === "failed") return session; + if (step.status === "complete" || step.status === "failed" || step.status === "skipped") return session; step.status = "skipped"; step.startedAt = null; step.completedAt = null; @@ -1004,7 +1004,9 @@ export function markStepFailed(stepName: string, message: string | null = null): export function completeSession(updates: SessionUpdates = {}): Session { const safeUpdates = filterSafeUpdates(updates); + let wasComplete = false; const updatedSession = updateSession((session) => { + wasComplete = session.status === "complete"; Object.assign(session, safeUpdates); session.status = "complete"; session.resumable = false; @@ -1021,13 +1023,15 @@ export function completeSession(updates: SessionUpdates = {}): Session { }), ); } - emitOnboardMachineEvent( - createOnboardMachineEvent({ - type: "onboard.completed", - session: updatedSession, - state: "complete", - }), - ); + if (!wasComplete) { + emitOnboardMachineEvent( + createOnboardMachineEvent({ + type: "onboard.completed", + session: updatedSession, + state: "complete", + }), + ); + } return updatedSession; } From 27630a04b3880a04933d978e46f309f7cd28fbe6 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Wed, 20 May 2026 14:33:11 -0700 Subject: [PATCH 6/6] test(e2e): use sandbox-first status helpers --- test/e2e/validation_suites/lib/baseline_onboarding.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/e2e/validation_suites/lib/baseline_onboarding.sh b/test/e2e/validation_suites/lib/baseline_onboarding.sh index 231962a310..4bddcde489 100755 --- a/test/e2e/validation_suites/lib/baseline_onboarding.sh +++ b/test/e2e/validation_suites/lib/baseline_onboarding.sh @@ -53,7 +53,7 @@ baseline_assert_sandbox_list_contains_context_sandbox() { baseline_assert_sandbox_status_exits_zero() { local out - if out=$(nemoclaw status "$E2E_SANDBOX_NAME" 2>&1); then + if out=$(nemoclaw "$E2E_SANDBOX_NAME" status 2>&1); then baseline_onboarding_pass validation.baseline_onboarding.sandbox_status "$E2E_SANDBOX_NAME status ok" else baseline_onboarding_fail validation.baseline_onboarding.sandbox_status "status failed: ${out:0:200}" @@ -62,7 +62,7 @@ baseline_assert_sandbox_status_exits_zero() { baseline_assert_logs_produce_output() { local out - if out=$(nemoclaw logs "$E2E_SANDBOX_NAME" 2>&1) && [[ -n "$out" ]]; then + if out=$(nemoclaw "$E2E_SANDBOX_NAME" logs 2>&1) && [[ -n "$out" ]]; then baseline_onboarding_pass validation.baseline_onboarding.logs_available "logs available" else baseline_onboarding_fail validation.baseline_onboarding.logs_available "logs unavailable"