diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 442bdfcd7d..c8f700a29f 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -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"); @@ -9589,88 +9590,57 @@ async function onboard(opts: OnboardOptions = {}): Promise { }); 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 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[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(); diff --git a/src/lib/onboard/machine/handlers/finalization.test.ts b/src/lib/onboard/machine/handlers/finalization.test.ts new file mode 100644 index 0000000000..5ffcd4547a --- /dev/null +++ b/src/lib/onboard/machine/handlers/finalization.test.ts @@ -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["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) => 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["deps"], +): FinalizationStateOptions { + 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"]); + }); +}); diff --git a/src/lib/onboard/machine/handlers/finalization.ts b/src/lib/onboard/machine/handlers/finalization.ts new file mode 100644 index 0000000000..6684ee10db --- /dev/null +++ b/src/lib/onboard/machine/handlers/finalization.ts @@ -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 { + sandboxName: string; + model: string; + provider: string; + nimContainer: string | null; + agent: Agent; + hermesAuthMethod: string | null; + hermesToolGateways: string[]; + stagedLegacyKeys: readonly string[]; + migratedLegacyKeys: ReadonlySet; + deps: { + ensureAgentDashboardForward(sandboxName: string, agent: NonNullable): number; + recordSessionComplete(updates: SessionUpdates): Promise; + toSessionUpdates(updates: Record): SessionUpdates; + removeLegacyCredentialsFile(): void; + cleanupStaleHostFiles(): void; + checkAndRecoverSandboxProcesses(sandboxName: string, options: { quiet: boolean }): void; + getChatUiUrl(): string; + buildVerifyChain(chatUiUrl: string): VerifyChain; + verifyDeployment(sandboxName: string, chain: VerifyChain): Promise; + 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({ + sandboxName, + model, + provider, + nimContainer, + agent, + hermesAuthMethod, + hermesToolGateways, + stagedLegacyKeys, + migratedLegacyKeys, + deps, +}: FinalizationStateOptions): Promise { + if (agent) deps.ensureAgentDashboardForward(sandboxName, agent as NonNullable); + + 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 }; +}