diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index d76fe59c56c..bc16597d4cd 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -3502,6 +3502,7 @@ const { buildOrphanedSandboxRollbackMessage, ensureDashboardForward, ensureAgentDashboardForward, + ensureFinalizationDashboardForward, ensureAgentFixedForward, fetchGatewayAuthTokenFromSandbox, getDashboardForwardPort, @@ -3522,7 +3523,6 @@ const { sleep: sleepSeconds, printAgentDashboardUi: agentOnboard.printDashboardUi, }); - const onboardRuntimeBoundary = new OnboardRuntimeBoundary({ toSessionUpdates: (updates: Record) => toSessionUpdates(updates as Parameters[0]), @@ -4240,7 +4240,7 @@ async function runOnboard(opts: OnboardOptions = {}): Promise { webSearchProvider: (config) => webSearchProviderForConfig(config), }, finalizationDeps: { - ensureAgentDashboardForward: (name, selectedAgent) => selectedAgent ? ensureAgentDashboardForward(name, selectedAgent) : ensureDashboardForward(name, process.env.CHAT_UI_URL), + ensureAgentDashboardForward: (name, selectedAgent) => selectedAgent ? ensureAgentDashboardForward(name, selectedAgent) : ensureFinalizationDashboardForward(name), setDefaultSandbox: registry.setDefault, verifyWebSearchInsideSandbox, toSessionUpdates: (updates) => diff --git a/src/lib/onboard/agent-dashboard-forward.ts b/src/lib/onboard/agent-dashboard-forward.ts index cc9383e4712..7db5d33ed33 100644 --- a/src/lib/onboard/agent-dashboard-forward.ts +++ b/src/lib/onboard/agent-dashboard-forward.ts @@ -117,7 +117,7 @@ export async function ensureAgentDashboardForward(options: { return actualAgentDashboardPort; } -function replaceUrlPort(value: string, port: number): string { +export function replaceUrlPort(value: string, port: number): string { try { const parsed = new URL(value.includes("://") ? value : `http://${value}`); parsed.port = String(port); diff --git a/src/lib/onboard/dashboard-port.ts b/src/lib/onboard/dashboard-port.ts index 1e795c16299..a0d9acdc41b 100644 --- a/src/lib/onboard/dashboard-port.ts +++ b/src/lib/onboard/dashboard-port.ts @@ -211,6 +211,20 @@ function mergeOccupiedPorts( return forwardOccupied; } +/** Host-wide registry view shared by the allocator and the persisted-port lookup. */ +function listHostRegistrySandboxes(): { sandboxes: SandboxRegistryEntry[] } { + return { + sandboxes: listHostGatewayRegistryEntries(process.env.HOME || os.homedir()).map( + ({ entry, gatewayPort }) => ({ + name: entry.name, + dashboardPort: entry.dashboardPort, + hermesApiPort: entry.hermesApiPort, + scopeGatewayPort: gatewayPort, + }), + ), + }; +} + /** * Build a cross-gateway occupancy map (port → owning sandbox name) from the * persisted sandbox registries, excluding the current sandbox only in the @@ -231,18 +245,7 @@ function getRegistryOccupiedPorts( listSandboxesFn?: ListSandboxesFn, ): Map { const occupied = new Map(); - const list = - listSandboxesFn ?? - (() => ({ - sandboxes: listHostGatewayRegistryEntries(process.env.HOME || os.homedir()).map( - ({ entry, gatewayPort }) => ({ - name: entry.name, - dashboardPort: entry.dashboardPort, - hermesApiPort: entry.hermesApiPort, - scopeGatewayPort: gatewayPort, - }), - ), - })); + const list = listSandboxesFn ?? listHostRegistrySandboxes; for (const entry of list().sandboxes) { if ( entry.name === currentSandboxName && @@ -276,6 +279,27 @@ export function getRegistryOccupiedDashboardPorts( ); } +/** + * Dashboard port recorded in the registry for one sandbox on the selected + * gateway. The onboard resume path skips sandbox creation, which is the step + * that normally publishes the created sandbox's port through `CHAT_UI_URL`, + * so finalization re-reads the port that onboarding persisted (#8214). + * Returns null when the entry has no positive integer port. (#8970) + */ +export function getPersistedDashboardPort( + currentSandboxName: string, + listSandboxesFn?: ListSandboxesFn, +): number | null { + const list = listSandboxesFn ?? listHostRegistrySandboxes; + for (const entry of list().sandboxes) { + if (entry.name !== currentSandboxName) continue; + if (entry.scopeGatewayPort !== undefined && entry.scopeGatewayPort !== GATEWAY_PORT) continue; + const port = entry.dashboardPort; + if (typeof port === "number" && Number.isInteger(port) && port > 0) return port; + } + return null; +} + /** * Cross-gateway occupancy view for Hermes API ports. The API forward is a * per-sandbox host resource just like the dashboard forward, so allocation diff --git a/src/lib/onboard/dashboard.ts b/src/lib/onboard/dashboard.ts index 63fa2d0a032..1fe61095a58 100644 --- a/src/lib/onboard/dashboard.ts +++ b/src/lib/onboard/dashboard.ts @@ -11,7 +11,10 @@ import { DASHBOARD_PORT } from "../core/ports"; import { buildChain, buildControlUiUrls, buildFallbackControlUiUrls } from "../dashboard/contract"; import * as nim from "../inference/nim"; import { runCapture as defaultRunCapture } from "../runner"; -import { ensureAgentDashboardForward as ensureAgentDashboardForwardForAgent } from "./agent-dashboard-forward"; +import { + ensureAgentDashboardForward as ensureAgentDashboardForwardForAgent, + replaceUrlPort, +} from "./agent-dashboard-forward"; import { ensureAgentFixedForward as ensureFixedAgentForward } from "./agent-fixed-forward"; import { fetchAgentWebAuthTokenFromSandbox as fetchAgentWebAuthToken } from "./agent-web-auth-token"; import * as dashboardAccess from "./dashboard-access"; @@ -23,6 +26,7 @@ import { import { findAvailableDashboardPort, getOccupiedPorts, + getPersistedDashboardPort, getRegistryOccupiedDashboardPorts, isLiveForwardStatus, type ListSandboxesFn, @@ -97,6 +101,7 @@ export interface OnboardDashboardHelpers { agent: { forwardPort?: number | null; forward_ports?: number[] | null }, options?: { beforeForwardPort?: (port: number) => Promise | void }, ): Promise; + ensureFinalizationDashboardForward(sandboxName: string): number; ensureAgentFixedForward(sandboxName: string, port: number, label: string): boolean; fetchGatewayAuthTokenFromSandbox(sandboxName: string): string | null; fetchAgentWebAuthTokenFromSandbox(sandboxName: string, agent: AgentDefinition): string | null; @@ -384,6 +389,36 @@ export function createOnboardDashboardHelpers(deps: OnboardDashboardDeps): Onboa return actualPort; } + /** + * Reconcile the dashboard forward for the agent-less OpenClaw finalization + * branch. The resume path skips sandbox creation, so `CHAT_UI_URL` does not + * carry the port the in-sandbox gateway listens on; the registry entry + * persisted by onboarding is the only record of that port. The forward and + * the in-sandbox gateway must share one port number (`openshell forward` + * binds the same port on both sides), so when the persisted port cannot be + * forwarded this throws instead of reallocating: the resumed gateway only + * listens on the persisted port, and a forward on any other port serves + * nothing. Post-verify builds its probe chain and Browser URL from + * `CHAT_UI_URL`, so after the forward starts this writes the bound port to + * `CHAT_UI_URL`. (#8970) + */ + function ensureFinalizationDashboardForward(sandboxName: string): number { + const envUrl = process.env.CHAT_UI_URL; + const persistedPort = envUrl ? null : getPersistedDashboardPort(sandboxName, deps.listSandboxes); + const requestedUrl = + envUrl || (persistedPort === null ? undefined : `http://127.0.0.1:${String(persistedPort)}`); + const actualPort = ensureDashboardForward( + sandboxName, + requestedUrl, + persistedPort === null ? {} : { allowPortReallocation: false }, + ); + process.env.CHAT_UI_URL = replaceUrlPort( + requestedUrl || `http://127.0.0.1:${String(actualPort)}`, + actualPort, + ); + return actualPort; + } + function ensureAgentDashboardForward( sandboxName: string, agent: { forwardPort?: number | null; forward_ports?: number[] | null }, @@ -582,6 +617,7 @@ export function createOnboardDashboardHelpers(deps: OnboardDashboardDeps): Onboa buildOrphanedSandboxRollbackMessage, ensureDashboardForward, ensureAgentDashboardForward, + ensureFinalizationDashboardForward, ensureAgentFixedForward, fetchGatewayAuthTokenFromSandbox, fetchAgentWebAuthTokenFromSandbox, diff --git a/test/onboard-finalization-dashboard-forward.test.ts b/test/onboard-finalization-dashboard-forward.test.ts new file mode 100644 index 00000000000..2d5e33b6eec --- /dev/null +++ b/test/onboard-finalization-dashboard-forward.test.ts @@ -0,0 +1,161 @@ +// 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 { GATEWAY_PORT } from "../src/lib/core/ports"; +import { createOnboardDashboardHelpers } from "../src/lib/onboard/dashboard"; +import type { ListSandboxesFn } from "../src/lib/onboard/dashboard-port"; + +function createFinalizationForwardHarness(options: { + forwardList: string; + listSandboxes: ListSandboxesFn; +}) { + const runOpenshell = vi.fn((_args: string[], _opts?: Record) => ({ + status: 0, + })); + const runCaptureOpenshell = vi.fn((args: string[], _opts?: Record) => + args.join(" ") === "forward list" ? options.forwardList : "", + ); + const openshellArgv = vi.fn((args: string[]) => [process.execPath, "-e", "", ...args]); + const helpers = createOnboardDashboardHelpers({ + runOpenshell, + runCaptureOpenshell, + openshellArgv, + cliName: () => "nemoclaw", + agentProductName: () => "NemoClaw", + getProviderLabel: (provider: string) => provider, + note: vi.fn(), + isWsl: () => false, + redact: (value: unknown) => String(value), + sleep: vi.fn(), + printAgentDashboardUi: vi.fn(), + listSandboxes: options.listSandboxes, + }); + return { helpers, runOpenshell, openshellArgv }; +} + +describe("finalization dashboard forward", () => { + it("prefers the persisted registry port over the default dashboard port when CHAT_UI_URL is unset (#8970)", () => { + vi.stubEnv("CHAT_UI_URL", undefined); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined); + const { helpers, openshellArgv } = createFinalizationForwardHarness({ + forwardList: + "SANDBOX BIND PORT PID STATUS\n" + + "baseline 127.0.0.1 18789 42000 running\n" + + "reonboard-test 127.0.0.1 18790 42001 running", + listSandboxes: () => ({ + sandboxes: [ + { name: "baseline", dashboardPort: 18789 }, + { name: "reonboard-test", dashboardPort: 18790 }, + ], + }), + }); + + try { + expect(helpers.ensureFinalizationDashboardForward("reonboard-test")).toBe(18790); + const warnings = warnSpy.mock.calls.map(([line]) => String(line)).join("\n"); + expect(warnings).not.toContain("is taken"); + const startCalls = openshellArgv.mock.calls + .map(([args]) => args.join(" ")) + .filter((line) => line.startsWith("forward start")); + expect(startCalls).toContainEqual("forward start --background 18790 reonboard-test"); + } finally { + warnSpy.mockRestore(); + } + }); + + it("publishes the established forward port back to CHAT_UI_URL (#8970)", () => { + vi.stubEnv("CHAT_UI_URL", undefined); + const { helpers } = createFinalizationForwardHarness({ + forwardList: + "SANDBOX BIND PORT PID STATUS\n" + "reonboard-test 127.0.0.1 18790 42001 running", + listSandboxes: () => ({ + sandboxes: [ + { name: "reonboard-test", dashboardPort: 18790, scopeGatewayPort: GATEWAY_PORT }, + ], + }), + }); + + expect(helpers.ensureFinalizationDashboardForward("reonboard-test")).toBe(18790); + expect(process.env.CHAT_UI_URL).toBe("http://127.0.0.1:18790"); + }); + + it("fails without reallocating when another sandbox holds the persisted port (#8970)", () => { + vi.stubEnv("CHAT_UI_URL", undefined); + const { helpers, openshellArgv } = createFinalizationForwardHarness({ + forwardList: + "SANDBOX BIND PORT PID STATUS\n" + + "baseline 127.0.0.1 18789 42000 running\n" + + "other-sandbox 127.0.0.1 18790 42002 running", + listSandboxes: () => ({ + sandboxes: [ + { name: "baseline", dashboardPort: 18789 }, + { name: "other-sandbox", dashboardPort: 18790 }, + { name: "reonboard-test", dashboardPort: 18790 }, + ], + }), + }); + + expect(() => helpers.ensureFinalizationDashboardForward("reonboard-test")).toThrow( + "Port 18790 is not available for 'reonboard-test' and cannot be reallocated.", + ); + expect(process.env.CHAT_UI_URL).toBeUndefined(); + const startCalls = openshellArgv.mock.calls + .map(([args]) => args.join(" ")) + .filter((line) => line.startsWith("forward start")); + expect(startCalls).toEqual([]); + }); + + it("falls back to the default dashboard port when no port is persisted (#8970)", () => { + vi.stubEnv("CHAT_UI_URL", undefined); + const { helpers } = createFinalizationForwardHarness({ + forwardList: "SANDBOX BIND PORT PID STATUS\n" + "my-sandbox 127.0.0.1 18789 42001 running", + listSandboxes: () => ({ sandboxes: [] }), + }); + + expect(helpers.ensureFinalizationDashboardForward("my-sandbox")).toBe(18789); + expect(process.env.CHAT_UI_URL).toBe("http://127.0.0.1:18789"); + }); + + it("honors an explicit CHAT_UI_URL override over the persisted port (#8970)", () => { + vi.stubEnv("CHAT_UI_URL", "http://127.0.0.1:19005"); + const { helpers } = createFinalizationForwardHarness({ + forwardList: + "SANDBOX BIND PORT PID STATUS\n" + "reonboard-test 127.0.0.1 19005 42001 running", + listSandboxes: () => ({ + sandboxes: [{ name: "reonboard-test", dashboardPort: 18790 }], + }), + }); + + expect(helpers.ensureFinalizationDashboardForward("reonboard-test")).toBe(19005); + expect(process.env.CHAT_UI_URL).toBe("http://127.0.0.1:19005"); + }); + + it("ignores a dashboard port persisted for another gateway scope (#8970)", () => { + vi.stubEnv("CHAT_UI_URL", undefined); + const { helpers } = createFinalizationForwardHarness({ + forwardList: + "SANDBOX BIND PORT PID STATUS\n" + "reonboard-test 127.0.0.1 18789 42001 running", + listSandboxes: () => ({ + sandboxes: [{ name: "reonboard-test", dashboardPort: 18790, scopeGatewayPort: 9999 }], + }), + }); + + expect(helpers.ensureFinalizationDashboardForward("reonboard-test")).toBe(18789); + expect(process.env.CHAT_UI_URL).toBe("http://127.0.0.1:18789"); + }); + + it("ignores a zero persisted dashboard port and uses the default dashboard port (#8970)", () => { + vi.stubEnv("CHAT_UI_URL", undefined); + const { helpers } = createFinalizationForwardHarness({ + forwardList: + "SANDBOX BIND PORT PID STATUS\n" + "reonboard-test 127.0.0.1 18789 42001 running", + listSandboxes: () => ({ + sandboxes: [{ name: "reonboard-test", dashboardPort: 0 }], + }), + }); + + expect(helpers.ensureFinalizationDashboardForward("reonboard-test")).toBe(18789); + expect(process.env.CHAT_UI_URL).toBe("http://127.0.0.1:18789"); + }); +});