Skip to content

Commit 2c2571d

Browse files
authored
Merge branch 'main' into codex/issue-3768-gateway-recovery-deadline
2 parents ffa3c9f + 5b9f002 commit 2c2571d

7 files changed

Lines changed: 211 additions & 11 deletions

File tree

test/e2e/fixtures/cleanup.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,12 @@ export interface CleanupResult {
1414
type CleanupFn = () => Promise<void> | void;
1515
type RedactFn = (text: string) => string;
1616

17+
export interface CleanupHost {
18+
cleanupSandbox(name: string): Promise<void>;
19+
cleanupGatewayRegistration(name: string): Promise<void>;
20+
cleanupForward(port: number): Promise<void>;
21+
}
22+
1723
interface CleanupEntry {
1824
name: string;
1925
run: CleanupFn;
@@ -34,6 +40,22 @@ export class CleanupRegistry {
3440
this.entries.push({ name, run });
3541
}
3642

43+
trackSandbox(host: CleanupHost, name: string): void {
44+
this.add(`destroy sandbox ${name}`, () => host.cleanupSandbox(name));
45+
}
46+
47+
trackGateway(host: CleanupHost, name: string): void {
48+
this.add(`remove gateway ${name}`, () => host.cleanupGatewayRegistration(name));
49+
}
50+
51+
trackForward(host: CleanupHost, port: number): void {
52+
this.add(`stop forward ${port}`, () => host.cleanupForward(port));
53+
}
54+
55+
trackDisposable(name: string, dispose: CleanupFn): void {
56+
this.add(name, dispose);
57+
}
58+
3759
async runAll(): Promise<CleanupResult> {
3860
const result: CleanupResult = { passed: [], failures: [] };
3961
for (const entry of [...this.entries].reverse()) {

test/e2e/fixtures/clients/host.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ const GATEWAY_ALREADY_ABSENT =
2121
/gateway[^\n]*(?:does not exist|not found)|No (?:active )?gateway|No gateway metadata found/i;
2222
const GATEWAY_REMOVE_UNSUPPORTED =
2323
/unrecognized subcommand ['"]remove['"]|unknown command ['"]remove['"]/i;
24+
const FORWARD_ALREADY_ABSENT =
25+
/no (?:active )?forward|forward[^\n]*(?:not found|not running)|forward stop[^\n]*not running/i;
2426

2527
export class HostCliClient {
2628
private readonly runner: CommandRunner;
@@ -151,6 +153,15 @@ export class HostCliClient {
151153
assertExitZero(destroy, `cleanup gateway registration ${gatewayName}`);
152154
}
153155

156+
async cleanupForward(port: number, options: ShellProbeRunOptions = {}): Promise<void> {
157+
const result = await this.command("openshell", ["forward", "stop", String(port)], {
158+
...options,
159+
artifactName: options.artifactName ?? `cleanup-forward-${port}`,
160+
});
161+
if (result.exitCode === 0 || FORWARD_ALREADY_ABSENT.test(resultText(result))) return;
162+
assertExitZero(result, `cleanup forward ${port}`);
163+
}
164+
154165
async bestEffortCleanupSandbox(
155166
sandboxName: string,
156167
options: ShellProbeRunOptions = {},

test/e2e/live/hermes-gpu-startup-integrity.ts

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -76,18 +76,23 @@ def parse_hash(data, label):
7676
text = data.decode("ascii")
7777
except UnicodeDecodeError:
7878
fail(f"{label} is not ASCII")
79-
if not text.endswith("\\n"):
80-
fail(f"{label} is missing its final newline")
81-
lines = text.splitlines()
79+
parts = text.split("\\n")
80+
if len(parts) != 4 or parts[-1] != "":
81+
fail(f"{label} does not contain exactly three records")
82+
lines = parts[:2]
8283
expected_paths = (str(config_path), str(env_path))
83-
if len(lines) != len(expected_paths):
84-
fail(f"{label} does not contain exactly two records")
8584
digests = []
8685
for line, expected_path in zip(lines, expected_paths):
8786
match = re.fullmatch(r"([0-9a-f]{64}) (.+)", line)
8887
if match is None or match.group(2) != expected_path:
89-
fail(f"{label} contains an unexpected record")
88+
fail(f"{label} contains an unexpected file record")
9089
digests.append(match.group(1))
90+
state_match = re.fullmatch(
91+
r"# nemoclaw-hermes-mcp-state-v1 intended=([0-9a-f]{64}) applied=([0-9a-f]{64})",
92+
parts[2],
93+
)
94+
if state_match is None:
95+
fail(f"{label} contains an unexpected MCP state record")
9196
return tuple(digests)
9297
9398
def digest(data):

test/e2e/live/sandbox-operations.test.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import os from "node:os";
1313
import path from "node:path";
1414
import { containsInteger42Answer } from "../../helpers/e2e-answer-assertions.ts";
1515
import { buildAvailabilityProbeEnv } from "../fixtures/availability-env.ts";
16+
import type { CleanupRegistry } from "../fixtures/cleanup.ts";
1617
import {
1718
assertExitZero as expectExitZero,
1819
outputContainsSandbox,
@@ -34,8 +35,6 @@ const SANDBOX_B = "e2e-sbx-b";
3435
const REGISTRY_FILE = path.join(process.env.HOME ?? os.homedir(), ".nemoclaw", "sandboxes.json");
3536
const GATEWAY_CONTAINER = "openshell-cluster-nemoclaw";
3637

37-
type CleanupRegistry = { add(name: string, run: () => Promise<void> | void): void };
38-
3938
async function onboardSandbox(
4039
host: HostCliClient,
4140
cleanup: CleanupRegistry,
@@ -44,7 +43,7 @@ async function onboardSandbox(
4443
hosted: HostedInferenceConfig,
4544
extraEnv: NodeJS.ProcessEnv = {},
4645
): Promise<ShellProbeResult> {
47-
cleanup.add(`destroy sandbox ${sandboxName}`, () => host.cleanupSandbox(sandboxName));
46+
cleanup.trackSandbox(host, sandboxName);
4847
const result = await host.nemoclaw(
4948
["onboard", "--non-interactive", "--yes", "--yes-i-accept-third-party-software"],
5049
{
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
import { describe, expect, it } from "vitest";
5+
6+
import { type CleanupHost, CleanupRegistry } from "../fixtures/cleanup.ts";
7+
8+
describe("cleanup resources", () => {
9+
it("tears down acquired resources in reverse order", async () => {
10+
const calls: string[] = [];
11+
const host: CleanupHost = {
12+
cleanupSandbox: async (name) => {
13+
calls.push(`sandbox:${name}`);
14+
},
15+
cleanupGatewayRegistration: async (name) => {
16+
calls.push(`gateway:${name}`);
17+
},
18+
cleanupForward: async (port) => {
19+
calls.push(`forward:${port}`);
20+
},
21+
};
22+
const cleanup = new CleanupRegistry();
23+
cleanup.trackGateway(host, "nemoclaw");
24+
cleanup.trackSandbox(host, "e2e-resource");
25+
cleanup.trackForward(host, 18789);
26+
27+
const result = await cleanup.runAll();
28+
expect(calls).toEqual(["forward:18789", "sandbox:e2e-resource", "gateway:nemoclaw"]);
29+
expect(result.failures).toEqual([]);
30+
});
31+
32+
it("supports partial setup and runs each registration only once", async () => {
33+
let calls = 0;
34+
const cleanup = new CleanupRegistry();
35+
cleanup.trackDisposable("close acquired server", () => {
36+
calls += 1;
37+
});
38+
39+
expect((await cleanup.runAll()).passed).toEqual(["close acquired server"]);
40+
expect(await cleanup.runAll()).toEqual({ passed: [], failures: [] });
41+
expect(calls).toBe(1);
42+
});
43+
44+
it("redacts failures and continues cleanup", async () => {
45+
const calls: string[] = [];
46+
const cleanup = new CleanupRegistry((text) => text.replaceAll("secret", "[REDACTED]"));
47+
cleanup.trackDisposable("later secret cleanup", () => {
48+
calls.push("later");
49+
});
50+
cleanup.trackDisposable("failing secret cleanup", () => {
51+
throw new Error("secret failure");
52+
});
53+
54+
const result = await cleanup.runAll();
55+
expect(calls).toEqual(["later"]);
56+
expect(result).toEqual({
57+
passed: ["later [REDACTED] cleanup"],
58+
failures: [{ name: "failing [REDACTED] cleanup", message: "[REDACTED] failure" }],
59+
});
60+
});
61+
});

test/e2e/support/e2e-clients.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,34 @@ describe("E2E fixture clients", () => {
183183
]);
184184
});
185185

186+
it.each([
187+
"No active forward",
188+
"forward 18789 not found",
189+
"forward stop failed: not running",
190+
])("host client accepts canonical already-absent forward output: %s", async (stderr) => {
191+
const runner = new FakeRunner();
192+
runner.enqueue({ exitCode: 1, stderr });
193+
const host = new HostCliClient(runner, { cliPath: "nemoclaw" });
194+
195+
await host.cleanupForward(18789);
196+
197+
expect(runner.calls.map((call) => call.args)).toEqual([["forward", "stop", "18789"]]);
198+
});
199+
200+
it.each([
201+
"permission denied",
202+
"daemon not running",
203+
"some unrelated error: not running",
204+
])("host client surfaces unexpected forward cleanup failure: %s", async (stderr) => {
205+
const runner = new FakeRunner();
206+
runner.enqueue({ exitCode: 1, stderr });
207+
const host = new HostCliClient(runner, { cliPath: "nemoclaw" });
208+
209+
await expect(host.cleanupForward(18789)).rejects.toThrow(
210+
`cleanup forward 18789 failed: ${stderr}`,
211+
);
212+
});
213+
186214
it("host client does not hide a current gateway remove failure behind the legacy verb", async () => {
187215
const runner = new FakeRunner();
188216
runner.enqueue({ exitCode: 1, stderr: "permission denied" });

test/e2e/support/hermes-gpu-startup-integrity.test.ts

Lines changed: 76 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ interface IntegrityFixture {
2222
}
2323

2424
const roots: string[] = [];
25+
const MCP_STATE_RECORD = `# nemoclaw-hermes-mcp-state-v1 intended=${"1".repeat(64)} applied=${"2".repeat(64)}`;
2526

2627
afterEach(() => {
2728
for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true });
@@ -38,7 +39,18 @@ function writeHash(
3839
envPath: string,
3940
env: string,
4041
): void {
41-
fs.writeFileSync(hashPath, `${digest(config)} ${configPath}\n${digest(env)} ${envPath}\n`);
42+
fs.writeFileSync(
43+
hashPath,
44+
`${digest(config)} ${configPath}\n${digest(env)} ${envPath}\n${MCP_STATE_RECORD}\n`,
45+
);
46+
}
47+
48+
function readHashRecords(hashPath: string): string[] {
49+
return fs.readFileSync(hashPath, "utf-8").split("\n").slice(0, -1);
50+
}
51+
52+
function writeHashRecords(hashPath: string, records: readonly string[]): void {
53+
fs.writeFileSync(hashPath, `${records.join("\n")}\n`);
4254
}
4355

4456
function createFixture(): IntegrityFixture {
@@ -96,7 +108,7 @@ function runProof(fixture: IntegrityFixture, extraEnv: NodeJS.ProcessEnv = {}) {
96108
}
97109

98110
describe("Hermes managed startup integrity proof", () => {
99-
it("accepts a current compatibility hash and one generated API key beyond the strict base", () => {
111+
it("accepts canonical file and MCP state records with one generated API key beyond the strict base (#6427)", () => {
100112
const fixture = createFixture();
101113
const rawStrictCheck = spawnSync("sha256sum", ["-c", fixture.strictHashPath, "--status"], {
102114
encoding: "utf-8",
@@ -111,6 +123,68 @@ describe("Hermes managed startup integrity proof", () => {
111123
expect(proof.stdout).toBe("OK\n");
112124
});
113125

126+
it("rejects a missing Hermes MCP state record (#6427)", () => {
127+
const fixture = createFixture();
128+
const [configRecord, envRecord] = readHashRecords(fixture.compatHashPath);
129+
writeHashRecords(fixture.compatHashPath, [configRecord!, envRecord!]);
130+
131+
const proof = runProof(fixture);
132+
expect(proof.status).not.toBe(0);
133+
expect(proof.stderr).toContain(
134+
"Hermes compatibility hash does not contain exactly three records",
135+
);
136+
});
137+
138+
it("rejects a malformed Hermes MCP state record (#6427)", () => {
139+
const fixture = createFixture();
140+
const [configRecord, envRecord] = readHashRecords(fixture.compatHashPath);
141+
writeHashRecords(fixture.compatHashPath, [
142+
configRecord!,
143+
envRecord!,
144+
`# nemoclaw-hermes-mcp-state-v1 intended=${"1".repeat(64)} applied=invalid`,
145+
]);
146+
147+
const proof = runProof(fixture);
148+
expect(proof.status).not.toBe(0);
149+
expect(proof.stderr).toContain(
150+
"Hermes compatibility hash contains an unexpected MCP state record",
151+
);
152+
});
153+
154+
it("rejects duplicate Hermes MCP state records (#6427)", () => {
155+
const fixture = createFixture();
156+
const records = readHashRecords(fixture.compatHashPath);
157+
writeHashRecords(fixture.compatHashPath, [...records, MCP_STATE_RECORD]);
158+
159+
const proof = runProof(fixture);
160+
expect(proof.status).not.toBe(0);
161+
expect(proof.stderr).toContain(
162+
"Hermes compatibility hash does not contain exactly three records",
163+
);
164+
});
165+
166+
it("rejects a reordered Hermes MCP state record (#6427)", () => {
167+
const fixture = createFixture();
168+
const [configRecord, envRecord, stateRecord] = readHashRecords(fixture.compatHashPath);
169+
writeHashRecords(fixture.compatHashPath, [stateRecord!, configRecord!, envRecord!]);
170+
171+
const proof = runProof(fixture);
172+
expect(proof.status).not.toBe(0);
173+
expect(proof.stderr).toContain("Hermes compatibility hash contains an unexpected file record");
174+
});
175+
176+
it("rejects unexpected records after the Hermes MCP state record (#6427)", () => {
177+
const fixture = createFixture();
178+
const records = readHashRecords(fixture.compatHashPath);
179+
writeHashRecords(fixture.compatHashPath, [...records, "unexpected"]);
180+
181+
const proof = runProof(fixture);
182+
expect(proof.status).not.toBe(0);
183+
expect(proof.stderr).toContain(
184+
"Hermes compatibility hash does not contain exactly three records",
185+
);
186+
});
187+
114188
it("rejects non-key environment drift even when the compatibility hash accepts it", () => {
115189
const fixture = createFixture();
116190
const config = fs.readFileSync(fixture.configPath, "utf-8");

0 commit comments

Comments
 (0)