Skip to content

Commit 7293d5d

Browse files
committed
fix(onboard): verify the agent API host forward before reporting ready
Deployment verification probed the agent gateway only from inside the sandbox and probed just the dashboard port on the host. For Hermes, whose manifest forwards a second host port for the OpenAI-compatible API, a failed API forward therefore left every checked link green: onboarding printed "Deployment verified" and "Hermes is ready", and advertised an API URL that refused every connection. Probe the agent API port from the host too, whenever the agent declares one distinct from the dashboard port, and fold it into the deployment health result so a dead forward pauses onboarding with an actionable diagnostic instead of a false success. Agents without a separate API port keep their single dashboard probe unchanged. Resolve that port per sandbox rather than from the manifest default, so a second Hermes sandbox holding a reallocated port from the 8642-8652 range is not reported as unreachable. Fixes #9290 Signed-off-by: Yanyun Liao <yanyunl@nvidia.com>
1 parent 588bb6d commit 7293d5d

12 files changed

Lines changed: 274 additions & 6 deletions

docs/reference/troubleshooting.mdx

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4893,6 +4893,26 @@ Expected output:
48934893
Point an OpenAI-compatible client at `http://127.0.0.1:8642/v1` for chat completions.
48944894
For terminal use, run `nemohermes launch <name>`.
48954895

4896+
### Onboarding reports "Hermes is not ready" with an unreachable API port
4897+
4898+
Deployment verification probes the OpenAI-compatible API on its host forward, not only inside the sandbox.
4899+
When the sandbox gateway is healthy but the host forward never came up, verification reports the API link as failed and onboarding finishes with `Hermes is not ready` instead of a successful handoff:
4900+
4901+
```text
4902+
✗ api: port forward not working (connection refused)
4903+
The OpenAI-compatible API on port 8642 is not reachable from the host. Run: openshell forward start --background 8642 <name>
4904+
```
4905+
4906+
The sandbox itself is intact, so start the forward and re-check:
4907+
4908+
```bash
4909+
openshell forward list # confirm no row owns the API port
4910+
openshell forward start --background 8642 <name>
4911+
curl -sf http://127.0.0.1:8642/health
4912+
```
4913+
4914+
If the forward still refuses connections, a foreign host listener usually holds the port; stop it, then rerun `$$nemoclaw onboard` to complete verification.
4915+
48964916
### `docker port` shows no mapping for 8642 even though forwarding works
48974917

48984918
OpenShell port forwards are host-side relays managed by the OpenShell gateway process, not Docker `-p` publish mappings on the sandbox container.

src/lib/onboard.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2906,6 +2906,7 @@ const setupOpenclaw = createOpenclawSetup({
29062906

29072907
const {
29082908
buildChain,
2909+
resolveVerifyAgentApiPort,
29092910
buildControlUiUrls,
29102911
buildOrphanedSandboxRollbackMessage,
29112912
ensureDashboardForward,
@@ -3644,8 +3645,8 @@ async function runOnboard(opts: OnboardOptions = {}): Promise<void> {
36443645
removeLegacyCredentialsFile,
36453646
cleanupStaleHostFiles,
36463647
getChatUiUrl: () => process.env.CHAT_UI_URL || `http://127.0.0.1:${DASHBOARD_PORT}`,
3647-
buildVerifyChain: (chatUiUrl) =>
3648-
buildChain({ chatUiUrl, isWsl: isWsl(), wslHostAddress: getWslHostAddress(), dashboardHealthEndpoint: agent?.dashboard.healthPath, gatewayPort: agent?.healthProbe?.port, gatewayHealthEndpoint: agent?.healthProbe?.url }),
3648+
buildVerifyChain: (chatUiUrl, verifiedSandboxName) =>
3649+
buildChain({ chatUiUrl, isWsl: isWsl(), wslHostAddress: getWslHostAddress(), dashboardHealthEndpoint: agent?.dashboard.healthPath, gatewayPort: resolveVerifyAgentApiPort(verifiedSandboxName, agent), gatewayHealthEndpoint: agent?.healthProbe?.url }),
36493650
verifyDeployment: async (name, chain) => {
36503651
const verifyDeploymentModule: typeof import("./verify-deployment") =
36513652
require("./verify-deployment");

src/lib/onboard/agent-dashboard-forward.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,11 @@ import {
1111
} from "./dashboard-runtime";
1212
import { resolveOnboardHermesApiPort } from "./hermes-api-port";
1313

14+
// The port deployment verification must probe lives with the rest of this
15+
// module's "which host port does this agent publish" logic, so onboarding
16+
// reaches it through the dashboard helpers it already consumes (#9290).
17+
export { resolveVerifyAgentApiPort } from "./hermes-api-port";
18+
1419
export type EnsureDashboardForward = (
1520
sandboxName: string,
1621
chatUiUrl?: string,

src/lib/onboard/dashboard.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { runCapture as defaultRunCapture } from "../runner";
1414
import {
1515
ensureAgentDashboardForward as ensureAgentDashboardForwardForAgent,
1616
replaceUrlPort,
17+
resolveVerifyAgentApiPort,
1718
} from "./agent-dashboard-forward";
1819
import { ensureAgentFixedForward as ensureFixedAgentForward } from "./agent-fixed-forward";
1920
import { fetchAgentWebAuthTokenFromSandbox as fetchAgentWebAuthToken } from "./agent-web-auth-token";
@@ -88,6 +89,7 @@ export interface OnboardDashboardDeps {
8889

8990
export interface OnboardDashboardHelpers {
9091
buildChain: typeof buildChain;
92+
resolveVerifyAgentApiPort: typeof resolveVerifyAgentApiPort;
9193
buildControlUiUrls: typeof buildControlUiUrls;
9294
buildOrphanedSandboxRollbackMessage(
9395
sandboxName: string,
@@ -630,6 +632,7 @@ export function createOnboardDashboardHelpers(deps: OnboardDashboardDeps): Onboa
630632

631633
return {
632634
buildChain,
635+
resolveVerifyAgentApiPort,
633636
buildControlUiUrls,
634637
buildOrphanedSandboxRollbackMessage,
635638
ensureDashboardForward,

src/lib/onboard/hermes-api-port.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
reserveCreateSandboxHermesApiPort,
1414
resolveOnboardHermesApiPort,
1515
resolveSandboxHermesApiPort,
16+
resolveVerifyAgentApiPort,
1617
retargetHermesApiPortInUrl,
1718
withHermesApiPortReservationScope,
1819
} from "./hermes-api-port";
@@ -323,3 +324,34 @@ describe("retargetHermesApiPortInUrl", () => {
323324
);
324325
});
325326
});
327+
328+
describe("resolveVerifyAgentApiPort (#9290)", () => {
329+
const hermes = { name: "hermes", healthProbe: { port: 8642 } };
330+
331+
it("targets the port this Hermes sandbox actually owns", () => {
332+
// A second Hermes sandbox serves its API on a reallocated port; probing the
333+
// manifest default would report a sibling sandbox's port as unreachable.
334+
expect(
335+
resolveVerifyAgentApiPort("second", hermes, { getSandbox: () => ({ hermesApiPort: 8643 }) }),
336+
).toBe(8643);
337+
});
338+
339+
it("falls back to the manifest default when the sandbox is not registered yet", () => {
340+
expect(resolveVerifyAgentApiPort("fresh", hermes, { getSandbox: () => null })).toBe(8642);
341+
});
342+
343+
it("keeps a non-Hermes agent's declared probe port", () => {
344+
expect(
345+
resolveVerifyAgentApiPort("sb", { name: "other", healthProbe: { port: 9000 } }, {
346+
getSandbox: () => ({ hermesApiPort: 8643 }),
347+
}),
348+
).toBe(9000);
349+
});
350+
351+
it("returns undefined when the agent declares no health probe port", () => {
352+
expect(resolveVerifyAgentApiPort("sb", { name: "openclaw" }, { getSandbox: () => null })).toBe(
353+
undefined,
354+
);
355+
expect(resolveVerifyAgentApiPort("sb", null, { getSandbox: () => null })).toBe(undefined);
356+
});
357+
});

src/lib/onboard/hermes-api-port.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -388,3 +388,27 @@ export function resolveOnboardHermesApiPort(
388388
}
389389
return publish(port);
390390
}
391+
392+
/**
393+
* Resolve the API port deployment verification must probe for `agent`.
394+
*
395+
* Returns the agent's declared health-probe port, except for Hermes, whose
396+
* per-sandbox allocation from the 8642-8652 range means the manifest default
397+
* would name a sibling sandbox's port. Returns undefined when the agent
398+
* declares no health-probe port, which leaves `buildChain` on its dashboard-port
399+
* fallback so agents without a separate API surface keep their existing single
400+
* host probe (#9290).
401+
*/
402+
export function resolveVerifyAgentApiPort(
403+
sandboxName: string,
404+
agent: { name?: string; healthProbe?: { port?: number } | null } | null | undefined,
405+
options: {
406+
getSandbox?: (name: string) => { hermesApiPort?: number | null } | null | undefined;
407+
} = {},
408+
): number | undefined {
409+
const declared = agent?.healthProbe?.port;
410+
if (!Number.isInteger(declared)) return undefined;
411+
if (agent?.name !== "hermes" || declared !== HERMES_OPENAI_API_PORT) return declared;
412+
const getSandbox = options.getSandbox ?? registry.getSandbox;
413+
return resolveSandboxHermesApiPort(getSandbox(sandboxName) ?? {});
414+
}

src/lib/onboard/machine/final-flow-phases.runtime.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ function deploymentResult(healthy: boolean): VerifyDeploymentResult {
2121
gatewayVersion: "test",
2222
inferenceRouteWorking: healthy,
2323
dashboardReachable: true,
24+
agentApiReachable: null,
2425
messagingBridgesHealthy: true,
2526
messagingRuntimeChannelsMissing: null,
2627
messagingConfigChannelsMissing: null,
@@ -269,6 +270,7 @@ describe("final onboard flow runtime boundary", () => {
269270
gatewayVersion: "test",
270271
inferenceRouteWorking: true,
271272
dashboardReachable: true,
273+
agentApiReachable: null,
272274
messagingBridgesHealthy: true,
273275
messagingRuntimeChannelsMissing: null,
274276
messagingConfigChannelsMissing: null,

src/lib/onboard/machine/handlers/finalization.test.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,9 @@ describe("finalization handlers", () => {
133133
);
134134
expect(calls.cleanupHost).toHaveBeenCalledOnce();
135135
expect(calls.recoverProcesses).toHaveBeenCalledWith("my-assistant", { quiet: true });
136-
expect(calls.buildChain).toHaveBeenCalledWith("http://127.0.0.1:18789");
136+
// The sandbox name lets the chain resolve this sandbox's own agent API
137+
// port rather than the agent manifest default (#9290).
138+
expect(calls.buildChain).toHaveBeenCalledWith("http://127.0.0.1:18789", "my-assistant");
137139
expect(calls.verify).toHaveBeenCalledWith("my-assistant", { port: 18789 });
138140
expect(calls.log).toHaveBeenCalledWith(" ✓ verified");
139141
expect(calls.dashboard).toHaveBeenCalledWith(

src/lib/onboard/machine/handlers/finalization.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,12 @@ export interface FinalizationStateOptions<Agent, VerifyChain, VerificationResult
6161
*/
6262
warmupScopeUpgrade(sandboxName: string): void;
6363
getChatUiUrl(): string;
64-
buildVerifyChain(chatUiUrl: string): VerifyChain;
64+
/**
65+
* `sandboxName` lets the chain target the API port this sandbox actually
66+
* owns: Hermes allocates a per-sandbox port from the 8642-8652 range, so
67+
* the manifest default would probe a sibling sandbox's port (#9290).
68+
*/
69+
buildVerifyChain(chatUiUrl: string, sandboxName: string): VerifyChain;
6570
verifyDeployment(sandboxName: string, chain: VerifyChain): Promise<VerificationResult>;
6671
formatVerificationDiagnostics(result: VerificationResult): string[];
6772
isDeploymentHealthy(result: VerificationResult): boolean;
@@ -231,7 +236,7 @@ export async function handlePostVerifyState<Agent, VerifyChain, VerificationResu
231236
(webSearchProvider !== null &&
232237
deps.verifyWebSearchInsideSandbox(sandboxName, agent, webSearchProvider));
233238
// Confirm the delivered sandbox is reachable before printing the live dashboard (#2342).
234-
const verifyChain = deps.buildVerifyChain(deps.getChatUiUrl());
239+
const verifyChain = deps.buildVerifyChain(deps.getChatUiUrl(), sandboxName);
235240
const verificationResult = await deps.verifyDeployment(sandboxName, verifyChain);
236241
deploymentHealthy =
237242
webSearchCredentialBoundarySafe && deps.isDeploymentHealthy(verificationResult);

src/lib/verify-deployment-agent.test.ts

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,3 +54,88 @@ describe("verifyDeployment agent dashboard probes", () => {
5454
expect(hostProbes).toContainEqual({ port: 18789, path: "/api/status" });
5555
});
5656
});
57+
58+
describe("verifyDeployment agent OpenAI-compatible API host forward (#9290)", () => {
59+
const agentChain = buildChain({
60+
chatUiUrl: "http://127.0.0.1:18789",
61+
dashboardHealthEndpoint: "/api/status",
62+
gatewayPort: 8642,
63+
gatewayHealthEndpoint: "/health",
64+
});
65+
66+
function runWithApiHostCode(apiCode: number) {
67+
const hostProbes: Array<{ port: number; path: string }> = [];
68+
const deps = makeDeps({
69+
probeHostPort: (port: number, path: string) => {
70+
hostProbes.push({ port, path });
71+
return port === 8642 ? apiCode : 200;
72+
},
73+
});
74+
return { hostProbes, run: () => verifyDeployment("my-sandbox", agentChain, deps, NO_RETRY) };
75+
}
76+
77+
it("probes the API port on the host, not just inside the sandbox", async () => {
78+
const { hostProbes, run } = runWithApiHostCode(200);
79+
80+
const result = await run();
81+
82+
// The in-sandbox gateway probe only proves the API answers inside the
83+
// sandbox; the host forward is what operators actually connect through.
84+
expect(hostProbes).toContainEqual({ port: 8642, path: "/health" });
85+
expect(result.healthy).toBe(true);
86+
expect(result.verification.agentApiReachable).toBe(true);
87+
});
88+
89+
it("fails verification when the API host forward refuses connections", async () => {
90+
// The reported regression: the sandbox gateway is healthy and the dashboard
91+
// forward is up, but port 8642 never became reachable on the host.
92+
const { run } = runWithApiHostCode(0);
93+
94+
const result = await run();
95+
96+
expect(result.healthy).toBe(false);
97+
expect(result.verification.agentApiReachable).toBe(false);
98+
expect(result.verification.gatewayReachable).toBe(true);
99+
expect(result.verification.dashboardReachable).toBe(true);
100+
const api = result.diagnostics.find((d) => d.link === "api");
101+
expect(api?.status).toBe("fail");
102+
expect(api?.hint).toContain("openshell forward start --background 8642 my-sandbox");
103+
});
104+
105+
it("fails verification when the API host forward answers with a server error", async () => {
106+
const { run } = runWithApiHostCode(502);
107+
108+
const result = await run();
109+
110+
expect(result.healthy).toBe(false);
111+
expect(result.diagnostics.find((d) => d.link === "api")?.detail).toContain("502");
112+
});
113+
114+
it("accepts an authenticated API host forward (HTTP 401)", async () => {
115+
// Bearer auth is enabled on the Hermes API; 401 still proves the forward
116+
// reaches a live listener.
117+
const { run } = runWithApiHostCode(401);
118+
119+
expect((await run()).healthy).toBe(true);
120+
});
121+
122+
it("keeps a single host probe for agents without a separate API port", async () => {
123+
// Regression lock: OpenClaw declares no gateway port, so buildChain falls
124+
// back to the dashboard port and no API probe or diagnostic is added.
125+
const openClawChain = buildChain({ chatUiUrl: "http://127.0.0.1:18789" });
126+
const hostProbes: Array<{ port: number; path: string }> = [];
127+
const deps = makeDeps({
128+
probeHostPort: (port: number, path: string) => {
129+
hostProbes.push({ port, path });
130+
return 200;
131+
},
132+
});
133+
134+
const result = await verifyDeployment("my-sandbox", openClawChain, deps, NO_RETRY);
135+
136+
expect(hostProbes).toEqual([{ port: 18789, path: "/health" }]);
137+
expect(result.diagnostics.some((d) => d.link === "api")).toBe(false);
138+
expect(result.verification.agentApiReachable).toBeNull();
139+
expect(result.healthy).toBe(true);
140+
});
141+
});

0 commit comments

Comments
 (0)