From 1b010a7f3b633ef8311baa4950931cc53e9838a9 Mon Sep 17 00:00:00 2001 From: Tinson Lai Date: Thu, 16 Jul 2026 12:30:35 +0000 Subject: [PATCH 1/3] fix(state): accept dashboardPort 0 as no dashboard A dcode sandbox persists dashboardPort 0 to mean "no dashboard", but the gateway registry parser rejected any value below 1 and threw for the entire read, so a single entry blocked every registry operation (list, onboard, rebuild) and could leave a rebuilt sandbox destroyed. Treat 0 as no dashboard (normalise to null) so one row can no longer fail the whole registry. Fixes #7020 Signed-off-by: Tinson Lai --- src/lib/state/gateway-registry.test.ts | 30 ++++++++++++++++++++++++++ src/lib/state/gateway-registry.ts | 7 ++++-- 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/src/lib/state/gateway-registry.test.ts b/src/lib/state/gateway-registry.test.ts index 5a0cc757280..c85e4deaf30 100644 --- a/src/lib/state/gateway-registry.test.ts +++ b/src/lib/state/gateway-registry.test.ts @@ -69,6 +69,36 @@ describe("host gateway registry index", () => { } }); + it("treats a zero persisted dashboard port as no dashboard instead of blocking the registry", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-gateway-index-zero-port-")); + 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: 0, + }, + }, + }), + ); + + const entries = listHostGatewayRegistryEntries(home); + + expect(entries).toHaveLength(1); + expect(entries[0].entry.name).toBe("instance-a"); + expect(entries[0].entry.dashboardPort).toBeNull(); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + } + }); + it("rejects sandbox names that could escape a gateway-owned snapshot directory", () => { const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-gateway-index-name-")); try { diff --git a/src/lib/state/gateway-registry.ts b/src/lib/state/gateway-registry.ts index 30381520ff6..8e4e4a654fc 100644 --- a/src/lib/state/gateway-registry.ts +++ b/src/lib/state/gateway-registry.ts @@ -98,14 +98,17 @@ function parseRegistry(filePath: string, raw: string): GatewayRegistryDocument { value.dashboardPort !== null && (typeof value.dashboardPort !== "number" || !Number.isInteger(value.dashboardPort) || - value.dashboardPort < 1 || + value.dashboardPort < 0 || value.dashboardPort > 65535) ) { throw stateError( `${filePath} has an invalid dashboardPort for sandbox ${JSON.stringify(name)}`, ); } - sandboxes[name] = value as GatewayRegistryEntry; + sandboxes[name] = + value.dashboardPort === 0 + ? { ...(value as GatewayRegistryEntry), dashboardPort: null } + : (value as GatewayRegistryEntry); } return { ...parsed, From 6dba5be4e7155a77cfe732c336933c53432c7265 Mon Sep 17 00:00:00 2001 From: Tinson Lai Date: Thu, 16 Jul 2026 13:25:11 +0000 Subject: [PATCH 2/3] test(state): add issue reference to zero dashboard port test title Signed-off-by: Tinson Lai --- src/lib/state/gateway-registry.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/state/gateway-registry.test.ts b/src/lib/state/gateway-registry.test.ts index c85e4deaf30..2dde76a4959 100644 --- a/src/lib/state/gateway-registry.test.ts +++ b/src/lib/state/gateway-registry.test.ts @@ -69,7 +69,7 @@ describe("host gateway registry index", () => { } }); - it("treats a zero persisted dashboard port as no dashboard instead of blocking the registry", () => { + 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 { const root = path.join(home, ".nemoclaw", "gateways", "9123"); From 400dda7fb0e99f0ffc8b5213fdc798efc7f95c15 Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Thu, 16 Jul 2026 10:02:03 -0700 Subject: [PATCH 3/3] fix(state): canonicalize no-dashboard registry writes Persist the no-dashboard zero sentinel as null at the common registry serialization boundary while retaining reader compatibility for existing state. Add focused coverage from reused terminal-sandbox metadata through persisted registry reads and host dashboard-port allocation. Signed-off-by: Apurv Kumaria Co-authored-by: Tinson Lai --- .../onboard/sandbox-registry-metadata.test.ts | 60 +++++++++++++++++++ src/lib/state/registry.ts | 8 ++- 2 files changed, 65 insertions(+), 3 deletions(-) diff --git a/src/lib/onboard/sandbox-registry-metadata.test.ts b/src/lib/onboard/sandbox-registry-metadata.test.ts index 025ef338dde..055deb69d84 100644 --- a/src/lib/onboard/sandbox-registry-metadata.test.ts +++ b/src/lib/onboard/sandbox-registry-metadata.test.ts @@ -127,6 +127,66 @@ describe("sandbox registry metadata", () => { }), ); }); + + it("persists a reused terminal sandbox without a dashboard port for host allocation (#7020)", async () => { + tmpDir = mkdtempSync(join(tmpdir(), "nemoclaw-reuse-terminal-metadata-")); + process.env.HOME = tmpDir; + vi.resetModules(); + + const configDir = join(tmpDir, ".nemoclaw"); + const registryFile = join(configDir, "sandboxes.json"); + mkdirSync(configDir, { recursive: true }); + writeFileSync( + registryFile, + JSON.stringify({ + sandboxes: { + "terminal-box": { + name: "terminal-box", + model: "old-model", + provider: "old-provider", + dashboardPort: 18789, + }, + }, + defaultSandbox: "terminal-box", + }), + ); + + const metadata = await import("./sandbox-registry-metadata"); + const dashboardPorts = await import("./dashboard-port"); + const gatewayRegistry = await import("../state/gateway-registry"); + const helpers = metadata.createSandboxRegistryMetadataHelpers({ + isLinuxDockerDriverGatewayEnabled: () => true, + getInstalledOpenshellVersion: () => "0.0.44", + runCaptureOpenshell: () => "openshell 0.0.44", + }); + + helpers.updateReusedSandboxMetadata( + "terminal-box", + { name: "langchain-deepagents-code" } as AgentDefinition, + "new-model", + "nvidia-prod", + 0, + ); + + const persisted = JSON.parse(readFileSync(registryFile, "utf8")); + expect(persisted.sandboxes["terminal-box"].dashboardPort).toBeNull(); + + const hostEntries = gatewayRegistry.listHostGatewayRegistryEntries(tmpDir); + expect(hostEntries).toHaveLength(1); + expect(hostEntries[0].entry.dashboardPort).toBeNull(); + + const occupied = dashboardPorts.getRegistryOccupiedDashboardPorts("other-sandbox"); + expect(occupied.size).toBe(0); + expect( + dashboardPorts.findAvailableDashboardPort( + "other-sandbox", + 18789, + null, + () => false, + occupied, + ), + ).toBe(18789); + }); }); describe("getSandboxRuntimeRegistryFields openshellDriver", () => { diff --git a/src/lib/state/registry.ts b/src/lib/state/registry.ts index b758be734b4..f577be81ab6 100644 --- a/src/lib/state/registry.ts +++ b/src/lib/state/registry.ts @@ -437,9 +437,10 @@ function normalizeSandboxEntryForRuntime(entry: SandboxEntry): SandboxEntry { } /** - * Prepare a sandbox entry for persistence: normalize messaging state and drop - * transient #5714 display-only markers plus legacy provider credential hashes - * that must never reach sandboxes.json. + * Prepare a sandbox entry for persistence: canonicalize a no-dashboard port to + * null, normalize messaging state, and drop transient #5714 display-only + * markers plus legacy provider credential hashes that must never reach + * sandboxes.json. */ function serializeSandboxEntryForDisk(entry: SandboxEntry): SandboxEntry { // Defensively drop non-durable recovery markers and legacy @@ -460,6 +461,7 @@ function serializeSandboxEntryForDisk(entry: SandboxEntry): SandboxEntry { const { messaging: _messaging, mcp: _mcp, ...rest } = durable; return { ...rest, + ...(rest.dashboardPort === 0 ? { dashboardPort: null } : {}), ...(messaging ? { messaging } : {}), ...(mcp ? { mcp } : {}), };