diff --git a/docs/get-started/quickstart-hermes.mdx b/docs/get-started/quickstart-hermes.mdx index c4f1fd83249..b3b7ded4f87 100644 --- a/docs/get-started/quickstart-hermes.mdx +++ b/docs/get-started/quickstart-hermes.mdx @@ -151,7 +151,7 @@ Use these details when your first-run path needs more control. ``` Leave `CHAT_UI_URL` unset when you use SSH local port forwarding to `127.0.0.1:18789`. - Hermes API clients authenticate with the bearer token from the generated Hermes environment, not an OpenClaw dashboard URL token. + Hermes API clients authenticate with the bearer token returned by `nemohermes my-hermes gateway-token --quiet`, not an OpenClaw dashboard URL token. diff --git a/docs/manage-sandboxes/recover-rebuild-sandboxes.mdx b/docs/manage-sandboxes/recover-rebuild-sandboxes.mdx index b1a2df6cc90..d5898e54009 100644 --- a/docs/manage-sandboxes/recover-rebuild-sandboxes.mdx +++ b/docs/manage-sandboxes/recover-rebuild-sandboxes.mdx @@ -141,6 +141,13 @@ The session then follows the current default selected through `inference set`, w The rebuild command preserves Hermes state, registered policies, and managed MCP configuration while recreating the container. +A rebuild creates a new sandbox home and a new Hermes API bearer token. +After the rebuild succeeds, retrieve the replacement token before reconnecting API clients: + +```bash +$$nemoclaw gateway-token --quiet +``` + Before post-restore repairs, NemoClaw verifies that the recreated sandbox still identifies as Hermes and exits nonzero if its identity does not match the rebuild target. After state restore, NemoClaw restores managed MCP configuration through the normal lifecycle, then re-proves or recovers gateway health and performs final MCP reconciliation. `rebuild` exits nonzero instead of reporting success when it cannot verify final gateway health or managed MCP state. diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx index d48c3bbb86c..932cede6712 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -1562,6 +1562,12 @@ Do not log it, share it, or commit it to version control. Print the Hermes API bearer token for a running sandbox to stdout. NemoClaw retrieves the sandbox's `API_SERVER_KEY`, which authenticates OpenAI-compatible clients on the forwarded API port. +During a normal sandbox lifecycle, the token is generated once for each sandbox home. +Different sandbox homes receive different tokens. +NemoClaw preserves it across a gateway restart, sandbox stop and start, and host OpenShell gateway restart. +When you rebuild or replace the sandbox, the replacement home receives a new token. +At gateway startup, NemoClaw also generates a new token when `API_SERVER_KEY` is missing or is not exactly 64 lowercase hexadecimal characters. +If an ordinary restart changes the token while the existing `API_SERVER_KEY` was present and valid, collect the before and after sandbox identity plus redacted mint logs and report it as a bug. Capture the token and pass it in the `Authorization` header: ```bash @@ -1570,8 +1576,13 @@ curl -fsS -H "Authorization: Bearer $TOKEN" \ http://127.0.0.1:8642/v1/models ``` + Treat the token like a password. Do not log it, share it, or commit it to version control. + + +The sandbox must be running for `nemohermes my-assistant gateway-token --quiet` to retrieve the token. +Use this supported command instead of reading or editing `.hermes/.env` directly. For browser access to the dashboard, use `nemohermes my-assistant dashboard-url`. @@ -2536,6 +2547,8 @@ After restore, the command runs `openclaw doctor --fix` for cross-version struct After restore, the command restores Hermes manifest-defined state and starts the rebuilt Hermes gateway with the regenerated `/sandbox/.hermes` config. +A rebuild creates a new sandbox home and a new Hermes API bearer token. +After the rebuild succeeds, retrieve the replacement token with `nemohermes my-assistant gateway-token --quiet` before reconnecting API clients. For an older Hermes image that predates sealed shields transitions, rebuild is the only workflow authorized to use the descriptor-safe compatibility transition. The compatibility path verifies the strict root-owned hash and the in-tree hash, publishes fresh config inodes to revoke retained write descriptors, and restores the trusted lock posture if the transition cannot finish. Ordinary `shields up` and `shields down` commands refuse the older protocol and direct you to rebuild. diff --git a/src/lib/actions/sandbox/rebuild-post-restore-phase.test.ts b/src/lib/actions/sandbox/rebuild-post-restore-phase.test.ts index 377cd9a15b3..a0f6ef33ac6 100644 --- a/src/lib/actions/sandbox/rebuild-post-restore-phase.test.ts +++ b/src/lib/actions/sandbox/rebuild-post-restore-phase.test.ts @@ -15,7 +15,7 @@ import * as rebuildMessaging from "./rebuild-messaging-phase"; import { runRebuildPostRestorePhase } from "./rebuild-post-restore-phase"; import * as sessionModels from "./reconcile-session-models"; -describe("rebuild post-restore session model reconciliation (#7102)", () => { +describe("rebuild post-restore phase", () => { let agentName: "openclaw" | "hermes"; let order: string[]; @@ -98,13 +98,13 @@ describe("rebuild post-restore session model reconciliation (#7102)", () => { }; } - it("reconciles OpenClaw sessions after doctor and before later config writes", async () => { + it("reconciles OpenClaw sessions after doctor and before later config writes (#7102)", async () => { await runRebuildPostRestorePhase(input()); expect(order).toEqual(["doctor", "reconcile", "messaging", "config-hash"]); }); - it("does not run OpenClaw session reconciliation for another agent", async () => { + it("does not run OpenClaw session reconciliation for another agent (#7102)", async () => { agentName = "hermes"; const args = input(); @@ -114,4 +114,99 @@ describe("rebuild post-restore session model reconciliation (#7102)", () => { expect(sessionModels.reconcileStalePinnedSessionModelsAfterRebuild).not.toHaveBeenCalled(); expect(processRecovery.executeSandboxCommand).not.toHaveBeenCalled(); }); + + it("points Hermes rebuilds to the replacement API token retrieval command (#7175)", async () => { + agentName = "hermes"; + + await runRebuildPostRestorePhase(input()); + + const outputLines = vi.mocked(console.log).mock.calls.flat().map(String); + const output = outputLines.join("\n"); + expect(output).toContain("Hermes API bearer token changed during rebuild"); + expect(output).toContain("nemoclaw alpha gateway-token --quiet"); + expect( + outputLines.findIndex((line) => line.includes("API bearer token changed")), + ).toBeGreaterThan(outputLines.findIndex((line) => line.includes("rebuilt successfully"))); + }); + + it("does not print the Hermes API token notice for OpenClaw rebuilds (#7175)", async () => { + await runRebuildPostRestorePhase(input()); + + const output = vi.mocked(console.log).mock.calls.flat().join("\n"); + expect(output).not.toContain("Hermes API bearer token"); + expect(output).not.toContain("gateway-token --quiet"); + }); + + it("does not print the Hermes API token notice when post-restore verification is incomplete (#7175)", async () => { + agentName = "hermes"; + vi.mocked(rebuildHermesPostRestore.ensureHermesGatewayAfterStateRestore).mockReturnValue( + "unverified", + ); + const args = input(); + + await runRebuildPostRestorePhase(args); + + const output = vi.mocked(console.log).mock.calls.flat().join("\n"); + expect(output).not.toContain("Hermes API bearer token changed during rebuild"); + expect(output).not.toContain("gateway-token --quiet"); + expect(args.bail).toHaveBeenCalledWith("Hermes post-restore verification failed for 'alpha'."); + }); + + it("still prints the Hermes API token notice when a non-fatal post-restore step is unverified (#7175)", async () => { + agentName = "hermes"; + vi.mocked(messagingHostForward.ensureMessagingHostForwardAfterRebuild).mockReturnValue(false); + const args = input(); + + await runRebuildPostRestorePhase(args); + + const output = vi.mocked(console.log).mock.calls.flat().join("\n"); + expect(args.bail).not.toHaveBeenCalled(); + expect(output).toContain("rebuilt but some post-restore steps were incomplete"); + expect(output).toContain("Hermes API bearer token changed during rebuild"); + expect(output).toContain("nemoclaw alpha gateway-token --quiet"); + }); + + it("does not print the Hermes API token notice when prepared backup recovery is incomplete (#7175)", async () => { + agentName = "hermes"; + vi.mocked(messagingHostForward.ensureMessagingHostForwardAfterRebuild).mockReturnValue(false); + const args = input(); + args.preparedBackupRecovery = true; + + await runRebuildPostRestorePhase(args); + + const output = vi.mocked(console.log).mock.calls.flat().join("\n"); + expect(output).not.toContain("Hermes API bearer token changed during rebuild"); + expect(output).not.toContain("gateway-token --quiet"); + expect(args.bail).toHaveBeenCalledWith( + "Prepared backup recovery for 'alpha' completed with unverified post-restore state.", + ); + }); + + it("prints the Hermes API token notice after gateway recovery (#7175)", async () => { + agentName = "hermes"; + vi.mocked(rebuildHermesPostRestore.ensureHermesGatewayAfterStateRestore).mockReturnValue( + "recovered", + ); + const args = input(); + + await runRebuildPostRestorePhase(args); + + const output = vi.mocked(console.log).mock.calls.flat().join("\n"); + expect(args.bail).not.toHaveBeenCalled(); + expect(output).toContain("Hermes gateway recovered after state restore"); + expect(output).toContain("Hermes API bearer token changed during rebuild"); + }); + + it("does not print the Hermes API token notice after a shields relock failure (#7175)", async () => { + agentName = "hermes"; + const args = input(); + args.relockShieldsIfNeeded = vi.fn(() => false); + + await runRebuildPostRestorePhase(args); + + const output = vi.mocked(console.log).mock.calls.flat().join("\n"); + expect(output).not.toContain("Hermes API bearer token changed during rebuild"); + expect(output).not.toContain("gateway-token --quiet"); + expect(args.bail).toHaveBeenCalledWith("Failed to re-apply shields lockdown."); + }); }); diff --git a/src/lib/actions/sandbox/rebuild-post-restore-phase.ts b/src/lib/actions/sandbox/rebuild-post-restore-phase.ts index 32c8c02b5e4..88e04e4374f 100644 --- a/src/lib/actions/sandbox/rebuild-post-restore-phase.ts +++ b/src/lib/actions/sandbox/rebuild-post-restore-phase.ts @@ -80,6 +80,16 @@ export function printSuccessfulRebuildSummary( } } +function printHermesApiTokenChangeNotice(sandboxName: string, targetAgentName: string): void { + if (targetAgentName !== "hermes") { + return; + } + console.log(` ${YW}\u26a0${R} Hermes API bearer token changed during rebuild.`); + console.log( + ` Retrieve the new token with \`${CLI_NAME} ${sandboxName} gateway-token --quiet\`.`, + ); +} + export function resolveRestoredPolicyRegistryState( sandboxEntry: Pick, restoredBuiltinPresets: readonly string[], @@ -324,5 +334,7 @@ export async function runRebuildPostRestorePhase( bail( `Prepared backup recovery for '${sandboxName}' completed with unverified post-restore state.`, ); + return; } + printHermesApiTokenChangeNotice(sandboxName, targetAgentName); } diff --git a/test/e2e/live/rebuild-hermes-host-tools.ts b/test/e2e/live/rebuild-hermes-host-tools.ts index 239019a5d2d..2f61b5d01ec 100644 --- a/test/e2e/live/rebuild-hermes-host-tools.ts +++ b/test/e2e/live/rebuild-hermes-host-tools.ts @@ -5,8 +5,37 @@ import fs from "node:fs"; import { buildAvailabilityProbeEnv } from "../fixtures/availability-env.ts"; import { assertExitZero } from "../fixtures/clients/command.ts"; import type { HostCliClient } from "../fixtures/clients/index.ts"; +import { expect } from "../fixtures/e2e-test.ts"; import { CLI_DIST_ENTRYPOINT, REPO_ROOT } from "../fixtures/paths.ts"; +export async function hermesApiTokenDigest( + host: HostCliClient, + sandboxName: string, + artifactName: string, + env: NodeJS.ProcessEnv, + redactionValues: string[], + timeoutMs: number, +): Promise { + const cli = JSON.stringify(host.commandPath); + const sandbox = JSON.stringify(sandboxName); + const result = await host.command( + "bash", + [ + "-lc", + [ + `token="$(${cli} ${sandbox} gateway-token --quiet)"`, + 'case "$token" in ""|*[!0-9a-f]*) exit 2 ;; esac', + '[ "${#token}" -eq 64 ] || exit 2', + "printf '%s' \"$token\" | sha256sum | cut -d' ' -f1", + ].join(" && "), + ], + { artifactName, env, redactionValues, timeoutMs }, + ); + assertExitZero(result, "retrieve and hash Hermes API bearer token"); + expect(result.stdout.trim()).toMatch(/^[0-9a-f]{64}$/); + return result.stdout.trim(); +} + export async function ensureRebuildHermesHostTools(host: HostCliClient): Promise { const bootstrapEnv = buildAvailabilityProbeEnv(); if (!fs.existsSync(CLI_DIST_ENTRYPOINT)) { diff --git a/test/e2e/live/rebuild-hermes.test.ts b/test/e2e/live/rebuild-hermes.test.ts index 07f5f0bebd2..aeef638a05d 100644 --- a/test/e2e/live/rebuild-hermes.test.ts +++ b/test/e2e/live/rebuild-hermes.test.ts @@ -37,6 +37,7 @@ import { verifyRebuildHermesOldBaseIsStale, } from "./rebuild-hermes-base-identity.ts"; import { buildRebuildHermesChildEnv, planRebuildHermesBaseReuse } from "./rebuild-hermes-env.ts"; +import { ensureRebuildHermesHostTools, hermesApiTokenDigest } from "./rebuild-hermes-host-tools.ts"; import { cleanupTrackedRebuildHermesImage, type RebuildHermesRegistryImageState, @@ -50,7 +51,6 @@ import { import { buildRebuildHermesOldSandboxDockerfile } from "./rebuild-hermes-old-sandbox.ts"; import { startRebuildHermesProgress } from "./rebuild-hermes-progress.ts"; import { buildHermesRuntimeExecArgs } from "./rebuild-hermes-runtime-exec.ts"; -import { ensureRebuildHermesHostTools } from "./rebuild-hermes-host-tools.ts"; import { buildRebuildHermesTimingSummary, describeRunnerClass } from "./rebuild-hermes-timing.ts"; // Protected PR E2E checks out the exact head while the trusted controller runs @@ -1179,6 +1179,14 @@ test(STALE_BASE_REBUILD }, session: sessionSummary, }); + const preRebuildApiTokenDigest = await hermesApiTokenDigest( + host, + SANDBOX_NAME, + "phase-4-api-token-before-rebuild", + testEnv(apiKey, { SANDBOX_NAME }), + redactionValues, + OPENSHELL_TIMEOUT_MS, + ); switch (STALE_BASE_REBUILD) { case false: { @@ -1215,6 +1223,8 @@ test(STALE_BASE_REBUILD }); expectExitZero(rebuild, "nemoclaw rebuild Hermes sandbox"); const rebuildOutput = resultText(rebuild); + expect(rebuildOutput).toContain("Hermes API bearer token changed during rebuild"); + expect(rebuildOutput).toContain(`nemoclaw ${SANDBOX_NAME} gateway-token --quiet`); expect(rebuildOutput).toContain(`Using Hermes Agent base image: ${phase1BaseResolution.ref}`); expect(rebuildOutput).not.toContain("Rebuilding Hermes Agent base image"); await waitForSandboxReady(host, apiKey, "phase-6-post-rebuild"); @@ -1357,6 +1367,25 @@ test(STALE_BASE_REBUILD expectExitZero(restoredEnv, "read Hermes .env after rebuild"); expect(restoredEnv.stdout).toContain(`DISCORD_BOT_TOKEN=${DISCORD_PLACEHOLDER}`); + const postRebuildApiTokenDigest = await hermesApiTokenDigest( + host, + SANDBOX_NAME, + "phase-7-api-token-after-rebuild", + testEnv(apiKey, { SANDBOX_NAME }), + redactionValues, + OPENSHELL_TIMEOUT_MS, + ); + const stablePostRebuildApiTokenDigest = await hermesApiTokenDigest( + host, + SANDBOX_NAME, + "phase-7-api-token-stability-check", + testEnv(apiKey, { SANDBOX_NAME }), + redactionValues, + OPENSHELL_TIMEOUT_MS, + ); + expect(postRebuildApiTokenDigest).not.toBe(preRebuildApiTokenDigest); + expect(stablePostRebuildApiTokenDigest).toBe(postRebuildApiTokenDigest); + const restoredConfig = await host.command( "openshell", ["sandbox", "exec", "--name", SANDBOX_NAME, "--", "cat", "/sandbox/.hermes/config.yaml"], diff --git a/test/hermes-api-key-lifecycle-docs.test.ts b/test/hermes-api-key-lifecycle-docs.test.ts new file mode 100644 index 00000000000..1b88007445f --- /dev/null +++ b/test/hermes-api-key-lifecycle-docs.test.ts @@ -0,0 +1,64 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { readFileSync } from "node:fs"; +import path from "node:path"; + +import { describe, expect, it } from "vitest"; + +const COMMANDS_PATH = "docs/reference/commands.mdx"; +const REBUILD_GUIDE_PATH = "docs/manage-sandboxes/recover-rebuild-sandboxes.mdx"; +const QUICKSTART_PATH = "docs/get-started/quickstart-hermes.mdx"; + +function readDoc(relativePath: string): string { + return readFileSync(path.join(process.cwd(), relativePath), "utf8"); +} + +function readHermesGatewayTokenSection(): string { + const commands = readDoc(COMMANDS_PATH); + const heading = "### `$$nemoclaw gateway-token`"; + const sectionStart = commands.indexOf(heading); + const sectionEnd = commands.indexOf("\n### `", sectionStart + heading.length); + + expect(sectionStart).toBeGreaterThanOrEqual(0); + expect(sectionEnd).toBeGreaterThan(sectionStart); + + const section = commands.slice(sectionStart, sectionEnd); + const hermesStart = section.indexOf(''); + const hermesEnd = section.indexOf("", hermesStart); + + expect(hermesStart).toBeGreaterThanOrEqual(0); + expect(hermesEnd).toBeGreaterThan(hermesStart); + return section.slice(hermesStart, hermesEnd); +} + +describe("Hermes API bearer token lifecycle documentation (#7175)", () => { + it("distinguishes stable restarts from key-generating replacement operations", () => { + const commands = readHermesGatewayTokenSection(); + + expect(commands).toContain("generated once for each sandbox home"); + expect(commands).toContain("Different sandbox homes receive different tokens"); + expect(commands).toContain( + "gateway restart, sandbox stop and start, and host OpenShell gateway restart", + ); + expect(commands).toContain("rebuild or replace the sandbox"); + expect(commands).toContain("missing or is not exactly 64 lowercase hexadecimal characters"); + expect(commands).toContain("while the existing `API_SERVER_KEY` was present and valid"); + }); + + it("points rebuild operators to supported token retrieval", () => { + const commands = readHermesGatewayTokenSection(); + const rebuildGuide = readDoc(REBUILD_GUIDE_PATH); + const quickstart = readDoc(QUICKSTART_PATH); + + expect(commands).toContain("nemohermes my-assistant gateway-token --quiet"); + expect(rebuildGuide).toContain("$$nemoclaw gateway-token --quiet"); + expect(commands).toContain("replacement home receives a new token"); + expect(rebuildGuide).toContain("new Hermes API bearer token"); + expect(commands).toContain("Treat the token like a password"); + expect(commands).toContain("The sandbox must be running"); + expect(commands).toContain("instead of reading or editing `.hermes/.env` directly"); + expect(quickstart).toContain("nemohermes my-hermes gateway-token --quiet"); + expect(quickstart).not.toContain("bearer token from the generated Hermes environment"); + }); +}); diff --git a/test/snapshot.test.ts b/test/snapshot.test.ts index c43ed4fe0ef..b82961ae390 100644 --- a/test/snapshot.test.ts +++ b/test/snapshot.test.ts @@ -1340,7 +1340,7 @@ process.exit(0); }); describe("Hermes durable state files", () => { - it("backs up and restores SOUL.md plus the SQLite state database without credential files", () => { + it("restores durable state without overwriting a replacement home's API key (#7175)", () => { const fixture = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-hermes-snapshot-")); const oldPath = process.env.PATH; const oldOpenshell = process.env.NEMOCLAW_OPENSHELL_BIN; @@ -1348,15 +1348,19 @@ describe("Hermes durable state files", () => { const binDir = path.join(fixture, "bin"); const fakeRoot = path.join(fixture, "sandbox-root"); const hermesDir = path.join(fakeRoot, ".hermes"); + const envPath = path.join(hermesDir, ".env"); const runtimeDir = path.join(hermesDir, "runtime"); const sshLog = path.join(fixture, "ssh-log.jsonl"); + const readText = (filePath: string) => fs.readFileSync(filePath, "utf-8"); fs.mkdirSync(binDir, { recursive: true }); fs.mkdirSync(runtimeDir, { recursive: true }); fs.writeFileSync(path.join(hermesDir, "SOUL.md"), "original soul\n"); fs.writeFileSync(path.join(hermesDir, ".hermes_history"), "original history\n"); fs.writeFileSync(path.join(runtimeDir, "state.db"), "original sqlite backup\n"); fs.writeFileSync(path.join(hermesDir, "config.yaml"), "token: should-not-copy\n"); - fs.writeFileSync(path.join(hermesDir, ".env"), "API_TOKEN=should-not-copy\n"); + const originalEnv = `API_SERVER_KEY=${"a".repeat(64)}\n`; + const replacementEnv = `API_SERVER_KEY=${"b".repeat(64)}\n`; + fs.writeFileSync(envPath, originalEnv); fs.writeFileSync(path.join(hermesDir, "auth.json"), '{"token":"should-not-copy"}\n'); const openshell = path.join(binDir, "openshell"); @@ -1448,6 +1452,8 @@ process.exit(0); const backup = sandboxState.backupSandboxState("hermes", { name: "hermes-state" }); expect(backup.success).toBe(true); + expect(backup.manifest).toBeDefined(); + const backupPath = backup.manifest!.backupPath; expect(backup.backedUpFiles).toEqual(["SOUL.md", ".hermes_history", "runtime/state.db"]); expect(backup.failedFiles).toEqual([]); expect(backup.manifest?.stateFiles).toEqual([ @@ -1456,32 +1462,26 @@ process.exit(0); { path: "runtime/state.db", strategy: "sqlite_backup" }, { path: "kanban.db", strategy: "sqlite_backup" }, ]); - expect(fs.readFileSync(path.join(backup.manifest!.backupPath, "SOUL.md"), "utf-8")).toBe( - "original soul\n", + expect(readText(path.join(backupPath, "SOUL.md"))).toBe("original soul\n"); + expect(readText(path.join(backupPath, ".hermes_history"))).toBe("original history\n"); + expect(readText(path.join(backupPath, "runtime", "state.db"))).toBe( + "original sqlite backup\n", ); - expect( - fs.readFileSync(path.join(backup.manifest!.backupPath, ".hermes_history"), "utf-8"), - ).toBe("original history\n"); - expect( - fs.readFileSync(path.join(backup.manifest!.backupPath, "runtime", "state.db"), "utf-8"), - ).toBe("original sqlite backup\n"); - expect(fs.existsSync(path.join(backup.manifest!.backupPath, "config.yaml"))).toBe(false); - expect(fs.existsSync(path.join(backup.manifest!.backupPath, ".env"))).toBe(false); - expect(fs.existsSync(path.join(backup.manifest!.backupPath, "auth.json"))).toBe(false); + expect(fs.existsSync(path.join(backupPath, "config.yaml"))).toBe(false); + expect(fs.existsSync(path.join(backupPath, ".env"))).toBe(false); + expect(fs.existsSync(path.join(backupPath, "auth.json"))).toBe(false); fs.writeFileSync(path.join(hermesDir, "SOUL.md"), "changed soul\n"); fs.writeFileSync(path.join(hermesDir, ".hermes_history"), "changed history\n"); fs.writeFileSync(path.join(runtimeDir, "state.db"), "changed db\n"); + fs.writeFileSync(envPath, replacementEnv); const restore = sandboxState.restoreSandboxState("hermes", backup.manifest!.backupPath); expect(restore.success).toBe(true); expect(restore.restoredFiles).toEqual(["SOUL.md", ".hermes_history", "runtime/state.db"]); - expect(fs.readFileSync(path.join(hermesDir, "SOUL.md"), "utf-8")).toBe("original soul\n"); - expect(fs.readFileSync(path.join(hermesDir, ".hermes_history"), "utf-8")).toBe( - "original history\n", - ); - expect(fs.readFileSync(path.join(runtimeDir, "state.db"), "utf-8")).toBe( - "original sqlite backup\n", - ); + expect(readText(path.join(hermesDir, "SOUL.md"))).toBe("original soul\n"); + expect(readText(path.join(hermesDir, ".hermes_history"))).toBe("original history\n"); + expect(readText(path.join(runtimeDir, "state.db"))).toBe("original sqlite backup\n"); + expect(readText(envPath)).toBe(replacementEnv); const loggedCommands = fs.readFileSync(sshLog, "utf-8"); expect(loggedCommands).toContain("sqlite3.connect");