Skip to content

Commit 32ce1db

Browse files
committed
test(e2e): move Hermes switch branches to helpers
Signed-off-by: Carlos Villela <cvillela@nvidia.com>
1 parent c57bee3 commit 32ce1db

2 files changed

Lines changed: 297 additions & 212 deletions

File tree

Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,236 @@
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 os from "node:os";
6+
import path from "node:path";
7+
8+
import { buildAvailabilityProbeEnv } from "../fixtures/availability-env.ts";
9+
import type { HostCliClient } from "../fixtures/clients/host.ts";
10+
import { resultText } from "../fixtures/clients/index.ts";
11+
import {
12+
type SandboxClient,
13+
trustedSandboxShellScript,
14+
validateSandboxName,
15+
} from "../fixtures/clients/sandbox.ts";
16+
import type { ShellProbeResult } from "../fixtures/shell-probe.ts";
17+
import { isTransientProviderValidationFailure } from "./network-policy-transient-provider.ts";
18+
19+
export const REPO_ROOT = path.resolve(import.meta.dirname, "../../..");
20+
export const CLI = path.join(REPO_ROOT, "bin", "nemoclaw.js");
21+
export const SANDBOX_NAME = process.env.NEMOCLAW_SANDBOX_NAME ?? "e2e-hermes-inference-switch";
22+
validateSandboxName(SANDBOX_NAME);
23+
export const SWITCH_PROVIDER = process.env.NEMOCLAW_SWITCH_PROVIDER ?? "nvidia-prod";
24+
export const SWITCH_MODEL = process.env.NEMOCLAW_SWITCH_MODEL ?? "z-ai/glm-5.1";
25+
export const SWITCH_API = process.env.NEMOCLAW_SWITCH_INFERENCE_API ?? "openai-completions";
26+
const INSTALL_ATTEMPTS = process.env.CI === "true" || process.env.GITHUB_ACTIONS === "true" ? 3 : 1;
27+
28+
export function env(apiKey?: string): NodeJS.ProcessEnv {
29+
const out: NodeJS.ProcessEnv = {
30+
...buildAvailabilityProbeEnv(),
31+
NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE: "1",
32+
NEMOCLAW_AGENT: "hermes",
33+
NEMOCLAW_NON_INTERACTIVE: "1",
34+
NEMOCLAW_RECREATE_SANDBOX: "1",
35+
NEMOCLAW_SANDBOX_NAME: SANDBOX_NAME,
36+
OPENSHELL_GATEWAY: process.env.OPENSHELL_GATEWAY ?? "nemoclaw",
37+
};
38+
apiKey && Object.assign(out, { NVIDIA_INFERENCE_API_KEY: apiKey, NVIDIA_API_KEY: apiKey });
39+
return out;
40+
}
41+
42+
export async function bestEffort(run: () => Promise<unknown>): Promise<void> {
43+
try {
44+
await run();
45+
} catch {}
46+
}
47+
48+
export function parseHermesModelBlock(text: string): Record<string, string> {
49+
const model: Record<string, string> = {};
50+
let inModel = false;
51+
for (const line of text.split(/\r?\n/u)) {
52+
const entersModel = /^model:\s*$/u.test(line);
53+
entersModel && (inModel = true);
54+
if (entersModel) continue;
55+
if (inModel && /^[A-Za-z0-9_-]+:/u.test(line)) break;
56+
const match = inModel ? line.match(/^\s+([A-Za-z0-9_-]+):\s*(.*?)\s*$/u) : null;
57+
match && (model[match[1]] = match[2].replace(/^['"]|['"]$/gu, ""));
58+
}
59+
return model;
60+
}
61+
62+
export function chatContent(raw: string): string {
63+
const parsed = JSON.parse(raw) as {
64+
choices?: Array<{ message?: Record<string, unknown> }>;
65+
content?: Array<{ text?: unknown }>;
66+
};
67+
const anthropicText = parsed.content?.find((part) => typeof part.text === "string")?.text;
68+
const message = parsed.choices?.[0]?.message ?? {};
69+
const values = [anthropicText, message.content, message.reasoning_content, message.reasoning];
70+
return (
71+
values
72+
.find((value): value is string => typeof value === "string" && value.trim().length > 0)
73+
?.trim() ?? ""
74+
);
75+
}
76+
77+
export async function cleanupHermesSwitch(
78+
host: HostCliClient,
79+
sandbox: SandboxClient,
80+
): Promise<void> {
81+
await bestEffort(() =>
82+
host.command("node", [CLI, SANDBOX_NAME, "destroy", "--yes"], {
83+
artifactName: "cleanup-nemoclaw-destroy",
84+
env: env(),
85+
timeoutMs: 120_000,
86+
}),
87+
);
88+
await bestEffort(() =>
89+
sandbox.openshell(["sandbox", "delete", SANDBOX_NAME], {
90+
artifactName: "cleanup-openshell-delete",
91+
env: env(),
92+
timeoutMs: 60_000,
93+
}),
94+
);
95+
}
96+
97+
export async function installHermes(
98+
host: HostCliClient,
99+
apiKey: string,
100+
): Promise<ShellProbeResult> {
101+
let install: ShellProbeResult | undefined;
102+
for (let attempt = 1; attempt <= INSTALL_ATTEMPTS; attempt += 1) {
103+
install = await host.command(
104+
"bash",
105+
["install.sh", "--non-interactive", "--yes-i-accept-third-party-software"],
106+
{
107+
artifactName: attempt === 1 ? "install-hermes" : `install-hermes-attempt-${attempt}`,
108+
cwd: REPO_ROOT,
109+
env: env(apiKey),
110+
redactionValues: [apiKey],
111+
timeoutMs: 25 * 60_000,
112+
},
113+
);
114+
const retry =
115+
install.exitCode !== 0 &&
116+
isTransientProviderValidationFailure(install) &&
117+
attempt < INSTALL_ATTEMPTS;
118+
install.exitCode === 0 && (attempt = INSTALL_ATTEMPTS + 1);
119+
retry && (await new Promise((resolve) => setTimeout(resolve, 10_000 * attempt)));
120+
!retry && install.exitCode !== 0 && (attempt = INSTALL_ATTEMPTS + 1);
121+
}
122+
if (!install) throw new Error("install command did not run");
123+
return install;
124+
}
125+
126+
export async function hermesGatewayPid(
127+
sandbox: SandboxClient,
128+
artifactName: string,
129+
): Promise<ShellProbeResult> {
130+
return await sandbox.execShell(
131+
SANDBOX_NAME,
132+
trustedSandboxShellScript(
133+
"ps -eo pid=,comm=,args= | awk '$0 ~ /hermes/ && $0 ~ /gateway run/ { print $1; exit }'",
134+
),
135+
{ artifactName, env: env(), timeoutMs: 30_000 },
136+
);
137+
}
138+
139+
export async function envHash(
140+
sandbox: SandboxClient,
141+
artifactName: string,
142+
): Promise<ShellProbeResult> {
143+
return await sandbox.exec(SANDBOX_NAME, ["sha256sum", "/sandbox/.hermes/.env"], {
144+
artifactName,
145+
env: env(),
146+
timeoutMs: 30_000,
147+
});
148+
}
149+
150+
export function maybeAssertPidStable(
151+
before: ShellProbeResult,
152+
after: ShellProbeResult,
153+
assertStable: (a: string, b: string) => void,
154+
): void {
155+
const beforePid = before.stdout.trim();
156+
const afterPid = after.stdout.trim();
157+
beforePid && afterPid && assertStable(afterPid, beforePid);
158+
}
159+
160+
export function expectedBaseUrl(): string {
161+
return SWITCH_API === "anthropic-messages"
162+
? "https://inference.local"
163+
: "https://inference.local/v1";
164+
}
165+
166+
export function expectedApiMode(): string | undefined {
167+
return new Map<string, string>([
168+
["anthropic-messages", "anthropic_messages"],
169+
["openai-responses", "codex_responses"],
170+
]).get(SWITCH_API);
171+
}
172+
173+
export async function apiKeyShape(sandbox: SandboxClient): Promise<ShellProbeResult> {
174+
return await sandbox.execShell(
175+
SANDBOX_NAME,
176+
trustedSandboxShellScript(
177+
"python3 - <<'PY'\nimport re\ntext=open('/sandbox/.hermes/config.yaml', encoding='utf-8').read()\nmatch=re.search(r'^\\s+api_key:\\s*[\\\"\\']?(sk-[^\\\"\\'\\s]+)', text, re.M)\nraise SystemExit(0 if match else 1)\nPY",
178+
),
179+
{ artifactName: "hermes-config-api-key-shape", env: env(), timeoutMs: 30_000 },
180+
);
181+
}
182+
183+
export async function hashCheck(
184+
sandbox: SandboxClient,
185+
file: string,
186+
artifact: string,
187+
): Promise<ShellProbeResult> {
188+
return await sandbox.execShell(
189+
SANDBOX_NAME,
190+
trustedSandboxShellScript(`sha256sum -c ${file} --status && echo OK`),
191+
{ artifactName: `hermes-${artifact}-hash-check`, env: env(), timeoutMs: 30_000 },
192+
);
193+
}
194+
195+
export async function strictHashPerms(sandbox: SandboxClient): Promise<ShellProbeResult> {
196+
return await sandbox.execShell(
197+
SANDBOX_NAME,
198+
trustedSandboxShellScript("stat -c '%u %a' /etc/nemoclaw/hermes.config-hash"),
199+
{ artifactName: "hermes-strict-hash-perms", env: env(), timeoutMs: 30_000 },
200+
);
201+
}
202+
203+
export function maybeAssertEnvHashStable(
204+
before: ShellProbeResult,
205+
after: ShellProbeResult,
206+
assertStable: (a: string, b: string) => void,
207+
): void {
208+
const beforeHash = before.stdout.split(/\s+/u)[0] ?? "";
209+
const afterHash = after.stdout.split(/\s+/u)[0] ?? "";
210+
beforeHash && assertStable(afterHash, beforeHash);
211+
}
212+
213+
export function registryState(): { registry: Record<string, any>; session: Record<string, any> } {
214+
return {
215+
registry: JSON.parse(
216+
fs.readFileSync(path.join(os.homedir(), ".nemoclaw", "sandboxes.json"), "utf8"),
217+
),
218+
session: JSON.parse(
219+
fs.readFileSync(path.join(os.homedir(), ".nemoclaw", "onboard-session.json"), "utf8"),
220+
),
221+
};
222+
}
223+
224+
function quotePayload(payload: string): string {
225+
return payload.replace(/'/gu, `'\\''`);
226+
}
227+
228+
export function inferenceLocalCommand(payload: string): string {
229+
return SWITCH_API === "anthropic-messages"
230+
? `curl -sS --max-time 90 https://inference.local/v1/messages -H 'Content-Type: application/json' -H 'anthropic-version: 2023-06-01' -d '${quotePayload(payload)}'`
231+
: `curl -sS --max-time 90 https://inference.local/v1/chat/completions -H 'Content-Type: application/json' -d '${quotePayload(payload)}'`;
232+
}
233+
234+
export function hermesApiCommand(payload: string): string {
235+
return `set -a; [ ! -f /sandbox/.hermes/.env ] || . /sandbox/.hermes/.env; set +a; curl -sS --max-time 120 http://localhost:8642/v1/chat/completions -H 'Content-Type: application/json' -H "Authorization: Bearer \${API_SERVER_KEY:-}" -d '${quotePayload(payload)}'`;
236+
}

0 commit comments

Comments
 (0)