Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
3ed5651
fix(onboard): verify the persisted dashboard port when resume skips s…
Dongni-Yang Aug 13, 2026
3ba493c
fix(onboard): apply documentation writer wording review
Dongni-Yang Aug 13, 2026
6117bf4
merge: resolve conflicts with main
github-actions[bot] Aug 13, 2026
3a84b55
test(onboard): use an ESM import for createOnboardDashboardHelpers
Dongni-Yang Aug 13, 2026
38b839d
Merge remote-tracking branch 'upstream/fix/8970-reonboard-stale-dashb…
Dongni-Yang Aug 13, 2026
217038f
fix(onboard): fail instead of reallocating the persisted dashboard port
Dongni-Yang Aug 13, 2026
af708bb
Merge branch 'main' into fix/8970-reonboard-stale-dashboard-port
rsliter Aug 13, 2026
536c34c
Merge branch 'main' into fix/8970-reonboard-stale-dashboard-port
rsliter Aug 13, 2026
91ef1a5
Merge branch 'main' into fix/8970-reonboard-stale-dashboard-port
rsliter Aug 13, 2026
938846a
Merge branch 'main' into fix/8970-reonboard-stale-dashboard-port
rsliter Aug 13, 2026
c400e7f
chore(ci): lower onboarding source budget
rsliter Aug 13, 2026
de4c0b1
Merge branch 'main' into fix/8970-reonboard-stale-dashboard-port
rsliter Aug 13, 2026
c0a76d2
Merge branch 'main' into fix/8970-reonboard-stale-dashboard-port
rsliter Aug 13, 2026
6ba418d
fix(test): preserve explicit WSL overrides
prekshivyas Aug 13, 2026
b370faa
fix(hermes): admit the managed API port
prekshivyas Aug 13, 2026
ccf67d3
test(onboard): use valid replacement identity
prekshivyas Aug 13, 2026
bab22f9
Merge remote-tracking branch 'origin/main' into fix/pr8983-conflict
prekshivyas Aug 13, 2026
2cf591f
merge: sync main into dashboard port verification
cv Aug 13, 2026
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
4 changes: 2 additions & 2 deletions src/lib/onboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3502,6 +3502,7 @@ const {
buildOrphanedSandboxRollbackMessage,
ensureDashboardForward,
ensureAgentDashboardForward,
ensureFinalizationDashboardForward,
ensureAgentFixedForward,
fetchGatewayAuthTokenFromSandbox,
getDashboardForwardPort,
Expand All @@ -3522,7 +3523,6 @@ const {
sleep: sleepSeconds,
printAgentDashboardUi: agentOnboard.printDashboardUi,
});

const onboardRuntimeBoundary = new OnboardRuntimeBoundary({
toSessionUpdates: (updates: Record<string, unknown>) =>
toSessionUpdates(updates as Parameters<typeof toSessionUpdates>[0]),
Expand Down Expand Up @@ -4240,7 +4240,7 @@ async function runOnboard(opts: OnboardOptions = {}): Promise<void> {
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) =>
Expand Down
2 changes: 1 addition & 1 deletion src/lib/onboard/agent-dashboard-forward.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
48 changes: 36 additions & 12 deletions src/lib/onboard/dashboard-port.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -231,18 +245,7 @@ function getRegistryOccupiedPorts(
listSandboxesFn?: ListSandboxesFn,
): Map<string, string> {
const occupied = new Map<string, string>();
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 &&
Expand Down Expand Up @@ -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
Expand Down
38 changes: 37 additions & 1 deletion src/lib/onboard/dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -23,6 +26,7 @@ import {
import {
findAvailableDashboardPort,
getOccupiedPorts,
getPersistedDashboardPort,
getRegistryOccupiedDashboardPorts,
isLiveForwardStatus,
type ListSandboxesFn,
Expand Down Expand Up @@ -97,6 +101,7 @@ export interface OnboardDashboardHelpers {
agent: { forwardPort?: number | null; forward_ports?: number[] | null },
options?: { beforeForwardPort?: (port: number) => Promise<void> | void },
): Promise<number>;
ensureFinalizationDashboardForward(sandboxName: string): number;
ensureAgentFixedForward(sandboxName: string, port: number, label: string): boolean;
fetchGatewayAuthTokenFromSandbox(sandboxName: string): string | null;
fetchAgentWebAuthTokenFromSandbox(sandboxName: string, agent: AgentDefinition): string | null;
Expand Down Expand Up @@ -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 },
Expand Down Expand Up @@ -582,6 +617,7 @@ export function createOnboardDashboardHelpers(deps: OnboardDashboardDeps): Onboa
buildOrphanedSandboxRollbackMessage,
ensureDashboardForward,
ensureAgentDashboardForward,
ensureFinalizationDashboardForward,
ensureAgentFixedForward,
fetchGatewayAuthTokenFromSandbox,
fetchAgentWebAuthTokenFromSandbox,
Expand Down
161 changes: 161 additions & 0 deletions test/onboard-finalization-dashboard-forward.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>) => ({
status: 0,
}));
const runCaptureOpenshell = vi.fn((args: string[], _opts?: Record<string, unknown>) =>
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");
});
});
Loading