Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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
13 changes: 9 additions & 4 deletions docs/manage-sandboxes/run-sandboxes.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ When the default port is already held by another sandbox, `$$nemoclaw onboard` s

<AgentOnly variant="hermes">
Each Hermes sandbox also needs its own OpenAI-compatible API port.
When another sandbox or a host listener already holds the default API port `8642`, `$$nemoclaw onboard` scans ports `8642` through `8652`, uses the next free port, and records it for the sandbox.
When creating a Hermes sandbox, if another sandbox or a host listener already holds the default API port `8642`, `$$nemoclaw onboard` scans ports `8642` through `8652`, uses the next free port, and records it for the sandbox.
</AgentOnly>

<AgentOnly variant="openclaw,hermes">
Expand All @@ -65,9 +65,14 @@ Gateway and dashboard cleanup is scoped by sandbox name and port.
A later onboarding run that uses a different `NEMOCLAW_GATEWAY_PORT` or `--control-ui-port` does not tear down the first sandbox's gateway or dashboard forward.
</AgentOnly>

<AgentOnly variant="openclaw">
If re-onboarding finds the registered dashboard port already bound, NemoClaw verifies that the listener is the exact OpenShell forward for that sandbox and reuses it without restarting the sandbox.
If ownership cannot be proved, onboarding fails closed and reports the listener conflict.
<AgentOnly variant="openclaw,hermes">
Repeated onboarding and resume keep the sandbox's registered dashboard port and restore a missing forward on that port.
If the port is already bound, NemoClaw verifies that the listener is the exact OpenShell forward for that sandbox and reuses it without restarting the sandbox.
Ports registered to another sandbox, foreign listeners, or unverified ownership stop onboarding with a port conflict error.
</AgentOnly>

<AgentOnly variant="hermes">
Hermes also retains or restores its registered OpenAI-compatible API forward using the same ownership checks.
</AgentOnly>

<AgentOnly variant="deepagents">
Expand Down
42 changes: 41 additions & 1 deletion src/lib/onboard/agent-dashboard-forward.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,27 @@
import { afterEach, describe, expect, it, vi } from "vitest";

import { ensureAgentDashboardForward } from "./agent-dashboard-forward";
import { canReuseDashboardForwardForAgent } from "./dashboard-runtime";

describe("agent dashboard forward reuse eligibility", () => {
it.each([
{ agent: undefined, expected: true },
{ agent: null, expected: true },
{ agent: { name: "openclaw" }, expected: true },
{ agent: { name: "hermes" }, expected: true },
{ agent: { name: "pi" }, expected: false },
{ agent: { name: "deepagents" }, expected: false },
])("permits reuse=$expected for $agent", ({ agent, expected }) => {
expect(canReuseDashboardForwardForAgent(agent)).toBe(expected);
});
});

describe("ensureAgentDashboardForward", () => {
afterEach(() => {
delete process.env.CHAT_UI_URL;
});

it("launches each declared host-forward port", async () => {
it.each([false, true])("forwards Hermes ports with reuse=%s", async (reuseExistingForward) => {
const ensureDashboardForward = vi.fn((_sandboxName, chatUiUrl = "http://127.0.0.1:18789") => {
const parsed = new URL(chatUiUrl);
return Number(parsed.port);
Expand All @@ -25,14 +39,17 @@ describe("ensureAgentDashboardForward", () => {
},
ensureDashboardForward,
hermesApiPort: 8642,
reuseExistingForward,
}),
).toBe(18789);

expect(ensureDashboardForward).toHaveBeenNthCalledWith(1, "hm", "http://127.0.0.1:18789", {
allowPortReallocation: false,
...(reuseExistingForward ? { reuseExistingForward: true } : {}),
});
expect(ensureDashboardForward).toHaveBeenNthCalledWith(2, "hm", "http://127.0.0.1:8642", {
allowPortReallocation: false,
...(reuseExistingForward ? { reuseExistingForward: true } : {}),
});
});

Expand Down Expand Up @@ -221,4 +238,27 @@ describe("ensureAgentDashboardForward", () => {
);
expect(process.env.CHAT_UI_URL).toBe("https://hermes.example.test:9120/ui");
});

it("fails reuse and restores the dashboard URL when the Hermes API forward fails", async () => {
process.env.CHAT_UI_URL = "http://127.0.0.1:18790";
const warn = vi.fn();
const ensureDashboardForward = vi
.fn()
.mockReturnValueOnce(18790)
.mockImplementationOnce(() => {
throw new Error("API forward ownership is unknown");
});
await expect(
ensureAgentDashboardForward({
sandboxName: "hm",
agent: { forwardPort: 18789, forward_ports: [18789, 8642] },
ensureDashboardForward,
hermesApiPort: 8643,
reuseExistingForward: true,
warn,
}),
).rejects.toThrow("API forward ownership is unknown");
expect(process.env.CHAT_UI_URL).toBe("http://127.0.0.1:18790");
expect(warn).not.toHaveBeenCalled();
});
});
13 changes: 7 additions & 6 deletions src/lib/onboard/agent-dashboard-forward.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ export type EnsureDashboardForward = (
chatUiUrl?: string,
options?: {
allowPortReallocation?: boolean;
reuseExistingOpenClawForward?: boolean;
reuseExistingForward?: boolean;
revalidateSandboxIdentity?: (operation: string) => void;
},
) => number;
Expand All @@ -40,7 +40,7 @@ export async function ensureAgentDashboardForward(options: {
/** Host port allocated to this sandbox's OpenAI-compatible API, when it has one. */
hermesApiPort?: number | null;
beforeForwardPort?: (port: number) => Promise<void> | void;
reuseExistingOpenClawForward?: boolean;
reuseExistingForward?: boolean;
revalidateSandboxIdentity?: (operation: string) => void;
warn?: (message: string) => void;
}): Promise<number> {
Expand All @@ -52,7 +52,7 @@ export async function ensureAgentDashboardForward(options: {
controlUiPort,
hermesApiPort,
beforeForwardPort,
reuseExistingOpenClawForward = false,
reuseExistingForward = false,
revalidateSandboxIdentity,
warn = (message: string) => console.warn(message),
} = options;
Expand Down Expand Up @@ -107,7 +107,7 @@ export async function ensureAgentDashboardForward(options: {
await beforeForwardPort?.(agentDashboardPort);
const actualAgentDashboardPort = ensureDashboardForward(sandboxName, requestedDashboardUrl, {
allowPortReallocation: false,
...(reuseExistingOpenClawForward ? { reuseExistingOpenClawForward: true } : {}),
...(reuseExistingForward ? { reuseExistingForward: true } : {}),
...(revalidateIdentity ? { revalidateSandboxIdentity: revalidateIdentity } : {}),
});
if (!usesFixedApiPort) {
Expand All @@ -125,10 +125,11 @@ export async function ensureAgentDashboardForward(options: {
: `http://127.0.0.1:${port}`;
ensureDashboardForward(sandboxName, forwardUrl, {
allowPortReallocation: false,
...(reuseExistingForward ? { reuseExistingForward: true } : {}),
...(revalidateIdentity ? { revalidateSandboxIdentity: revalidateIdentity } : {}),
});
} catch (err) {
if (err === identityFailure) throw err;
if (reuseExistingForward || err === identityFailure) throw err;
warn(
` ! Could not start optional agent port forward ${port}: ${
err instanceof Error ? err.message : String(err)
Expand All @@ -140,7 +141,7 @@ export async function ensureAgentDashboardForward(options: {
revalidateIdentity?.(`report successful dashboard forwarding for sandbox '${sandboxName}'`);
return actualAgentDashboardPort;
} catch (error) {
if (error === identityFailure) {
if (reuseExistingForward || error === identityFailure) {
restoreChatUiUrl();
}
throw error;
Expand Down
6 changes: 3 additions & 3 deletions src/lib/onboard/dashboard-forward-control.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,18 @@ export interface DashboardForwardOptions {
rollbackSandboxOnFailure?: boolean;
gatewayName?: string;
allowPortReallocation?: boolean;
reuseExistingOpenClawForward?: boolean;
reuseExistingForward?: boolean;
revalidateSandboxIdentity?: (operation: string) => void;
}

export function normalizeDashboardForwardOptions(options: DashboardForwardOptions = {}): {
rollbackSandboxOnFailure: boolean;
allowPortReallocation: boolean;
reuseExistingOpenClawForward: boolean;
reuseExistingForward: boolean;
} {
return {
rollbackSandboxOnFailure: options.rollbackSandboxOnFailure === true,
allowPortReallocation: options.allowPortReallocation !== false,
reuseExistingOpenClawForward: options.reuseExistingOpenClawForward === true,
reuseExistingForward: options.reuseExistingForward === true,
};
}
6 changes: 6 additions & 0 deletions src/lib/onboard/dashboard-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,9 @@ export function shouldManageDashboardForAgent(agent: DashboardRuntimeAgent): boo
if (!agent || !isTerminalAgent(agent)) return true;
return getAgentDeclaredForwardPorts(agent).length > 0;
}

export function canReuseDashboardForwardForAgent(
agent: { name: string } | null | undefined,
): boolean {
return agent == null || agent.name === "openclaw" || agent.name === "hermes";
}
50 changes: 38 additions & 12 deletions src/lib/onboard/dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,11 @@ import {
findAvailableDashboardPort,
getPersistedDashboardPort,
getRegistryOccupiedDashboardPorts,
getRegistryOccupiedHermesApiPorts,
isPortBoundOnHost,
type ListSandboxesFn,
} from "./dashboard-port";
import { canReuseDashboardForwardForAgent } from "./dashboard-runtime";
import {
ensureMessagingHostForwardForSandbox,
productionForwardServiceRegistryContext,
Expand Down Expand Up @@ -382,7 +384,7 @@ export function createOnboardDashboardHelpers(deps: OnboardDashboardDeps): Onboa
options: DashboardForwardOptions = {},
): number {
chatUiUrl ||= `http://127.0.0.1:${CONTROL_UI_PORT}`;
const { rollbackSandboxOnFailure, allowPortReallocation, reuseExistingOpenClawForward } =
const { rollbackSandboxOnFailure, allowPortReallocation, reuseExistingForward } =
normalizeDashboardForwardOptions(options);
const { revalidateSandboxIdentity } = options;
const preferredPort = Number(getDashboardForwardPort(chatUiUrl));
Expand All @@ -397,11 +399,19 @@ export function createOnboardDashboardHelpers(deps: OnboardDashboardDeps): Onboa
);
const isPortBound = deps.isPortBoundOnHost ?? isPortBoundOnHost;
const persistedPort = getPersistedDashboardPort(sandboxName, listSandboxes);
const registryOccupiedPorts = getRegistryOccupiedDashboardPorts(sandboxName, listSandboxes);
if (persistedPort === preferredPort && isPortBound(preferredPort)) {
const registryOccupiedPorts = new Map([
...getRegistryOccupiedDashboardPorts(sandboxName, listSandboxes),
...getRegistryOccupiedHermesApiPorts(sandboxName, listSandboxes),
]);
const fixedPort = persistedPort === preferredPort || reuseExistingForward;
if (fixedPort && registryOccupiedPorts.has(String(preferredPort))) {
throw new Error(
`Port ${String(preferredPort)} is not available for '${sandboxName}'; another sandbox registered it.`,
);
}
if (fixedPort && isPortBound(preferredPort)) {
if (
reuseExistingOpenClawForward &&
!registryOccupiedPorts.has(String(preferredPort)) &&
reuseExistingForward &&
ownsDashboardForward(sandboxName, forwardGateway, preferredPort, chatUiUrl)
) {
revalidateSandboxIdentity?.(
Expand All @@ -428,7 +438,7 @@ export function createOnboardDashboardHelpers(deps: OnboardDashboardDeps): Onboa
}

if (actualPort !== preferredPort) {
if (!allowPortReallocation) {
if (!allowPortReallocation || fixedPort) {
throw new Error(
`Port ${preferredPort} is not available for '${sandboxName}' and cannot be reallocated.`,
);
Expand Down Expand Up @@ -460,6 +470,17 @@ export function createOnboardDashboardHelpers(deps: OnboardDashboardDeps): Onboa
(forwardService?.launch ?? launchForwardService)(
forwardTarget(sandboxName, actualGateway, actualPort, actualTarget),
);
if (
reuseExistingForward &&
!ownsDashboardForward(sandboxName, actualGateway, actualPort, parsedUrl.toString())
) {
throw new Error(
`Could not verify forward ownership on port ${String(actualPort)} for '${sandboxName}'.`,
);
}
revalidateSandboxIdentity?.(
`accept dashboard forward ${String(actualPort)} for sandbox '${sandboxName}'`,
);
fwdOk = true;
} catch (error) {
fwdDiagnostic = error instanceof Error ? error.message : String(error);
Expand All @@ -479,6 +500,11 @@ export function createOnboardDashboardHelpers(deps: OnboardDashboardDeps): Onboa
);
rollbackSandboxAndExit(sandboxName, err, options.gatewayName);
}
if (reuseExistingForward) {
throw new Error(
`Failed to start dashboard forward on port ${actualPort}: ${fwdDiagnostic.slice(0, 240)}`,
);
}
if (looksLikePortConflict) {
console.warn(
`! Port ${actualPort} forward did not start — port may be in use by another process.`,
Expand Down Expand Up @@ -533,7 +559,7 @@ export function createOnboardDashboardHelpers(deps: OnboardDashboardDeps): Onboa
envUrl || (persistedPort === null ? undefined : `http://127.0.0.1:${String(persistedPort)}`);
const actualPort = ensureDashboardForward(sandboxName, requestedUrl, {
allowPortReallocation: false,
reuseExistingOpenClawForward: true,
reuseExistingForward: true,
...(revalidateSandboxIdentity ? { revalidateSandboxIdentity } : {}),
});
revalidateSandboxIdentity?.(`publish the dashboard URL for sandbox '${sandboxName}'`);
Expand All @@ -549,7 +575,7 @@ export function createOnboardDashboardHelpers(deps: OnboardDashboardDeps): Onboa
agent: { forwardPort?: number | null; forward_ports?: number[] | null },
options: {
beforeForwardPort?: (port: number) => Promise<void> | void;
reuseExistingOpenClawForward?: boolean;
reuseExistingForward?: boolean;
revalidateSandboxIdentity?: (operation: string) => void;
} = {},
): Promise<number> {
Expand All @@ -562,7 +588,7 @@ export function createOnboardDashboardHelpers(deps: OnboardDashboardDeps): Onboa
controlUiPort: chatUiUrl ? Number(getDashboardForwardPort(chatUiUrl)) : undefined,
hermesApiPort: getSandbox?.(sandboxName)?.hermesApiPort,
beforeForwardPort: options.beforeForwardPort,
reuseExistingOpenClawForward: options.reuseExistingOpenClawForward,
reuseExistingForward: options.reuseExistingForward,
revalidateSandboxIdentity: options.revalidateSandboxIdentity,
});
}
Expand All @@ -578,16 +604,16 @@ export function createOnboardDashboardHelpers(deps: OnboardDashboardDeps): Onboa
if (!agent) {
return ensureFinalizationDashboardForward(sandboxName, revalidateSandboxIdentity);
}
const mayReuseOpenClawForward = agent.name === "openclaw";
if (mayReuseOpenClawForward) {
const mayReuseForward = canReuseDashboardForwardForAgent(agent);
if (mayReuseForward) {
const registeredPort = getPersistedDashboardPort(sandboxName, listSandboxes);
if (!process.env.CHAT_UI_URL && registeredPort !== null) {
process.env.CHAT_UI_URL = `http://127.0.0.1:${String(registeredPort)}`;
}
}
return ensureAgentDashboardForward(sandboxName, agent, {
revalidateSandboxIdentity,
...(mayReuseOpenClawForward ? { reuseExistingOpenClawForward: true } : {}),
...(mayReuseForward ? { reuseExistingForward: true } : {}),
beforeForwardPort: portReservation
? (port) => portReservation.releaseBeforeForward(agent.name, port)
: undefined,
Expand Down
Loading
Loading