Skip to content
Closed
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
e617b42
fix(cli): report the dashboard bind a sandbox was created with
Dongni-Yang Sep 3, 2026
8602f34
fix(cli): record the bind the forward was started with
Dongni-Yang Sep 3, 2026
7a8ebd1
merge: resolve conflicts with main
github-actions[bot] Sep 3, 2026
07b5d38
fix(cli): record the dashboard bind at every forward launch
Dongni-Yang Sep 8, 2026
0376480
fix(cli): print a usable dashboard URL and record the reused-sandbox …
Dongni-Yang Sep 8, 2026
999a7a2
fix(cli): never start an undisclosed wide dashboard forward
Dongni-Yang Sep 8, 2026
f99b1c9
fix(cli): start a dashboard forward only when its record will describ…
Dongni-Yang Sep 8, 2026
5f585dd
fix(onboard): put the bind record back when a reused forward does not…
Dongni-Yang Sep 8, 2026
d7e0309
fix(onboard): record a bind before every launch that changes exposure…
Dongni-Yang Sep 8, 2026
bafb6ca
fix(onboard): clear or restore a recorded bind whose forward never st…
Dongni-Yang Sep 8, 2026
c829cdf
Merge upstream/main into fix/10861-report-actual-dashboard-bind
Dongni-Yang Sep 8, 2026
54e6f60
test(onboard): register the reuse harness's registry write
Dongni-Yang Sep 8, 2026
fdb1cce
docs(dashboard): state the host forward bind a remote CHAT_UI_URL cre…
Dongni-Yang Sep 9, 2026
fe4625f
docs(hermes): use the literal CLI name on the single-variant quicksta…
Dongni-Yang Sep 9, 2026
57f0591
Merge remote-tracking branch 'upstream/main' into fix/10861-report-ac…
Dongni-Yang Sep 9, 2026
01907ea
docs(dashboard): describe the recovery bind rule and what status repo…
Dongni-Yang Sep 9, 2026
47057b8
fix(dashboard): record the bind where the forward starts (#10861)
Dongni-Yang Sep 9, 2026
86de168
docs(dashboard): make Run Sandboxes the owner of the CHAT_UI_URL guid…
Dongni-Yang Sep 9, 2026
9de4014
Merge upstream/main into fix/10861-report-actual-dashboard-bind
Dongni-Yang Sep 10, 2026
894aaa5
fix(dashboard): record the bind only for the dashboard forward (#10861)
Dongni-Yang Sep 10, 2026
a11f5fe
docs(dashboard): describe the bind under the explicit remote-bind opt…
Dongni-Yang Sep 10, 2026
b21bd41
docs(dashboard): describe the bind record under the opt-in rule in co…
Dongni-Yang Sep 10, 2026
b821212
merge: reconcile dashboard bind reporting with main
rsliter Sep 20, 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
106 changes: 106 additions & 0 deletions src/lib/actions/sandbox/forward-recovery-dashboard-bind-record.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { beforeEach, describe, expect, it, vi } from "vitest";

const mocks = vi.hoisted(() => ({
captureOpenshell: vi.fn(),
runOpenshell: vi.fn((_args: string[], _options?: unknown) => ({ status: 0 })),
getSandbox: vi.fn(),
updateSandbox: vi.fn(() => true),
isLocalForwardReachable: vi.fn(() => false),
launchForwardService: vi.fn(),
}));

vi.mock("../../adapters/openshell/forward-service", async (importOriginal) => ({
...(await importOriginal<typeof import("../../adapters/openshell/forward-service")>()),
launchForwardService: mocks.launchForwardService,
}));
vi.mock("../../adapters/openshell/resolve", () => ({
resolveOpenshell: () => "/usr/local/bin/openshell",
}));
vi.mock("../../adapters/openshell/runtime", () => ({
captureOpenshell: mocks.captureOpenshell,
runOpenshell: mocks.runOpenshell,
isCommandTimeout: () => false,
}));
vi.mock("../../agent/runtime", () => ({
getSessionAgent: () => ({ forward_ports: [18789], forwardPort: 18789 }),
hasGatewayRuntime: () => true,
}));
vi.mock("../../state/registry", () => ({
getSandbox: mocks.getSandbox,
updateSandbox: mocks.updateSandbox,
}));
vi.mock("./hermes-dashboard-recovery", () => ({
getHermesDashboardRecoveryConfig: () => null,
ensureHermesDashboardPortForwardIfEnabled: vi.fn(() => null),
}));
vi.mock("./forward-health", async (importOriginal) => ({
...(await importOriginal<typeof import("./forward-health")>()),
isLocalForwardReachable: mocks.isLocalForwardReachable,
}));

import { ensureSandboxPortForward, ensureSandboxPortForwardForPort } from "./forward-recovery";

const SANDBOX = { name: "hm", agent: "hermes", dashboardPort: 18789 };

beforeEach(() => {
vi.clearAllMocks();
vi.unstubAllEnvs();
mocks.runOpenshell.mockReturnValue({ status: 0 });
// An empty forward list for the legacy-migration probe that runs before a
// launch; `clearAllMocks` keeps return values, so set it for every test.
mocks.captureOpenshell.mockReturnValue({ status: 0, output: "SANDBOX BIND PORT PID STATUS" });
mocks.updateSandbox.mockReturnValue(true);
mocks.getSandbox.mockReturnValue(SANDBOX);
// Not reachable until the launch runs, so every test below exercises the
// path that actually creates a forward.
mocks.isLocalForwardReachable.mockReturnValue(false);
mocks.launchForwardService.mockImplementation(() => {
mocks.isLocalForwardReachable.mockReturnValue(true);
});
});

describe("the recorded dashboard bind follows the forward (#10861)", () => {
it("records the loopback bind when recovery re-creates the forward without an opt-in", () => {
expect(ensureSandboxPortForward("hm", { isWsl: false })).toBe(true);

expect(mocks.launchForwardService).toHaveBeenCalledOnce();
expect(mocks.updateSandbox).toHaveBeenCalledWith("hm", { dashboardBindAddress: "127.0.0.1" });
});

it("records the wide bind when the operator opted in and the sandbox was prepared for it", () => {
vi.stubEnv("NEMOCLAW_DASHBOARD_BIND", "0.0.0.0");
mocks.getSandbox.mockReturnValue({ ...SANDBOX, dashboardRemoteBindPrepared: true });

expect(ensureSandboxPortForward("hm", { isWsl: false })).toBe(true);

expect(mocks.updateSandbox).toHaveBeenCalledWith("hm", { dashboardBindAddress: "0.0.0.0" });
});

it("leaves the record alone when the forward is already reachable", () => {
mocks.isLocalForwardReachable.mockReturnValue(true);

expect(ensureSandboxPortForward("hm", { isWsl: false })).toBe(true);

expect(mocks.launchForwardService).not.toHaveBeenCalled();
expect(mocks.updateSandbox).not.toHaveBeenCalled();
});

it("does not record a bind for a forward that is not the dashboard's", () => {
expect(ensureSandboxPortForwardForPort("hm", 8642, { expectedBind: "127.0.0.1" })).toBe(true);

expect(mocks.launchForwardService).toHaveBeenCalledOnce();
expect(mocks.updateSandbox).not.toHaveBeenCalled();
});

it("keeps the forward up and warns when the record cannot be written", () => {
const stderr = vi.spyOn(console, "error").mockImplementation(() => undefined);
mocks.updateSandbox.mockReturnValue(false);

expect(ensureSandboxPortForward("hm", { isWsl: false })).toBe(true);

expect(stderr).toHaveBeenCalledWith(expect.stringContaining("could not be recorded"));
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
});
39 changes: 32 additions & 7 deletions src/lib/actions/sandbox/forward-recovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,7 @@ export function ensureSandboxPortForward(
return ensureSandboxPortForwardForPort(sandboxName, port, {
forwardTarget: allInterfaceBindRequired ? `0.0.0.0:${port}` : String(port),
expectedBind: allInterfaceBindRequired ? "0.0.0.0" : "127.0.0.1",
recordDashboardBind: true,
afterSuccess: options.afterSuccess,
beforeStart: () =>
(!remoteBindRequested ||
Expand Down Expand Up @@ -352,6 +353,20 @@ export function isSandboxPortForwardHealthy(
return true;
}

/** Persist the bind a dashboard forward was just started with; a failed write must not fail the forward. */
function recordDashboardBindAddress(sandboxName: string, port: number, bindAddress: string): void {
try {
if (registry.updateSandbox(sandboxName, { dashboardBindAddress: bindAddress })) return;
console.error(
` Warning: the dashboard forward for port ${String(port)} is bound on ${bindAddress}, but that could not be recorded for '${sandboxName}'; \`dashboard-url\` may report a stale address until the next onboard.`,
);
} catch (error) {
console.error(
` Warning: the dashboard forward for port ${String(port)} is bound on ${bindAddress}, but recording it for '${sandboxName}' failed: ${error instanceof Error ? error.message : String(error)}`,
);
}
}

export function ensureSandboxPortForwardForPort(
sandboxName: string,
port: number,
Expand All @@ -360,14 +375,23 @@ export function ensureSandboxPortForwardForPort(
forwardTarget?: string;
expectedBind?: string;
beforeStart?: () => boolean;
/**
* Record the bind this call establishes as the sandbox's dashboard bind.
* Only the dashboard caller sets this; messaging and declared-port
* forwards share this function and must not overwrite that field.
*/
recordDashboardBind?: boolean;
} = {},
): boolean {
const {
afterSuccess = () => true,
forwardTarget = String(port),
expectedBind,
beforeStart = () => true,
recordDashboardBind = false,
} = options;
const bindAddress =
expectedBind ?? (forwardTarget.startsWith("0.0.0.0:") ? "0.0.0.0" : "127.0.0.1");
const acceptSuccessfulForward = () => {
let accepted = false;
try {
Expand All @@ -388,14 +412,15 @@ export function ensureSandboxPortForwardForPort(
const executable = resolveOpenshell();
if (!executable) throw new Error("OpenShell is unavailable");
launchForwardService(
forwardServiceTarget(
executable,
gatewayName,
sandboxName,
port,
expectedBind ?? (forwardTarget.startsWith("0.0.0.0:") ? "0.0.0.0" : "127.0.0.1"),
),
forwardServiceTarget(executable, gatewayName, sandboxName, port, bindAddress),
);
// The record follows the forward, not the sandbox. Onboarding writes it
// when it starts the first forward; every later re-creation — restart,
// connect, gateway recovery — decides the bind again from the current
// environment, and a record left from onboarding would report the bind
// that used to be true (#10861). Only a launch writes: on the healthy
// path above nothing was created, and the listener may not be ours.
if (recordDashboardBind) recordDashboardBindAddress(sandboxName, port, bindAddress);
return acceptSuccessfulForward();
} catch (error) {
console.error(
Expand Down
13 changes: 10 additions & 3 deletions src/lib/actions/sandbox/status-text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -337,9 +337,16 @@ function printDashboardRemoteAccessHint(context: SandboxStatusTextContext): void
const { sandboxName, sb } = context;
const dashboardPort = sb?.dashboardPort;
if (!dashboardPort) return;
const accessUrl = sb?.dashboardRemoteBindPrepared
? `http://0.0.0.0:${dashboardPort}`
: process.env.CHAT_UI_URL;
// The recorded bind is the only durable answer: CHAT_UI_URL decided it at
// onboard time and later commands rarely carry it, so reading the live
// environment reports a loopback bind for a sandbox exposed on every
// interface (#10861). Rows written before that field fall back as before.
const recordedBindAddress = sb?.dashboardBindAddress;
const accessUrl = recordedBindAddress
? `http://${recordedBindAddress}:${dashboardPort}`
: sb?.dashboardRemoteBindPrepared
? `http://0.0.0.0:${dashboardPort}`
: process.env.CHAT_UI_URL;
if (!buildSshForwardHintLines({ port: dashboardPort, accessUrl })) return;
console.log(
` Remote access: run \`${CLI_NAME} ${shellQuote(sandboxName)} dashboard-url\` for SSH port forward instructions.`,
Expand Down
63 changes: 63 additions & 0 deletions src/lib/dashboard-url-command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,69 @@ describe("dashboard-url command helpers", () => {
expect(sinks.out).toEqual(["http://172.22.1.1:19000/#token=secret-token"]);
});

it("prefers the recorded bind over an access URL recomputed without CHAT_UI_URL", () => {
const sinks = makeSinks();

runDashboardUrlCommand(
"alpha",
{ quiet: true },
{
fetchToken: () => "secret-token",
getSandbox: () => ({
agent: "openclaw",
dashboardPort: 19000,
dashboardBindAddress: "0.0.0.0",
}),
getAccessUrl: () => "http://127.0.0.1:19000",
log: sinks.log,
error: sinks.error,
},
);

expect(sinks.out).toEqual(["http://0.0.0.0:19000/#token=secret-token"]);
});

it("prefers the recorded bind in the session-auth branch too", () => {
const sinks = makeSinks();

runDashboardUrlCommand(
"alpha",
{ quiet: true },
{
fetchToken: () => "secret-token",
getSandbox: () => ({
agent: "hermes",
dashboardPort: 18790,
dashboardBindAddress: "0.0.0.0",
}),
getAgentDashboardAuth: () => "session",
getAccessUrl: () => "http://127.0.0.1:18790",
log: sinks.log,
error: sinks.error,
},
);

expect(sinks.out).toEqual(["http://0.0.0.0:18790/"]);
});

it("keeps the recomputed access URL for a row with no recorded bind", () => {
const sinks = makeSinks();

runDashboardUrlCommand(
"alpha",
{ quiet: true },
{
fetchToken: () => "secret-token",
getSandbox: () => ({ agent: "openclaw", dashboardPort: 19000 }),
getAccessUrl: () => "http://172.22.1.1:19000",
log: sinks.log,
error: sinks.error,
},
);

expect(sinks.out).toEqual(["http://172.22.1.1:19000/#token=secret-token"]);
});

it("prints a human label and warning outside quiet mode", () => {
const sinks = makeSinks();
runDashboardUrlCommand(
Expand Down
23 changes: 19 additions & 4 deletions src/lib/dashboard-url-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export interface DashboardUrlCommandDeps {
/** Pull gateway.auth.token from the sandbox config (host-side helper). */
fetchToken: (sandboxName: string) => string | null;
/** Read sandbox metadata such as agent name and recorded dashboard port. */
getSandbox?: (sandboxName: string) => Pick<SandboxEntry, "agent" | "dashboardPort"> | null;
getSandbox?: (sandboxName: string) => Pick<SandboxEntry, "agent" | "dashboardPort" | "dashboardBindAddress"> | null;
/** Resolve the browser-facing dashboard base URL for this host, when known. */
getAccessUrl?: (port: number) => string | null;
/** Resolve a registered agent's dashboard auth contract. */
Expand Down Expand Up @@ -58,6 +58,21 @@ function dashboardUrlFail(lines: string | readonly string[], exitCode = 1): neve
throw new DashboardUrlCommandError(lines, exitCode);
}

/**
* Prefer the bind this sandbox was created with over one recomputed here.
* `CHAT_UI_URL` decides the bind at onboard time and is rarely set for later
* commands, so recomputing reports a loopback address for a dashboard that is
* listening on every interface (#10861). Rows written before the field was
* recorded keep the recomputed answer.
*/
function recordedAccessUrl(
sandbox: Pick<SandboxEntry, "dashboardBindAddress"> | null,
port: number,
): string | null {
const bindAddress = sandbox?.dashboardBindAddress;
return bindAddress ? `http://${bindAddress}:${String(port)}` : null;
}

function resolveDashboardPort(sandbox: Pick<SandboxEntry, "dashboardPort"> | null): number {
const port = sandbox?.dashboardPort;
return typeof port === "number" && Number.isInteger(port) && port >= 1 && port <= 65535
Expand Down Expand Up @@ -141,7 +156,7 @@ export function runDashboardUrlCommand(
for (const line of hint) log(line);
};

let sandbox: Pick<SandboxEntry, "agent" | "dashboardPort"> | null = null;
let sandbox: Pick<SandboxEntry, "agent" | "dashboardPort" | "dashboardBindAddress"> | null = null;
if (deps.getSandbox) {
try {
sandbox = deps.getSandbox(sandboxName);
Expand Down Expand Up @@ -170,7 +185,7 @@ export function runDashboardUrlCommand(
}
if (dashboardAuth === "session" || dashboardAuth === "none") {
const port = resolveDashboardPort(sandbox);
const accessUrl = deps.getAccessUrl?.(port) ?? null;
const accessUrl = recordedAccessUrl(sandbox, port) ?? deps.getAccessUrl?.(port) ?? null;
const url = buildPlainDashboardUrl(port, accessUrl ?? undefined);
if (options.quiet) {
log(url);
Expand All @@ -197,7 +212,7 @@ export function runDashboardUrlCommand(
}

const port = resolveDashboardPort(sandbox);
const accessUrl = deps.getAccessUrl?.(port) ?? null;
const accessUrl = recordedAccessUrl(sandbox, port) ?? deps.getAccessUrl?.(port) ?? null;
const url = buildDashboardUrl(token, port, accessUrl ?? undefined);
if (options.quiet) {
log(url);
Expand Down
8 changes: 8 additions & 0 deletions src/lib/onboard/created-sandbox-finalization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { resolveSandboxImageTagFromCreateOutput } from "../domain/sandbox/image-
import { restoreDefaultAfterRecreate } from "./default-preservation";
import { createDcodeSelectionDriftReader } from "./dcode-selection-drift";
import * as dockerGpuLocalInference from "./docker-gpu-local-inference";
import { buildDashboardChain } from "./dashboard-access";
import type { HermesDashboardOnboardState } from "./hermes-dashboard";
import type { HermesPortableConfiguredReceipt } from "./experimental/hermes-portable-receipt";
import { warnIfLandlockUnsupported } from "./landlock-warning";
Expand Down Expand Up @@ -95,6 +96,7 @@ type RegistrationSeed = Omit<
| "openclawImagePluginInstalls"
| "hermesDashboardState"
| "dashboardPort"
| "dashboardBindAddress"
| "lifecycleGeneration"
| "lifecycleLiveIdentityFingerprint"
| "inferenceRouteReservation"
Expand Down Expand Up @@ -339,6 +341,10 @@ export function createCreatedSandboxCompletionActions(
): CreatedSandboxCompletionActions {
let chatUiUrl = options.dashboard.chatUiUrl;
let dashboardPort = 0;
// Captured from the URL the forward is actually started with. Below, a
// reallocated port rewrites chatUiUrl to loopback, so reading it at
// registration time would record 127.0.0.1 for a forward bound wide.
let dashboardBindAddress: string | null = null;
let hermesDashboardState = options.dashboard.initialHermesState;
async function verifyCreatedProviderGpu(created: SandboxGpuCreateFlowResult): Promise<void> {
await dockerGpuLocalInference.verifyGpuSandboxLocalInferenceAndCommitAfterReady(
Expand Down Expand Up @@ -369,6 +375,7 @@ export function createCreatedSandboxCompletionActions(
deps.revalidateSandboxIdentity?.(
`recording dashboard capability for sandbox '${options.finalization.sandboxName}'`,
);
dashboardBindAddress = buildDashboardChain(chatUiUrl).bindAddress;
dashboardPort = Number(options.dashboard.getForwardPort(chatUiUrl));
if (!Number.isInteger(dashboardPort) || dashboardPort < 1 || dashboardPort > 65_535) {
throw new Error(
Expand Down Expand Up @@ -473,6 +480,7 @@ export function createCreatedSandboxCompletionActions(
openclawImagePluginInstalls,
hermesDashboardState,
dashboardPort,
dashboardBindAddress,
...finalLifecycle,
inferenceRouteReservation: verifiedInferenceRouteReservation,
verifiedCreate,
Expand Down
3 changes: 3 additions & 0 deletions src/lib/onboard/sandbox-registration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,8 @@ export interface CreatedSandboxRegistryEntryInput {
/** True only when schema-5 receipt authority owns this Hermes registration. */
hermesPortableLifecycle?: boolean;
dashboardPort: number;
/** Address the dashboard forward was bound to at creation (#10861). */
dashboardBindAddress?: string | null;
dashboardRemoteBindPrepared?: boolean;
lifecycleGeneration?: string;
lifecycleLiveIdentityFingerprint?: string;
Expand Down Expand Up @@ -275,6 +277,7 @@ export function buildCreatedSandboxRegistryEntry(
}))
: undefined,
dashboardPort: input.dashboardPort,
dashboardBindAddress: input.dashboardBindAddress ?? null,
dashboardRemoteBindPrepared: input.dashboardRemoteBindPrepared === true,
lifecycleGeneration: input.lifecycleGeneration,
lifecycleLiveIdentityFingerprint: input.lifecycleLiveIdentityFingerprint,
Expand Down
1 change: 1 addition & 0 deletions src/lib/state/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -529,6 +529,7 @@ export function registerSandbox(
hermesApiPort: entry.hermesApiPort ?? undefined,
dashboardPort: entry.dashboardPort ?? undefined,
dashboardRemoteBindPrepared: entry.dashboardRemoteBindPrepared === true ? true : undefined,
dashboardBindAddress: entry.dashboardBindAddress ?? undefined,
gatewayName: entry.gatewayName ?? undefined,
gatewayPort: entry.gatewayPort ?? undefined,
pendingRouteReservation: options.pending === true ? true : undefined,
Expand Down
Loading
Loading