diff --git a/.github/actions/stage-native-podman-e2e-toolchains/action.yaml b/.github/actions/stage-native-podman-e2e-toolchains/action.yaml index cb5a3d48ff2..933f90eb76e 100644 --- a/.github/actions/stage-native-podman-e2e-toolchains/action.yaml +++ b/.github/actions/stage-native-podman-e2e-toolchains/action.yaml @@ -21,7 +21,7 @@ runs: shell: bash env: GH_TOKEN: ${{ inputs.github-token }} - SOURCE_RUN_ID: "33211526093" + SOURCE_RUN_ID: "36534476155" run: | set -euo pipefail verify_artifact() { @@ -39,13 +39,13 @@ runs: END { exit found ? 0 : 1 }' } verify_artifact \ - 10385514729 \ - native-runtime-podman-toolchain-amd64 \ - sha256:1f73b66ef2a70862e860b0897e191ed05b8976d3b10b0f84b41e76d8f1cb58ba + 11018865557 \ + native-podman-e2e-toolchain-amd64 \ + sha256:673dbb608ba6595e76ba8479d0e0680b41340546101c7938f28160470846fa8b verify_artifact \ - 10386378988 \ - native-runtime-podman-toolchain-arm64 \ - sha256:470487563f801c77b95f1665510fe1a11894c913e2787ebfb9994f56bac8cf56 + 11019020387 \ + native-podman-e2e-toolchain-arm64 \ + sha256:c6a23c1132b8c3011252035dc2dae73e2ed47ff97151ba41380e3b07acfe6cdc - name: Download immutable native Podman amd64 toolchain if: ${{ inputs.enabled == 'true' }} @@ -53,8 +53,8 @@ runs: with: github-token: ${{ inputs.github-token }} repository: NVIDIA/NemoClaw - run-id: "33211526093" - artifact-ids: "10385514729" + run-id: "36534476155" + artifact-ids: "11018865557" path: ${{ runner.temp }}/native-podman-e2e-toolchain-amd64 - name: Download immutable native Podman arm64 toolchain @@ -63,8 +63,8 @@ runs: with: github-token: ${{ inputs.github-token }} repository: NVIDIA/NemoClaw - run-id: "33211526093" - artifact-ids: "10386378988" + run-id: "36534476155" + artifact-ids: "11019020387" path: ${{ runner.temp }}/native-podman-e2e-toolchain-arm64 - name: Publish native Podman amd64 toolchain for this run diff --git a/.github/workflows/e2e.yaml b/.github/workflows/e2e.yaml index 038b03de2ce..d8417d6f983 100644 --- a/.github/workflows/e2e.yaml +++ b/.github/workflows/e2e.yaml @@ -699,7 +699,7 @@ jobs: # Publish immutable source-run toolchains before candidate checkout or # candidate-controlled workspace preparation can execute on this runner. - name: Stage immutable native Podman E2E toolchains - uses: NVIDIA/NemoClaw/.github/actions/stage-native-podman-e2e-toolchains@8d7409d66a0e664829f9ddab177aa8460974291f + uses: NVIDIA/NemoClaw/.github/actions/stage-native-podman-e2e-toolchains@6b0acb521f2644fb48f8a735fde875ff798d9047 with: enabled: ${{ contains(format(',{0},', inputs.gateway_runtimes || inputs.gateway_runtime || 'docker'), ',podman,') && 'true' || 'false' }} github-token: ${{ github.token }} diff --git a/src/commands/sandbox/dashboard-url.ts b/src/commands/sandbox/dashboard-url.ts index 6fec127bfed..8b2d37fb79d 100644 --- a/src/commands/sandbox/dashboard-url.ts +++ b/src/commands/sandbox/dashboard-url.ts @@ -9,7 +9,9 @@ import type { SandboxEntry } from "../../lib/state/registry"; type DashboardUrlRuntimeBridge = { fetchGatewayAuthTokenFromSandbox: (sandboxName: string) => Promise; - getSandbox: (sandboxName: string) => Pick | null; + getSandbox: ( + sandboxName: string, + ) => Pick | null; getAccessUrl?: (port: number) => string | null; }; diff --git a/src/lib/dashboard-url-command.test.ts b/src/lib/dashboard-url-command.test.ts index 8f64c34cc1c..6177cdc306d 100644 --- a/src/lib/dashboard-url-command.test.ts +++ b/src/lib/dashboard-url-command.test.ts @@ -183,6 +183,52 @@ describe("dashboard-url command helpers", () => { expect(sinks.err).toEqual([]); }); + it("prints the persisted external dashboard URL for a session-auth agent (#11439)", async () => { + const sinks = makeSinks(); + + await runDashboardUrlCommand( + "hermes", + { quiet: true }, + { + fetchToken: () => null, + getSandbox: () => ({ + agent: "hermes", + dashboardPort: 18789, + dashboardExternalUrl: "https://dash.example.com:18789", + }), + getAgentDashboardAuth: () => "session", + // A host access URL must not override the persisted external origin. + getAccessUrl: () => "http://172.22.1.1:18789", + log: sinks.log, + error: sinks.error, + }, + ); + + expect(sinks.out).toEqual(["https://dash.example.com:18789/"]); + }); + + it("embeds the token in the persisted external dashboard URL for token-auth agents (#11439)", async () => { + const sinks = makeSinks(); + + await runDashboardUrlCommand( + "agent-ui", + { quiet: true }, + { + fetchToken: () => "agent-token", + getSandbox: () => ({ + agent: "agent-ui", + dashboardPort: 19001, + dashboardExternalUrl: "https://dash.example.com:19001", + }), + getAgentDashboardAuth: () => "url_token", + log: sinks.log, + error: sinks.error, + }, + ); + + expect(sinks.out).toEqual(["https://dash.example.com:19001/#token=agent-token"]); + }); + it("fetches a token for non-OpenClaw agents with token-auth dashboards", async () => { const sinks = makeSinks(); const fetchToken = vi.fn(() => "agent-token"); diff --git a/src/lib/dashboard-url-command.ts b/src/lib/dashboard-url-command.ts index 55c29354d0f..fa19604def8 100644 --- a/src/lib/dashboard-url-command.ts +++ b/src/lib/dashboard-url-command.ts @@ -17,7 +17,9 @@ export interface DashboardUrlCommandDeps { /** Pull gateway.auth.token from the sandbox config (host-side helper). */ fetchToken: (sandboxName: string) => Promise | string | null; /** Read sandbox metadata such as agent name and recorded dashboard port. */ - getSandbox?: (sandboxName: string) => Pick | null; + getSandbox?: ( + sandboxName: string, + ) => Pick | 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. */ @@ -65,6 +67,21 @@ function resolveDashboardPort(sandbox: Pick | nul : DASHBOARD_PORT; } +/** + * Prefer the persisted external dashboard URL (the browser-facing HTTPS reverse + * proxy origin resolved from `CHAT_UI_URL` at onboard time) over any + * host-derived access URL, falling back to the loopback form only when no + * external origin was configured (#11439). + */ +function resolveDashboardBaseUrl( + sandbox: Pick | null, + accessUrl: string | null, +): string | null { + const external = sandbox?.dashboardExternalUrl; + if (typeof external === "string" && external.length > 0) return external; + return accessUrl; +} + export function buildDashboardUrl( token: string, port = DASHBOARD_PORT, @@ -141,7 +158,7 @@ export async function runDashboardUrlCommand( for (const line of hint) log(line); }; - let sandbox: Pick | null = null; + let sandbox: Pick | null = null; if (deps.getSandbox) { try { sandbox = deps.getSandbox(sandboxName); @@ -170,7 +187,7 @@ export async function runDashboardUrlCommand( } if (dashboardAuth === "session" || dashboardAuth === "none") { const port = resolveDashboardPort(sandbox); - const accessUrl = deps.getAccessUrl?.(port) ?? null; + const accessUrl = resolveDashboardBaseUrl(sandbox, deps.getAccessUrl?.(port) ?? null); const url = buildPlainDashboardUrl(port, accessUrl ?? undefined); if (options.quiet) { log(url); @@ -197,7 +214,7 @@ export async function runDashboardUrlCommand( } const port = resolveDashboardPort(sandbox); - const accessUrl = deps.getAccessUrl?.(port) ?? null; + const accessUrl = resolveDashboardBaseUrl(sandbox, deps.getAccessUrl?.(port) ?? null); const url = buildDashboardUrl(token, port, accessUrl ?? undefined); if (options.quiet) { log(url); diff --git a/src/lib/dashboard/url.test.ts b/src/lib/dashboard/url.test.ts index e68ecb8a795..37fd06fd9ea 100644 --- a/src/lib/dashboard/url.test.ts +++ b/src/lib/dashboard/url.test.ts @@ -3,7 +3,12 @@ import { describe, expect, it } from "vitest"; -import { rebindLoopbackDashboardUrlPort } from "./url"; +import { + isValidDashboardExternalUrl, + rebindLoopbackDashboardUrlPort, + resolveExternalDashboardUrl, + resolveExternalDashboardUrlForPort, +} from "./url"; describe("rebindLoopbackDashboardUrlPort", () => { it.each([ @@ -14,3 +19,70 @@ describe("rebindLoopbackDashboardUrlPort", () => { expect(rebindLoopbackDashboardUrlPort(input, 29_443)).toBe(expected); }); }); + +describe("resolveExternalDashboardUrl (#11439)", () => { + it("returns a genuine external origin, trimming a trailing slash", () => { + expect(resolveExternalDashboardUrl("https://dash.example.com:18789/")).toBe( + "https://dash.example.com:18789", + ); + expect(resolveExternalDashboardUrl("https://dash.example.com:18789")).toBe( + "https://dash.example.com:18789", + ); + }); + + it("returns null for loopback dashboard URLs (loopback reported from dashboardPort)", () => { + expect(resolveExternalDashboardUrl("http://127.0.0.1:18789")).toBeNull(); + expect(resolveExternalDashboardUrl("http://localhost:18789/")).toBeNull(); + }); + + it("returns null for empty or malformed input", () => { + expect(resolveExternalDashboardUrl(null)).toBeNull(); + expect(resolveExternalDashboardUrl(undefined)).toBeNull(); + expect(resolveExternalDashboardUrl("")).toBeNull(); + expect(resolveExternalDashboardUrl("not a url")).toBeNull(); + }); + + it("returns null for non-http(s), credentialed, or over-long origins so a persisted value can be read back", () => { + // These would otherwise be rejected at registry read time and brick list/status. + expect(resolveExternalDashboardUrl("ws://proxy.example.com:18789")).toBeNull(); + expect(resolveExternalDashboardUrl("ftp://proxy.example.com:18789")).toBeNull(); + expect(resolveExternalDashboardUrl("https://user:pass@dash.example.com:18789")).toBeNull(); + expect(resolveExternalDashboardUrl(`https://dash.example.com/${"a".repeat(3000)}`)).toBeNull(); + }); +}); + +describe("isValidDashboardExternalUrl shared write/read predicate (#11439)", () => { + it("accepts absolute http(s) origins without credentials", () => { + expect(isValidDashboardExternalUrl("https://dash.example.com:18789")).toBe(true); + expect(isValidDashboardExternalUrl("http://dash.example.com/path")).toBe(true); + }); + + it("rejects non-http(s), credentialed, control-char, and over-long values", () => { + expect(isValidDashboardExternalUrl("ws://dash.example.com:18789")).toBe(false); + expect(isValidDashboardExternalUrl("https://user:pass@dash.example.com")).toBe(false); + expect(isValidDashboardExternalUrl("https://dash.example.com/\u0000")).toBe(false); + expect(isValidDashboardExternalUrl(`https://dash.example.com/${"a".repeat(3000)}`)).toBe(false); + expect(isValidDashboardExternalUrl("")).toBe(false); + expect(isValidDashboardExternalUrl("dash.example.com:18789")).toBe(false); + }); +}); + +describe("resolveExternalDashboardUrlForPort (#11439)", () => { + it("rebinds the CHAT_UI_URL origin to the effective dashboard port", () => { + expect(resolveExternalDashboardUrlForPort("https://dash.example.com:18789", 18790)).toBe( + "https://dash.example.com:18790", + ); + }); + + it("adds a scheme to a bare host:port origin before rebinding", () => { + expect(resolveExternalDashboardUrlForPort("dash.example.com:18789", 18790)).toBe( + "http://dash.example.com:18790", + ); + }); + + it("returns null for a loopback or missing origin", () => { + expect(resolveExternalDashboardUrlForPort("http://127.0.0.1:18789", 18790)).toBeNull(); + expect(resolveExternalDashboardUrlForPort(null, 18790)).toBeNull(); + expect(resolveExternalDashboardUrlForPort("", 18790)).toBeNull(); + }); +}); diff --git a/src/lib/dashboard/url.ts b/src/lib/dashboard/url.ts index e6f36dcaf66..39b1792c74b 100644 --- a/src/lib/dashboard/url.ts +++ b/src/lib/dashboard/url.ts @@ -15,3 +15,78 @@ export function rebindLoopbackDashboardUrlPort(value: string, port: number): str parsed.port = String(port); return parsed.toString(); } + +const CONTROL_CHARACTER = /[\u0000-\u001f\u007f]/u; + +/** + * A persistable external dashboard URL must be an absolute http(s) URL with a + * host and no embedded credentials, matching what onboarding derives from + * `CHAT_UI_URL`. This is the single source of truth shared by the writer + * (`resolveExternalDashboardUrl`) and the registry read-side validator so a + * value that persists can always be read back (#11439). Userinfo is rejected so + * an operator-facing address is never a secret; control characters and + * unbounded length are rejected as registry-hardening. + */ +export function isValidDashboardExternalUrl(value: string): boolean { + if (value.length === 0 || value.length > 2048 || CONTROL_CHARACTER.test(value)) return false; + let parsed: URL; + try { + parsed = new URL(value); + } catch { + return false; + } + return ( + (parsed.protocol === "http:" || parsed.protocol === "https:") && + parsed.hostname.length > 0 && + parsed.username === "" && + parsed.password === "" + ); +} + +/** + * Resolve the external dashboard URL to persist from the operator's + * `CHAT_UI_URL`, rebinding its port to the effective dashboard port. Returns + * null when no external origin is configured, the origin is loopback, or the + * value is not a valid persistable external origin. Shared by the fresh-create, + * reuse/resume, and OpenClaw-forward persistence paths so all record the same + * value (#11439). + */ +export function resolveExternalDashboardUrlForPort( + chatUiUrlEnv: string | null | undefined, + effectivePort: number, +): string | null { + if (!chatUiUrlEnv) return null; + const normalized = chatUiUrlEnv.includes("://") ? chatUiUrlEnv : `http://${chatUiUrlEnv}`; + let rebound: string; + try { + const parsed = new URL(normalized); + parsed.port = String(effectivePort); + rebound = parsed.toString(); + } catch { + return null; + } + return resolveExternalDashboardUrl(rebound); +} + +/** + * Return the browser-facing external dashboard URL to persist for a sandbox, or + * null when the resolved dashboard URL is a plain loopback address or is not a + * valid persistable external origin. A loopback URL adds nothing over the + * persisted `dashboardPort`, so only a genuine external origin (e.g. the HTTPS + * reverse proxy behind `CHAT_UI_URL`) is worth recording so `status`, + * `dashboard-url`, and `list` can report it later (#11439). The value is + * validated with the same predicate the registry read-side enforces, so a + * persisted value can always be read back. Malformed or non-http(s) input + * yields null. + */ +export function resolveExternalDashboardUrl(chatUiUrl: string | null | undefined): string | null { + if (!chatUiUrl) return null; + const normalized = chatUiUrl.replace(/\/$/, ""); + if (!isValidDashboardExternalUrl(normalized)) return null; + try { + if (isLoopbackDashboardUrl(normalized)) return null; + } catch { + return null; + } + return normalized; +} diff --git a/src/lib/inventory/index.ts b/src/lib/inventory/index.ts index 8081289a42d..dceb14c5d80 100644 --- a/src/lib/inventory/index.ts +++ b/src/lib/inventory/index.ts @@ -30,6 +30,7 @@ export interface SandboxEntry { messaging?: SandboxMessagingState | null; agent?: string | null; dashboardPort?: number | null; + dashboardExternalUrl?: string | null; // Passthrough of the durable registry reservation marker so list and status // hide registrations that have not committed their lifecycle yet. pendingRouteReservation?: true; @@ -106,6 +107,7 @@ export interface SandboxInventoryRow { policies: string[]; agent: string; dashboardPort?: number | null; + dashboardExternalUrl?: string | null; isDefault: boolean; activeSessionCount: number | null; // #5714: row recovered display-only from the live gateway. Its agent/GPU/ @@ -202,6 +204,7 @@ export interface StatusSandboxRow { }; phase?: "pending" | "configuring" | "active"; dashboardPort?: number | null; + dashboardExternalUrl?: string | null; isDefault: boolean; } @@ -308,6 +311,7 @@ async function projectPublicSandboxFields( typeof sandbox.dashboardPort === "number" && Number.isFinite(sandbox.dashboardPort) ? sandbox.dashboardPort : null; + const dashboardExternalUrl = safeStatusString(sandbox.dashboardExternalUrl); return { name: safeStatusString(sandbox.name) ?? sandbox.name, model: safeStatusString(inference.model), @@ -324,6 +328,7 @@ async function projectPublicSandboxFields( ), agent: safeStatusString(resolveDisplayAgent(sandbox)) ?? "unknown", ...(dashboardPort != null ? { dashboardPort } : {}), + ...(dashboardExternalUrl != null ? { dashboardExternalUrl } : {}), }; } @@ -497,7 +502,9 @@ export function renderSandboxInventoryText( if (providerDrifted) parts.push(`provider=${sandbox.provider || "unknown"}`); log(` (live OpenShell gateway differs from onboarded: ${parts.join(", ")})`); } - if (sandbox.dashboardPort != null) { + if (sandbox.dashboardExternalUrl != null) { + log(` dashboard: ${sandbox.dashboardExternalUrl}`); + } else if (sandbox.dashboardPort != null) { log(` dashboard: http://127.0.0.1:${sandbox.dashboardPort}/`); } } @@ -728,6 +735,10 @@ export async function showStatusCommand(deps: ShowStatusCommandDeps): Promise { imageTag: "hermes:test", hermesPortableLifecycle: schema5, dashboardPort: manageDashboard ? 8643 : 0, + // Loopback chatUiUrl -> no external URL persisted (#11439). + dashboardExternalUrl: null, lifecycleGeneration: "generation-1", lifecycleLiveIdentityFingerprint: "a".repeat(64), inferenceSelection: inferenceRouteReservation.authority.selection, diff --git a/src/lib/onboard/created-sandbox-finalization.ts b/src/lib/onboard/created-sandbox-finalization.ts index b73444dbf8b..52676d49ffc 100644 --- a/src/lib/onboard/created-sandbox-finalization.ts +++ b/src/lib/onboard/created-sandbox-finalization.ts @@ -17,6 +17,7 @@ import { } from "../actions/sandbox/runtime/openclaw-lifecycle"; import type { OpenShellSandboxBufferedCommandExecutor } from "../adapters/openshell/sandbox-command"; import * as buildContext from "../build-context"; +import { resolveExternalDashboardUrl } from "../dashboard/url"; import { resolveSandboxImageTagFromCreateOutput } from "../domain/sandbox/image-tag"; import type { SandboxEntry, SandboxGpuProofResult } from "../state/registry"; import type { QualifiedSandboxInferenceRouteReservation } from "../state/registry/route-reservation"; @@ -104,6 +105,7 @@ type RegistrationSeed = Omit< | "workload" | "hermesDashboardState" | "dashboardPort" + | "dashboardExternalUrl" | "lifecycleGeneration" | "lifecycleLiveIdentityFingerprint" | "inferenceRouteReservation" @@ -357,6 +359,7 @@ export function createCreatedSandboxCompletionActions( ): CreatedSandboxCompletionActions { let chatUiUrl = options.dashboard.chatUiUrl; let dashboardPort = 0; + let dashboardExternalUrl: string | null = null; let hermesDashboardState = options.dashboard.initialHermesState; async function verifyCreatedProviderGpu(created: SandboxGpuCreateFlowResult): Promise { await dockerGpuLocalInference.verifyGpuSandboxLocalInferenceAndCommitAfterReady( @@ -396,6 +399,7 @@ export function createCreatedSandboxCompletionActions( ); } process.env.CHAT_UI_URL = chatUiUrl; + dashboardExternalUrl = resolveExternalDashboardUrl(chatUiUrl); hermesDashboardState = options.dashboard.resolveHermesState(dashboardPort); deps.revalidateSandboxIdentity?.( `recording Hermes dashboard capability for sandbox '${options.finalization.sandboxName}'`, @@ -500,6 +504,7 @@ export function createCreatedSandboxCompletionActions( workload: resolved.workloadReceipt, hermesDashboardState, dashboardPort, + dashboardExternalUrl, ...currentLifecycle, inferenceRouteReservation: verifiedInferenceRouteReservation, verifiedCreate, diff --git a/src/lib/onboard/dashboard-port.test.ts b/src/lib/onboard/dashboard-port.test.ts index 39bce763270..4103f9a25bf 100644 --- a/src/lib/onboard/dashboard-port.test.ts +++ b/src/lib/onboard/dashboard-port.test.ts @@ -22,6 +22,7 @@ import { findAvailableDashboardPortFromObservations, getRegistryOccupiedDashboardPorts, hasExplicitDashboardPortOverride, + lsofOutputBlocksLoopbackBind, preflightDashboardPortRangeAvailability, reserveCreateSandboxDashboardPort, reserveDashboardPort, @@ -108,6 +109,55 @@ async function unusedLoopbackPort(): Promise { return address.port; } +describe("lsofOutputBlocksLoopbackBind interface-specific loopback probe (#11439)", () => { + const loopback = (port: number) => + `COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME\nx 1 u 3u IPv4 1 0t0 TCP 127.0.0.1:${port} (LISTEN)`; + const external = (port: number) => + `COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME\nsocat 1 u 6u IPv4 1 0t0 TCP 10.63.144.115:${port} (LISTEN)`; + const wildcard = (port: number) => + `COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME\nx 1 u 3u IPv4 1 0t0 TCP *:${port} (LISTEN)`; + + it("treats an external-interface-only listener as non-blocking for the loopback bind", () => { + expect(lsofOutputBlocksLoopbackBind(external(18789), 18789)).toBe(false); + }); + + it("treats a loopback listener as blocking", () => { + expect(lsofOutputBlocksLoopbackBind(loopback(18789), 18789)).toBe(true); + }); + + it("treats a wildcard 0.0.0.0 or star listener as blocking, preserving docker-proxy detection (#3260)", () => { + expect(lsofOutputBlocksLoopbackBind(wildcard(18789), 18789)).toBe(true); + expect( + lsofOutputBlocksLoopbackBind("x 1 u 3u IPv4 1 0t0 TCP 0.0.0.0:18789 (LISTEN)", 18789), + ).toBe(true); + }); + + it("treats an IPv6 loopback listener as blocking", () => { + expect( + lsofOutputBlocksLoopbackBind("x 1 u 3u IPv6 1 0t0 TCP [::1]:18789 (LISTEN)", 18789), + ).toBe(true); + }); + + it("only matches the requested port", () => { + expect(lsofOutputBlocksLoopbackBind(loopback(18790), 18789)).toBe(false); + }); + + it("returns false for empty or missing output", () => { + expect(lsofOutputBlocksLoopbackBind("", 18789)).toBe(false); + expect(lsofOutputBlocksLoopbackBind(null, 18789)).toBe(false); + expect(lsofOutputBlocksLoopbackBind(undefined, 18789)).toBe(false); + }); + + it("ignores non-LISTEN rows", () => { + expect( + lsofOutputBlocksLoopbackBind( + "x 1 u 3u IPv4 1 0t0 TCP 127.0.0.1:18789->10.0.0.2:5000 (ESTABLISHED)", + 18789, + ), + ).toBe(false); + }); +}); + describe("typed OpenShell dashboard-port observation", () => { it("checks gateway authority once for each multi-port batch (#11963)", async () => { const observeForwards = vi.fn( diff --git a/src/lib/onboard/dashboard-port.ts b/src/lib/onboard/dashboard-port.ts index 391bb652f9e..923719461d1 100644 --- a/src/lib/onboard/dashboard-port.ts +++ b/src/lib/onboard/dashboard-port.ts @@ -133,26 +133,89 @@ export function probePortBoundSync(port: number): boolean { } /** - * Synchronous check whether a TCP port has an active listener on the host. + * Classify an `lsof -n` NAME bind address as one that would block a loopback + * dashboard bind. NemoClaw binds its dashboard forward on `127.0.0.1` + * (see {@link reserveDashboardPort} and {@link probePortBoundSync}), so a + * listener only conflicts when it already owns the loopback interface or a + * wildcard address that includes it. A listener bound solely to an external + * interface (e.g. a TLS reverse proxy in front of `CHAT_UI_URL`) leaves the + * loopback socket free and must not be treated as blocking (#11439). + */ +function bindAddressBlocksLoopback(address: string): boolean { + const addr = address.replace(/^\[/, "").replace(/\]$/, "").toLowerCase(); + // Wildcard binds (all interfaces) always include loopback. + if (addr === "*" || addr === "0.0.0.0" || addr === "::" || addr === "0:0:0:0:0:0:0:0") { + return true; + } + // IPv4 loopback (127.0.0.0/8) and IPv6 loopback (::1), including the + // IPv4-mapped form docker-proxy can report. + if (addr.startsWith("127.")) return true; + if (addr === "::1" || addr === "0:0:0:0:0:0:0:1") return true; + if (addr.startsWith("::ffff:127.")) return true; + return false; +} + +/** + * Decide whether `lsof -sTCP:LISTEN -P -n` output shows a listener on `port` + * that would block a loopback dashboard bind. Only loopback and wildcard binds + * count; an external-interface-only listener does not. Returns false when no + * matching loopback/wildcard listener is present so callers fall through to the + * authoritative `127.0.0.1` bind probe (#11439). Preserves docker-proxy / + * loopback / `0.0.0.0` detection (#3260), which report wildcard or loopback + * addresses. + */ +export function lsofOutputBlocksLoopbackBind( + output: string | null | undefined, + port: number, +): boolean { + if (!output) return false; + for (const rawLine of output.split("\n")) { + const line = rawLine.trim(); + if (!/\(LISTEN\)$/.test(line)) continue; + // NAME column holds the bind endpoint, e.g. "TCP 127.0.0.1:18789 (LISTEN)", + // "TCP *:18789 (LISTEN)", or "TCP [::1]:18789 (LISTEN)". + const match = line.match(/\s(\S+):(\d+)\s+\(LISTEN\)$/); + if (!match) continue; + if (Number(match[2]) !== port) continue; + if (bindAddressBlocksLoopback(match[1])) return true; + } + return false; +} + +/** + * Synchronous check whether a TCP port has an active listener that would block + * NemoClaw's dashboard bind. + * + * The default loopback dashboard forward binds `127.0.0.1`, so the decision is + * interface-specific: an external-interface-only listener on the same port + * number does not count. When `loopbackOnly` is false — an operator opted into a + * remote (`0.0.0.0`) bind via `NEMOCLAW_DASHBOARD_BIND` — the forward binds all + * interfaces, so any listener on the port (including an external-interface-only + * one) genuinely conflicts and counts (#3259, #11439). * * Detection chain — any positive signal short-circuits: * 1. `lsof` — finds listeners owned by the current user. * 2. `sudo -n lsof` — catches root-owned listeners (e.g., docker-proxy on - * macOS) that the unprivileged lsof can't see. Silently no-ops when - * the user can't escalate non-interactively. - * 3. Node `net` bind probe — authoritative fallback when both lsof - * invocations come up empty, mirroring the direct ForwardTcp bind. + * macOS) that the unprivileged lsof can't see. Silently no-ops when the + * user can't escalate non-interactively. + * 3. Node `net` bind probe — authoritative `127.0.0.1` check, run whenever the + * lsof invocations show no blocking listener, mirroring the direct + * ForwardTcp loopback bind. * * Returns false (optimistic) when every probe is inconclusive. The detached * OpenShell launch performs the final bind check. */ -export function isPortBoundOnHost(port: number): boolean { +export function isPortBoundOnHost(port: number, loopbackOnly = true): boolean { + const blocks = (output: ReturnType): boolean => + loopbackOnly + ? lsofOutputBlocksLoopbackBind(output, port) + : Boolean(output && output.trim().length > 0); try { const out: ReturnType = runCapture( ["lsof", "-i", `:${port}`, "-sTCP:LISTEN", "-P", "-n"], { ignoreError: true }, ); - if (out && out.trim().length > 0) return true; + if (blocks(out)) return true; } catch { /* fall through to the next probe */ } @@ -162,7 +225,7 @@ export function isPortBoundOnHost(port: number): boolean { ["sudo", "-n", "lsof", "-i", `:${port}`, "-sTCP:LISTEN", "-P", "-n"], { ignoreError: true }, ); - if (sudoOut && sudoOut.trim().length > 0) return true; + if (blocks(sudoOut)) return true; } catch { /* fall through to the bind probe */ } diff --git a/src/lib/onboard/sandbox-registration.ts b/src/lib/onboard/sandbox-registration.ts index 1449f4b7f34..2ab6b70e42f 100644 --- a/src/lib/onboard/sandbox-registration.ts +++ b/src/lib/onboard/sandbox-registration.ts @@ -84,6 +84,12 @@ export interface CreatedSandboxRegistryEntryInput { /** True only when schema-5 receipt authority owns this Hermes registration. */ hermesPortableLifecycle?: boolean; dashboardPort: number; + /** + * Browser-facing external dashboard URL resolved from `CHAT_UI_URL`, or null + * when the dashboard is a plain loopback address. Persisted so post-onboard + * commands can report the external origin (#11439). + */ + dashboardExternalUrl?: string | null; dashboardRemoteBindPrepared?: boolean; lifecycleGeneration?: string; lifecycleLiveIdentityFingerprint?: string; @@ -275,6 +281,9 @@ export function buildCreatedSandboxRegistryEntry( })) : undefined, dashboardPort: input.dashboardPort, + ...(input.dashboardExternalUrl != null + ? { dashboardExternalUrl: input.dashboardExternalUrl } + : {}), dashboardRemoteBindPrepared: input.dashboardRemoteBindPrepared === true, lifecycleGeneration: input.lifecycleGeneration, lifecycleLiveIdentityFingerprint: input.lifecycleLiveIdentityFingerprint, diff --git a/src/lib/onboard/sandbox-reuse.test.ts b/src/lib/onboard/sandbox-reuse.test.ts index 1f0d11b2934..5be480bca97 100644 --- a/src/lib/onboard/sandbox-reuse.test.ts +++ b/src/lib/onboard/sandbox-reuse.test.ts @@ -77,6 +77,8 @@ describe("applyReusedSandboxDashboardState", () => { hermesDashboardPort: enabled ? 18789 : undefined, hermesDashboardInternalPort: enabled ? 19119 : undefined, hermesDashboardTui: undefined, + // No external CHAT_UI_URL configured -> loopback stays the reported form. + dashboardExternalUrl: null, gatewayName: "nemoclaw", gatewayPort: 8080, }); @@ -88,6 +90,46 @@ describe("applyReusedSandboxDashboardState", () => { }, ); + it("persists the external dashboard URL rebound to the effective port on reuse (#11439)", async () => { + const updateSandbox = vi.fn(); + const ensureDashboardForward = vi.fn(() => 18790); + const sandboxGpuConfig: SandboxGpuConfig = { + hostGpuDetected: false, + hostGpuPlatform: null, + sandboxGpuEnabled: false, + mode: "auto", + sandboxGpuDevice: null, + errors: [], + }; + + await applyReusedSandboxDashboardState({ + sandboxName: "reuse-me", + chatUiUrl: "http://127.0.0.1:18790", + env: { CHAT_UI_URL: "https://dash.example.com:18789" }, + agent: loadAgent("hermes"), + model: "test-model", + provider: "openai-compatible", + selectionVerified: true, + sandboxGpuConfig, + gatewayName: "nemoclaw", + gatewayPort: 8080, + ensureDashboardForward, + hermesDashboardForwarding: { + resolveStateForPort: vi.fn(() => ({ enabled: false, config: null })), + ensureForState: vi.fn(), + }, + updateSandbox, + updateReusedSandboxMetadata: vi.fn(), + }); + + expect(updateSandbox).toHaveBeenCalledWith( + "reuse-me", + expect.objectContaining({ + dashboardExternalUrl: "https://dash.example.com:18790", + }), + ); + }); + it("skips dashboard forwarding while preserving reuse metadata for terminal agents", async () => { const updateSandbox = vi.fn(); const env: NodeJS.ProcessEnv = { CHAT_UI_URL: "https://chat.example.test:19000" }; diff --git a/src/lib/onboard/sandbox-reuse.ts b/src/lib/onboard/sandbox-reuse.ts index c91f6eb818d..0d206e343b3 100644 --- a/src/lib/onboard/sandbox-reuse.ts +++ b/src/lib/onboard/sandbox-reuse.ts @@ -4,6 +4,7 @@ import type { AgentDefinition } from "../agent/defs"; import type { SandboxEntry } from "../state/registry"; import * as registry from "../state/registry"; +import { resolveExternalDashboardUrlForPort } from "../dashboard/url"; import { canReuseDashboardForwardForAgent } from "./dashboard-runtime"; import { getHermesDashboardRegistryFields, @@ -136,6 +137,10 @@ export async function applyReusedSandboxDashboardState( input: ReusedSandboxDashboardStateInput, ): Promise { const manageDashboard = input.manageDashboard ?? true; + // Capture the operator's external origin before the loopback rewrite below + // overwrites `input.env.CHAT_UI_URL`, so the persisted external URL reflects + // the browser-facing address rather than the internal loopback bind (#11439). + const externalDashboardOrigin = input.env.CHAT_UI_URL; if ( manageDashboard && input.env.NEMOCLAW_DASHBOARD_BIND === "0.0.0.0" && @@ -192,8 +197,17 @@ export async function applyReusedSandboxDashboardState( input.revalidateSandboxIdentity?.( `record reused dashboard state for sandbox '${input.sandboxName}'`, ); + // Persist (or clear) the browser-facing external dashboard URL derived from + // the operator's `CHAT_UI_URL`, rebinding its port to the effective dashboard + // port, so a re-onboard that adds, changes, or removes an external origin + // keeps status/dashboard-url/list accurate rather than reporting a stale or + // missing URL (#11439). Only meaningful when this run manages the dashboard. + const externalDashboardUrl = manageDashboard + ? resolveExternalDashboardUrlForPort(externalDashboardOrigin, dashboardPort) + : null; (input.updateSandbox ?? registry.updateSandbox)(input.sandboxName, { ...getHermesDashboardRegistryFields(hermesDashboardState), + ...(manageDashboard ? { dashboardExternalUrl: externalDashboardUrl } : {}), gatewayName: input.gatewayName, gatewayPort: input.gatewayPort, }); diff --git a/src/lib/state/gateway-registry.test.ts b/src/lib/state/gateway-registry.test.ts index 9a5fc5f1044..49d1a3fcc52 100644 --- a/src/lib/state/gateway-registry.test.ts +++ b/src/lib/state/gateway-registry.test.ts @@ -143,6 +143,63 @@ describe("host gateway registry index", () => { } }); + it("rejects a malformed persisted dashboardExternalUrl (#11439)", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-gateway-index-exturl-")); + try { + const root = path.join(home, ".nemoclaw", "gateways", "9123"); + fs.mkdirSync(root, { recursive: true }); + fs.writeFileSync( + path.join(root, "sandboxes.json"), + JSON.stringify({ + defaultSandbox: "instance-a", + sandboxes: { + "instance-a": { + name: "instance-a", + gatewayName: "nemoclaw-9123", + gatewayPort: 9123, + dashboardPort: 18789, + dashboardExternalUrl: "not a url", + }, + }, + }), + ); + + expect(() => listHostGatewayRegistryEntries(home)).toThrow(/invalid dashboardExternalUrl/); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + } + }); + + it("accepts a valid persisted dashboardExternalUrl (#11439)", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-gateway-index-exturl-ok-")); + try { + const root = path.join(home, ".nemoclaw", "gateways", "9123"); + fs.mkdirSync(root, { recursive: true }); + fs.writeFileSync( + path.join(root, "sandboxes.json"), + JSON.stringify({ + defaultSandbox: "instance-a", + sandboxes: { + "instance-a": { + name: "instance-a", + gatewayName: "nemoclaw-9123", + gatewayPort: 9123, + dashboardPort: 18789, + dashboardExternalUrl: "https://dash.example.com:18789", + }, + }, + }), + ); + + const entries = listHostGatewayRegistryEntries(home); + + expect(entries).toHaveLength(1); + expect(entries[0].entry.dashboardExternalUrl).toBe("https://dash.example.com:18789"); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + } + }); + it("treats a zero persisted dashboard port as no dashboard instead of blocking the registry (#7020)", () => { const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-gateway-index-zero-port-")); try { diff --git a/src/lib/state/gateway-registry.ts b/src/lib/state/gateway-registry.ts index 0c4b1ebbc2d..ab4ab1b3c90 100644 --- a/src/lib/state/gateway-registry.ts +++ b/src/lib/state/gateway-registry.ts @@ -8,6 +8,7 @@ import { isErrnoException } from "../core/errno"; import { isObjectRecord } from "../core/json-types"; import { resolveLegacyModelRouterPort } from "../core/model-router-port"; import { DEFAULT_GATEWAY_PORT } from "../core/ports"; +import { isValidDashboardExternalUrl } from "../dashboard/url"; import { NAME_MAX_LENGTH, NAME_VALID_PATTERN } from "../name-validation"; import { resolveGatewayName, resolveGatewayPortFromName } from "../onboard/gateway-binding"; import { GATEWAYS_SUBDIR, nemoclawStateRoot } from "./state-root"; @@ -32,6 +33,7 @@ const MAX_GATEWAY_DIRECTORY_ENTRIES = 1024; export interface GatewayRegistryEntry extends Record { name: string; dashboardPort?: number | null; + dashboardExternalUrl?: string | null; hermesApiPort?: number | null; gatewayName?: string | null; gatewayPort?: number | null; @@ -128,6 +130,16 @@ function parseRegistry(filePath: string, raw: string): GatewayRegistryDocument { throw stateError(`${filePath} has an invalid ${field} for sandbox ${JSON.stringify(name)}`); } } + const externalUrl = value.dashboardExternalUrl; + if ( + externalUrl !== undefined && + externalUrl !== null && + (typeof externalUrl !== "string" || !isValidDashboardExternalUrl(externalUrl)) + ) { + throw stateError( + `${filePath} has an invalid dashboardExternalUrl for sandbox ${JSON.stringify(name)}`, + ); + } const gatewayStateDir = value.openshellGatewayStateDir; if ( gatewayStateDir !== undefined && diff --git a/src/lib/state/registry-normalization.test.ts b/src/lib/state/registry-normalization.test.ts index 4dcc2f64431..1454305c76c 100644 --- a/src/lib/state/registry-normalization.test.ts +++ b/src/lib/state/registry-normalization.test.ts @@ -250,6 +250,40 @@ describe("sandbox registry normalization", () => { }); }); + it("round-trips the persisted external dashboard URL (#11439)", async () => { + const registry = await loadRegistryWith({}); + registry.registerSandbox({ + name: "proxied", + dashboardPort: 18_789, + dashboardExternalUrl: "https://dash.example.com:18789", + }); + + vi.resetModules(); + const reloadedRegistry = await import("./registry"); + expect(reloadedRegistry.getSandbox("proxied")).toMatchObject({ + dashboardPort: 18_789, + dashboardExternalUrl: "https://dash.example.com:18789", + }); + }); + + it("preserves a persisted external dashboard URL when only the port is updated (#11439)", async () => { + const registry = await loadRegistryWith({}); + registry.registerSandbox({ + name: "proxied", + dashboardPort: 18_789, + dashboardExternalUrl: "https://dash.example.com:18789", + }); + + // Mirror the non-clearing persistDashboardPort update: a loopback re-onboard + // must not overwrite the external URL with null. + registry.updateSandbox("proxied", { dashboardPort: 18_790 }); + + expect(registry.getSandbox("proxied")).toMatchObject({ + dashboardPort: 18_790, + dashboardExternalUrl: "https://dash.example.com:18789", + }); + }); + it("backfills a lifecycle generation only for the unchanged legacy Docker row (#8584)", async () => { const registry = await loadRegistryWith({}); const { compareAndSetLegacySandboxLifecycleGeneration } = diff --git a/src/lib/state/registry.ts b/src/lib/state/registry.ts index d79e6e8e1da..188603148dc 100644 --- a/src/lib/state/registry.ts +++ b/src/lib/state/registry.ts @@ -539,6 +539,7 @@ export function registerSandbox( hermesDashboardTui: entry.hermesDashboardTui === true ? true : undefined, hermesApiPort: entry.hermesApiPort ?? undefined, dashboardPort: entry.dashboardPort ?? undefined, + dashboardExternalUrl: entry.dashboardExternalUrl ?? undefined, dashboardRemoteBindPrepared: entry.dashboardRemoteBindPrepared === true ? true : undefined, gatewayName: entry.gatewayName ?? undefined, gatewayPort: entry.gatewayPort ?? undefined, diff --git a/src/lib/state/registry/types.ts b/src/lib/state/registry/types.ts index 14fcaad4c49..599af7e0062 100644 --- a/src/lib/state/registry/types.ts +++ b/src/lib/state/registry/types.ts @@ -152,6 +152,13 @@ export interface SandboxEntry extends Partial { */ hermesApiPort?: number | null; dashboardPort?: number | null; + /** + * Browser-facing external dashboard URL resolved from `CHAT_UI_URL` at + * onboard time (host + scheme with the effective dashboard port). Persisted + * only when an external origin was configured; a plain loopback dashboard is + * left unset and reported as `http://127.0.0.1:/` (#11439). + */ + dashboardExternalUrl?: string | null; /** Remote dashboard exposure was included in the sandbox's generated config. */ dashboardRemoteBindPrepared?: boolean; /** Generation proving which durable same-name recreate registered this row. */ diff --git a/test/e2e/support/shared-e2e-workflow-boundary.test.ts b/test/e2e/support/shared-e2e-workflow-boundary.test.ts index ecadc8173a4..ec31a49c161 100644 --- a/test/e2e/support/shared-e2e-workflow-boundary.test.ts +++ b/test/e2e/support/shared-e2e-workflow-boundary.test.ts @@ -144,9 +144,9 @@ const stagingReferenceVariants = [ ]; const actionMutations: Array<[string, (source: string) => string]> = [ - ["artifact-id", (source) => source.replace('artifact-ids: "10385514729"', 'artifact-ids: "1"')], + ["artifact-id", (source) => source.replace('artifact-ids: "11018865557"', 'artifact-ids: "1"')], ["digest", (source) => source.replace(/sha256:[a-f0-9]{64}/, "sha256:" + "0".repeat(64))], - ["source-run", (source) => source.replace('run-id: "33211526093"', 'run-id: "1"')], + ["source-run", (source) => source.replace('run-id: "36534476155"', 'run-id: "1"')], [ "verification-order", (source) => { diff --git a/tools/e2e/workflow-boundary-policy.mts b/tools/e2e/workflow-boundary-policy.mts index b0133cf0ff1..562ce89ae38 100644 --- a/tools/e2e/workflow-boundary-policy.mts +++ b/tools/e2e/workflow-boundary-policy.mts @@ -23,8 +23,8 @@ export const E2E_ACTION_PROVENANCE = { }, stageNativePodmanToolchains: { reference: - "NVIDIA/NemoClaw/.github/actions/stage-native-podman-e2e-toolchains@8d7409d66a0e664829f9ddab177aa8460974291f", - contentSha256: "4178d1938477d197033b5e73cca34417eb9b31302af3714a2a7c584c2b0f2810", + "NVIDIA/NemoClaw/.github/actions/stage-native-podman-e2e-toolchains@6b0acb521f2644fb48f8a735fde875ff798d9047", + contentSha256: "83416ddcd1db9e9517c93e896a6204d281eac9725ec7e0892cd3318d6b7a824f", }, restoreCliArtifact: { reference: