From 3ed5651158f8063f1167af281b9cae7bf8b9a22c Mon Sep 17 00:00:00 2001 From: Dongni Yang Date: Thu, 13 Aug 2026 18:34:31 +0800 Subject: [PATCH 1/8] fix(onboard): verify the persisted dashboard port when resume skips sandbox creation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-onboarding an existing Ready sandbox takes the resume path, which skips sandbox creation — the step that publishes the sandbox's bumped dashboard port through CHAT_UI_URL. Finalization then requested the default port, printed a spurious port bump, and post-verify probed the default port while the in-sandbox gateway listens on the bumped port, so verification reported gateway HTTP 0 and onboard exited 1. Finalization now prefers the registry port persisted by onboarding (#8214) and publishes the port the forward was bound on back to CHAT_UI_URL, so the post-verify probe chain and Browser URL use the live port. Closes #8970 Signed-off-by: Dongni Yang Co-Authored-By: Claude Fable 5 --- src/lib/onboard.ts | 4 +- src/lib/onboard/agent-dashboard-forward.ts | 2 +- src/lib/onboard/dashboard-port.ts | 48 +++-- src/lib/onboard/dashboard.ts | 31 +++- ...ard-finalization-dashboard-forward.test.ts | 167 ++++++++++++++++++ 5 files changed, 236 insertions(+), 16 deletions(-) create mode 100644 test/onboard-finalization-dashboard-forward.test.ts diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index ae2f25cc44d..0367a926df9 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -3541,6 +3541,7 @@ const { buildOrphanedSandboxRollbackMessage, ensureDashboardForward, ensureAgentDashboardForward, + ensureFinalizationDashboardForward, ensureAgentFixedForward, fetchGatewayAuthTokenFromSandbox, getDashboardForwardPort, @@ -3561,7 +3562,6 @@ const { sleep: sleepSeconds, printAgentDashboardUi: agentOnboard.printDashboardUi, }); - const onboardRuntimeBoundary = new OnboardRuntimeBoundary({ toSessionUpdates: (updates: Record) => toSessionUpdates(updates as Parameters[0]), @@ -4290,7 +4290,7 @@ async function runOnboard(opts: OnboardOptions = {}): Promise { }, finalizationDeps: { // biome-ignore format: keep src/lib/onboard.ts net-neutral for growth guardrail. - 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..c7ec092f511 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 sandbox has no valid persisted 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..f6eff5b8b44 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,29 @@ 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), and post-verify builds its probe + * chain and Browser URL from `CHAT_UI_URL`, so this publishes the port that + * `ensureDashboardForward` bound back to `CHAT_UI_URL`. (#8970) + */ + function ensureFinalizationDashboardForward(sandboxName: string): number { + const persistedPort = getPersistedDashboardPort(sandboxName, deps.listSandboxes); + const requestedUrl = + process.env.CHAT_UI_URL || + (persistedPort === null ? undefined : `http://127.0.0.1:${String(persistedPort)}`); + const actualPort = ensureDashboardForward(sandboxName, requestedUrl); + 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 +610,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..6c6d4a174c2 --- /dev/null +++ b/test/onboard-finalization-dashboard-forward.test.ts @@ -0,0 +1,167 @@ +// 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 type { OnboardDashboardDeps, OnboardDashboardHelpers } from "../src/lib/onboard/dashboard"; +import type { ListSandboxesFn } from "../src/lib/onboard/dashboard-port"; + +const { createOnboardDashboardHelpers } = require("../src/lib/onboard/dashboard") as { + createOnboardDashboardHelpers: (deps: OnboardDashboardDeps) => OnboardDashboardHelpers; +}; + +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 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("publishes the reallocated port when the persisted port was taken over (#8970)", () => { + vi.stubEnv("CHAT_UI_URL", undefined); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined); + const { helpers } = 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\n" + + "reonboard-test 127.0.0.1 18791 42001 running", + listSandboxes: () => ({ + sandboxes: [ + { name: "baseline", dashboardPort: 18789 }, + { name: "other-sandbox", dashboardPort: 18790 }, + { name: "reonboard-test", dashboardPort: 18790 }, + ], + }), + }); + + try { + expect(helpers.ensureFinalizationDashboardForward("reonboard-test")).toBe(18791); + expect(process.env.CHAT_UI_URL).toBe("http://127.0.0.1:18791"); + const warnings = warnSpy.mock.calls.map(([line]) => String(line)).join("\n"); + expect(warnings).toContain("Port 18790 is taken. Using port 18791 instead."); + } finally { + warnSpy.mockRestore(); + } + }); + + 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 an invalid persisted dashboard port and falls back to the default (#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"); + }); +}); From 3ba493ce66ab588ab20fbb7416d2b3c689662c9f Mon Sep 17 00:00:00 2001 From: Dongni Yang Date: Thu, 13 Aug 2026 18:38:07 +0800 Subject: [PATCH 2/8] fix(onboard): apply documentation writer wording review Signed-off-by: Dongni Yang Co-Authored-By: Claude Fable 5 --- src/lib/onboard/dashboard-port.ts | 2 +- src/lib/onboard/dashboard.ts | 6 +++--- test/onboard-finalization-dashboard-forward.test.ts | 6 +++--- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/lib/onboard/dashboard-port.ts b/src/lib/onboard/dashboard-port.ts index c7ec092f511..a0d9acdc41b 100644 --- a/src/lib/onboard/dashboard-port.ts +++ b/src/lib/onboard/dashboard-port.ts @@ -284,7 +284,7 @@ export function getRegistryOccupiedDashboardPorts( * 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 sandbox has no valid persisted port. (#8970) + * Returns null when the entry has no positive integer port. (#8970) */ export function getPersistedDashboardPort( currentSandboxName: string, diff --git a/src/lib/onboard/dashboard.ts b/src/lib/onboard/dashboard.ts index f6eff5b8b44..7588eb0e25a 100644 --- a/src/lib/onboard/dashboard.ts +++ b/src/lib/onboard/dashboard.ts @@ -395,9 +395,9 @@ export function createOnboardDashboardHelpers(deps: OnboardDashboardDeps): Onboa * 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), and post-verify builds its probe - * chain and Browser URL from `CHAT_UI_URL`, so this publishes the port that - * `ensureDashboardForward` bound back to `CHAT_UI_URL`. (#8970) + * binds the same port on both sides). 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 persistedPort = getPersistedDashboardPort(sandboxName, deps.listSandboxes); diff --git a/test/onboard-finalization-dashboard-forward.test.ts b/test/onboard-finalization-dashboard-forward.test.ts index 6c6d4a174c2..079a314e782 100644 --- a/test/onboard-finalization-dashboard-forward.test.ts +++ b/test/onboard-finalization-dashboard-forward.test.ts @@ -39,7 +39,7 @@ function createFinalizationForwardHarness(options: { } describe("finalization dashboard forward", () => { - it("prefers the persisted registry port over the default when CHAT_UI_URL is unset (#8970)", () => { + 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({ @@ -84,7 +84,7 @@ describe("finalization dashboard forward", () => { expect(process.env.CHAT_UI_URL).toBe("http://127.0.0.1:18790"); }); - it("publishes the reallocated port when the persisted port was taken over (#8970)", () => { + it("publishes the reallocated port when another sandbox holds the persisted port (#8970)", () => { vi.stubEnv("CHAT_UI_URL", undefined); const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined); const { helpers } = createFinalizationForwardHarness({ @@ -151,7 +151,7 @@ describe("finalization dashboard forward", () => { expect(process.env.CHAT_UI_URL).toBe("http://127.0.0.1:18789"); }); - it("ignores an invalid persisted dashboard port and falls back to the default (#8970)", () => { + it("ignores a zero persisted dashboard port and uses the default dashboard port (#8970)", () => { vi.stubEnv("CHAT_UI_URL", undefined); const { helpers } = createFinalizationForwardHarness({ forwardList: From 3a84b55665bd13459b9857914b3b53d0e495437e Mon Sep 17 00:00:00 2001 From: Dongni Yang Date: Thu, 13 Aug 2026 20:02:10 +0800 Subject: [PATCH 3/8] test(onboard): use an ESM import for createOnboardDashboardHelpers Signed-off-by: Dongni Yang Co-Authored-By: Claude Fable 5 --- test/onboard-finalization-dashboard-forward.test.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/test/onboard-finalization-dashboard-forward.test.ts b/test/onboard-finalization-dashboard-forward.test.ts index 079a314e782..e48534bce76 100644 --- a/test/onboard-finalization-dashboard-forward.test.ts +++ b/test/onboard-finalization-dashboard-forward.test.ts @@ -3,13 +3,9 @@ import { describe, expect, it, vi } from "vitest"; import { GATEWAY_PORT } from "../src/lib/core/ports"; -import type { OnboardDashboardDeps, OnboardDashboardHelpers } from "../src/lib/onboard/dashboard"; +import { createOnboardDashboardHelpers } from "../src/lib/onboard/dashboard"; import type { ListSandboxesFn } from "../src/lib/onboard/dashboard-port"; -const { createOnboardDashboardHelpers } = require("../src/lib/onboard/dashboard") as { - createOnboardDashboardHelpers: (deps: OnboardDashboardDeps) => OnboardDashboardHelpers; -}; - function createFinalizationForwardHarness(options: { forwardList: string; listSandboxes: ListSandboxesFn; From 217038fcf5b1c51fa1e3b221fe00198f870e9adc Mon Sep 17 00:00:00 2001 From: Dongni Yang Date: Thu, 13 Aug 2026 20:22:10 +0800 Subject: [PATCH 4/8] fix(onboard): fail instead of reallocating the persisted dashboard port The resumed sandbox's gateway only listens on the persisted port and `openshell forward` binds the same port number on both sides, so a reallocated forward serves nothing. When another listener holds the persisted port, finalization now throws with the port and sandbox name instead of publishing a dead forward. Addresses PR advisor PRA-1. Refs #8970 Signed-off-by: Dongni Yang Co-Authored-By: Claude Fable 5 --- src/lib/onboard/dashboard.ts | 21 ++++++++++------ ...ard-finalization-dashboard-forward.test.ts | 24 +++++++++---------- 2 files changed, 25 insertions(+), 20 deletions(-) diff --git a/src/lib/onboard/dashboard.ts b/src/lib/onboard/dashboard.ts index 7588eb0e25a..1fe61095a58 100644 --- a/src/lib/onboard/dashboard.ts +++ b/src/lib/onboard/dashboard.ts @@ -395,16 +395,23 @@ export function createOnboardDashboardHelpers(deps: OnboardDashboardDeps): Onboa * 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). 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) + * 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 persistedPort = getPersistedDashboardPort(sandboxName, deps.listSandboxes); + const envUrl = process.env.CHAT_UI_URL; + const persistedPort = envUrl ? null : getPersistedDashboardPort(sandboxName, deps.listSandboxes); const requestedUrl = - process.env.CHAT_UI_URL || - (persistedPort === null ? undefined : `http://127.0.0.1:${String(persistedPort)}`); - const actualPort = ensureDashboardForward(sandboxName, 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, diff --git a/test/onboard-finalization-dashboard-forward.test.ts b/test/onboard-finalization-dashboard-forward.test.ts index e48534bce76..2d5e33b6eec 100644 --- a/test/onboard-finalization-dashboard-forward.test.ts +++ b/test/onboard-finalization-dashboard-forward.test.ts @@ -80,15 +80,13 @@ describe("finalization dashboard forward", () => { expect(process.env.CHAT_UI_URL).toBe("http://127.0.0.1:18790"); }); - it("publishes the reallocated port when another sandbox holds the persisted port (#8970)", () => { + it("fails without reallocating when another sandbox holds the persisted port (#8970)", () => { vi.stubEnv("CHAT_UI_URL", undefined); - const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined); - const { helpers } = createFinalizationForwardHarness({ + 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\n" + - "reonboard-test 127.0.0.1 18791 42001 running", + "other-sandbox 127.0.0.1 18790 42002 running", listSandboxes: () => ({ sandboxes: [ { name: "baseline", dashboardPort: 18789 }, @@ -98,14 +96,14 @@ describe("finalization dashboard forward", () => { }), }); - try { - expect(helpers.ensureFinalizationDashboardForward("reonboard-test")).toBe(18791); - expect(process.env.CHAT_UI_URL).toBe("http://127.0.0.1:18791"); - const warnings = warnSpy.mock.calls.map(([line]) => String(line)).join("\n"); - expect(warnings).toContain("Port 18790 is taken. Using port 18791 instead."); - } finally { - warnSpy.mockRestore(); - } + 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)", () => { From c400e7f79733f8d5c4f82edaf44f97b288f9c8da Mon Sep 17 00:00:00 2001 From: Rebecca Sliter <571084+rsliter@users.noreply.github.com> Date: Thu, 13 Aug 2026 10:05:09 -0700 Subject: [PATCH 5/8] chore(ci): lower onboarding source budget Signed-off-by: Rebecca Sliter <571084+rsliter@users.noreply.github.com> --- ci/source-architecture-budget.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ci/source-architecture-budget.json b/ci/source-architecture-budget.json index c6d2f0527af..7394b419845 100644 --- a/ci/source-architecture-budget.json +++ b/ci/source-architecture-budget.json @@ -56,7 +56,7 @@ }, "allowedCycles": [], "maxRootFiles": { - "src/lib/onboard": 309, + "src/lib/onboard": 308, "src/lib/actions": 19, "src/lib/actions/sandbox": 183, "src/lib/state": 38, From 6ba418d0bfdccb87563c887c0feb71206a5477ab Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Thu, 13 Aug 2026 10:42:35 -0700 Subject: [PATCH 6/8] fix(test): preserve explicit WSL overrides Signed-off-by: Prekshi Vyas --- test/support/connect-flow-test-harness.ts | 24 ++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/test/support/connect-flow-test-harness.ts b/test/support/connect-flow-test-harness.ts index bae208eaadb..c5bf17699ff 100644 --- a/test/support/connect-flow-test-harness.ts +++ b/test/support/connect-flow-test-harness.ts @@ -11,9 +11,9 @@ import type { WslDetectionOptions } from "../../src/lib/platform"; import type { ConfigObject } from "../../src/lib/security/credential-filter"; import type { SandboxEntry } from "../../src/lib/state/registry"; -type ConnectSandbox = typeof import("../../src/lib/actions/sandbox/connect")["connectSandbox"]; +type ConnectSandbox = (typeof import("../../src/lib/actions/sandbox/connect"))["connectSandbox"]; type GatewayRouteMutationLock = - typeof import("../../src/lib/inference/gateway-route-mutation-lock")["withGatewayRouteMutationLock"]; + (typeof import("../../src/lib/inference/gateway-route-mutation-lock"))["withGatewayRouteMutationLock"]; type LaunchReadinessPublicationResult = import("../../src/lib/actions/sandbox/launch-readiness").LaunchReadinessPublicationResult; @@ -275,15 +275,17 @@ export function createConnectHarness(options: ConnectHarnessOptions = {}): Conne const probeOllamaAuthProxyHealthSpy = vi .spyOn(ollamaProxy, "probeOllamaAuthProxyHealth") .mockReturnValue({ ok: true }); - const realIsWsl = platform.isWsl as (opts?: WslDetectionOptions) => boolean; - // Pin the platform gate for every isWsl consumer the harness loads: isWsl - // answers false off Linux before it reads WSL_DISTRO_NAME, so a case that - // stubs that variable cannot reach the WSL route on a macOS contributor - // machine. With the gate pinned, the stubbed environment decides, on every - // host, and a caller's own options still win over the pin (#8868). - vi.spyOn(platform, "isWsl").mockImplementation((...args: unknown[]) => - realIsWsl({ platform: "linux", ...((args[0] as WslDetectionOptions | undefined) ?? {}) }), - ); + if (typeof options.isWsl !== "boolean") { + const realIsWsl = platform.isWsl as (opts?: WslDetectionOptions) => boolean; + // Pin the platform gate for every isWsl consumer the harness loads: isWsl + // answers false off Linux before it reads WSL_DISTRO_NAME, so a case that + // stubs that variable cannot reach the WSL route on a macOS contributor + // machine. With the gate pinned, the stubbed environment decides, on every + // host, and a caller's own options still win over the pin (#8868). + vi.spyOn(platform, "isWsl").mockImplementation((...args: unknown[]) => + realIsWsl({ platform: "linux", ...((args[0] as WslDetectionOptions | undefined) ?? {}) }), + ); + } const primaryRegistryEntry: SandboxEntry = { name: "alpha", agent: options.agentName ?? "openclaw", From b370faac1c06026fc6bd41674c482478b622dcbe Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Thu, 13 Aug 2026 09:36:52 -0700 Subject: [PATCH 7/8] fix(hermes): admit the managed API port Managed image activation assigns Hermes a per-sandbox API port, but the runtime secret-boundary validator rejected that non-secret variable and forced the replacement container into a restart loop. Allow the assigned port, refresh the validator integrity pin, and cover the runtime environment contract. Signed-off-by: Prekshi Vyas --- agents/hermes/Dockerfile | 2 +- agents/hermes/validate-env-secret-boundary.py | 1 + test/hermes-env-secret-boundary-hardening.test.ts | 7 +++++++ 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/agents/hermes/Dockerfile b/agents/hermes/Dockerfile index fd25b8d97f6..222dae207ca 100644 --- a/agents/hermes/Dockerfile +++ b/agents/hermes/Dockerfile @@ -662,7 +662,7 @@ RUN node --experimental-strip-types \ ARG NEMOCLAW_HERMES_WRAPPER_SHA256=f4276e9833638b7a620176c88bd329d6b6d4948538a3227b727a1397146a0e0e ARG NEMOCLAW_HERMES_CLI_ADAPTER_SHA256=989edf54a8c09c6efb348600a8aa2f264c0b71408eb9d7bcd579b92cbeccf9b1 ARG NEMOCLAW_HERMES_CLI_ADAPTER_VALIDATOR_SHA256=db4046e79e513eab67b069a8eda20167b8b65529cf26842531d2ad673c670330 -ARG NEMOCLAW_HERMES_VALIDATOR_SHA256=a0c87387c0a00e7aad5892375303115577d72293ea6af7d15d1861e6a02c62c6 +ARG NEMOCLAW_HERMES_VALIDATOR_SHA256=5cdac58c052f1df745d70f0126bc658eff1daef745688dc7570ca7795c068228 ARG NEMOCLAW_HERMES_TIRITH_FINALIZER_SHA256=a1e6b1c53ab297569abb87c29d15c294d729e46005bfd022136b4c447a791819 ARG NEMOCLAW_HERMES_CRON_RESTORE_CONTROLLER_SHA256=e8593cf1580bffa4663e91c079ba0ce31c3d26391f5b1718872701138ce250b0 # hadolint ignore=DL4006 diff --git a/agents/hermes/validate-env-secret-boundary.py b/agents/hermes/validate-env-secret-boundary.py index e1f43f0d6a7..d807c3869fa 100755 --- a/agents/hermes/validate-env-secret-boundary.py +++ b/agents/hermes/validate-env-secret-boundary.py @@ -47,6 +47,7 @@ "API_SERVER_HOST", "API_SERVER_PORT", "GPG_KEY", + "NEMOCLAW_HERMES_API_PORT", "NEMOCLAW_INFERENCE_API", "NEMOCLAW_INFERENCE_PROVIDER_ID", "NEMOCLAW_PROVIDER_KEY", diff --git a/test/hermes-env-secret-boundary-hardening.test.ts b/test/hermes-env-secret-boundary-hardening.test.ts index b7cb310d591..1b98b6889ad 100644 --- a/test/hermes-env-secret-boundary-hardening.test.ts +++ b/test/hermes-env-secret-boundary-hardening.test.ts @@ -438,6 +438,13 @@ wait "$child" }); describe("Hermes durable lazy-install target", () => { + it("accepts the provider-assigned Hermes API port in the runtime environment", () => { + const result = runRuntimeEnvValidation({ NEMOCLAW_HERMES_API_PORT: "8645" }); + + expect(result.status, result.stderr).toBe(0); + expect(result.stderr).toBe(""); + }); + it("accepts the image-owned lazy target in the runtime environment (#8613)", () => { const result = runRuntimeEnvValidation({ HERMES_LAZY_INSTALL_TARGET: "/sandbox/.hermes/lazy-packages", From ccf67d3f2edd678ad95f7d1d96f69ec0e27a97ba Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Thu, 13 Aug 2026 12:16:24 -0700 Subject: [PATCH 8/8] test(onboard): use valid replacement identity Signed-off-by: Prekshi Vyas --- .../onboard/machine/handlers/sandbox-recreate-journal.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/onboard/machine/handlers/sandbox-recreate-journal.test.ts b/src/lib/onboard/machine/handlers/sandbox-recreate-journal.test.ts index e74784361b2..0f5bfab492a 100644 --- a/src/lib/onboard/machine/handlers/sandbox-recreate-journal.test.ts +++ b/src/lib/onboard/machine/handlers/sandbox-recreate-journal.test.ts @@ -62,7 +62,7 @@ it("journals not-ready repair on the selected non-default gateway (#6492)", asyn reference: "openshell/sandbox-from:new", }, lifecycleGeneration: "replacement-generation", - lifecycleLiveIdentityFingerprint: "replacement-identity", + lifecycleLiveIdentityFingerprint: fingerprintSandboxRecreateValue("replacement-identity"), }; const phases: Array = []; const updateSession = vi.fn((mutator: (value: Session) => Session | void) => {