Skip to content

Commit 720bee9

Browse files
authored
fix(onboard): announce and recover declared agent forward_ports (#5389)
## Summary Hermes onboard declares `forward_ports: [18789, 8642]` but the dashboard summary only printed the primary port and process recovery only re-established the primary forward. After the OpenShell gateway restarted during policy-preset apply, the secondary OpenAI-compatible API forward on port 8642 was silently dropped and never restored. ## Related Issue Fixes #5206 ## Changes - `printDashboardUi` now walks `agent.forward_ports` and emits a labelled block per non-primary entry. - `checkAndRecoverSandboxProcesses` now invokes a new `ensureDeclaredAgentForwardPortsHealthy` helper in all three branches. - Regression tests cover the print output and the recovery loop. ## Type of Change - [x] Code change (feature, bug fix, or refactor) - [ ] Code change with doc updates - [ ] Doc only (prose changes, no code sample modifications) - [ ] Doc only (includes code sample changes) ## Verification - [x] `npx prek run --all-files` passes - [x] `npm test` passes - [x] Tests added or updated for new or changed behavior - [x] No secrets, API keys, or credentials committed - [ ] Docs updated for user-facing behavior changes - [ ] `npm run docs` builds without warnings (doc changes only) - [ ] Doc pages follow the [style guide](https://github.com/NVIDIA/NemoClaw/blob/main/docs/CONTRIBUTING.md) (doc changes only) - [ ] New doc pages include SPDX header and frontmatter (new pages only) --- Signed-off-by: Tinson Lai <tinsonl@nvidia.com> <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Sandbox recovery now re-establishes secondary agent-declared port forwards in addition to the primary dashboard forward. * Dashboard/onboarding output now shows forwarded OpenAI-compatible API and other secondary endpoint URLs with forwarding notes. * **Tests** * Added tests verifying secondary forwards are detected and re-established when missing and that onboarding prints secondary forward URLs correctly. * **Documentation** * Quickstart and troubleshooting guides updated to show the forwarded OpenAI-compatible API and how to verify/recover missing forwards. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Signed-off-by: Tinson Lai <tinsonl@nvidia.com>
1 parent ff5322b commit 720bee9

6 files changed

Lines changed: 519 additions & 5 deletions

File tree

docs/get-started/quickstart-hermes.mdx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -101,9 +101,9 @@ Use the provider variables from [Inference Options](../inference/inference-optio
101101

102102
## Connect to Hermes
103103

104-
When onboarding completes, NemoClaw prints the sandbox name, model, lifecycle commands, and Hermes dashboard URL.
104+
When onboarding completes, NemoClaw prints the sandbox name, model, lifecycle commands, the Hermes dashboard URL, and the OpenAI-compatible API URL.
105105
Hermes exposes its built-in browser dashboard on port `18789`.
106-
NemoClaw also forwards the OpenAI-compatible API on port `8642` for local clients.
106+
NemoClaw also forwards the OpenAI-compatible API on port `8642` for local clients, and the summary now announces both URLs.
107107
NemoClaw builds the Hermes dashboard assets into the sandbox image, so the dashboard starts without running `npm` as the sandbox user under `/opt/hermes`.
108108
Dashboard chat uses the prebuilt `/opt/hermes/ui-tui` bundle.
109109
If you need to recover the Hermes dashboard manually, use `hermes dashboard --tui --skip-build` so recovery does not try to rebuild assets under root-owned install paths.
@@ -122,6 +122,10 @@ Access
122122
Port 18789 must be forwarded before opening this URL.
123123
http://127.0.0.1:18789/
124124
125+
Hermes Agent OpenAI-compatible API
126+
Port 8642 must be forwarded before connecting.
127+
http://127.0.0.1:8642/v1
128+
125129
Terminal:
126130
nemohermes my-hermes connect
127131

docs/reference/troubleshooting.mdx

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1624,6 +1624,20 @@ Expected output:
16241624
Point an OpenAI-compatible client at `http://127.0.0.1:8642/v1` for chat completions.
16251625
For terminal use, run `nemohermes <name> connect` and then `hermes` inside the sandbox.
16261626

1627+
### `docker port` shows no mapping for 8642 even though forwarding works
1628+
1629+
OpenShell port forwards are host-side relays managed by the OpenShell gateway process, not Docker `-p` publish mappings on the sandbox container.
1630+
`docker port openshell-hermes-<id>` reflects only Docker-published ports, so it returns nothing for OpenShell-managed forwards even when the host bind is live.
1631+
1632+
Use OpenShell's own view as the supported acceptance signal:
1633+
1634+
```bash
1635+
openshell forward list # shows the host bind for each forwarded port
1636+
curl -sf http://127.0.0.1:8642/health # confirms the relayed endpoint answers
1637+
```
1638+
1639+
If `openshell forward list` does not show port `8642`, run `nemohermes <name> connect --probe-only` (or `nemohermes <name> recover`) to ask the recovery path to re-establish every manifest-declared agent forward port that has gone missing.
1640+
16271641
### `nemohermes` reports `Sandbox 'X' already exists as OpenClaw`
16281642

16291643
Each sandbox name maps to exactly one agent type.

src/lib/actions/sandbox/process-recovery.ts

Lines changed: 85 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -431,6 +431,60 @@ function ensureHermesDashboardPortForwardIfEnabled(sandboxName: string): boolean
431431
});
432432
}
433433

434+
/**
435+
* Re-establish every declared `forward_ports` entry on the active agent
436+
* manifest that is not already owned by another recovery helper. The
437+
* primary dashboard port is owned by `ensureSandboxPortForward`; the
438+
* optional Hermes web dashboard port (a registry-recorded per-sandbox
439+
* override that the manifest cannot statically declare) is owned by
440+
* `ensureHermesDashboardPortForwardIfEnabled`. Skipping both here keeps
441+
* the helpers orthogonal and avoids issuing duplicate `forward start`
442+
* calls when an operator pins the Hermes dashboard to one of the
443+
* manifest-declared ports.
444+
*
445+
* Without this helper, any remaining manifest-declared port (e.g.
446+
* Hermes' OpenAI-compatible API on 8642) would be silently dropped after
447+
* a gateway restart and never re-established by the recovery flow.
448+
*
449+
* Returns true when every covered declared port is healthy (probed or
450+
* re-established), false when at least one declared port could not be
451+
* re-established, and `null` when there is no active agent or no
452+
* declared port left to manage after the skip set is applied.
453+
*/
454+
function ensureDeclaredAgentForwardPortsHealthy(
455+
sandboxName: string,
456+
primaryPort: number,
457+
): boolean | null {
458+
const agent = agentRuntime.getSessionAgent(sandboxName);
459+
if (!agent) return null;
460+
const declared = (agent as { forward_ports?: unknown }).forward_ports;
461+
if (!Array.isArray(declared) || declared.length === 0) return null;
462+
const hermesDashboard = getHermesDashboardRecoveryConfig(sandboxName);
463+
const skipSet = new Set<number>([primaryPort]);
464+
if (hermesDashboard && Number.isInteger(hermesDashboard.publicPort)) {
465+
skipSet.add(hermesDashboard.publicPort);
466+
}
467+
let sawCovered = false;
468+
let allHealthy = true;
469+
for (const candidate of declared) {
470+
if (typeof candidate !== "number") continue;
471+
if (!Number.isInteger(candidate) || candidate < 1 || candidate > 65535) continue;
472+
if (skipSet.has(candidate)) continue;
473+
sawCovered = true;
474+
const health = isSandboxPortForwardHealthy(sandboxName, candidate);
475+
if (health === true) continue;
476+
if (health === "occupied") {
477+
allHealthy = false;
478+
continue;
479+
}
480+
if (!ensureSandboxPortForwardForPort(sandboxName, candidate)) {
481+
allHealthy = false;
482+
}
483+
}
484+
if (!sawCovered) return null;
485+
return allHealthy;
486+
}
487+
434488
function recoverHermesDashboardProcessIfEnabled(sandboxName: string): boolean | null {
435489
return recoverHermesDashboardProcess(sandboxName, { executeCommand: executeSandboxCommand });
436490
}
@@ -466,6 +520,10 @@ export function checkAndRecoverSandboxProcesses(
466520
}
467521
const forwardRecovered = ensureSandboxPortForward(sandboxName);
468522
const dashboardForwardRecovered = ensureHermesDashboardPortForwardIfEnabled(sandboxName);
523+
const declaredForwardsRecovered = ensureDeclaredAgentForwardPortsHealthy(
524+
sandboxName,
525+
recoveryPort,
526+
);
469527
if (!quiet) {
470528
if (forwardRecovered) {
471529
console.log(` ${G}${R} Dashboard port forward re-established.`);
@@ -475,6 +533,9 @@ export function checkAndRecoverSandboxProcesses(
475533
` Run \`openshell forward start --background <port> ${sandboxName}\` manually.`,
476534
);
477535
}
536+
if (declaredForwardsRecovered === false) {
537+
console.error(" One or more agent-declared port forwards could not be re-established.");
538+
}
478539
}
479540
return {
480541
checked: true,
@@ -483,7 +544,8 @@ export function checkAndRecoverSandboxProcesses(
483544
forwardRecovered:
484545
forwardRecovered ||
485546
dashboardForwardRecovered === true ||
486-
dashboardProcessRecovered === true,
547+
dashboardProcessRecovered === true ||
548+
declaredForwardsRecovered === true,
487549
};
488550
}
489551
if (forwardHealthy === "occupied") {
@@ -495,11 +557,21 @@ export function checkAndRecoverSandboxProcesses(
495557
return { checked: true, wasRunning: true, recovered: false, forwardRecovered: false };
496558
}
497559
const dashboardForwardRecovered = ensureHermesDashboardPortForwardIfEnabled(sandboxName);
560+
const declaredForwardsRecovered = ensureDeclaredAgentForwardPortsHealthy(
561+
sandboxName,
562+
recoveryPort,
563+
);
564+
if (!quiet && declaredForwardsRecovered === false) {
565+
console.error(" One or more agent-declared port forwards could not be re-established.");
566+
}
498567
return {
499568
checked: true,
500569
wasRunning: true,
501570
recovered: false,
502-
forwardRecovered: dashboardForwardRecovered === true || dashboardProcessRecovered === true,
571+
forwardRecovered:
572+
dashboardForwardRecovered === true ||
573+
dashboardProcessRecovered === true ||
574+
declaredForwardsRecovered === true,
503575
};
504576
}
505577

@@ -530,6 +602,10 @@ export function checkAndRecoverSandboxProcesses(
530602
}
531603
const forwardRecovered = ensureSandboxPortForward(sandboxName);
532604
const dashboardForwardRecovered = ensureHermesDashboardPortForwardIfEnabled(sandboxName);
605+
const declaredForwardsRecovered = ensureDeclaredAgentForwardPortsHealthy(
606+
sandboxName,
607+
recoveryPort,
608+
);
533609
if (!quiet) {
534610
console.log(
535611
` ${G}${R} ${agentRuntime.getAgentDisplayName(recoveryAgent)} gateway restarted inside sandbox.`,
@@ -542,12 +618,18 @@ export function checkAndRecoverSandboxProcesses(
542618
` Run \`openshell forward start --background <port> ${sandboxName}\` manually.`,
543619
);
544620
}
621+
if (declaredForwardsRecovered === false) {
622+
console.error(" One or more agent-declared port forwards could not be re-established.");
623+
}
545624
}
546625
return {
547626
checked: true,
548627
wasRunning: false,
549628
recovered,
550-
forwardRecovered: forwardRecovered || dashboardForwardRecovered === true,
629+
forwardRecovered:
630+
forwardRecovered ||
631+
dashboardForwardRecovered === true ||
632+
declaredForwardsRecovered === true,
551633
};
552634
}
553635
if (!quiet) {

src/lib/agent/onboard.test.ts

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,100 @@ describe("printDashboardUi — regression for #2078 (port 8642 is not a chat UI)
188188
expect(noteSpy).not.toHaveBeenCalled();
189189
});
190190

191+
it("announces manifest-declared secondary forward_ports alongside the primary dashboard", () => {
192+
const hermesShipped = makeAgent({
193+
name: "hermes",
194+
displayName: "Hermes Agent",
195+
forwardPort: 18789,
196+
forward_ports: [18789, 8642],
197+
healthProbe: { url: "http://localhost:8642/health", port: 8642, timeout_seconds: 90 },
198+
dashboard: {
199+
kind: "ui",
200+
label: "Dashboard",
201+
path: "/",
202+
healthPath: "/api/status",
203+
auth: "session",
204+
},
205+
});
206+
207+
printDashboardUi("hermes-box", null, hermesShipped, {
208+
note: noteSpy,
209+
buildControlUiUrls: buildUrlsLoopback,
210+
});
211+
212+
const output = logSpy.mock.calls.map((args) => String(args[0])).join("\n");
213+
expect(output).toContain("Hermes Agent Dashboard");
214+
expect(output).toContain("Port 18789 must be forwarded before opening this URL.");
215+
expect(output).toContain("http://127.0.0.1:18789/");
216+
expect(output).toContain("Hermes Agent OpenAI-compatible API");
217+
expect(output).toContain("Port 8642 must be forwarded before connecting.");
218+
expect(output).toContain("http://127.0.0.1:8642/v1");
219+
});
220+
221+
it("labels a non-health-probe secondary forward port as 'additional port' rooted at /", () => {
222+
const dualAgent = makeAgent({
223+
name: "experimental",
224+
displayName: "Experimental",
225+
forwardPort: 18789,
226+
forward_ports: [18789, 9100],
227+
healthProbe: { url: "http://localhost:18789/health", port: 18789, timeout_seconds: 30 },
228+
dashboard: {
229+
kind: "ui",
230+
label: "Dashboard",
231+
path: "/",
232+
healthPath: "/health",
233+
auth: "session",
234+
},
235+
});
236+
237+
printDashboardUi("agent-box", null, dualAgent, {
238+
note: noteSpy,
239+
buildControlUiUrls: buildUrlsLoopback,
240+
});
241+
242+
const output = logSpy.mock.calls.map((args) => String(args[0])).join("\n");
243+
expect(output).toContain("Experimental additional port");
244+
expect(output).toContain("Port 9100 must be forwarded before connecting.");
245+
expect(output).toContain("http://127.0.0.1:9100/");
246+
expect(output).not.toContain("OpenAI-compatible API");
247+
expect(output).not.toContain("http://127.0.0.1:9100/v1");
248+
});
249+
250+
it("emits a URL for a secondary forward port that resolves to the scheme default", () => {
251+
// Regression: `new URL("http://h:80").port === ""`. A strict equality
252+
// filter against String(port) silently drops the URL line. The helper
253+
// must normalise scheme-default ports before filtering.
254+
const buildUrlsWithDefaultPort = (_token: string | null, port: number): string[] => {
255+
if (port === 80) return ["http://127.0.0.1:80/"];
256+
return [`http://127.0.0.1:${port}/`];
257+
};
258+
259+
const httpAgent = makeAgent({
260+
name: "experimental",
261+
displayName: "Experimental",
262+
forwardPort: 18789,
263+
forward_ports: [18789, 80],
264+
healthProbe: { url: "http://localhost:18789/health", port: 18789, timeout_seconds: 30 },
265+
dashboard: {
266+
kind: "ui",
267+
label: "Dashboard",
268+
path: "/",
269+
healthPath: "/health",
270+
auth: "session",
271+
},
272+
});
273+
274+
printDashboardUi("agent-box", null, httpAgent, {
275+
note: noteSpy,
276+
buildControlUiUrls: buildUrlsWithDefaultPort,
277+
});
278+
279+
const output = logSpy.mock.calls.map((args) => String(args[0])).join("\n");
280+
expect(output).toContain("Experimental additional port");
281+
expect(output).toContain("Port 80 must be forwarded before connecting.");
282+
expect(output).toMatch(/http:\/\/127\.0\.0\.1(:80)?\//);
283+
});
284+
191285
it("redacts tokenized URLs for UI-kind agents and shows the token retrieval command", () => {
192286
const token = "a".repeat(64);
193287
printDashboardUi("sandbox-y", token, uiAgent, {

src/lib/agent/onboard.ts

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -569,6 +569,7 @@ export function printDashboardUi(
569569
console.log(` ${dashboardUrlForDisplay(url)}`);
570570
}
571571
printOptionalDashboardUi(agent, { ...deps, redactUrl: dashboardUrlForDisplay });
572+
printAdditionalForwardPorts(agent, info.port, deps.buildControlUiUrls);
572573
return;
573574
}
574575

@@ -578,6 +579,8 @@ export function printDashboardUi(
578579
for (const url of deps.buildControlUiUrls(null, info.port)) {
579580
console.log(` ${dashboardUrlForDisplay(url)}`);
580581
}
582+
printOptionalDashboardUi(agent, { ...deps, redactUrl: dashboardUrlForDisplay });
583+
printAdditionalForwardPorts(agent, info.port, deps.buildControlUiUrls);
581584
return;
582585
}
583586

@@ -598,4 +601,90 @@ export function printDashboardUi(
598601
}
599602
}
600603
printOptionalDashboardUi(agent, { ...deps, redactUrl: dashboardUrlForDisplay });
604+
printAdditionalForwardPorts(agent, info.port, deps.buildControlUiUrls);
605+
}
606+
607+
/**
608+
* Print one block per manifest-declared `forward_ports` entry that is not
609+
* the primary dashboard port. Each block announces the port and renders a
610+
* loopback URL using the same `buildControlUiUrls` chain as the primary
611+
* dashboard so WSL host-address fallbacks remain consistent.
612+
*
613+
* The label is sourced from the agent's `health_probe.port` match — that
614+
* is the only manifest signal today that a declared secondary port is the
615+
* OpenAI-compatible API surface (Hermes manifest sets
616+
* `health_probe.port: 8642` alongside `forward_ports: [18789, 8642]`).
617+
* Any other declared port gets a neutral "additional port" label.
618+
*
619+
* The URL filter normalises empty `URL.port` results to the scheme
620+
* default. `new URL("http://h:80").port` returns `""` because WHATWG
621+
* URL elides the default scheme port; a strict `urlPort === String(port)`
622+
* comparison would silently drop URLs for ports 80 and 443 even though
623+
* the underlying `forward_ports` validation accepts them. The
624+
* normalisation keeps the filter sound while still excluding any URL
625+
* whose port truly does not match the declared entry.
626+
*/
627+
function printAdditionalForwardPorts(
628+
agent: AgentDefinition,
629+
primaryPort: number,
630+
buildControlUiUrls: (token: string | null, port: number) => string[],
631+
): void {
632+
const declared = Array.isArray(agent.forward_ports) ? agent.forward_ports : [];
633+
if (declared.length === 0) return;
634+
const apiPort = agent.healthProbe.port;
635+
for (const port of declared) {
636+
if (!Number.isInteger(port) || port < 1 || port > 65535) continue;
637+
if (port === primaryPort) continue;
638+
const isApi = port === apiPort;
639+
const sectionLabel = isApi ? "OpenAI-compatible API" : "additional port";
640+
console.log("");
641+
console.log(` ${agent.displayName} ${sectionLabel}`);
642+
console.log(` Port ${port} must be forwarded before connecting.`);
643+
const seen = new Set<string>();
644+
for (const baseUrl of buildControlUiUrls(null, port)) {
645+
const withoutHash = baseUrl.split("#")[0].replace(/\/$/, "");
646+
const resolvedUrlPort = resolveUrlPort(withoutHash);
647+
if (resolvedUrlPort !== port) continue;
648+
const url = isApi ? `${withoutHash}/v1` : `${withoutHash}/`;
649+
if (seen.has(url)) continue;
650+
seen.add(url);
651+
console.log(` ${dashboardUrlForDisplay(url)}`);
652+
}
653+
}
654+
}
655+
656+
/**
657+
* Resolve the effective port of `candidate`, normalising the WHATWG
658+
* URL behaviour that returns an empty string for the scheme-default
659+
* port (`http://h:80` → `""`, `https://h:443` → `""`). Returns the
660+
* integer port, or `null` when the input is unparseable or carries no
661+
* recoverable port. The mapping is intentionally limited to `http` /
662+
* `https` / `ws` / `wss` — the four schemes the dashboard URL builder
663+
* emits — so an unknown scheme falls through to `null` instead of
664+
* silently mapping to 80 or 443.
665+
*/
666+
function resolveUrlPort(candidate: string): number | null {
667+
let parsed: URL;
668+
try {
669+
parsed = new URL(candidate);
670+
} catch {
671+
return null;
672+
}
673+
if (parsed.port !== "") {
674+
const numeric = Number(parsed.port);
675+
return Number.isInteger(numeric) ? numeric : null;
676+
}
677+
const protocol = parsed.protocol.replace(/:$/, "").toLowerCase();
678+
switch (protocol) {
679+
case "http":
680+
return 80;
681+
case "https":
682+
return 443;
683+
case "ws":
684+
return 80;
685+
case "wss":
686+
return 443;
687+
default:
688+
return null;
689+
}
601690
}

0 commit comments

Comments
 (0)