From 682320a1147a9609d7d9ffa6f46ab45cf963bb1d Mon Sep 17 00:00:00 2001 From: Charan Jagwani Date: Fri, 24 Apr 2026 11:16:05 -0700 Subject: [PATCH 1/6] test(install-preflight): bump nvm upgrade test timeout to 15s MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause: install.sh sources scripts/install.sh (1374-line payload), which runs resolve_installer_version() → resolve_repo_root() → git describe at source time. In isolation the test takes ~0.9s. Under vitest's default parallelism on macOS (up to 14 workers), fork()/execve() serializes and each subprocess spawn balloons 50-200x, pushing total runtime to 5-7s and blowing past the default 5000ms testTimeout. The default timeout is correct for every other test in the file; this one has a legitimately different workload (real bash + real subprocess chain). Bumping its per-test timeout is the minimum-scope fix; caching the payload's init subshells in scripts/install.sh is the general improvement and is tracked for follow-up. Co-Authored-By: Claude Opus 4.7 (1M context) Signed-off-by: Charan Jagwani --- test/install-preflight.test.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/test/install-preflight.test.ts b/test/install-preflight.test.ts index 4f6a4889857..dcce6d25c87 100644 --- a/test/install-preflight.test.ts +++ b/test/install-preflight.test.ts @@ -104,6 +104,16 @@ echo "unexpected npm invocation: $*" >&2; exit 98`, // --------------------------------------------------------------------------- describe("installer runtime preflight", () => { + // install.sh sources scripts/install.sh (1374-line payload), which runs + // resolve_installer_version() → resolve_repo_root() → git describe at source + // time. In isolation this takes ~0.9s. Under vitest's default parallelism on + // macOS (up to 14 workers), fork()/execve() serializes and each subprocess + // spawn balloons 50-200× (200-500ms instead of 1-10ms). With 5-6 subprocess + // spawns per installer run, total can reach 5-7s, blowing past the default + // 5000ms testTimeout. The default is correct for every other test here; this + // one has a legitimately different workload (real bash + real subprocesses). + const INSTALLER_TEST_TIMEOUT = 15_000; + it("attempts nvm upgrade when system Node.js is below minimum version", () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-install-preflight-")); const fakeBin = path.join(tmp, "bin"); @@ -156,7 +166,7 @@ exit 1 expect(output).toMatch(/v18\.19\.1.*found but NemoClaw requires/); expect(output).toMatch(/upgrading via nvm/); expect(output).toMatch(/Failed to download nvm installer/); - }); + }, INSTALLER_TEST_TIMEOUT); it("treats the installer script's checkout as the source root even when cwd is elsewhere", () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-install-fallback-")); From 49dbf1c7358fd0a2aaee291b3f08b87d43ce3ac2 Mon Sep 17 00:00:00 2001 From: Charan Jagwani Date: Fri, 24 Apr 2026 11:17:18 -0700 Subject: [PATCH 2/6] fix(onboard): auto-allocate dashboard port on multi-sandbox conflict MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second onboard previously crashed mid-flight when port 18789 was held by the first sandbox, blocking 12 P0/P1 QA tests that keep a baseline sandbox alive while onboarding a short-lived second one. The guard from v0.0.21 fixed silent forward stealing but left no recovery path — users had to set CHAT_UI_URL manually and the assigned port was not discoverable post-hoc. Changes: - src/lib/ports.ts: add findFreeDashboardPort picker with 10-port window and injectable probe seam for tests. - src/lib/onboard.ts: ensureDashboardForward now auto-allocates on loopback conflict and returns the chosen port; throws only on user-pinned CHAT_UI_URL or window exhaustion. Rollback try/catch around the forward + registry block deletes the openshell sandbox on failure so nemoclaw list and openshell sandbox list no longer drift. - src/lib/registry.ts: persist dashboardPort on SandboxEntry so the assigned port survives across CLI invocations. - src/lib/inventory-commands.ts: surface dashboard URL in nemoclaw list and nemoclaw status. - src/nemoclaw.ts: surface dashboard URL in nemoclaw status. - src/lib/onboard-command.ts: add --control-ui-port flag with precedence flag > CHAT_UI_URL env > auto-alloc > default. Fixes #2174 Co-Authored-By: Claude Opus 4.7 (1M context) Signed-off-by: Charan Jagwani --- src/lib/inventory-commands.test.ts | 52 +++++++++ src/lib/inventory-commands.ts | 7 ++ src/lib/onboard-command.test.ts | 39 +++++++ src/lib/onboard-command.ts | 32 +++++- src/lib/onboard.ts | 168 ++++++++++++++++++++--------- src/lib/ports.test.ts | 36 ++++++- src/lib/ports.ts | 34 ++++++ src/lib/registry.ts | 2 + src/nemoclaw.ts | 3 + 9 files changed, 322 insertions(+), 51 deletions(-) diff --git a/src/lib/inventory-commands.test.ts b/src/lib/inventory-commands.test.ts index 6ec2c7084de..b7f25fe4c72 100644 --- a/src/lib/inventory-commands.test.ts +++ b/src/lib/inventory-commands.test.ts @@ -50,6 +50,39 @@ describe("inventory commands", () => { ); }); + it("prints the dashboard URL for each sandbox when dashboardPort is set", async () => { + const lines: string[] = []; + await listSandboxesCommand({ + recoverRegistryEntries: async () => ({ + sandboxes: [ + { + name: "alpha", + model: "m", + provider: "p", + gpuEnabled: false, + policies: [], + dashboardPort: 18789, + }, + { + name: "beta", + model: "m", + provider: "p", + gpuEnabled: false, + policies: [], + dashboardPort: 18790, + }, + ], + defaultSandbox: "alpha", + }), + getLiveInference: () => null, + loadLastSession: () => null, + log: (message = "") => lines.push(message), + }); + + expect(lines).toContain(" dashboard: http://127.0.0.1:18789"); + expect(lines).toContain(" dashboard: http://127.0.0.1:18790"); + }); + it("shows stored sandbox inference instead of live gateway inference in list output", async () => { const lines: string[] = []; await listSandboxesCommand({ @@ -88,6 +121,25 @@ describe("inventory commands", () => { ); }); + it("prints the dashboard URL per sandbox in status when dashboardPort is set", () => { + const lines: string[] = []; + showStatusCommand({ + listSandboxes: () => ({ + sandboxes: [ + { name: "alpha", model: "m", dashboardPort: 18789 }, + { name: "beta", model: "m", dashboardPort: 18790 }, + ], + defaultSandbox: "alpha", + }), + getLiveInference: () => null, + showServiceStatus: vi.fn(), + log: (message = "") => lines.push(message), + }); + + expect(lines).toContain(" dashboard: http://127.0.0.1:18789"); + expect(lines).toContain(" dashboard: http://127.0.0.1:18790"); + }); + it("flags messaging bridge as degraded when checkMessagingBridgeHealth reports conflicts", () => { const lines: string[] = []; const checkMessagingBridgeHealth = vi.fn().mockReturnValue([ diff --git a/src/lib/inventory-commands.ts b/src/lib/inventory-commands.ts index a04a034231f..c6823094a27 100644 --- a/src/lib/inventory-commands.ts +++ b/src/lib/inventory-commands.ts @@ -11,6 +11,7 @@ export interface SandboxEntry { policies?: string[] | null; messagingChannels?: string[] | null; agent?: string | null; + dashboardPort?: number; } export interface MessagingBridgeHealth { @@ -98,6 +99,9 @@ export async function listSandboxesCommand(deps: ListSandboxesCommandDeps): Prom const connected = sessionCount !== null && sessionCount > 0 ? " ●" : ""; log(` ${sb.name}${def}${connected}`); log(` model: ${model} provider: ${provider} ${gpu} policies: ${presets}`); + if (typeof sb.dashboardPort === "number") { + log(` dashboard: http://127.0.0.1:${sb.dashboardPort}`); + } } log(""); log(" * = default sandbox"); @@ -116,6 +120,9 @@ export function showStatusCommand(deps: ShowStatusCommandDeps): void { const def = isDefault ? " *" : ""; const model = sb.model; log(` ${sb.name}${def}${model ? ` (${model})` : ""}`); + if (typeof sb.dashboardPort === "number") { + log(` dashboard: http://127.0.0.1:${sb.dashboardPort}`); + } } log(""); } diff --git a/src/lib/onboard-command.test.ts b/src/lib/onboard-command.test.ts index c6889d322b2..6137e04a2fc 100644 --- a/src/lib/onboard-command.test.ts +++ b/src/lib/onboard-command.test.ts @@ -32,6 +32,7 @@ describe("onboard command", () => { acceptThirdPartySoftware: true, agent: null, dangerouslySkipPermissions: false, + controlUiPort: null, }); }); @@ -57,6 +58,7 @@ describe("onboard command", () => { acceptThirdPartySoftware: true, agent: null, dangerouslySkipPermissions: false, + controlUiPort: null, }); }); @@ -81,6 +83,7 @@ describe("onboard command", () => { acceptThirdPartySoftware: false, agent: null, dangerouslySkipPermissions: false, + controlUiPort: null, }); }); @@ -128,9 +131,43 @@ describe("onboard command", () => { acceptThirdPartySoftware: false, agent: null, dangerouslySkipPermissions: false, + controlUiPort: null, }); }); + it("parses --control-ui-port ", () => { + const result = parseOnboardArgs( + ["--resume", "--control-ui-port", "18795"], + "--yes-i-accept-third-party-software", + "NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE", + { + env: {}, + error: () => {}, + exit: ((code: number) => { + throw new Error(String(code)); + }) as never, + }, + ); + expect(result.controlUiPort).toBe(18795); + }); + + it("exits when --control-ui-port is out of range", () => { + expect(() => + parseOnboardArgs( + ["--control-ui-port", "80"], + "--yes-i-accept-third-party-software", + "NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE", + { + env: {}, + error: () => {}, + exit: ((code: number) => { + throw new Error(`exit:${code}`); + }) as never, + }, + ), + ).toThrow("exit:1"); + }); + it("exits when --from is missing its Dockerfile path", () => { expect(() => parseOnboardArgs( @@ -191,6 +228,7 @@ describe("onboard command", () => { acceptThirdPartySoftware: false, agent: "openclaw", dangerouslySkipPermissions: true, + controlUiPort: null, }); }); @@ -242,6 +280,7 @@ describe("onboard command", () => { acceptThirdPartySoftware: false, agent: null, dangerouslySkipPermissions: false, + controlUiPort: null, }); }); diff --git a/src/lib/onboard-command.ts b/src/lib/onboard-command.ts index f3f4c5d8f9f..978e78aff9d 100644 --- a/src/lib/onboard-command.ts +++ b/src/lib/onboard-command.ts @@ -9,6 +9,7 @@ export interface OnboardCommandOptions { acceptThirdPartySoftware: boolean; agent: string | null; dangerouslySkipPermissions: boolean; + controlUiPort: number | null; } export interface RunOnboardCommandDeps { @@ -36,7 +37,7 @@ const ONBOARD_BASE_ARGS = [ function onboardUsageLines(noticeAcceptFlag: string): string[] { return [ - ` Usage: nemoclaw onboard [--non-interactive] [--resume] [--recreate-sandbox] [--from ] [--agent ] [--dangerously-skip-permissions] [${noticeAcceptFlag}]`, + ` Usage: nemoclaw onboard [--non-interactive] [--resume] [--recreate-sandbox] [--from ] [--agent ] [--control-ui-port ] [--dangerously-skip-permissions] [${noticeAcceptFlag}]`, "", ]; } @@ -69,6 +70,28 @@ export function parseOnboardArgs( parsedArgs.splice(fromIdx, 2); } + let controlUiPort: number | null = null; + const controlUiPortIdx = parsedArgs.indexOf("--control-ui-port"); + if (controlUiPortIdx !== -1) { + const raw = parsedArgs[controlUiPortIdx + 1]; + if (typeof raw !== "string" || raw.startsWith("--")) { + error(" --control-ui-port requires a port number"); + printOnboardUsage(error, noticeAcceptFlag); + exit(1); + } + if (!/^\d+$/.test(raw)) { + error(` --control-ui-port '${raw}' must be an integer between 1024 and 65535`); + exit(1); + } + const parsed = Number(raw); + if (parsed < 1024 || parsed > 65535) { + error(` --control-ui-port '${raw}' must be an integer between 1024 and 65535`); + exit(1); + } + controlUiPort = parsed; + parsedArgs.splice(controlUiPortIdx, 2); + } + let agent: string | null = null; const agentIdx = parsedArgs.indexOf("--agent"); if (agentIdx !== -1) { @@ -105,6 +128,7 @@ export function parseOnboardArgs( parsedArgs.includes(noticeAcceptFlag) || String(deps.env[noticeAcceptEnv] || "") === "1", agent, dangerouslySkipPermissions: parsedArgs.includes("--dangerously-skip-permissions"), + controlUiPort, }; } @@ -116,6 +140,12 @@ export async function runOnboardCommand(deps: RunOnboardCommandDeps): Promise 0) { registry.updateSandbox(sandboxName, { providerCredentialHashes: abortHashes }); } - ensureDashboardForward(sandboxName, chatUiUrl); + const reusePort = ensureDashboardForward(sandboxName, chatUiUrl); + registry.updateSandbox(sandboxName, { dashboardPort: reusePort }); return sandboxName; } } catch (err) { @@ -3533,7 +3537,8 @@ async function createSandbox( if (Object.keys(abortHashes).length > 0) { registry.updateSandbox(sandboxName, { providerCredentialHashes: abortHashes }); } - ensureDashboardForward(sandboxName, chatUiUrl); + const reusePort = ensureDashboardForward(sandboxName, chatUiUrl); + registry.updateSandbox(sandboxName, { dashboardPort: reusePort }); return sandboxName; } } @@ -3944,33 +3949,61 @@ async function createSandbox( } } - // Release any stale forward on the dashboard port before claiming it for the new sandbox. - // A previous onboard run may have left the port forwarded to a different sandbox, - // which would silently prevent the new sandbox's dashboard from being reachable. - ensureDashboardForward(sandboxName, chatUiUrl); - - // Register only after confirmed ready — prevents phantom entries - const effectiveAgent = agent || agentDefs.loadAgent("openclaw"); - const providerCredentialHashes = {}; - for (const { envKey, token } of messagingTokenDefs) { - if (token) { - providerCredentialHashes[envKey] = hashCredential(token); - } - } - registry.registerSandbox({ - name: sandboxName, - model: model || null, - provider: provider || null, - gpuEnabled: !!gpu, - agent: agent ? agent.name : null, - agentVersion: fromDockerfile ? null : effectiveAgent.expectedVersion || null, - imageTag: `openshell/sandbox-from:${buildId}`, - dangerouslySkipPermissions: dangerouslySkipPermissions || undefined, - providerCredentialHashes: - Object.keys(providerCredentialHashes).length > 0 ? providerCredentialHashes : undefined, - messagingChannels: activeMessagingChannels, - disabledChannels: disabledChannels.length > 0 ? [...disabledChannels] : undefined, - }); + // Release any stale forward on the dashboard port, then register the sandbox + // with nemoclaw's local registry. Any throw between "openshell sandbox create + // succeeded" and "registerSandbox returned" leaves a ghost state where openshell + // has a live sandbox that nemoclaw doesn't know about (#2174 title claim). + // We roll back: stop any forward we started, remove any partial registry entry, + // then delete the openshell sandbox. + let allocatedDashboardPort; + let registryWritten = false; + try { + allocatedDashboardPort = ensureDashboardForward(sandboxName, chatUiUrl); + // Register only after confirmed ready — prevents phantom entries + const effectiveAgent = agent || agentDefs.loadAgent("openclaw"); + const providerCredentialHashes = {}; + for (const { envKey, token } of messagingTokenDefs) { + if (token) { + providerCredentialHashes[envKey] = hashCredential(token); + } + } + registry.registerSandbox({ + name: sandboxName, + model: model || null, + provider: provider || null, + gpuEnabled: !!gpu, + agent: agent ? agent.name : null, + agentVersion: fromDockerfile ? null : effectiveAgent.expectedVersion || null, + imageTag: `openshell/sandbox-from:${buildId}`, + dangerouslySkipPermissions: dangerouslySkipPermissions || undefined, + providerCredentialHashes: + Object.keys(providerCredentialHashes).length > 0 ? providerCredentialHashes : undefined, + messagingChannels: activeMessagingChannels, + disabledChannels: disabledChannels.length > 0 ? [...disabledChannels] : undefined, + dashboardPort: allocatedDashboardPort, + }); + registryWritten = true; + } catch (err) { + if (allocatedDashboardPort !== undefined) { + runOpenshell(["forward", "stop", String(allocatedDashboardPort)], { ignoreError: true }); + } + if (registryWritten) { + try { + registry.removeSandbox(sandboxName); + } catch { + /* best effort */ + } + } + const delResult = runOpenshell(["sandbox", "delete", sandboxName], { ignoreError: true }); + console.error(""); + console.error(` Post-create setup failed for '${sandboxName}'.`); + if (delResult.status === 0) { + console.error(" Rolled back the partially-created sandbox — safe to retry."); + } else { + console.error(` Automatic cleanup failed. Manual: openshell sandbox delete "${sandboxName}"`); + } + throw err; + } // Restore workspace state if we backed it up during credential rotation. if (pendingStateRestore?.success) { @@ -6032,30 +6065,66 @@ const CONTROL_UI_PORT = DASHBOARD_PORT; const { resolveDashboardForwardTarget, buildControlUiUrls } = dashboard; function ensureDashboardForward(sandboxName, chatUiUrl = `http://127.0.0.1:${CONTROL_UI_PORT}`) { - const portToStop = getDashboardForwardPort(chatUiUrl); - const forwardTarget = getDashboardForwardTarget(chatUiUrl); - // Detect port already claimed by a different sandbox and fail fast with an - // actionable message rather than silently stealing that sandbox's forward. - // (Same sandbox is always allowed — covers reconnect and resume paths.) - const existingForwards = runCaptureOpenshell(["forward", "list"], { ignoreError: true }); - // Parse line-by-line to avoid false positives from substring matches. + const requestedPort = Number(getDashboardForwardPort(chatUiUrl)); + // Parse the forward list once; reuse for both conflict detection and picker. // openshell forward list columns: SANDBOX BIND PORT PID STATUS - // Port is at column index 2; sandbox name is at column index 0. - const portLine = existingForwards - ?.split("\n") + const existingForwards = runCaptureOpenshell(["forward", "list"], { ignoreError: true }); + const forwardEntries = (existingForwards ?? "") + .split("\n") .map((l) => l.trim()) - .find((l) => { + .filter(Boolean) + .map((l) => { const parts = l.split(/\s+/); - return parts[2] === portToStop; + return { sandbox: parts[0] ?? "", port: Number(parts[2]) }; + }) + .filter((e) => Number.isFinite(e.port) && e.port > 0); + const ownerOfRequested = + forwardEntries.find((e) => e.port === requestedPort)?.sandbox ?? null; + + let effectivePort = requestedPort; + if (ownerOfRequested !== null && ownerOfRequested !== sandboxName) { + // Port is held by a different sandbox. Decide: auto-allocate (loopback default) + // or fail fast (user explicitly pinned via CHAT_UI_URL env or non-loopback URL). + const userPinnedEnv = !!process.env.CHAT_UI_URL; + let isLoopback = true; + try { + const parsed = new URL( + /^[a-z]+:\/\//i.test(chatUiUrl) ? chatUiUrl : `http://${chatUiUrl}`, + ); + isLoopback = isLoopbackHostname(parsed.hostname); + } catch { + isLoopback = true; + } + if (userPinnedEnv || !isLoopback) { + throw new Error( + `Port ${requestedPort} is already forwarded for sandbox '${ownerOfRequested}'. ` + + `Unset CHAT_UI_URL or pick a free port to onboard '${sandboxName}'.`, + ); + } + const heldPorts = forwardEntries.map((e) => e.port); + const chosen = findFreeDashboardPort(requestedPort, { + probe: { listForwardPorts: () => heldPorts, probePortFree: () => true }, }); - const portOwner = portLine ? (portLine.split(/\s+/)[0] ?? null) : null; - if (portOwner !== null && portOwner !== sandboxName) { - throw new Error( - `Port ${portToStop} is already forwarded for sandbox '${portOwner}'. ` + - `Set CHAT_UI_URL to a different local port (e.g. http://127.0.0.1:18790) ` + - `before onboarding a second sandbox.`, + if (chosen === null) { + const windowSummary = forwardEntries + .filter((e) => e.port >= requestedPort && e.port < requestedPort + 10) + .map((e) => ` port ${e.port} → sandbox '${e.sandbox}'`) + .join("\n"); + throw new Error( + `All ports ${requestedPort}-${requestedPort + 9} are forwarded:\n${windowSummary}\n` + + ` Destroy an unused sandbox, or set CHAT_UI_URL=http://127.0.0.1:${requestedPort + 10} (or higher) and retry.`, + ); + } + effectivePort = chosen; + console.log( + ` Dashboard port ${requestedPort} in use by '${ownerOfRequested}'; allocated port ${effectivePort} for '${sandboxName}'.`, ); } + + const portToStop = String(effectivePort); + const effectiveChatUiUrl = + effectivePort === requestedPort ? chatUiUrl : `http://127.0.0.1:${effectivePort}`; + const forwardTarget = getDashboardForwardTarget(effectiveChatUiUrl); runOpenshell(["forward", "stop", portToStop], { ignoreError: true }); // Use stdio "ignore" to prevent spawnSync from waiting on inherited pipe fds. // The --background flag forks a child that inherits stdout/stderr; if those are @@ -6077,6 +6146,7 @@ function ensureDashboardForward(sandboxName, chatUiUrl = `http://127.0.0.1:${CON ); console.warn(` Free the port, then reconnect: nemoclaw ${sandboxName} connect`); } + return effectivePort; } function findOpenclawJsonPath(dir) { diff --git a/src/lib/ports.test.ts b/src/lib/ports.test.ts index 3769c2bf8bd..e07bc1391be 100644 --- a/src/lib/ports.test.ts +++ b/src/lib/ports.test.ts @@ -3,7 +3,7 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest"; // Import from compiled dist/ so coverage is attributed correctly. -import { parsePort } from "../../dist/lib/ports"; +import { parsePort, findFreeDashboardPort } from "../../dist/lib/ports"; describe("parsePort", () => { const ENV_KEY = "TEST_PORT"; @@ -70,3 +70,37 @@ describe("parsePort", () => { expect(() => parsePort(ENV_KEY, 8080)).toThrow("Invalid port"); }); }); + +describe("findFreeDashboardPort", () => { + it("skips ports already held by openshell forwards", () => { + const port = findFreeDashboardPort(18789, { + probe: { + listForwardPorts: () => [18789, 18790], + probePortFree: () => true, + }, + }); + expect(port).toBe(18791); + }); + + it("skips ports bound by other host processes", () => { + const bound = new Set([18789, 18790]); + const port = findFreeDashboardPort(18789, { + probe: { + listForwardPorts: () => [], + probePortFree: (p) => !bound.has(p), + }, + }); + expect(port).toBe(18791); + }); + + it("returns null when the 10-port window is exhausted", () => { + const allHeld = Array.from({ length: 10 }, (_, i) => 18789 + i); + const port = findFreeDashboardPort(18789, { + probe: { + listForwardPorts: () => allHeld, + probePortFree: () => true, + }, + }); + expect(port).toBeNull(); + }); +}); diff --git a/src/lib/ports.ts b/src/lib/ports.ts index ad5d8166ad7..36fb6448e6c 100644 --- a/src/lib/ports.ts +++ b/src/lib/ports.ts @@ -46,3 +46,37 @@ export const VLLM_PORT = parsePort("NEMOCLAW_VLLM_PORT", 8000); export const OLLAMA_PORT = parsePort("NEMOCLAW_OLLAMA_PORT", 11434); /** Ollama auth proxy port (default 11435, override via NEMOCLAW_OLLAMA_PROXY_PORT). */ export const OLLAMA_PROXY_PORT = parsePort("NEMOCLAW_OLLAMA_PROXY_PORT", 11435); + +/** + * Injectable probes so tests can drive `findFreeDashboardPort` without touching + * real openshell or real sockets. + */ +export interface PortProbe { + listForwardPorts: () => number[]; + probePortFree: (port: number) => boolean; +} + +/** + * Try up to 10 ports starting at the preferred port before giving up. + * Anything beyond that and the operator has bigger problems; fail loudly. + */ +const PORT_WINDOW = 10; + +/** + * Find a free dashboard port, preferring `preferred` and walking upward. + * Skips ports already claimed by openshell forwards and ports bound by + * other host processes. Returns null if the 10-port window is exhausted. + */ +export function findFreeDashboardPort( + preferred: number, + options: { probe?: PortProbe } = {}, +): number | null { + const probe = options.probe; + const held = new Set(probe?.listForwardPorts() ?? []); + const isFree = probe?.probePortFree ?? (() => true); + for (let offset = 0; offset < PORT_WINDOW; offset++) { + const port = preferred + offset; + if (!held.has(port) && isFree(port)) return port; + } + return null; +} diff --git a/src/lib/registry.ts b/src/lib/registry.ts index 445e20f17df..2f21d0ee48f 100644 --- a/src/lib/registry.ts +++ b/src/lib/registry.ts @@ -22,6 +22,8 @@ export interface SandboxEntry { providerCredentialHashes?: Record; messagingChannels?: string[]; disabledChannels?: string[]; + /** Host-side port for the openshell forward to the OpenClaw dashboard (#2174). */ + dashboardPort?: number; } export interface SandboxRegistry { diff --git a/src/nemoclaw.ts b/src/nemoclaw.ts index 231ab24f267..f53804c2c36 100644 --- a/src/nemoclaw.ts +++ b/src/nemoclaw.ts @@ -1429,6 +1429,9 @@ async function sandboxStatus(sandboxName) { } console.log(` GPU: ${sb.gpuEnabled ? "yes" : "no"}`); console.log(` Policies: ${(sb.policies || []).join(", ") || "none"}`); + if (typeof sb.dashboardPort === "number") { + console.log(` Dashboard: http://127.0.0.1:${sb.dashboardPort}`); + } // Active session indicator try { From 6297eaa605d971dbfa4ef87710b1349651682b97 Mon Sep 17 00:00:00 2001 From: Charan Jagwani Date: Fri, 24 Apr 2026 11:55:13 -0700 Subject: [PATCH 3/6] Revert "test(install-preflight): bump nvm upgrade test timeout to 15s" This reverts commit 682320a1147a9609d7d9ffa6f46ab45cf963bb1d. Investigation showed the flake only reproduces when the local machine is already CPU-saturated (a stuck OneDrive sync service was burning a full core on my Mac). Raising the test timeout papers over that rather than fixing anything. Keeping the flake fix out of #2174 so the PR is purely about dashboard port auto-allocation. Signed-off-by: Charan Jagwani --- test/install-preflight.test.ts | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/test/install-preflight.test.ts b/test/install-preflight.test.ts index dcce6d25c87..4f6a4889857 100644 --- a/test/install-preflight.test.ts +++ b/test/install-preflight.test.ts @@ -104,16 +104,6 @@ echo "unexpected npm invocation: $*" >&2; exit 98`, // --------------------------------------------------------------------------- describe("installer runtime preflight", () => { - // install.sh sources scripts/install.sh (1374-line payload), which runs - // resolve_installer_version() → resolve_repo_root() → git describe at source - // time. In isolation this takes ~0.9s. Under vitest's default parallelism on - // macOS (up to 14 workers), fork()/execve() serializes and each subprocess - // spawn balloons 50-200× (200-500ms instead of 1-10ms). With 5-6 subprocess - // spawns per installer run, total can reach 5-7s, blowing past the default - // 5000ms testTimeout. The default is correct for every other test here; this - // one has a legitimately different workload (real bash + real subprocesses). - const INSTALLER_TEST_TIMEOUT = 15_000; - it("attempts nvm upgrade when system Node.js is below minimum version", () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-install-preflight-")); const fakeBin = path.join(tmp, "bin"); @@ -166,7 +156,7 @@ exit 1 expect(output).toMatch(/v18\.19\.1.*found but NemoClaw requires/); expect(output).toMatch(/upgrading via nvm/); expect(output).toMatch(/Failed to download nvm installer/); - }, INSTALLER_TEST_TIMEOUT); + }); it("treats the installer script's checkout as the source root even when cwd is elsewhere", () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-install-fallback-")); From c1a68a2c6fdc35f9f44cc0e0c8de4790d378df2f Mon Sep 17 00:00:00 2001 From: Charan Jagwani Date: Fri, 24 Apr 2026 12:28:56 -0700 Subject: [PATCH 4/6] fix(onboard): address PR #2444 review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five fixes from CodeRabbit's review, all within #2174 scope: - src/lib/ports.ts: clamp findFreeDashboardPort to valid range — if preferred is out of 1024-65535 return null, and break the loop when candidates would exceed 65535. + 2 boundary tests. - src/lib/registry.ts: registerSandbox now persists dashboardPort in the stored entry (body was silently dropping the field). - src/lib/onboard.ts: reuse paths seed chatUiUrl from the sandbox's stored dashboardPort via new reuseChatUiUrlFor helper, so re-onboarding an auto-allocated sandbox no longer ratchets its port upward on each run. - src/lib/onboard.ts: picker now uses a real lsof-based bind probe instead of () => true, so auto-alloc won't hand out a port held by a non-openshell process (Docker container, etc.). - src/lib/onboard.ts: getDashboardAccessInfo and getDashboardGuidanceLines read the stored dashboardPort as a precedence tier so the completion banner shows the port ensureDashboardForward actually allocated (precedence: caller option > CHAT_UI_URL env > stored > default). Co-Authored-By: Claude Opus 4.7 (1M context) Signed-off-by: Charan Jagwani --- src/lib/onboard.ts | 56 +++++++++++++++++++++++++++++++++++++------ src/lib/ports.test.ts | 16 +++++++++++++ src/lib/ports.ts | 4 ++++ src/lib/registry.ts | 6 +++++ 4 files changed, 75 insertions(+), 7 deletions(-) diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index f25c739cf93..e74c2507903 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -3454,7 +3454,8 @@ async function createSandbox( " Pass --recreate-sandbox or set NEMOCLAW_RECREATE_SANDBOX=1 to force recreation.", ); } - const reusePort = ensureDashboardForward(sandboxName, chatUiUrl); + const reuseUrl = reuseChatUiUrlFor(sandboxName, chatUiUrl); + const reusePort = ensureDashboardForward(sandboxName, reuseUrl); registry.updateSandbox(sandboxName, { dashboardPort: reusePort }); return sandboxName; } @@ -3482,7 +3483,8 @@ async function createSandbox( const normalizedAnswer = answer.trim().toLowerCase(); if (normalizedAnswer !== "n" && normalizedAnswer !== "no") { upsertMessagingProviders(messagingTokenDefs); - const reusePort = ensureDashboardForward(sandboxName, chatUiUrl); + const reuseUrl = reuseChatUiUrlFor(sandboxName, chatUiUrl); + const reusePort = ensureDashboardForward(sandboxName, reuseUrl); registry.updateSandbox(sandboxName, { dashboardPort: reusePort }); return sandboxName; } @@ -3522,7 +3524,8 @@ async function createSandbox( if (Object.keys(abortHashes).length > 0) { registry.updateSandbox(sandboxName, { providerCredentialHashes: abortHashes }); } - const reusePort = ensureDashboardForward(sandboxName, chatUiUrl); + const reuseUrl = reuseChatUiUrlFor(sandboxName, chatUiUrl); + const reusePort = ensureDashboardForward(sandboxName, reuseUrl); registry.updateSandbox(sandboxName, { dashboardPort: reusePort }); return sandboxName; } @@ -6064,6 +6067,18 @@ const CONTROL_UI_PORT = DASHBOARD_PORT; // isLoopbackHostname — see urlUtils import above const { resolveDashboardForwardTarget, buildControlUiUrls } = dashboard; +/** + * Pick the chatUiUrl to use when re-forwarding an already-registered sandbox. + * Precedence: user-set CHAT_UI_URL env > stored dashboardPort > caller-supplied + * default. This prevents reuse paths from always re-requesting the default port + * and ratcheting an auto-allocated sandbox upward on each onboard (#2174). + */ +function reuseChatUiUrlFor(sandboxName, fallbackUrl) { + if (process.env.CHAT_UI_URL) return fallbackUrl; + const stored = registry.getSandbox(sandboxName)?.dashboardPort; + return typeof stored === "number" ? `http://127.0.0.1:${stored}` : fallbackUrl; +} + function ensureDashboardForward(sandboxName, chatUiUrl = `http://127.0.0.1:${CONTROL_UI_PORT}`) { const requestedPort = Number(getDashboardForwardPort(chatUiUrl)); // Parse the forward list once; reuse for both conflict detection and picker. @@ -6102,8 +6117,21 @@ function ensureDashboardForward(sandboxName, chatUiUrl = `http://127.0.0.1:${CON ); } const heldPorts = forwardEntries.map((e) => e.port); + // Also reject ports bound by non-openshell processes (Docker containers, + // other local servers). Without this, auto-alloc would hand out a port + // that `openshell forward start` then fails to bind, leaving a persisted + // dashboardPort pointing at a dead forward. + const isPortBoundLocally = (p) => { + const out = runCapture(["lsof", "-i", `:${p}`, "-sTCP:LISTEN", "-P", "-n"], { + ignoreError: true, + }); + return typeof out === "string" && out.trim().length > 0; + }; const chosen = findFreeDashboardPort(requestedPort, { - probe: { listForwardPorts: () => heldPorts, probePortFree: () => true }, + probe: { + listForwardPorts: () => heldPorts, + probePortFree: (p) => !isPortBoundLocally(p), + }, }); if (chosen === null) { const windowSummary = forwardEntries @@ -6247,8 +6275,16 @@ function getDashboardAccessInfo(sandboxName, options = {}) { const token = Object.prototype.hasOwnProperty.call(options, "token") ? options.token : fetchGatewayAuthTokenFromSandbox(sandboxName); + // Precedence: caller-supplied > CHAT_UI_URL env > stored dashboardPort > default. + // The stored-port tier ensures the completion banner shows the port actually + // allocated by ensureDashboardForward, not the original default (#2174). + const storedPort = registry.getSandbox(sandboxName)?.dashboardPort; + const storedUrl = typeof storedPort === "number" ? `http://127.0.0.1:${storedPort}` : null; const chatUiUrl = - options.chatUiUrl || process.env.CHAT_UI_URL || `http://127.0.0.1:${CONTROL_UI_PORT}`; + options.chatUiUrl || + process.env.CHAT_UI_URL || + storedUrl || + `http://127.0.0.1:${CONTROL_UI_PORT}`; const dashboardPort = Number(getDashboardForwardPort(chatUiUrl)); const dashboardAccess = buildControlUiUrls(token, dashboardPort).map((url, index) => ({ label: index === 0 ? "Dashboard" : `Alt ${index}`, @@ -6270,8 +6306,14 @@ function getDashboardAccessInfo(sandboxName, options = {}) { } function getDashboardGuidanceLines(dashboardAccess = [], options = {}) { + const storedPort = + options.sandboxName && registry.getSandbox(options.sandboxName)?.dashboardPort; + const storedUrl = typeof storedPort === "number" ? `http://127.0.0.1:${storedPort}` : null; const dashboardPort = getDashboardForwardPort( - options.chatUiUrl || process.env.CHAT_UI_URL || `http://127.0.0.1:${CONTROL_UI_PORT}`, + options.chatUiUrl || + process.env.CHAT_UI_URL || + storedUrl || + `http://127.0.0.1:${CONTROL_UI_PORT}`, ); const guidance = [`Port ${dashboardPort} must be forwarded before opening these URLs.`]; if (isWsl(options)) { @@ -6303,7 +6345,7 @@ function printDashboard(sandboxName, model, provider, nimContainer = null, agent const token = fetchGatewayAuthTokenFromSandbox(sandboxName); const dashboardAccess = getDashboardAccessInfo(sandboxName, { token }); - const guidanceLines = getDashboardGuidanceLines(dashboardAccess); + const guidanceLines = getDashboardGuidanceLines(dashboardAccess, { sandboxName }); console.log(""); console.log(` ${"─".repeat(50)}`); diff --git a/src/lib/ports.test.ts b/src/lib/ports.test.ts index e07bc1391be..dd1fc2b7d27 100644 --- a/src/lib/ports.test.ts +++ b/src/lib/ports.test.ts @@ -103,4 +103,20 @@ describe("findFreeDashboardPort", () => { }); expect(port).toBeNull(); }); + + it("does not allocate ports above 65535 when preferred is near the upper bound", () => { + const port = findFreeDashboardPort(65535, { + probe: { + listForwardPorts: () => [65535], + probePortFree: () => true, + }, + }); + expect(port).toBeNull(); + }); + + it("returns null when preferred port is out of range", () => { + expect(findFreeDashboardPort(0)).toBeNull(); + expect(findFreeDashboardPort(65536)).toBeNull(); + expect(findFreeDashboardPort(1023)).toBeNull(); + }); }); diff --git a/src/lib/ports.ts b/src/lib/ports.ts index 36fb6448e6c..1d29c0135cc 100644 --- a/src/lib/ports.ts +++ b/src/lib/ports.ts @@ -71,11 +71,15 @@ export function findFreeDashboardPort( preferred: number, options: { probe?: PortProbe } = {}, ): number | null { + if (!Number.isInteger(preferred) || preferred < 1024 || preferred > 65535) { + return null; + } const probe = options.probe; const held = new Set(probe?.listForwardPorts() ?? []); const isFree = probe?.probePortFree ?? (() => true); for (let offset = 0; offset < PORT_WINDOW; offset++) { const port = preferred + offset; + if (port > 65535) break; if (!held.has(port) && isFree(port)) return port; } return null; diff --git a/src/lib/registry.ts b/src/lib/registry.ts index 2f21d0ee48f..58bbbf09126 100644 --- a/src/lib/registry.ts +++ b/src/lib/registry.ts @@ -178,6 +178,12 @@ export function registerSandbox(entry: SandboxEntry): void { Array.isArray(entry.disabledChannels) && entry.disabledChannels.length > 0 ? [...entry.disabledChannels] : undefined, + dashboardPort: + Number.isInteger(entry.dashboardPort) && + (entry.dashboardPort as number) >= 1024 && + (entry.dashboardPort as number) <= 65535 + ? entry.dashboardPort + : undefined, }; if (!data.defaultSandbox) { data.defaultSandbox = entry.name; From d877ec2e33600db33e298dd3152f4a8edf39d821 Mon Sep 17 00:00:00 2001 From: Charan Jagwani Date: Fri, 24 Apr 2026 12:45:11 -0700 Subject: [PATCH 5/6] test(e2e): add dashboard port auto-alloc assertions and wire to nightly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends test/e2e/test-double-onboard.sh with three #2174 regression checks: - Second-sandbox onboard output must log "allocated port" (auto-alloc fired) - nemoclaw list must show two distinct dashboard ports for A and B The existing script already exercises the two-sandbox flow (Phase 4) but didn't assert anything port-specific — a silent regression of the auto-alloc path would have passed. Also wires the script into nightly-e2e.yaml as a new double-onboard-e2e job. Uses the script's fake OpenAI endpoint so no NVIDIA_API_KEY is required. Co-Authored-By: Claude Opus 4.7 (1M context) Signed-off-by: Charan Jagwani --- .github/workflows/nightly-e2e.yaml | 33 +++++++++++++++++++++++++++++- test/e2e/test-double-onboard.sh | 21 +++++++++++++++++++ 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/.github/workflows/nightly-e2e.yaml b/.github/workflows/nightly-e2e.yaml index 489cfbb0def..f9dfa196ff7 100644 --- a/.github/workflows/nightly-e2e.yaml +++ b/.github/workflows/nightly-e2e.yaml @@ -14,6 +14,9 @@ # Discord coverage with cross-talk assertions. See issue #1903. # sandbox-survival-e2e Sandbox survival across gateway restarts (onboard, inference, # gateway stop/start, verify sandbox + workspace + inference). +# double-onboard-e2e Two sandboxes alive at once — second must auto-allocate a free +# dashboard port. Guards #2174 (crash on second onboard) and #849 +# (first sandbox destroyed by second). Fake OpenAI endpoint. # hermes-e2e Hermes Agent E2E — install → onboard --agent hermes → health # probe → live inference. Validates the multi-agent architecture. # skip-permissions-e2e Validates --dangerously-skip-permissions activates the permissive @@ -266,6 +269,34 @@ jobs: path: /tmp/nemoclaw-e2e-install.log if-no-files-found: ignore + # ── Double-onboard E2E ─────────────────────────────────────── + # Two sandboxes alive at once: first takes the default dashboard port, + # second must auto-allocate to the next free port. Guards #2174 (crash + # on second onboard) and #849 (first sandbox destroyed by second). Uses + # a local fake OpenAI-compatible endpoint — no NVIDIA_API_KEY required. + double-onboard-e2e: + if: github.repository == 'NVIDIA/NemoClaw' + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Run double-onboard E2E test + env: + NEMOCLAW_NON_INTERACTIVE: "1" + NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE: "1" + GITHUB_TOKEN: ${{ github.token }} + run: bash test/e2e/test-double-onboard.sh + + - name: Upload install log on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: double-onboard-install-log + path: /tmp/nemoclaw-e2e-install.log + if-no-files-found: ignore + # ── Hermes Agent E2E ───────────────────────────────────────── # Validates the multi-agent architecture by onboarding with --agent hermes, # verifying the Hermes health probe, and running live inference through the @@ -663,7 +694,7 @@ jobs: notify-on-failure: runs-on: ubuntu-latest - needs: [cloud-e2e, cloud-experimental-e2e, messaging-providers-e2e, token-rotation-e2e, sandbox-survival-e2e, hermes-e2e, skip-permissions-e2e, sandbox-operations-e2e, inference-routing-e2e, network-policy-e2e, deployment-services-e2e, diagnostics-e2e, snapshot-commands-e2e, shields-config-e2e, rebuild-openclaw-e2e, upgrade-stale-sandbox-e2e, rebuild-hermes-e2e, gpu-e2e] + needs: [cloud-e2e, cloud-experimental-e2e, messaging-providers-e2e, token-rotation-e2e, sandbox-survival-e2e, double-onboard-e2e, hermes-e2e, skip-permissions-e2e, sandbox-operations-e2e, inference-routing-e2e, network-policy-e2e, deployment-services-e2e, diagnostics-e2e, snapshot-commands-e2e, shields-config-e2e, rebuild-openclaw-e2e, upgrade-stale-sandbox-e2e, rebuild-hermes-e2e, gpu-e2e] if: ${{ always() && (contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled')) }} permissions: issues: write diff --git a/test/e2e/test-double-onboard.sh b/test/e2e/test-double-onboard.sh index acf17d5f017..294edd7309a 100755 --- a/test/e2e/test-double-onboard.sh +++ b/test/e2e/test-double-onboard.sh @@ -354,6 +354,27 @@ else fail "First sandbox '$SANDBOX_A' disappeared after creating '$SANDBOX_B' (regression: #849)" fi +# #2174 regression: B must auto-allocate to a different dashboard port, +# surface it in nemoclaw list, and not collide with A's 18789. +if grep -q "allocated port" <<<"$output3"; then + pass "Second-sandbox onboard logged auto-allocation (#2174)" +else + fail "Second-sandbox onboard did not log 'allocated port' — auto-alloc may not have fired (#2174)" +fi + +LIST_LOG="$(mktemp)" +run_nemoclaw list >"$LIST_LOG" 2>&1 || true +list_output="$(cat "$LIST_LOG")" +rm -f "$LIST_LOG" + +dashboard_ports_in_list="$(grep -oE 'dashboard: http://127\.0\.0\.1:[0-9]+' <<<"$list_output" | awk -F: '{print $NF}' | sort -u)" +distinct_count="$(wc -l <<<"$dashboard_ports_in_list" | tr -d ' ')" +if [ "$distinct_count" = "2" ]; then + pass "nemoclaw list shows two distinct dashboard ports (#2174)" +else + fail "nemoclaw list did not show two distinct dashboard ports (got $distinct_count: $(tr '\n' ' ' <<<"$dashboard_ports_in_list"))" +fi + # ══════════════════════════════════════════════════════════════════ # Phase 5: Stale registry reconciliation # ══════════════════════════════════════════════════════════════════ From c2f0cdc0a6a67c0b0b74f9c3f9a358dc02008ca5 Mon Sep 17 00:00:00 2001 From: Charan Jagwani Date: Fri, 24 Apr 2026 12:54:41 -0700 Subject: [PATCH 6/6] Revert "test(e2e): add dashboard port auto-alloc assertions and wire to nightly" This reverts commit d877ec2e33600db33e298dd3152f4a8edf39d821. Signed-off-by: Charan Jagwani --- .github/workflows/nightly-e2e.yaml | 33 +----------------------------- test/e2e/test-double-onboard.sh | 21 ------------------- 2 files changed, 1 insertion(+), 53 deletions(-) diff --git a/.github/workflows/nightly-e2e.yaml b/.github/workflows/nightly-e2e.yaml index f9dfa196ff7..489cfbb0def 100644 --- a/.github/workflows/nightly-e2e.yaml +++ b/.github/workflows/nightly-e2e.yaml @@ -14,9 +14,6 @@ # Discord coverage with cross-talk assertions. See issue #1903. # sandbox-survival-e2e Sandbox survival across gateway restarts (onboard, inference, # gateway stop/start, verify sandbox + workspace + inference). -# double-onboard-e2e Two sandboxes alive at once — second must auto-allocate a free -# dashboard port. Guards #2174 (crash on second onboard) and #849 -# (first sandbox destroyed by second). Fake OpenAI endpoint. # hermes-e2e Hermes Agent E2E — install → onboard --agent hermes → health # probe → live inference. Validates the multi-agent architecture. # skip-permissions-e2e Validates --dangerously-skip-permissions activates the permissive @@ -269,34 +266,6 @@ jobs: path: /tmp/nemoclaw-e2e-install.log if-no-files-found: ignore - # ── Double-onboard E2E ─────────────────────────────────────── - # Two sandboxes alive at once: first takes the default dashboard port, - # second must auto-allocate to the next free port. Guards #2174 (crash - # on second onboard) and #849 (first sandbox destroyed by second). Uses - # a local fake OpenAI-compatible endpoint — no NVIDIA_API_KEY required. - double-onboard-e2e: - if: github.repository == 'NVIDIA/NemoClaw' - runs-on: ubuntu-latest - timeout-minutes: 30 - steps: - - name: Checkout - uses: actions/checkout@v6 - - - name: Run double-onboard E2E test - env: - NEMOCLAW_NON_INTERACTIVE: "1" - NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE: "1" - GITHUB_TOKEN: ${{ github.token }} - run: bash test/e2e/test-double-onboard.sh - - - name: Upload install log on failure - if: failure() - uses: actions/upload-artifact@v4 - with: - name: double-onboard-install-log - path: /tmp/nemoclaw-e2e-install.log - if-no-files-found: ignore - # ── Hermes Agent E2E ───────────────────────────────────────── # Validates the multi-agent architecture by onboarding with --agent hermes, # verifying the Hermes health probe, and running live inference through the @@ -694,7 +663,7 @@ jobs: notify-on-failure: runs-on: ubuntu-latest - needs: [cloud-e2e, cloud-experimental-e2e, messaging-providers-e2e, token-rotation-e2e, sandbox-survival-e2e, double-onboard-e2e, hermes-e2e, skip-permissions-e2e, sandbox-operations-e2e, inference-routing-e2e, network-policy-e2e, deployment-services-e2e, diagnostics-e2e, snapshot-commands-e2e, shields-config-e2e, rebuild-openclaw-e2e, upgrade-stale-sandbox-e2e, rebuild-hermes-e2e, gpu-e2e] + needs: [cloud-e2e, cloud-experimental-e2e, messaging-providers-e2e, token-rotation-e2e, sandbox-survival-e2e, hermes-e2e, skip-permissions-e2e, sandbox-operations-e2e, inference-routing-e2e, network-policy-e2e, deployment-services-e2e, diagnostics-e2e, snapshot-commands-e2e, shields-config-e2e, rebuild-openclaw-e2e, upgrade-stale-sandbox-e2e, rebuild-hermes-e2e, gpu-e2e] if: ${{ always() && (contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled')) }} permissions: issues: write diff --git a/test/e2e/test-double-onboard.sh b/test/e2e/test-double-onboard.sh index 294edd7309a..acf17d5f017 100755 --- a/test/e2e/test-double-onboard.sh +++ b/test/e2e/test-double-onboard.sh @@ -354,27 +354,6 @@ else fail "First sandbox '$SANDBOX_A' disappeared after creating '$SANDBOX_B' (regression: #849)" fi -# #2174 regression: B must auto-allocate to a different dashboard port, -# surface it in nemoclaw list, and not collide with A's 18789. -if grep -q "allocated port" <<<"$output3"; then - pass "Second-sandbox onboard logged auto-allocation (#2174)" -else - fail "Second-sandbox onboard did not log 'allocated port' — auto-alloc may not have fired (#2174)" -fi - -LIST_LOG="$(mktemp)" -run_nemoclaw list >"$LIST_LOG" 2>&1 || true -list_output="$(cat "$LIST_LOG")" -rm -f "$LIST_LOG" - -dashboard_ports_in_list="$(grep -oE 'dashboard: http://127\.0\.0\.1:[0-9]+' <<<"$list_output" | awk -F: '{print $NF}' | sort -u)" -distinct_count="$(wc -l <<<"$dashboard_ports_in_list" | tr -d ' ')" -if [ "$distinct_count" = "2" ]; then - pass "nemoclaw list shows two distinct dashboard ports (#2174)" -else - fail "nemoclaw list did not show two distinct dashboard ports (got $distinct_count: $(tr '\n' ' ' <<<"$dashboard_ports_in_list"))" -fi - # ══════════════════════════════════════════════════════════════════ # Phase 5: Stale registry reconciliation # ══════════════════════════════════════════════════════════════════