Skip to content
Merged
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
124 changes: 47 additions & 77 deletions src/lib/onboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,7 @@ const nim: typeof import("./inference/nim") = require("./inference/nim");
const onboardSession: typeof import("./state/onboard-session") = require("./state/onboard-session");
const { OnboardRuntimeBoundary }: typeof import("./onboard/runtime-boundary") = require("./onboard/runtime-boundary");
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 { handleGatewayState }: typeof import("./onboard/machine/handlers/gateway") = require("./onboard/machine/handlers/gateway");
const { handlePoliciesState }: typeof import("./onboard/machine/handlers/policies") = require("./onboard/machine/handlers/policies");
const { handlePreflightState }: typeof import("./onboard/machine/handlers/preflight") = require("./onboard/machine/handlers/preflight");
Expand Down Expand Up @@ -9589,88 +9590,57 @@ async function onboard(opts: OnboardOptions = {}): Promise<void> {
});
session = policiesResult.session;

if (agent) {
ensureAgentDashboardForward(sandboxName, agent);
}

await recordSessionComplete(
toSessionUpdates({ sandboxName, provider, model, hermesAuthMethod, hermesToolGateways }),
);
completed = true;
// Onboarding finished successfully. Delete the legacy plaintext
// credentials.json only when every staged *value* was actually pushed
// to the gateway in this run. A successful upsert under the same
// env-key name with a different value (e.g. vllm-local upserting
// `OPENAI_API_KEY: "dummy"` while the legacy file held a real
// `sk-…` cloud key) does not count as a migration — the gateway
// never received the legacy secret, so unlinking the file would
// strand the user's only copy.
const allStagedMigrated =
stagedLegacyKeys.length > 0 && stagedLegacyKeys.every((k) => migratedLegacyKeys.has(k));
if (allStagedMigrated) {
removeLegacyCredentialsFile();
} else if (stagedLegacyKeys.length > 0) {
const unmigrated = stagedLegacyKeys.filter((k) => !migratedLegacyKeys.has(k));
console.error(
` Kept ~/.nemoclaw/credentials.json: ${String(unmigrated.length)} ` +
`legacy credential(s) were not migrated verbatim to the gateway in this run ` +
`(${unmigrated.join(", ")}). Re-run onboard with the relevant ` +
`providers/channels enabled to migrate them, then the file is removed automatically.`,
);
}
// Sweep stale host files left over from older NemoClaw versions —
// e.g. an empty/orphaned ~/.nemoclaw/credentials.json from upgrades
// before the credentials-gateway move (issue #3105). Each registered
// entry enforces its own safety guards; this call is a no-op when
// every target is already clean.
cleanupStaleHostFiles();

// Step [8/8] policy-apply restarts the sandbox container; the OpenClaw
// gateway inside the new container is launched lazily (normally by the
// first `nemoclaw <name> connect`). Bring it up explicitly here so the
// verifyDeployment block below does not race the post-policy startup and
// surface a false "gateway crashed during startup" warning. The helper
// is a no-op when the gateway is already running. Fixes #3573.
const processRecovery: typeof import("./actions/sandbox/process-recovery") =
require("./actions/sandbox/process-recovery");
processRecovery.checkAndRecoverSandboxProcesses(sandboxName, { quiet: true });

// Post-deployment verification — confirm the full delivery chain is
// operational before telling the user "YOUR AGENT IS LIVE". Fixes #2342.
const verifyDeploymentModule: typeof import("./verify-deployment") = require("./verify-deployment");
const _verifyChatUiUrl = process.env.CHAT_UI_URL || `http://127.0.0.1:${DASHBOARD_PORT}`;
const verifyChain = buildChain({ chatUiUrl: _verifyChatUiUrl, isWsl: isWsl(), wslHostAddress: getWslHostAddress() });
const verificationResult = await verifyDeploymentModule.verifyDeployment(
await handleFinalizationState({
sandboxName,
verifyChain,
{
executeSandboxCommand: (name: string, script: string) => {
return executeSandboxCommandForVerification(name, script);
model,
provider,
nimContainer,
agent,
hermesAuthMethod,
hermesToolGateways,
stagedLegacyKeys,
migratedLegacyKeys,
deps: {
ensureAgentDashboardForward,
recordSessionComplete,
toSessionUpdates: (updates) => toSessionUpdates(updates as Parameters<typeof toSessionUpdates>[0]),
removeLegacyCredentialsFile,
cleanupStaleHostFiles,
checkAndRecoverSandboxProcesses: (name, options) => {
const processRecovery: typeof import("./actions/sandbox/process-recovery") =
require("./actions/sandbox/process-recovery");
processRecovery.checkAndRecoverSandboxProcesses(name, options);
},
probeHostPort: (port: number, probePath: string) => {
const result = runCapture(
["curl", "-so", "/dev/null", "-w", "%{http_code}", "--max-time", "3",
`http://127.0.0.1:${port}${probePath}`],
{ ignoreError: true },
);
return parseInt(result.trim(), 10) || 0;
getChatUiUrl: () => process.env.CHAT_UI_URL || `http://127.0.0.1:${DASHBOARD_PORT}`,
buildVerifyChain: (chatUiUrl) =>
buildChain({ chatUiUrl, isWsl: isWsl(), wslHostAddress: getWslHostAddress() }),
verifyDeployment: async (name, chain) => {
const verifyDeploymentModule: typeof import("./verify-deployment") = require("./verify-deployment");
return verifyDeploymentModule.verifyDeployment(name, chain, {
executeSandboxCommand: (sandbox: string, script: string) =>
executeSandboxCommandForVerification(sandbox, script),
probeHostPort: (port: number, probePath: string) => {
const result = runCapture(
["curl", "-so", "/dev/null", "-w", "%{http_code}", "--max-time", "3", `http://127.0.0.1:${port}${probePath}`],
{ ignoreError: true },
);
return parseInt(result.trim(), 10) || 0;
},
captureForwardList: () => runCaptureOpenshell(["forward", "list"], { ignoreError: true }) || null,
getMessagingChannels: () => selectedMessagingChannels || [],
providerExistsInGateway: (providerName: string) => providerExistsInGateway(providerName),
});
},
captureForwardList: () => {
const output = runCaptureOpenshell(["forward", "list"], { ignoreError: true });
return output || null;
formatVerificationDiagnostics: (result) => {
const verifyDeploymentModule: typeof import("./verify-deployment") = require("./verify-deployment");
return verifyDeploymentModule.formatVerificationDiagnostics(result);
},
getMessagingChannels: (_name: string) => selectedMessagingChannels || [],
providerExistsInGateway: (providerName: string) => providerExistsInGateway(providerName),
printDashboard,
error: (message) => console.error(message),
log: (message) => console.log(message),
},
);

// Print verification diagnostics
const diagLines = verifyDeploymentModule.formatVerificationDiagnostics(verificationResult);
for (const line of diagLines) {
console.log(line);
}

printDashboard(sandboxName, model, provider, nimContainer, agent);
});
completed = true;
} finally {
releaseOnboardLock();
onboardRuntimeBoundary.clear();
Expand Down
124 changes: 124 additions & 0 deletions src/lib/onboard/machine/handlers/finalization.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
// 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 SessionUpdates } from "../../../state/onboard-session";
import { handleFinalizationState, type FinalizationStateOptions } from "./finalization";

type Agent = { name: string } | null;
type VerifyChain = { port: number };
type VerificationResult = { ok: boolean };

function createDeps(overrides: Partial<FinalizationStateOptions<Agent, VerifyChain, VerificationResult>["deps"]> = {}) {
const calls = {
ensureAgentDashboard: vi.fn(() => 18789),
complete: vi.fn(async () => createSession({ status: "complete" })),
removeLegacy: vi.fn(),
cleanupHost: vi.fn(),
recoverProcesses: vi.fn(),
getChatUiUrl: vi.fn(() => "http://127.0.0.1:18789"),
buildChain: vi.fn(() => ({ port: 18789 })),
verify: vi.fn(async () => ({ ok: true })),
diagnostics: vi.fn(() => [" ✓ verified"]),
dashboard: vi.fn(),
error: vi.fn(),
log: vi.fn(),
};
return {
calls,
deps: {
ensureAgentDashboardForward: calls.ensureAgentDashboard,
recordSessionComplete: calls.complete,
toSessionUpdates: (updates: Record<string, unknown>) => updates as SessionUpdates,
removeLegacyCredentialsFile: calls.removeLegacy,
cleanupStaleHostFiles: calls.cleanupHost,
checkAndRecoverSandboxProcesses: calls.recoverProcesses,
getChatUiUrl: calls.getChatUiUrl,
buildVerifyChain: calls.buildChain,
verifyDeployment: calls.verify,
formatVerificationDiagnostics: calls.diagnostics,
printDashboard: calls.dashboard,
error: calls.error,
log: calls.log,
...overrides,
},
};
}

function baseOptions(
deps: FinalizationStateOptions<Agent, VerifyChain, VerificationResult>["deps"],
): FinalizationStateOptions<Agent, VerifyChain, VerificationResult> {
return {
sandboxName: "my-assistant",
model: "model",
provider: "provider",
nimContainer: null,
agent: null,
hermesAuthMethod: null,
hermesToolGateways: [],
stagedLegacyKeys: [],
migratedLegacyKeys: new Set(),
deps,
};
}

describe("handleFinalizationState", () => {
it("completes the session, verifies deployment, and prints the dashboard", async () => {
const { deps, calls } = createDeps();

const result = await handleFinalizationState(baseOptions(deps));

expect(calls.complete).toHaveBeenCalledWith({
sandboxName: "my-assistant",
provider: "provider",
model: "model",
hermesAuthMethod: null,
hermesToolGateways: [],
});
expect(calls.cleanupHost).toHaveBeenCalledOnce();
expect(calls.recoverProcesses).toHaveBeenCalledWith("my-assistant", { quiet: true });
expect(calls.buildChain).toHaveBeenCalledWith("http://127.0.0.1:18789");
expect(calls.verify).toHaveBeenCalledWith("my-assistant", { port: 18789 });
expect(calls.log).toHaveBeenCalledWith(" ✓ verified");
expect(calls.dashboard).toHaveBeenCalledWith("my-assistant", "model", "provider", null, null);
expect(result.verificationDiagnostics).toEqual([" ✓ verified"]);
});

it("ensures agent dashboard forwarding before completion for non-OpenClaw agents", async () => {
const { deps, calls } = createDeps();
const agent = { name: "hermes" };

await handleFinalizationState({ ...baseOptions(deps), agent });

expect(calls.ensureAgentDashboard).toHaveBeenCalledWith("my-assistant", agent);
expect(calls.dashboard).toHaveBeenCalledWith("my-assistant", "model", "provider", null, agent);
});

it("removes legacy credentials only when all staged values migrated", async () => {
const { deps, calls } = createDeps();

await handleFinalizationState({
...baseOptions(deps),
stagedLegacyKeys: ["NVIDIA_API_KEY", "SLACK_BOT_TOKEN"],
migratedLegacyKeys: new Set(["NVIDIA_API_KEY", "SLACK_BOT_TOKEN"]),
});

expect(calls.removeLegacy).toHaveBeenCalledOnce();
expect(calls.error).not.toHaveBeenCalled();
});

it("keeps legacy credentials and warns when migration is incomplete", async () => {
const { deps, calls } = createDeps();

const result = await handleFinalizationState({
...baseOptions(deps),
stagedLegacyKeys: ["NVIDIA_API_KEY", "SLACK_BOT_TOKEN"],
migratedLegacyKeys: new Set(["NVIDIA_API_KEY"]),
});

expect(calls.removeLegacy).not.toHaveBeenCalled();
expect(calls.error).toHaveBeenCalledWith(expect.stringContaining("SLACK_BOT_TOKEN"));
expect(result.unmigratedLegacyKeys).toEqual(["SLACK_BOT_TOKEN"]);
});
});
91 changes: 91 additions & 0 deletions src/lib/onboard/machine/handlers/finalization.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import type { Session, SessionUpdates } from "../../../state/onboard-session";

export interface FinalizationStateOptions<Agent, VerifyChain, VerificationResult> {
sandboxName: string;
model: string;
provider: string;
nimContainer: string | null;
agent: Agent;
hermesAuthMethod: string | null;
hermesToolGateways: string[];
stagedLegacyKeys: readonly string[];
migratedLegacyKeys: ReadonlySet<string>;
deps: {
ensureAgentDashboardForward(sandboxName: string, agent: NonNullable<Agent>): number;
recordSessionComplete(updates: SessionUpdates): Promise<Session>;
toSessionUpdates(updates: Record<string, unknown>): SessionUpdates;
removeLegacyCredentialsFile(): void;
cleanupStaleHostFiles(): void;
checkAndRecoverSandboxProcesses(sandboxName: string, options: { quiet: boolean }): void;
getChatUiUrl(): string;
buildVerifyChain(chatUiUrl: string): VerifyChain;
verifyDeployment(sandboxName: string, chain: VerifyChain): Promise<VerificationResult>;
formatVerificationDiagnostics(result: VerificationResult): string[];
printDashboard(
sandboxName: string,
model: string,
provider: string,
nimContainer: string | null,
agent: Agent,
): void;
error(message?: string): void;
log(message?: string): void;
};
}

export interface FinalizationStateResult {
session: Session;
unmigratedLegacyKeys: string[];
verificationDiagnostics: string[];
}

export async function handleFinalizationState<Agent, VerifyChain, VerificationResult>({
sandboxName,
model,
provider,
nimContainer,
agent,
hermesAuthMethod,
hermesToolGateways,
stagedLegacyKeys,
migratedLegacyKeys,
deps,
}: FinalizationStateOptions<Agent, VerifyChain, VerificationResult>): Promise<FinalizationStateResult> {
if (agent) deps.ensureAgentDashboardForward(sandboxName, agent as NonNullable<Agent>);

const session = await deps.recordSessionComplete(
deps.toSessionUpdates({ sandboxName, provider, model, hermesAuthMethod, hermesToolGateways }),
);

const allStagedMigrated =
stagedLegacyKeys.length > 0 && stagedLegacyKeys.every((key) => migratedLegacyKeys.has(key));
const unmigratedLegacyKeys = stagedLegacyKeys.filter((key) => !migratedLegacyKeys.has(key));
if (allStagedMigrated) {
deps.removeLegacyCredentialsFile();
} else if (stagedLegacyKeys.length > 0) {
deps.error(
` Kept ~/.nemoclaw/credentials.json: ${String(unmigratedLegacyKeys.length)} ` +
`legacy credential(s) were not migrated verbatim to the gateway in this run ` +
`(${unmigratedLegacyKeys.join(", ")}). Re-run onboard with the relevant ` +
`providers/channels enabled to migrate them, then the file is removed automatically.`,
);
}

// Sweep stale host files left by older credential migration paths (#3105).
deps.cleanupStaleHostFiles();
// Policy application can restart the sandbox; recover OpenClaw before verification (#3573).
deps.checkAndRecoverSandboxProcesses(sandboxName, { quiet: true });

// Confirm the delivered sandbox is reachable before printing the live dashboard (#2342).
const verifyChain = deps.buildVerifyChain(deps.getChatUiUrl());
const verificationResult = await deps.verifyDeployment(sandboxName, verifyChain);
const verificationDiagnostics = deps.formatVerificationDiagnostics(verificationResult);
for (const line of verificationDiagnostics) deps.log(line);

deps.printDashboard(sandboxName, model, provider, nimContainer, agent);

return { session, unmigratedLegacyKeys, verificationDiagnostics };
}