From 79726a5182cd21566413b41aed3c7baf5328c3df Mon Sep 17 00:00:00 2001 From: Senthil Ravichandran Date: Wed, 19 Aug 2026 22:47:12 -0700 Subject: [PATCH 1/3] fix(hermes): migrate dashboard state before gateway health Signed-off-by: Senthil Ravichandran --- agents/hermes/start.sh | 5 +++++ test/hermes-mcp-integrity-state.test.ts | 22 ++++++++++++++++------ 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/agents/hermes/start.sh b/agents/hermes/start.sh index 8e621a03fc3..4587d7cc505 100755 --- a/agents/hermes/start.sh +++ b/agents/hermes/start.sh @@ -3482,6 +3482,11 @@ prepare_restricted_log /tmp/gateway.log gateway:gateway 600 # shellcheck disable=SC2119 validate_tmp_permissions +# Migrate and seed the dashboard profile before Hermes reads the shared home. +# Waiting until dashboard launch leaves restored legacy state in the gateway's +# HERMES_HOME during its readiness check. +prepare_hermes_dashboard_home sandbox:sandbox || exit 1 + # Start Hermes gateway. Messaging egress goes directly through OpenShell. launch_hermes_gateway start_gateway_log_stream diff --git a/test/hermes-mcp-integrity-state.test.ts b/test/hermes-mcp-integrity-state.test.ts index 6506da12567..2df7843b4eb 100644 --- a/test/hermes-mcp-integrity-state.test.ts +++ b/test/hermes-mcp-integrity-state.test.ts @@ -26,10 +26,10 @@ const TRANSACTION = path.join( ); const START = path.join(import.meta.dirname, "..", "agents", "hermes", "start.sh"); -function runHermesRootMcpStartup(commitStatus: 0 | 1) { +function runHermesRootMcpStartup(opts: { commitStatus: 0 | 1; dashboardStatus?: 0 | 1 }) { const source = fs.readFileSync(START, "utf-8"); const startupBlock = source.match( - /^launch_hermes_gateway\nstart_gateway_log_stream\nwait_for_hermes_gateway_internal "\$GATEWAY_PID"\nensure_hermes_supervised_auxiliaries\nfinalize_tirith_marker_retry\nif ! commit_hermes_mcp_applied_if_pending; then\n[\s\S]*?^restore_hermes_config_permissions_after_dashboard_start$/m, + /^prepare_hermes_dashboard_home sandbox:sandbox \|\| exit 1$\n[\s\S]*?^launch_hermes_gateway\nstart_gateway_log_stream\nwait_for_hermes_gateway_internal "\$GATEWAY_PID"\nensure_hermes_supervised_auxiliaries\nfinalize_tirith_marker_retry\nif ! commit_hermes_mcp_applied_if_pending; then\n[\s\S]*?^restore_hermes_config_permissions_after_dashboard_start$/m, )?.[0]; expect(startupBlock).toBeDefined(); const startupScript = startupBlock as string; @@ -42,11 +42,12 @@ function runHermesRootMcpStartup(commitStatus: 0 | 1) { "#!/usr/bin/env bash", "set -euo pipefail", 'trace() { printf "%s\\n" "$*"; }', + `prepare_hermes_dashboard_home() { trace dashboard-profile; return ${opts.dashboardStatus ?? 0}; }`, 'launch_hermes_gateway() { GATEWAY_PID=4242; trace "launch:$GATEWAY_PID"; }', "start_gateway_log_stream() { trace log-stream; }", 'wait_for_hermes_gateway_internal() { trace "health:$1"; }', "ensure_hermes_supervised_auxiliaries() { trace auxiliaries; }\nfinalize_tirith_marker_retry() { trace tirith-finalize; }", - `commit_hermes_mcp_applied_if_pending() { trace commit-applied; return ${commitStatus}; }`, + `commit_hermes_mcp_applied_if_pending() { trace commit-applied; return ${opts.commitStatus}; }`, "stop_hermes_gateway_fail_closed() { trace stop-fail-closed; }", "restore_hermes_config_permissions_after_dashboard_start() { trace restore-permissions; }", startupScript, @@ -590,10 +591,11 @@ print(json.dumps({"state": state, "config_reads": config_reads})) expect(JSON.parse(result.stdout)).toEqual({ state: "current", config_reads: 1 }); }); - it("commits pending state after root gateway health before continuing startup", () => { - const success = runHermesRootMcpStartup(0); + it("prepares the dashboard profile before root gateway health and applied-state commit", () => { + const success = runHermesRootMcpStartup({ commitStatus: 0 }); expect(success.status, success.stderr).toBe(0); expect(success.stdout.trim().split("\n")).toEqual([ + "dashboard-profile", "launch:4242", "log-stream", "health:4242", @@ -605,10 +607,18 @@ print(json.dumps({"state": state, "config_reads": config_reads})) ]); }); + it("fails root startup closed when dashboard profile preparation fails", () => { + const failure = runHermesRootMcpStartup({ commitStatus: 0, dashboardStatus: 1 }); + expect(failure.status).toBe(1); + expect(failure.stdout.trim().split("\n")).toEqual(["dashboard-profile"]); + expect(failure.stdout).not.toContain("launch:"); + }); + it("fails root startup closed when the applied-state commit fails after gateway health", () => { - const failure = runHermesRootMcpStartup(1); + const failure = runHermesRootMcpStartup({ commitStatus: 1 }); expect(failure.status).toBe(1); expect(failure.stdout.trim().split("\n")).toEqual([ + "dashboard-profile", "launch:4242", "log-stream", "health:4242", From 18b2271daab2f056202bc5025e38adea2818b80d Mon Sep 17 00:00:00 2001 From: Senthil Ravichandran Date: Wed, 19 Aug 2026 23:01:48 -0700 Subject: [PATCH 2/3] test(hermes): exercise dashboard seed failure before launch Signed-off-by: Senthil Ravichandran --- test/hermes-mcp-integrity-state.test.ts | 30 ++++++++++++++++++++++--- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/test/hermes-mcp-integrity-state.test.ts b/test/hermes-mcp-integrity-state.test.ts index 2df7843b4eb..37635da9549 100644 --- a/test/hermes-mcp-integrity-state.test.ts +++ b/test/hermes-mcp-integrity-state.test.ts @@ -26,7 +26,7 @@ const TRANSACTION = path.join( ); const START = path.join(import.meta.dirname, "..", "agents", "hermes", "start.sh"); -function runHermesRootMcpStartup(opts: { commitStatus: 0 | 1; dashboardStatus?: 0 | 1 }) { +function runHermesRootMcpStartup(opts: { commitStatus: 0 | 1; dashboardSeedStatus?: 0 | 23 }) { const source = fs.readFileSync(START, "utf-8"); const startupBlock = source.match( /^prepare_hermes_dashboard_home sandbox:sandbox \|\| exit 1$\n[\s\S]*?^launch_hermes_gateway\nstart_gateway_log_stream\nwait_for_hermes_gateway_internal "\$GATEWAY_PID"\nensure_hermes_supervised_auxiliaries\nfinalize_tirith_marker_retry\nif ! commit_hermes_mcp_applied_if_pending; then\n[\s\S]*?^restore_hermes_config_permissions_after_dashboard_start$/m, @@ -36,13 +36,34 @@ function runHermesRootMcpStartup(opts: { commitStatus: 0 | 1; dashboardStatus?: const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-hermes-mcp-root-start-")); const scriptPath = path.join(tempDir, "run.sh"); + const fakePython = path.join(tempDir, "fake-python.sh"); + const hermesHome = path.join(tempDir, ".hermes"); + const dashboardHome = path.join(hermesHome, "profiles", "dashboard-home"); + fs.writeFileSync( + fakePython, + [ + "#!/usr/bin/env bash", + "set -euo pipefail", + 'printf "dashboard-profile\\n"', + 'if [ "${NEMOCLAW_TEST_STEPPED_DOWN:-0}" != 1 ]; then exit 99; fi', + `exit ${opts.dashboardSeedStatus ?? 0}`, + ].join("\n"), + { mode: 0o700 }, + ); fs.writeFileSync( scriptPath, [ "#!/usr/bin/env bash", "set -euo pipefail", 'trace() { printf "%s\\n" "$*"; }', - `prepare_hermes_dashboard_home() { trace dashboard-profile; return ${opts.dashboardStatus ?? 0}; }`, + 'id() { [ "${1:-}" = "-u" ] && printf "0\\n" || command id "$@"; }', + extractShellFunction(source, "prepare_hermes_dashboard_home"), + `HERMES_DIR=${bashPrintfQ(hermesHome)}`, + `HERMES_DASHBOARD_HOME=${bashPrintfQ(dashboardHome)}`, + `_HERMES_PYTHON=${bashPrintfQ(fakePython)}`, + `_HERMES_DASHBOARD_CONFIG_SEEDER=${bashPrintfQ(path.join(tempDir, "seed-dashboard-config.py"))}`, + `_HERMES_MANAGED_POLICY=${bashPrintfQ(path.join(tempDir, "managed-policy.json"))}`, + "STEP_DOWN_PREFIX_SANDBOX=(env NEMOCLAW_TEST_STEPPED_DOWN=1)", 'launch_hermes_gateway() { GATEWAY_PID=4242; trace "launch:$GATEWAY_PID"; }', "start_gateway_log_stream() { trace log-stream; }", 'wait_for_hermes_gateway_internal() { trace "health:$1"; }', @@ -608,9 +629,12 @@ print(json.dumps({"state": state, "config_reads": config_reads})) }); it("fails root startup closed when dashboard profile preparation fails", () => { - const failure = runHermesRootMcpStartup({ commitStatus: 0, dashboardStatus: 1 }); + const failure = runHermesRootMcpStartup({ commitStatus: 0, dashboardSeedStatus: 23 }); expect(failure.status).toBe(1); expect(failure.stdout.trim().split("\n")).toEqual(["dashboard-profile"]); + expect(failure.stderr).toContain( + "[dashboard] ERROR: config seed exited 23; refusing dashboard startup", + ); expect(failure.stdout).not.toContain("launch:"); }); From 2b88ccdf40b7324be806b92c5efd3c1c190a7836 Mon Sep 17 00:00:00 2001 From: Senthil Ravichandran Date: Wed, 19 Aug 2026 23:10:22 -0700 Subject: [PATCH 3/3] test(hermes): isolate startup failure evidence Signed-off-by: Senthil Ravichandran --- test/hermes-mcp-integrity-state.test.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/test/hermes-mcp-integrity-state.test.ts b/test/hermes-mcp-integrity-state.test.ts index 37635da9549..041e240a264 100644 --- a/test/hermes-mcp-integrity-state.test.ts +++ b/test/hermes-mcp-integrity-state.test.ts @@ -76,12 +76,14 @@ function runHermesRootMcpStartup(opts: { commitStatus: 0 | 1; dashboardSeedStatu ].join("\n"), { mode: 0o700 }, ); + const env = { ...process.env }; + delete env.NEMOCLAW_TEST_STEPPED_DOWN; try { return spawnSync("bash", [scriptPath], { encoding: "utf-8", timeout: 5000, - env: process.env, + env, }); } finally { fs.rmSync(tempDir, { recursive: true, force: true }); @@ -631,11 +633,12 @@ print(json.dumps({"state": state, "config_reads": config_reads})) it("fails root startup closed when dashboard profile preparation fails", () => { const failure = runHermesRootMcpStartup({ commitStatus: 0, dashboardSeedStatus: 23 }); expect(failure.status).toBe(1); - expect(failure.stdout.trim().split("\n")).toEqual(["dashboard-profile"]); + expect(failure.stdout).toContain("dashboard-profile"); expect(failure.stderr).toContain( "[dashboard] ERROR: config seed exited 23; refusing dashboard startup", ); expect(failure.stdout).not.toContain("launch:"); + expect(failure.stdout).not.toContain("startup-complete"); }); it("fails root startup closed when the applied-state commit fails after gateway health", () => {