Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ The prompt tells your agent to use NemoClaw docs and skills, ask one question at

Review [Prerequisites](https://docs.nvidia.com/nemoclaw/latest/get-started/prerequisites.html) before installing.
For Hermes, set `NEMOCLAW_AGENT=hermes` before running the installer, or use the `nemohermes` alias after install.
When connecting to a Hermes sandbox from a light terminal, NemoClaw may install a managed `nemoclaw-light` Hermes skin for readable assistant text; it removes that managed skin state again when the terminal no longer needs it and preserves any user-selected Hermes skin.

| Agent | Guide |
|-------|-------|
Expand Down
31 changes: 31 additions & 0 deletions scripts/checks/hermes-light-skin-boundary.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";

import { NEMOCLAW_HERMES_LIGHT_SKIN_REVIEWED_HERMES_VERSIONS } from "../../src/lib/domain/sandbox/connect-env";

const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const HERMES_DOCKERFILE_BASE = "agents/hermes/Dockerfile.base";

function main(): void {
const dockerfile = fs.readFileSync(path.join(REPO_ROOT, HERMES_DOCKERFILE_BASE), "utf8");
const pinnedVersion = dockerfile.match(/^ARG HERMES_VERSION=(\S+)$/m)?.[1];
if (!pinnedVersion) {
throw new Error(`${HERMES_DOCKERFILE_BASE}: could not find ARG HERMES_VERSION`);
}
const reviewedVersions: readonly string[] = NEMOCLAW_HERMES_LIGHT_SKIN_REVIEWED_HERMES_VERSIONS;
if (!reviewedVersions.includes(pinnedVersion)) {
throw new Error(
[
"Hermes light terminal compatibility skin needs re-review.",
`${HERMES_DOCKERFILE_BASE} pins ${pinnedVersion}, but connect-env.ts was reviewed for ${reviewedVersions.join(", ")}.`,
"Remove the NemoClaw-managed light skin if upstream Hermes is readable in light terminals, or update the reviewed version constant after validating it still needs the shim.",
].join(" "),
);
}
}

main();
5 changes: 5 additions & 0 deletions scripts/checks/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@ const CHECKS: readonly CheckCommand[] = [
command: TSX,
args: ["scripts/checks/local-credential-helper-pin.ts"],
},
{
name: "hermes-light-skin-boundary",
command: TSX,
args: ["scripts/checks/hermes-light-skin-boundary.ts"],
},
{
name: "no-coverage-ignore",
command: TSX,
Expand Down
121 changes: 121 additions & 0 deletions src/lib/actions/sandbox/connect-hermes-light-skin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { runOpenshell } from "../../adapters/openshell/runtime";
import { OPENSHELL_PROBE_TIMEOUT_MS } from "../../adapters/openshell/timeouts";
import { R, YW } from "../../cli/terminal-style";
import { shellQuote } from "../../core/shell-quote";
import {
applyHermesLightSkinConfig,
hermesConfigUsesManagedLightSkin,
NEMOCLAW_HERMES_LIGHT_SKIN_YAML,
removeHermesLightSkinConfig,
shouldApplyHermesLightSkin,
shouldInspectHermesLightSkinConfig,
shouldRemoveHermesLightSkin,
} from "../../domain/sandbox/connect-env";
import { readSandboxConfig, resolveAgentConfig, writeSandboxConfig } from "../../sandbox/config";
import { redact } from "../../security/redact";

type ConnectAgent = { name?: string } | null | undefined;

function encodeForSandboxWrite(content: string): string {
return Buffer.from(content, "utf8").toString("base64");
}

function warnHermesLightSkinFailure(action: string, error: unknown): void {
const detail = error instanceof Error && error.message ? `: ${redact(error.message)}` : "";
console.error(` ${YW}⚠${R} Could not ${action} Hermes light terminal skin${detail}`);
}

function writeHermesLightSkinFile(sandboxName: string): boolean {
const skinB64 = encodeForSandboxWrite(NEMOCLAW_HERMES_LIGHT_SKIN_YAML);
const script = [
"set -eu",
'hermes_home="${HERMES_HOME:-/sandbox/.hermes}"',
'skin_dir="$hermes_home/skins"',
'mkdir -p "$skin_dir"',
'tmp="$(mktemp "$skin_dir/.nemoclaw-light.XXXXXX")"',
"trap 'rm -f \"$tmp\"' EXIT",
`printf %s ${shellQuote(skinB64)} | base64 -d > "$tmp"`,
'chmod 640 "$tmp"',
'mv -f "$tmp" "$skin_dir/nemoclaw-light.yaml"',
'chown sandbox:sandbox "$skin_dir/nemoclaw-light.yaml" 2>/dev/null || true',
].join("\n");
const result = runOpenshell(
["sandbox", "exec", "--name", sandboxName, "--", "sh", "-c", script],
{
ignoreError: true,
stdio: "ignore",
timeout: OPENSHELL_PROBE_TIMEOUT_MS,
},
);
if (result.status === 0 && !result.error && !result.signal) return true;
warnHermesLightSkinFailure("write", result.error ?? `exit ${result.status ?? result.signal}`);
return false;
}

function removeHermesLightSkinFile(sandboxName: string): boolean {
const script = [
"set -eu",
'hermes_home="${HERMES_HOME:-/sandbox/.hermes}"',
'skin_dir="$hermes_home/skins"',
'rm -f "$skin_dir/nemoclaw-light.yaml"',
].join("\n");
const result = runOpenshell(
["sandbox", "exec", "--name", sandboxName, "--", "sh", "-c", script],
{
ignoreError: true,
stdio: "ignore",
timeout: OPENSHELL_PROBE_TIMEOUT_MS,
},
);
if (result.status === 0 && !result.error && !result.signal) return true;
warnHermesLightSkinFailure("remove", result.error ?? `exit ${result.status ?? result.signal}`);
return false;
}

export function prepareHermesLightTerminalSkin(
sandboxName: string,
agent: ConnectAgent,
env: NodeJS.ProcessEnv,
): void {
if (agent?.name !== "hermes") return;
if (!shouldInspectHermesLightSkinConfig(agent, env)) return;

const target = resolveAgentConfig(sandboxName);
if (target.agentName !== "hermes") return;

let config: ReturnType<typeof readSandboxConfig>;
try {
config = readSandboxConfig(sandboxName, target);
} catch (error) {
warnHermesLightSkinFailure("read", error);
return;
}

if (shouldRemoveHermesLightSkin(agent, env, config)) {
if (!removeHermesLightSkinConfig(config)) return;
try {
writeSandboxConfig(sandboxName, target, config);
} catch (error) {
warnHermesLightSkinFailure("update", error);
return;
}
if (!removeHermesLightSkinFile(sandboxName)) return;
return;
}

if (!shouldApplyHermesLightSkin(agent, env, config)) return;
const changed = applyHermesLightSkinConfig(config);
if (!changed && !hermesConfigUsesManagedLightSkin(config)) return;
if (!writeHermesLightSkinFile(sandboxName)) return;
if (!changed) return;

try {
writeSandboxConfig(sandboxName, target, config);
} catch (error) {
warnHermesLightSkinFailure("update", error);
if (!removeHermesLightSkinFile(sandboxName)) return;
}
}
Loading
Loading