|
| 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