Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions src/lib/onboard/sandbox-registry-metadata.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
30 changes: 30 additions & 0 deletions src/lib/state/gateway-registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,36 @@ describe("host gateway registry index", () => {
}
});

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");
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 {
Expand Down
7 changes: 5 additions & 2 deletions src/lib/state/gateway-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
8 changes: 5 additions & 3 deletions src/lib/state/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 } : {}),
};
Expand Down
Loading