diff --git a/README.md b/README.md index 11cee57eb5c..b1193249d6e 100644 --- a/README.md +++ b/README.md @@ -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 | |-------|-------| diff --git a/scripts/checks/hermes-light-skin-boundary.ts b/scripts/checks/hermes-light-skin-boundary.ts new file mode 100644 index 00000000000..f9d784dbbc4 --- /dev/null +++ b/scripts/checks/hermes-light-skin-boundary.ts @@ -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(); diff --git a/scripts/checks/run.ts b/scripts/checks/run.ts index 8e372c193ce..60f05131d08 100644 --- a/scripts/checks/run.ts +++ b/scripts/checks/run.ts @@ -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, diff --git a/src/lib/actions/sandbox/connect-hermes-light-skin.ts b/src/lib/actions/sandbox/connect-hermes-light-skin.ts new file mode 100644 index 00000000000..4a61f6fd38f --- /dev/null +++ b/src/lib/actions/sandbox/connect-hermes-light-skin.ts @@ -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; + 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; + } +} diff --git a/src/lib/actions/sandbox/connect-hermes-light-theme.test.ts b/src/lib/actions/sandbox/connect-hermes-light-theme.test.ts new file mode 100644 index 00000000000..2a09e63e87b --- /dev/null +++ b/src/lib/actions/sandbox/connect-hermes-light-theme.test.ts @@ -0,0 +1,360 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { afterEach, beforeEach, describe, expect, it, type MockInstance, vi } from "vitest"; + +import { + connectModulePath, + createConnectHarness, + requireDist, +} from "../../../../test/support/connect-flow-test-harness"; +import { NEMOCLAW_HERMES_LIGHT_SKIN_NAME } from "../../domain/sandbox/connect-env"; + +const REDACTED_URL_CANARY = "https://user:secret@example.test/hermes"; + +type ConnectHarness = ReturnType; + +function connectCalls(harness: ConnectHarness, sandboxName = "alpha") { + return harness.spawnSyncSpy.mock.calls.filter( + ([command, args]) => + command === "openshell" && + Array.isArray(args) && + args.join(" ") === `sandbox connect ${sandboxName}`, + ); +} + +function skinWriteCalls(harness: ConnectHarness, sandboxName = "alpha") { + return harness.runOpenshellSpy.mock.calls.filter( + ([args]) => + Array.isArray(args) && + args.slice(0, 6).join(" ") === `sandbox exec --name ${sandboxName} -- sh` && + String(args[7] ?? "").includes('mv -f "$tmp" "$skin_dir/nemoclaw-light.yaml"'), + ); +} + +function skinRemoveCalls(harness: ConnectHarness, sandboxName = "alpha") { + return harness.runOpenshellSpy.mock.calls.filter( + ([args]) => + Array.isArray(args) && + args.slice(0, 6).join(" ") === `sandbox exec --name ${sandboxName} -- sh` && + String(args[7] ?? "").includes('rm -f "$skin_dir/nemoclaw-light.yaml"'), + ); +} + +function warningText(harness: ConnectHarness): string { + return harness.errorSpy.mock.calls.map((call) => call.join(" ")).join("\n"); +} + +function expectConnectSucceeded(harness: ConnectHarness, exitSpy: MockInstance): void { + expect(connectCalls(harness)).toHaveLength(1); + expect(exitSpy).toHaveBeenCalledWith(0); +} + +describe("Hermes sandbox connect light terminal skin", () => { + let exitSpy: MockInstance; + const originalStdoutIsTty = process.stdout.isTTY; + + beforeEach(() => { + vi.stubEnv("NEMOCLAW_TEST_NO_SLEEP", "1"); + vi.stubEnv("HERMES_TUI_LIGHT", ""); + vi.stubEnv("HERMES_TUI_THEME", ""); + Object.defineProperty(process.stdout, "isTTY", { + configurable: true, + value: true, + }); + exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number | string | null) => { + throw new Error(`process.exit(${code ?? 0})`); + }) as never); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + Object.defineProperty(process.stdout, "isTTY", { + configurable: true, + value: originalStdoutIsTty, + }); + delete require.cache[requireDist.resolve(connectModulePath)]; + }); + + it("prepares the NemoClaw Hermes light skin inside the sandbox on light macOS Terminal.app (#6380)", async () => { + vi.stubEnv("TERM_PROGRAM", "Apple_Terminal"); + vi.stubEnv("COLORFGBG", "0;15"); + vi.stubEnv("HERMES_TUI_LIGHT", ""); + const harness = createConnectHarness({ + agentName: "hermes", + hermesConfig: { model: "test" }, + sessionAgent: { + name: "hermes", + runtime: { kind: "terminal", interactive_command: "hermes" }, + }, + }); + + await expect(harness.connectSandbox("alpha")).rejects.toThrow("process.exit(0)"); + + expect(harness.readSandboxConfigSpy).toHaveBeenCalledWith( + "alpha", + expect.objectContaining({ agentName: "hermes" }), + ); + const skinWriteCall = skinWriteCalls(harness)[0]; + expect(skinWriteCall?.[0][7]).toContain("nemoclaw-light.yaml"); + expect(skinWriteCall?.[0][7]).not.toContain("config.yaml"); + expect(harness.writeSandboxConfigSpy).toHaveBeenCalledOnce(); + expect(harness.writeSandboxConfigSpy.mock.calls[0][2]).toMatchObject({ + display: { skin: NEMOCLAW_HERMES_LIGHT_SKIN_NAME }, + }); + + const connectCall = connectCalls(harness)[0]; + expect(connectCall?.[2]).toEqual( + expect.objectContaining({ + env: expect.objectContaining({ + COLORFGBG: "0;15", + TERM_PROGRAM: "Apple_Terminal", + }), + }), + ); + expect(connectCall?.[2]?.env).not.toEqual(expect.objectContaining({ HERMES_TUI_LIGHT: "1" })); + expectConnectSucceeded(harness, exitSpy); + }); + + it("does not prepare the NemoClaw Hermes light skin when the sandbox Hermes config already sets display.skin (#6380)", async () => { + vi.stubEnv("TERM_PROGRAM", "Apple_Terminal"); + vi.stubEnv("COLORFGBG", "0;15"); + const harness = createConnectHarness({ + agentName: "hermes", + hermesConfig: { display: { skin: "solarized-light" } }, + sessionAgent: { + name: "hermes", + runtime: { kind: "terminal", interactive_command: "hermes" }, + }, + }); + + await expect(harness.connectSandbox("alpha")).rejects.toThrow("process.exit(0)"); + + expect(skinWriteCalls(harness)).toHaveLength(0); + expect(harness.writeSandboxConfigSpy).not.toHaveBeenCalled(); + expect(harness.readSandboxConfigSpy).toHaveBeenCalledOnce(); + expect(harness.errorSpy).not.toHaveBeenCalledWith(expect.stringContaining("Hermes light")); + expectConnectSucceeded(harness, exitSpy); + }); + + it("removes NemoClaw-managed Hermes light skin state when reconnecting from a dark terminal (#6380)", async () => { + vi.stubEnv("COLORFGBG", "0;0"); + const hermesConfig = { + display: { skin: NEMOCLAW_HERMES_LIGHT_SKIN_NAME }, + model: "test", + }; + const harness = createConnectHarness({ + agentName: "hermes", + hermesConfig, + sessionAgent: { + name: "hermes", + runtime: { kind: "terminal", interactive_command: "hermes" }, + }, + }); + + await expect(harness.connectSandbox("alpha")).rejects.toThrow("process.exit(0)"); + + expect(harness.resolveAgentConfigSpy).toHaveBeenCalledOnce(); + expect(harness.readSandboxConfigSpy).toHaveBeenCalledOnce(); + expect(skinWriteCalls(harness)).toHaveLength(0); + expect(skinRemoveCalls(harness)).toHaveLength(1); + expect(harness.writeSandboxConfigSpy).toHaveBeenCalledOnce(); + expect(hermesConfig).toEqual({ model: "test" }); + expectConnectSucceeded(harness, exitSpy); + }); + + it("warns but continues when dark-terminal skin file cleanup fails (#6380)", async () => { + vi.stubEnv("COLORFGBG", "0;0"); + const hermesConfig = { + display: { skin: NEMOCLAW_HERMES_LIGHT_SKIN_NAME }, + model: "test", + }; + const harness = createConnectHarness({ + agentName: "hermes", + hermesConfig, + sessionAgent: { + name: "hermes", + runtime: { kind: "terminal", interactive_command: "hermes" }, + }, + }); + harness.runOpenshellSpy.mockImplementation((args: unknown) => { + const script = Array.isArray(args) ? String(args[7] ?? "") : ""; + return script.includes('rm -f "$skin_dir/nemoclaw-light.yaml"') + ? { status: 2, error: new Error(`remove failed ${REDACTED_URL_CANARY}`) } + : { status: 0 }; + }); + + await expect(harness.connectSandbox("alpha")).rejects.toThrow("process.exit(0)"); + + expect(skinRemoveCalls(harness)).toHaveLength(1); + expect(harness.writeSandboxConfigSpy).toHaveBeenCalledOnce(); + expect(warningText(harness)).toContain("Could not remove Hermes light terminal skin"); + expect(warningText(harness)).not.toContain("user:secret"); + expectConnectSucceeded(harness, exitSpy); + }); + + it("does not read or write Hermes config when a Hermes theme override is set (#6380)", async () => { + vi.stubEnv("COLORFGBG", "0;15"); + vi.stubEnv("HERMES_TUI_THEME", "dark"); + const harness = createConnectHarness({ + agentName: "hermes", + hermesConfig: { model: "test" }, + sessionAgent: { + name: "hermes", + runtime: { kind: "terminal", interactive_command: "hermes" }, + }, + }); + + await expect(harness.connectSandbox("alpha")).rejects.toThrow("process.exit(0)"); + + expect(harness.resolveAgentConfigSpy).not.toHaveBeenCalled(); + expect(harness.readSandboxConfigSpy).not.toHaveBeenCalled(); + expect(skinWriteCalls(harness)).toHaveLength(0); + expect(harness.writeSandboxConfigSpy).not.toHaveBeenCalled(); + expect(warningText(harness)).not.toContain("Could not"); + expectConnectSucceeded(harness, exitSpy); + }); + + it("targets only the requested Hermes sandbox when sibling sandboxes are registered (#6380)", async () => { + vi.stubEnv("TERM_PROGRAM", "Apple_Terminal"); + vi.stubEnv("COLORFGBG", "0;15"); + const alphaConfig = { model: "alpha" }; + const betaConfig = { display: { skin: "beta-owned" }, model: "beta" }; + const harness = createConnectHarness({ + agentName: "hermes", + registryEntries: [ + { name: "alpha", agent: "hermes" }, + { name: "beta", agent: "hermes" }, + ], + sessionAgent: { + name: "hermes", + runtime: { kind: "terminal", interactive_command: "hermes" }, + }, + }); + harness.readSandboxConfigSpy.mockImplementation((name: unknown) => + String(name) === "alpha" ? alphaConfig : betaConfig, + ); + + await expect(harness.connectSandbox("alpha")).rejects.toThrow("process.exit(0)"); + + expect(harness.resolveAgentConfigSpy.mock.calls.map(([name]) => String(name))).toEqual([ + "alpha", + ]); + expect(harness.readSandboxConfigSpy.mock.calls.map(([name]) => String(name))).toEqual([ + "alpha", + ]); + expect(harness.writeSandboxConfigSpy.mock.calls.map(([name]) => String(name))).toEqual([ + "alpha", + ]); + expect(skinWriteCalls(harness, "alpha")).toHaveLength(1); + expect(skinWriteCalls(harness, "beta")).toHaveLength(0); + expect(betaConfig).toEqual({ + display: { skin: "beta-owned" }, + model: "beta", + }); + expect(connectCalls(harness, "beta")).toHaveLength(0); + expectConnectSucceeded(harness, exitSpy); + }); + + it("continues connecting when Hermes config read fails during light-skin preparation (#6380)", async () => { + vi.stubEnv("COLORFGBG", "0;15"); + const harness = createConnectHarness({ + agentName: "hermes", + sessionAgent: { + name: "hermes", + runtime: { kind: "terminal", interactive_command: "hermes" }, + }, + }); + harness.readSandboxConfigSpy.mockImplementationOnce(() => { + throw new Error(`read failed ${REDACTED_URL_CANARY}`); + }); + + await expect(harness.connectSandbox("alpha")).rejects.toThrow("process.exit(0)"); + + expect(skinWriteCalls(harness)).toHaveLength(0); + expect(harness.writeSandboxConfigSpy).not.toHaveBeenCalled(); + expect(warningText(harness)).toContain("Could not read Hermes light terminal skin"); + expect(warningText(harness)).not.toContain("user:secret"); + expectConnectSucceeded(harness, exitSpy); + }); + + it("continues connecting when Hermes skin file write fails before config update (#6380)", async () => { + vi.stubEnv("COLORFGBG", "0;15"); + const harness = createConnectHarness({ + agentName: "hermes", + hermesConfig: { model: "test" }, + sessionAgent: { + name: "hermes", + runtime: { kind: "terminal", interactive_command: "hermes" }, + }, + }); + harness.runOpenshellSpy.mockImplementation((args: unknown) => + Array.isArray(args) && args.slice(0, 6).join(" ") === "sandbox exec --name alpha -- sh" + ? { status: 2, error: new Error(`write failed ${REDACTED_URL_CANARY}`) } + : { status: 0 }, + ); + + await expect(harness.connectSandbox("alpha")).rejects.toThrow("process.exit(0)"); + + expect(skinWriteCalls(harness)).toHaveLength(1); + expect(harness.writeSandboxConfigSpy).not.toHaveBeenCalled(); + expect(warningText(harness)).toContain("Could not write Hermes light terminal skin"); + expect(warningText(harness)).not.toContain("user:secret"); + expectConnectSucceeded(harness, exitSpy); + }); + + it("continues connecting when Hermes config update fails after skin write (#6380)", async () => { + vi.stubEnv("COLORFGBG", "0;15"); + const harness = createConnectHarness({ + agentName: "hermes", + hermesConfig: { model: "test" }, + sessionAgent: { + name: "hermes", + runtime: { kind: "terminal", interactive_command: "hermes" }, + }, + }); + harness.writeSandboxConfigSpy.mockImplementationOnce(() => { + throw new Error(`update failed ${REDACTED_URL_CANARY}`); + }); + + await expect(harness.connectSandbox("alpha")).rejects.toThrow("process.exit(0)"); + + expect(skinWriteCalls(harness)).toHaveLength(1); + expect(skinRemoveCalls(harness)).toHaveLength(1); + expect(harness.writeSandboxConfigSpy).toHaveBeenCalledOnce(); + expect(warningText(harness)).toContain("Could not update Hermes light terminal skin"); + expect(warningText(harness)).not.toContain("user:secret"); + expectConnectSucceeded(harness, exitSpy); + }); + + it("warns when rollback cleanup fails after Hermes config update failure (#6380)", async () => { + vi.stubEnv("COLORFGBG", "0;15"); + const harness = createConnectHarness({ + agentName: "hermes", + hermesConfig: { model: "test" }, + sessionAgent: { + name: "hermes", + runtime: { kind: "terminal", interactive_command: "hermes" }, + }, + }); + harness.writeSandboxConfigSpy.mockImplementationOnce(() => { + throw new Error(`update failed ${REDACTED_URL_CANARY}`); + }); + harness.runOpenshellSpy.mockImplementation((args: unknown) => { + const script = Array.isArray(args) ? String(args[7] ?? "") : ""; + return script.includes('rm -f "$skin_dir/nemoclaw-light.yaml"') + ? { status: 2, error: new Error(`remove failed ${REDACTED_URL_CANARY}`) } + : { status: 0 }; + }); + + await expect(harness.connectSandbox("alpha")).rejects.toThrow("process.exit(0)"); + + expect(skinWriteCalls(harness)).toHaveLength(1); + expect(skinRemoveCalls(harness)).toHaveLength(1); + expect(warningText(harness)).toContain("Could not update Hermes light terminal skin"); + expect(warningText(harness)).toContain("Could not remove Hermes light terminal skin"); + expect(warningText(harness)).not.toContain("user:secret"); + expectConnectSucceeded(harness, exitSpy); + }); +}); diff --git a/src/lib/actions/sandbox/connect.ts b/src/lib/actions/sandbox/connect.ts index 413c8090205..648474a76c9 100644 --- a/src/lib/actions/sandbox/connect.ts +++ b/src/lib/actions/sandbox/connect.ts @@ -63,6 +63,7 @@ import { exitOnMcpReconciliationRefusal, exitOnSecretBoundaryRefusal, } from "./connect-boundary-refusal"; +import { prepareHermesLightTerminalSkin } from "./connect-hermes-light-skin"; import { assertSandboxGatewayRouteCompatible, buildGatewayInferenceGetArgs, @@ -1173,10 +1174,11 @@ export async function connectSandbox( // OPENSHELL_SANDBOX) and covers every other interactive entry path too. console.log(""); } + prepareHermesLightTerminalSkin(sandboxName, agent, process.env); const result = spawnSync(getOpenshellBinary(), ["sandbox", "connect", sandboxName], { stdio: "inherit", cwd: ROOT, - env: process.env, + env: { ...process.env }, }); exitWithConnectSpawnResult(sandboxName, result); } diff --git a/src/lib/domain/sandbox/connect-env.test.ts b/src/lib/domain/sandbox/connect-env.test.ts new file mode 100644 index 00000000000..21402eef891 --- /dev/null +++ b/src/lib/domain/sandbox/connect-env.test.ts @@ -0,0 +1,130 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; +import YAML from "yaml"; + +import { + applyHermesLightSkinConfig, + hermesConfigUsesManagedLightSkin, + NEMOCLAW_HERMES_LIGHT_SKIN_NAME, + NEMOCLAW_HERMES_LIGHT_SKIN_REVIEWED_HERMES_VERSIONS, + NEMOCLAW_HERMES_LIGHT_SKIN_YAML, + removeHermesLightSkinConfig, + shouldApplyHermesLightSkin, + shouldInspectHermesLightSkinConfig, + shouldRemoveHermesLightSkin, +} from "./connect-env"; + +describe("sandbox connect environment helpers", () => { + it("tracks Hermes versions reviewed for the managed light skin compatibility shim (#6380)", () => { + expect(NEMOCLAW_HERMES_LIGHT_SKIN_REVIEWED_HERMES_VERSIONS).toEqual([ + "v2026.6.19", + "v2026.7.1", + ]); + }); + + it("inspects Hermes config only when NemoClaw owns the theme decision (#6380)", () => { + expect( + shouldInspectHermesLightSkinConfig( + { name: "hermes" }, + { COLORFGBG: "0;15", TERM_PROGRAM: "Apple_Terminal" }, + ), + ).toBe(true); + expect( + shouldInspectHermesLightSkinConfig( + { name: "hermes" }, + { COLORFGBG: "0;0", TERM_PROGRAM: "Apple_Terminal" }, + ), + ).toBe(true); + for (const env of [{ HERMES_TUI_LIGHT: "0" }, { HERMES_TUI_THEME: "dark" }]) { + expect( + shouldInspectHermesLightSkinConfig({ name: "hermes" }, { COLORFGBG: "0;15", ...env }), + ).toBe(false); + } + expect(shouldInspectHermesLightSkinConfig({ name: "openclaw" }, { COLORFGBG: "0;15" })).toBe( + false, + ); + }); + + it("does not infer light mode from Apple Terminal without usable COLORFGBG (#6380)", () => { + expect( + shouldApplyHermesLightSkin( + { name: "hermes" }, + { TERM_PROGRAM: "Apple_Terminal" }, + { model: "test" }, + ), + ).toBe(false); + expect( + shouldApplyHermesLightSkin( + { name: "hermes" }, + { COLORFGBG: "not-a-color", TERM_PROGRAM: "Apple_Terminal" }, + { model: "test" }, + ), + ).toBe(false); + }); + + it("pins readable body and startup list colors in the managed Hermes light skin (#6380)", () => { + const skin = YAML.parse(NEMOCLAW_HERMES_LIGHT_SKIN_YAML) as { + colors: Record; + }; + expect(skin.colors).toMatchObject({ + response_body: "#7A5A0F", + response_text: "#7A5A0F", + skill_list_text: "#7A5A0F", + tool_list_text: "#7A5A0F", + }); + }); + + it("applies only the NemoClaw-managed Hermes light skin (#6380)", () => { + const config = { model: "test" }; + + expect(shouldApplyHermesLightSkin({ name: "hermes" }, { COLORFGBG: "0;15" }, config)).toBe( + true, + ); + expect(applyHermesLightSkinConfig(config)).toBe(true); + expect(hermesConfigUsesManagedLightSkin(config)).toBe(true); + }); + + it("removes only the NemoClaw-managed Hermes light skin from config (#6380)", () => { + const config = { + display: { skin: NEMOCLAW_HERMES_LIGHT_SKIN_NAME, width: 100 }, + model: "test", + }; + + expect(shouldRemoveHermesLightSkin({ name: "hermes" }, { COLORFGBG: "0;0" }, config)).toBe( + true, + ); + expect(removeHermesLightSkinConfig(config)).toBe(true); + expect(config).toEqual({ display: { width: 100 }, model: "test" }); + }); + + it("removes the empty display section when it only contains the managed Hermes skin (#6380)", () => { + const config = { + display: { skin: NEMOCLAW_HERMES_LIGHT_SKIN_NAME }, + model: "test", + }; + + expect(removeHermesLightSkinConfig(config)).toBe(true); + expect(config).toEqual({ model: "test" }); + }); + + it("preserves user-owned Hermes display skins (#6380)", () => { + const userConfig = { display: { skin: "solarized-light" } }; + expect(shouldApplyHermesLightSkin({ name: "hermes" }, { COLORFGBG: "0;15" }, userConfig)).toBe( + false, + ); + expect(applyHermesLightSkinConfig(userConfig)).toBe(false); + expect(userConfig.display.skin).toBe("solarized-light"); + }); + + it("preserves explicit non-string Hermes display skin values (#6380)", () => { + const config = { display: { skin: null }, model: "test" }; + + expect(shouldApplyHermesLightSkin({ name: "hermes" }, { COLORFGBG: "0;15" }, config)).toBe( + false, + ); + expect(applyHermesLightSkinConfig(config)).toBe(false); + expect(config.display.skin).toBeNull(); + }); +}); diff --git a/src/lib/domain/sandbox/connect-env.ts b/src/lib/domain/sandbox/connect-env.ts new file mode 100644 index 00000000000..c4e5e4dc172 --- /dev/null +++ b/src/lib/domain/sandbox/connect-env.ts @@ -0,0 +1,126 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { ConfigObject, ConfigValue } from "../../security/credential-filter"; + +export const NEMOCLAW_HERMES_LIGHT_SKIN_NAME = "nemoclaw-light"; +export const NEMOCLAW_HERMES_LIGHT_SKIN_REVIEWED_HERMES_VERSIONS = [ + "v2026.6.19", + "v2026.7.1", +] as const; + +// Compatibility boundary: remove this NemoClaw-managed light skin once the +// pinned Hermes version in agents/hermes/Dockerfile.base includes upstream +// readable light-terminal defaults for assistant response and startup list text. +// The paired unit test intentionally fails on a Hermes version bump so this +// compatibility shim is re-reviewed instead of silently aging forward. +export const NEMOCLAW_HERMES_LIGHT_SKIN_YAML = `name: ${NEMOCLAW_HERMES_LIGHT_SKIN_NAME} +description: NemoClaw-managed Hermes light terminal compatibility skin +colors: + banner_border: "#CD7F32" + banner_title: "#FFD700" + banner_accent: "#FFBF00" + banner_dim: "#B8860B" + banner_text: "#7A5A0F" + prompt: "#7A5A0F" + response_text: "#7A5A0F" + response_body: "#7A5A0F" + response_border: "#FFD700" + tool_list_text: "#7A5A0F" + skill_list_text: "#7A5A0F" +`; + +function hasEnvValue(value: string | undefined): boolean { + return String(value ?? "").trim().length > 0; +} + +function isConfigRecord(value: ConfigValue): value is ConfigObject { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +export function hermesConfigDisplaySkin(config: ConfigObject): string | null { + const display = config.display; + if (!isConfigRecord(display)) return null; + return typeof display.skin === "string" ? display.skin : null; +} + +export function hermesConfigUsesManagedLightSkin(config: ConfigObject): boolean { + return hermesConfigDisplaySkin(config) === NEMOCLAW_HERMES_LIGHT_SKIN_NAME; +} + +function canApplyHermesLightSkinConfig(config: ConfigObject): boolean { + const display = config.display; + if (display === undefined) return true; + if (!isConfigRecord(display)) return false; + return display.skin === undefined || display.skin === NEMOCLAW_HERMES_LIGHT_SKIN_NAME; +} + +export function applyHermesLightSkinConfig(config: ConfigObject): boolean { + const display = config.display; + if (isConfigRecord(display)) { + if (display.skin !== undefined && display.skin !== NEMOCLAW_HERMES_LIGHT_SKIN_NAME) { + return false; + } + if (display.skin === NEMOCLAW_HERMES_LIGHT_SKIN_NAME) return false; + display.skin = NEMOCLAW_HERMES_LIGHT_SKIN_NAME; + return true; + } + if (display !== undefined) return false; + config.display = { skin: NEMOCLAW_HERMES_LIGHT_SKIN_NAME }; + return true; +} + +export function removeHermesLightSkinConfig(config: ConfigObject): boolean { + const display = config.display; + if (!isConfigRecord(display) || display.skin !== NEMOCLAW_HERMES_LIGHT_SKIN_NAME) { + return false; + } + delete display.skin; + if (Object.keys(display).length === 0) delete config.display; + return true; +} + +export function hostTerminalLooksLight(env: NodeJS.ProcessEnv): boolean { + const colorfgbg = String(env.COLORFGBG ?? "").trim(); + if (!colorfgbg) return false; + + const lastField = colorfgbg.split(";").at(-1) ?? ""; + const bg = Number(lastField); + if (!Number.isInteger(bg) || bg < 0 || bg > 15) return false; + return bg === 7 || bg === 15; +} + +export function shouldInspectHermesLightSkinConfig( + agent: { name?: string } | null | undefined, + env: NodeJS.ProcessEnv, +): boolean { + return ( + agent?.name === "hermes" && + !hasEnvValue(env.HERMES_TUI_LIGHT) && + !hasEnvValue(env.HERMES_TUI_THEME) + ); +} + +export function shouldApplyHermesLightSkin( + agent: { name?: string } | null | undefined, + env: NodeJS.ProcessEnv, + config: ConfigObject, +): boolean { + return ( + shouldInspectHermesLightSkinConfig(agent, env) && + hostTerminalLooksLight(env) && + canApplyHermesLightSkinConfig(config) + ); +} + +export function shouldRemoveHermesLightSkin( + agent: { name?: string } | null | undefined, + env: NodeJS.ProcessEnv, + config: ConfigObject, +): boolean { + return ( + shouldInspectHermesLightSkinConfig(agent, env) && + !hostTerminalLooksLight(env) && + hermesConfigUsesManagedLightSkin(config) + ); +} diff --git a/src/lib/onboard/gateway-recovery.test.ts b/src/lib/onboard/gateway-recovery.test.ts index 64ae881acaf..9cc4ce8af57 100644 --- a/src/lib/onboard/gateway-recovery.test.ts +++ b/src/lib/onboard/gateway-recovery.test.ts @@ -167,7 +167,10 @@ describe("gateway recovery", () => { // break the side effects the caller relies on after readiness. vi.stubEnv("NEMOCLAW_HEALTH_POLL_COUNT", "3"); vi.stubEnv("NEMOCLAW_HEALTH_POLL_INTERVAL", "2"); + const clock = makeVirtualClock(); const deps = createDeps({ + sleepSeconds: clock.sleeper, + now: clock.now, runCaptureOpenshell: vi.fn(() => "Connected"), isGatewayHealthy: () => true, isGatewayHttpReady: async () => true, @@ -188,7 +191,10 @@ describe("gateway recovery", () => { // Probe #1 fails the health predicate, probe #2 passes. Each probe // reads status + gateway info -g + gateway info (3 calls). let healthCalls = 0; + const clock = makeVirtualClock(); const deps = createDeps({ + sleepSeconds: clock.sleeper, + now: clock.now, runCaptureOpenshell: vi.fn(() => "Connected"), isGatewayHealthy: () => { healthCalls++; diff --git a/test/support/connect-flow-test-harness.ts b/test/support/connect-flow-test-harness.ts index 8dbfa42db0e..63b4ef79edf 100644 --- a/test/support/connect-flow-test-harness.ts +++ b/test/support/connect-flow-test-harness.ts @@ -7,6 +7,7 @@ import { createRequire } from "node:module"; import { type MockInstance, vi } from "vitest"; import type { SecretBoundaryRefusalReason } from "../../src/lib/actions/sandbox/hermes-secret-boundary-recovery"; +import type { ConfigObject } from "../../src/lib/security/credential-filter"; import type { SandboxEntry } from "../../src/lib/state/registry"; type ConnectSandbox = typeof import("../../src/lib/actions/sandbox/connect")["connectSandbox"]; @@ -31,12 +32,15 @@ export type ConnectHarness = { errorSpy: MockInstance; logSpy: MockInstance; preflightVllmSpy: MockInstance; + readSandboxConfigSpy: MockInstance; registryEntries: SandboxEntry[]; + resolveAgentConfigSpy: MockInstance; runAutoPairSpy: MockInstance; runOpenshellSpy: MockInstance; runSetupDnsProxySpy: MockInstance; spawnSyncSpy: MockInstance; withGatewayRouteMutationLockSpy: MockInstance; + writeSandboxConfigSpy: MockInstance; }; export type ConnectHarnessOptions = { @@ -45,6 +49,7 @@ export type ConnectHarnessOptions = { inferenceProbeResponses?: Array< string | { status?: number | null; output?: string | null; stderr?: string | null } >; + hermesConfig?: ConfigObject; registryEntry?: Partial; registryEntries?: Array & Pick>; sessionAgent?: unknown; @@ -110,6 +115,7 @@ export function createConnectHarness(options: ConnectHarnessOptions = {}): Conne "../../src/lib/inference/gateway-route-mutation-lock.js", ); const sandboxVersion = requireDist("../../src/lib/sandbox/version.js"); + const sandboxConfig = requireDist("../../src/lib/sandbox/config.js"); const registry = requireDist("../../src/lib/state/registry.js"); const sandboxSession = requireDist("../../src/lib/state/sandbox-session.js"); const vmDnsMonkeypatch = requireDist("../../src/lib/actions/sandbox/vm-dns-monkeypatch.js"); @@ -202,6 +208,26 @@ export function createConnectHarness(options: ConnectHarnessOptions = {}): Conne sandboxes: registryEntries, defaultSandbox: primaryRegistryEntry.name, }); + const hermesConfigTarget = { + agentName: "hermes", + configPath: "/sandbox/.hermes/config.yaml", + configDir: "/sandbox/.hermes", + format: "yaml", + configFile: "config.yaml", + sensitiveFiles: ["/sandbox/.hermes/.config-hash", "/sandbox/.hermes/.env"], + }; + const resolveAgentConfigSpy = vi + .spyOn(sandboxConfig, "resolveAgentConfig") + .mockImplementation((name: unknown) => { + const entry = registryEntries.find((candidate) => candidate.name === String(name)); + return entry?.agent === "hermes" ? hermesConfigTarget : sandboxConfig.DEFAULT_AGENT_CONFIG; + }); + const readSandboxConfigSpy = vi + .spyOn(sandboxConfig, "readSandboxConfig") + .mockReturnValue(options.hermesConfig ?? {}); + const writeSandboxConfigSpy = vi + .spyOn(sandboxConfig, "writeSandboxConfig") + .mockImplementation(() => undefined); vi.spyOn(agentRuntime, "getSessionAgent").mockReturnValue( (options.sessionAgent ?? { name: "openclaw" }) as never, ); @@ -224,11 +250,14 @@ export function createConnectHarness(options: ConnectHarnessOptions = {}): Conne errorSpy, logSpy, preflightVllmSpy, + readSandboxConfigSpy, registryEntries, + resolveAgentConfigSpy, runAutoPairSpy, runOpenshellSpy, runSetupDnsProxySpy, spawnSyncSpy, withGatewayRouteMutationLockSpy, + writeSandboxConfigSpy, }; }