Skip to content

Commit 40398f0

Browse files
committed
fix(hermes): decide broker reuse from ownership
The managed-tool broker decided it could reuse an existing broker from three conditions, and one of them checked only that the health endpoint answered. The other two already covered both ownership proofs, so that branch was reachable only when NemoClaw did not own the listener. It also set `brokerStartedThisRun`, which feeds `currentBrokerOwned` module-wide, so one adopting call suppressed the clone preflight's ownership refusal for the rest of the process. `planHermesToolGatewayBrokerReuse` now makes that decision in one place, next to the existing refresh planner. `ensureHermesToolGatewayBroker` refuses a listener it cannot prove it owns before any path adopts it, restarts around it, or stages credentials against it, and names the held port instead of failing silently. Reuse of a broker started by another NemoClaw process still works through the recorded pid. A lost pid file with a live broker now reports no usable broker rather than adopting it, because the two states are indistinguishable until `/health` authenticates the broker token. Refs #9304 Signed-off-by: 1PoPTRoN <vrxn.arp1traj@gmail.com>
1 parent ea804b5 commit 40398f0

3 files changed

Lines changed: 336 additions & 19 deletions

File tree

src/lib/hermes-tool-gateway-broker.ts

Lines changed: 58 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,8 @@ const HERMES_TOOL_GATEWAY_CONTROL_CONTRACT_PATH = path.join(
6363
);
6464
const HERMES_TOOL_GATEWAY_RUNTIME_MISMATCH_RECOVERY =
6565
"Reauthorize every managed-tool Hermes sandbox, then retry.";
66+
const HERMES_TOOL_GATEWAY_UNOWNED_LISTENER_RECOVERY =
67+
"Stop the process holding that port, then retry.";
6668
const HERMES_TOOL_GATEWAY_CONTROL_CLIENT_SOURCE = [
6769
'const http = require("node:http");',
6870
"const [socketPath, route, timeoutValue] = process.argv.slice(1);",
@@ -730,6 +732,36 @@ function planHermesToolGatewayBrokerRefresh({
730732
return "start-or-restart";
731733
}
732734

735+
/**
736+
* Decide whether the listener already on HERMES_TOOL_GATEWAY_PORT may be
737+
* adopted as this run's broker.
738+
*
739+
* sourceOfTruth: This is the only place that converts broker reachability into
740+
* a reuse decision for the no-credential path.
741+
* invalidState: `/health` is unauthenticated and binds a fixed port, so a
742+
* reachable endpoint proves liveness only. Ownership must come from the pid
743+
* file or from a broker this process started; reachability must never stand in
744+
* for identity, and must never latch `brokerStartedThisRun` for later callers.
745+
* regressionTest: test/hermes-tool-gateway-broker-unowned-listener.test.ts.
746+
* removalCondition: remove the "refuse-unowned-listener" outcome only when the
747+
* broker authenticates `/health` with the per-sandbox broker token, so that a
748+
* probe can prove identity instead of reachability.
749+
*/
750+
function planHermesToolGatewayBrokerReuse({
751+
brokerHealthy,
752+
currentBrokerOwned,
753+
forceRestart = false,
754+
hashMatches,
755+
}) {
756+
if (brokerHealthy && !currentBrokerOwned) {
757+
return "refuse-unowned-listener";
758+
}
759+
if (!forceRestart && hashMatches && brokerHealthy) {
760+
return "reuse-current";
761+
}
762+
return "no-usable-broker";
763+
}
764+
733765
function ensureHermesToolGatewayBroker(options = {}) {
734766
const refreshToken =
735767
typeof options.refreshToken === "string" && options.refreshToken.trim()
@@ -739,7 +771,27 @@ function ensureHermesToolGatewayBroker(options = {}) {
739771
const hashMatches = readBrokerHash() === desiredHash;
740772
const pid = readPid();
741773
const currentBrokerOwned = isHermesToolGatewayBrokerProcess(pid) || brokerStartedThisRun;
742-
const currentBrokerHealthy = currentBrokerOwned && isHermesToolGatewayBrokerHealthy();
774+
const brokerHealthy = isHermesToolGatewayBrokerHealthy();
775+
const currentBrokerHealthy = currentBrokerOwned && brokerHealthy;
776+
const reusePlan = planHermesToolGatewayBrokerReuse({
777+
brokerHealthy,
778+
currentBrokerOwned,
779+
forceRestart: options.forceRestart,
780+
hashMatches,
781+
});
782+
// Refuse before any path can adopt, restart around, or stage credentials
783+
// against a listener NemoClaw cannot prove it owns. The clone preflight
784+
// already rejects this state; every entry point has to agree, because a
785+
// single adopting path latches `brokerStartedThisRun` and thereby satisfies
786+
// the ownership test for the rest of the process.
787+
if (reusePlan === "refuse-unowned-listener") {
788+
console.error(
789+
"Hermes managed-tool broker health endpoint is not owned by NemoClaw; " +
790+
`refusing to reuse the listener on port ${HERMES_TOOL_GATEWAY_PORT}. ` +
791+
HERMES_TOOL_GATEWAY_UNOWNED_LISTENER_RECOVERY,
792+
);
793+
return false;
794+
}
743795
if (options.startWithoutCredential) {
744796
if (currentBrokerHealthy) {
745797
return hashMatches && fs.existsSync(HERMES_TOOL_GATEWAY_CONTROL_SOCKET_PATH);
@@ -799,24 +851,10 @@ function ensureHermesToolGatewayBroker(options = {}) {
799851
return false;
800852
}
801853

802-
if (
803-
!options.forceRestart &&
804-
hashMatches &&
805-
brokerStartedThisRun &&
806-
isHermesToolGatewayBrokerHealthy()
807-
) {
808-
return true;
809-
}
810-
if (
811-
!options.forceRestart &&
812-
hashMatches &&
813-
isHermesToolGatewayBrokerProcess(pid) &&
814-
isHermesToolGatewayBrokerHealthy()
815-
) {
816-
brokerStartedThisRun = true;
817-
return true;
818-
}
819-
if (!options.forceRestart && hashMatches && isHermesToolGatewayBrokerHealthy()) {
854+
// `currentBrokerOwned` already covers both ownership proofs the three former
855+
// branches tested separately (`brokerStartedThisRun` and a live broker pid),
856+
// and the unowned case returned above, so reuse is one decision.
857+
if (reusePlan === "reuse-current") {
820858
brokerStartedThisRun = true;
821859
return true;
822860
}
@@ -913,6 +951,7 @@ module.exports = {
913951
discardHermesToolGatewayCloneBinding,
914952
bindHermesToolGatewayCloneProviderState,
915953
planHermesToolGatewayBrokerRefresh,
954+
planHermesToolGatewayBrokerReuse,
916955
isHermesToolGatewayBrokerHealthy,
917956
killStaleHermesToolGatewayBroker,
918957
ensureHermesToolGatewayBroker,
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
//
4+
// Support for the broker ownership regression. The branching setup lives here
5+
// so the test bodies stay linear, which `tools/growth-guardrails/test-conditionals.mts`
6+
// requires of every `*.test.ts` file.
7+
8+
import http from "node:http";
9+
10+
/** A listener that answers the broker health probe and serves nothing else. */
11+
export function createUnownedHealthListener(): http.Server {
12+
return http.createServer((req, res) => {
13+
if (req.url === "/health") {
14+
res.writeHead(200, { "Content-Type": "application/json" });
15+
res.end(JSON.stringify({ ok: true, services: [] }));
16+
return;
17+
}
18+
res.writeHead(404).end();
19+
});
20+
}
21+
22+
export function listenOn(server: http.Server, port: number): Promise<void> {
23+
return new Promise((resolve, reject) => {
24+
server.once("error", reject);
25+
server.listen(port, "127.0.0.1", () => resolve());
26+
});
27+
}
28+
29+
export function closeServer(server: http.Server): Promise<void> {
30+
return new Promise((resolve) => server.close(() => resolve()));
31+
}
32+
33+
/** Wait for a killed broker to release the fixed managed-tool gateway port. */
34+
export async function waitForPortFree(port: number): Promise<void> {
35+
const deadline = Date.now() + 10_000;
36+
while (Date.now() < deadline) {
37+
const probe = http.createServer();
38+
const free = await new Promise<boolean>((resolve) => {
39+
probe.once("error", () => resolve(false));
40+
probe.listen(port, "127.0.0.1", () => resolve(true));
41+
});
42+
await closeServer(probe);
43+
if (free) return;
44+
await new Promise((resolve) => setTimeout(resolve, 100));
45+
}
46+
throw new Error(`port ${port} was still held after the staged broker was killed`);
47+
}
48+
49+
/** Restore a captured environment value, including the previously-unset case. */
50+
export function restoreEnv(key: string, previous: string | undefined): void {
51+
if (previous === undefined) {
52+
delete process.env[key];
53+
return;
54+
}
55+
process.env[key] = previous;
56+
}
Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
import fs from "node:fs";
5+
import { createRequire } from "node:module";
6+
import os from "node:os";
7+
import path from "node:path";
8+
import {
9+
closeServer,
10+
createUnownedHealthListener,
11+
listenOn,
12+
restoreEnv,
13+
waitForPortFree,
14+
} from "./helpers/hermes-tool-gateway-broker-ownership-fixture";
15+
import { describe, expect, test as it } from "./helpers/owned-test-resources";
16+
import { testTimeout } from "./helpers/timeouts";
17+
18+
const require = createRequire(import.meta.url);
19+
const BROKER_WRAPPER = path.join(
20+
import.meta.dirname,
21+
"..",
22+
"src",
23+
"lib",
24+
"hermes-tool-gateway-broker.ts",
25+
);
26+
27+
const BROKER_TEST_TIMEOUT_MS = testTimeout(45_000);
28+
29+
/** Every reuse input that must still refuse a listener NemoClaw does not own. */
30+
const UNOWNED_LISTENER_CASES = [
31+
{ label: "a matching hash", forceRestart: false, hashMatches: true },
32+
{ label: "a mismatched hash", forceRestart: false, hashMatches: false },
33+
{ label: "a restart request", forceRestart: true, hashMatches: true },
34+
{ label: "a restart request and a mismatched hash", forceRestart: true, hashMatches: false },
35+
];
36+
37+
/** Every reuse input that resolves to an ordinary restart when the port is dead. */
38+
const UNREACHABLE_PORT_CASES = [
39+
{ label: "an unowned broker and a matching hash", currentBrokerOwned: false, forceRestart: false, hashMatches: true },
40+
{ label: "an unowned broker and a mismatched hash", currentBrokerOwned: false, forceRestart: false, hashMatches: false },
41+
{ label: "an unowned broker and a restart request", currentBrokerOwned: false, forceRestart: true, hashMatches: true },
42+
{ label: "an unowned broker, a restart request, and a mismatched hash", currentBrokerOwned: false, forceRestart: true, hashMatches: false },
43+
{ label: "an owned broker and a matching hash", currentBrokerOwned: true, forceRestart: false, hashMatches: true },
44+
{ label: "an owned broker and a mismatched hash", currentBrokerOwned: true, forceRestart: false, hashMatches: false },
45+
{ label: "an owned broker and a restart request", currentBrokerOwned: true, forceRestart: true, hashMatches: true },
46+
{ label: "an owned broker, a restart request, and a mismatched hash", currentBrokerOwned: true, forceRestart: true, hashMatches: false },
47+
];
48+
49+
function loadBrokerWithHome(home: string) {
50+
process.env.HOME = home;
51+
// The module resolves its credential paths at load time, and
52+
// `brokerStartedThisRun` is module state, so each case needs a fresh copy.
53+
delete require.cache[require.resolve(BROKER_WRAPPER)];
54+
return require(BROKER_WRAPPER);
55+
}
56+
57+
describe("Hermes managed-tool broker ownership", () => {
58+
it("refuses to adopt a healthy listener it does not own", () => {
59+
const broker = require(BROKER_WRAPPER);
60+
61+
expect(
62+
broker.planHermesToolGatewayBrokerReuse({
63+
brokerHealthy: true,
64+
currentBrokerOwned: false,
65+
forceRestart: false,
66+
hashMatches: true,
67+
}),
68+
).toBe("refuse-unowned-listener");
69+
});
70+
71+
// Ownership is the only input that may admit a listener. A mismatched hash or
72+
// a restart request must not downgrade the refusal into an ordinary "no
73+
// broker" outcome, because only the refusal names the held port.
74+
it.each(UNOWNED_LISTENER_CASES)(
75+
"keeps refusing an unowned listener with $label",
76+
({ forceRestart, hashMatches }) => {
77+
const broker = require(BROKER_WRAPPER);
78+
79+
expect(
80+
broker.planHermesToolGatewayBrokerReuse({
81+
brokerHealthy: true,
82+
currentBrokerOwned: false,
83+
forceRestart,
84+
hashMatches,
85+
}),
86+
).toBe("refuse-unowned-listener");
87+
},
88+
);
89+
90+
it("treats an omitted restart request as no restart", () => {
91+
const broker = require(BROKER_WRAPPER);
92+
93+
// `ensureHermesToolGatewayBroker` forwards `options.forceRestart`, which is
94+
// undefined whenever a caller omits it, so the default is the production
95+
// path rather than a convenience.
96+
expect(
97+
broker.planHermesToolGatewayBrokerReuse({
98+
brokerHealthy: true,
99+
currentBrokerOwned: true,
100+
hashMatches: true,
101+
}),
102+
).toBe("reuse-current");
103+
expect(
104+
broker.planHermesToolGatewayBrokerReuse({
105+
brokerHealthy: true,
106+
currentBrokerOwned: false,
107+
hashMatches: true,
108+
}),
109+
).toBe("refuse-unowned-listener");
110+
});
111+
112+
it("adopts a healthy listener it owns when the runtime hash still matches", () => {
113+
const broker = require(BROKER_WRAPPER);
114+
115+
expect(
116+
broker.planHermesToolGatewayBrokerReuse({
117+
brokerHealthy: true,
118+
currentBrokerOwned: true,
119+
forceRestart: false,
120+
hashMatches: true,
121+
}),
122+
).toBe("reuse-current");
123+
});
124+
125+
it("declines an owned broker when the runtime hash moved or a restart was requested", () => {
126+
const broker = require(BROKER_WRAPPER);
127+
128+
expect(
129+
broker.planHermesToolGatewayBrokerReuse({
130+
brokerHealthy: true,
131+
currentBrokerOwned: true,
132+
forceRestart: false,
133+
hashMatches: false,
134+
}),
135+
).toBe("no-usable-broker");
136+
expect(
137+
broker.planHermesToolGatewayBrokerReuse({
138+
brokerHealthy: true,
139+
currentBrokerOwned: true,
140+
forceRestart: true,
141+
hashMatches: true,
142+
}),
143+
).toBe("no-usable-broker");
144+
expect(
145+
broker.planHermesToolGatewayBrokerReuse({
146+
brokerHealthy: true,
147+
currentBrokerOwned: true,
148+
forceRestart: true,
149+
hashMatches: false,
150+
}),
151+
).toBe("no-usable-broker");
152+
});
153+
154+
// An unreachable port cannot be refused as unowned; it is the ordinary
155+
// restart case whether or not a prior broker was ours.
156+
it.each(UNREACHABLE_PORT_CASES)(
157+
"reports no usable broker when nothing answers on the port with $label",
158+
({ currentBrokerOwned, forceRestart, hashMatches }) => {
159+
const broker = require(BROKER_WRAPPER);
160+
161+
expect(
162+
broker.planHermesToolGatewayBrokerReuse({
163+
brokerHealthy: false,
164+
currentBrokerOwned,
165+
forceRestart,
166+
hashMatches,
167+
}),
168+
).toBe("no-usable-broker");
169+
},
170+
);
171+
172+
it(
173+
"reports no broker when a foreign listener answers health after a stale hash is left behind",
174+
async ({ skip }) => {
175+
const previousHome = process.env.HOME;
176+
const home = fs.mkdtempSync(path.join(os.tmpdir(), "nc-broker-unowned-"));
177+
const impostor = createUnownedHealthListener();
178+
179+
try {
180+
const staging = loadBrokerWithHome(home);
181+
const credsDir = path.dirname(staging.HERMES_TOOL_GATEWAY_STATE_DIR);
182+
const hashPath = path.join(credsDir, "hermes-tool-gateway-broker.hash");
183+
const pidPath = path.join(credsDir, "hermes-tool-gateway-broker.pid");
184+
185+
// Let the module write its own runtime hash rather than recomputing it
186+
// here. A hand-built hash would stop matching the moment the runtime
187+
// inputs change, and this case would then pass for the wrong reason.
188+
// The hash is written when the broker is spawned, so it exists whether
189+
// or not that broker went on to become healthy.
190+
staging.ensureHermesToolGatewayBroker({ startWithoutCredential: true });
191+
const runtimeHash = fs.readFileSync(hashPath, "utf8");
192+
193+
staging.killStaleHermesToolGatewayBroker();
194+
await waitForPortFree(staging.HERMES_TOOL_GATEWAY_PORT);
195+
196+
// A broker that crashed leaves its runtime hash behind while the pid
197+
// goes away. That is the exact state this regression covers.
198+
fs.writeFileSync(hashPath, runtimeHash, { mode: 0o600 });
199+
expect(fs.existsSync(pidPath)).toBe(false);
200+
201+
const settled = loadBrokerWithHome(home);
202+
await listenOn(impostor, settled.HERMES_TOOL_GATEWAY_PORT);
203+
// The probe shells out to curl. Where a harness blocks loopback HTTP
204+
// for subprocesses, no listener can be observed as healthy and this
205+
// case would assert nothing, so report it as skipped instead of passed.
206+
skip(
207+
!settled.isHermesToolGatewayBrokerHealthy(),
208+
"curl cannot read a loopback response in this environment",
209+
);
210+
211+
// Reachability alone must not answer "broker ready".
212+
expect(settled.ensureHermesToolGatewayBroker({})).toBe(false);
213+
} finally {
214+
await closeServer(impostor);
215+
restoreEnv("HOME", previousHome);
216+
delete require.cache[require.resolve(BROKER_WRAPPER)];
217+
fs.rmSync(home, { recursive: true, force: true });
218+
}
219+
},
220+
BROKER_TEST_TIMEOUT_MS,
221+
);
222+
});

0 commit comments

Comments
 (0)