Skip to content
Merged
Show file tree
Hide file tree
Changes from 13 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
8 changes: 7 additions & 1 deletion src/lib/actions/inference-set.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,9 +86,15 @@ function baseSession(overrides: Partial<Session> = {}): Session {
telegramConfig: null,
wechatConfig: null,
metadata: { gatewayName: "nemoclaw", fromDockerfile: null },
machine: {
version: 1,
state: "complete",
stateEnteredAt: "2026-05-11T00:00:00.000Z",
revision: 0,
},
steps: {},
...overrides,
};
} as Session;
}

function createDeps(options: {
Expand Down
4 changes: 2 additions & 2 deletions src/lib/agent/onboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ export interface OnboardContext {
buildSandboxConfigSyncScript: (config: LooseObject) => string;
writeSandboxConfigSyncFile: (script: string) => string;
cleanupTempDir: (file: string, prefix: string) => void;
startRecordedStep: (stepName: string, updates: LooseObject) => void;
startRecordedStep: (stepName: string, updates: LooseObject) => Promise<void>;
skippedStepMessage: (stepName: string, sandboxName: string) => void;
}

Expand Down Expand Up @@ -424,7 +424,7 @@ export async function handleAgentSetup(
}
}

startRecordedStep("agent_setup", { sandboxName, provider, model });
await startRecordedStep("agent_setup", { sandboxName, provider, model });
step(7, 8, `Setting up ${agent.displayName} inside sandbox`);

const binaryAvailability = verifyAgentBinaryAvailable(sandboxName, agent, runCaptureOpenshell);
Expand Down
965 changes: 291 additions & 674 deletions src/lib/onboard.ts

Large diffs are not rendered by default.

166 changes: 166 additions & 0 deletions src/lib/onboard/machine/events.ts
Original file line number Diff line number Diff line change
@@ -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<Record<string, OnboardMachineState>>;

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<OnboardMachineEventListener>();

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<string, unknown> | 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<string, unknown> | 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.
}
}
}
122 changes: 122 additions & 0 deletions src/lib/onboard/machine/handlers/agent-setup.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
// 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 { createSession, type Session, type SessionUpdates } from "../../../state/onboard-session";
import { handleAgentSetupState, type AgentSetupStateOptions } from "./agent-setup";

type Agent = { name: string; displayName: string };

function createDeps(overrides: Partial<AgentSetupStateOptions<Agent>["deps"]> = {}) {
const calls = {
handleAgentSetup: vi.fn(async () => undefined),
context: vi.fn(() => ({ ctx: true })),
ensureDashboard: vi.fn(() => 18789),
skipped: vi.fn(async () => createSession()),
openclawReady: vi.fn(() => false),
skippedMessage: vi.fn(),
startStep: vi.fn(async () => undefined),
setupOpenclaw: vi.fn(async () => undefined),
complete: vi.fn(async () => createSession()),
};
return {
calls,
deps: {
handleAgentSetup: calls.handleAgentSetup,
agentSetupContext: calls.context,
ensureAgentDashboardForward: calls.ensureDashboard,
recordStepSkipped: calls.skipped,
isOpenclawReady: calls.openclawReady,
skippedStepMessage: calls.skippedMessage,
startRecordedStep: calls.startStep,
setupOpenclaw: calls.setupOpenclaw,
recordStepComplete: calls.complete,
toSessionUpdates: (updates: Record<string, unknown>) => updates as SessionUpdates,
...overrides,
},
};
}

function baseOptions(
deps: AgentSetupStateOptions<Agent>["deps"],
agent: Agent | null = null,
): AgentSetupStateOptions<Agent> {
return {
agent,
sandboxName: "my-assistant",
model: "model",
provider: "provider",
resume: false,
session: createSession(),
hermesAuthMethod: null,
hermesToolGateways: [],
deps,
};
}

describe("handleAgentSetupState", () => {
it("delegates non-OpenClaw agent setup and skips openclaw", async () => {
const { deps, calls } = createDeps();
const agent = { name: "hermes", displayName: "Hermes" };
const session = createSession();

await handleAgentSetupState({ ...baseOptions(deps, agent), session, resume: true });

expect(calls.handleAgentSetup).toHaveBeenCalledWith(
"my-assistant",
"model",
"provider",
agent,
true,
session,
{ ctx: true },
);
expect(calls.ensureDashboard).toHaveBeenCalledWith("my-assistant", agent);
expect(calls.skipped).toHaveBeenCalledWith("openclaw");
expect(calls.setupOpenclaw).not.toHaveBeenCalled();
});

it("skips OpenClaw setup on resume when OpenClaw is ready", async () => {
const { deps, calls } = createDeps({ isOpenclawReady: vi.fn(() => true) });

await handleAgentSetupState({ ...baseOptions(deps), resume: true });

expect(calls.skippedMessage).toHaveBeenCalledWith("openclaw", "my-assistant");
expect(calls.startStep).not.toHaveBeenCalled();
expect(calls.setupOpenclaw).not.toHaveBeenCalled();
expect(calls.complete).toHaveBeenCalledWith(
"openclaw",
expect.objectContaining({ sandboxName: "my-assistant", provider: "provider", model: "model" }),
);
expect(calls.skipped).toHaveBeenCalledWith("agent_setup");
});

it("runs OpenClaw setup and skips agent_setup for the default agent", async () => {
const { deps, calls } = createDeps();

await handleAgentSetupState({
...baseOptions(deps),
hermesAuthMethod: "oauth",
hermesToolGateways: ["github"],
});

expect(calls.startStep).toHaveBeenCalledWith("openclaw", {
sandboxName: "my-assistant",
provider: "provider",
model: "model",
});
expect(calls.setupOpenclaw).toHaveBeenCalledWith("my-assistant", "model", "provider");
expect(calls.complete).toHaveBeenCalledWith(
"openclaw",
expect.objectContaining({
sandboxName: "my-assistant",
provider: "provider",
model: "model",
hermesAuthMethod: "oauth",
hermesToolGateways: ["github"],
}),
);
expect(calls.skipped).toHaveBeenCalledWith("agent_setup");
});
});
Loading
Loading