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
10 changes: 10 additions & 0 deletions agents/hermes/start.sh
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,14 @@ hermes_config_root_is_locked() {
&& hermes_config_path_is_locked "${HERMES_DIR}/.env"
}

apply_shields_up_runtime_env() {
hermes_config_root_is_locked || return 0
if [ -z "${HERMES_KANBAN_DISPATCH_IN_GATEWAY:-}" ]; then
export HERMES_KANBAN_DISPATCH_IN_GATEWAY=0
echo "[gateway] Shields-up: HERMES_KANBAN_DISPATCH_IN_GATEWAY=0 (embedded kanban dispatcher suspended; kanban.db on locked config root is read-only)" >&2
fi
}

ensure_hermes_config_root_mode() {
if [ -L "$HERMES_DIR" ] || [ ! -d "$HERMES_DIR" ]; then
echo "[SECURITY] Refusing Hermes layout repair because ${HERMES_DIR} is not a safe directory" >&2
Expand Down Expand Up @@ -839,6 +847,7 @@ if [ "$(id -u)" -ne 0 ]; then
echo "[SECURITY] Config integrity check failed — refusing to start (non-root mode)" >&2
exit 1
fi
apply_shields_up_runtime_env
refresh_hermes_provider_placeholders
configure_messaging_channels

Expand Down Expand Up @@ -887,6 +896,7 @@ fi

export HERMES_HOME="${HERMES_DIR}"
verify_config_integrity "${HERMES_DIR}" "${HERMES_HASH_FILE}"
apply_shields_up_runtime_env
refresh_hermes_provider_placeholders
configure_messaging_channels

Expand Down
120 changes: 102 additions & 18 deletions test/hermes-start.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,23 @@ function runTirithMarkerBootstrap(opts: {
}
}

const LOCKED_HERMES_CONFIG_STAT_MOCK = [
"stat() {",
' if [ "${1:-}" = "-c" ] && [ "${2:-}" = "%U:%G" ] && [ "${3:-}" = "$HERMES_DIR" ]; then printf "root:root\\n"; return 0; fi',
' if [ "${1:-}" = "-c" ] && [ "${2:-}" = "%a" ] && [ "${3:-}" = "$HERMES_DIR" ]; then printf "755\\n"; return 0; fi',
' if [ "${1:-}" = "-f" ] && [ "${2:-}" = "%Su:%Sg" ] && [ "${3:-}" = "$HERMES_DIR" ]; then printf "root:root\\n"; return 0; fi',
' if [ "${1:-}" = "-f" ] && [ "${2:-}" = "%Lp" ] && [ "${3:-}" = "$HERMES_DIR" ]; then printf "755\\n"; return 0; fi',
' case "${3:-}" in "$HERMES_DIR/config.yaml"|"$HERMES_DIR/.env")',
' if [ "${1:-}" = "-c" ] && [ "${2:-}" = "%U:%G" ]; then printf "root:root\\n"; return 0; fi',
' if [ "${1:-}" = "-c" ] && [ "${2:-}" = "%a" ]; then printf "444\\n"; return 0; fi',
' if [ "${1:-}" = "-f" ] && [ "${2:-}" = "%Su:%Sg" ]; then printf "root:root\\n"; return 0; fi',
' if [ "${1:-}" = "-f" ] && [ "${2:-}" = "%Lp" ]; then printf "444\\n"; return 0; fi',
" ;;",
" esac",
' command stat "$@"',
"}",
].join("\n");

function writeFakeProcCmdline(procRoot: string, pid: number, argv: string[]) {
const pidDir = path.join(procRoot, String(pid));
fs.mkdirSync(pidDir, { recursive: true });
Expand Down Expand Up @@ -217,24 +234,7 @@ function runHermesGatewayRuntimeCleanup(opts: {
'id() { if [ "${1:-}" = "-u" ]; then printf "1000\\n"; else command id "$@"; fi; }',
`HERMES_DIR=${shellQuote(hermesHome)}`,
`NEMOCLAW_PROC_ROOT=${shellQuote(procRoot)}`,
opts.lockedConfigRoot || opts.rootOwnedConfigRoot
? [
'stat() {',
' if [ "${1:-}" = "-c" ] && [ "${2:-}" = "%U:%G" ] && [ "${3:-}" = "$HERMES_DIR" ]; then printf "root:root\\n"; return 0; fi',
' if [ "${1:-}" = "-c" ] && [ "${2:-}" = "%a" ] && [ "${3:-}" = "$HERMES_DIR" ]; then printf "755\\n"; return 0; fi',
' if [ "${1:-}" = "-f" ] && [ "${2:-}" = "%Su:%Sg" ] && [ "${3:-}" = "$HERMES_DIR" ]; then printf "root:root\\n"; return 0; fi',
' if [ "${1:-}" = "-f" ] && [ "${2:-}" = "%Lp" ] && [ "${3:-}" = "$HERMES_DIR" ]; then printf "755\\n"; return 0; fi',
' case "${3:-}" in "$HERMES_DIR/config.yaml"|"$HERMES_DIR/.env")',
' if [ "${1:-}" = "-c" ] && [ "${2:-}" = "%U:%G" ]; then printf "root:root\\n"; return 0; fi',
' if [ "${1:-}" = "-c" ] && [ "${2:-}" = "%a" ]; then printf "444\\n"; return 0; fi',
' if [ "${1:-}" = "-f" ] && [ "${2:-}" = "%Su:%Sg" ]; then printf "root:root\\n"; return 0; fi',
' if [ "${1:-}" = "-f" ] && [ "${2:-}" = "%Lp" ]; then printf "444\\n"; return 0; fi',
' ;;',
' esac',
' command stat "$@"',
'}',
].join("\n")
: "",
opts.lockedConfigRoot || opts.rootOwnedConfigRoot ? LOCKED_HERMES_CONFIG_STAT_MOCK : "",
"PUBLIC_PORT=8642",
"INTERNAL_PORT=18642",
"HERMES_DASHBOARD_PUBLIC_PORT=9119",
Expand Down Expand Up @@ -465,6 +465,90 @@ describe("agents/hermes/start.sh gateway runtime cleanup", () => {
});
});

function runShieldsUpRuntimeEnv(opts: {
locked: boolean;
presetValue?: string;
}) {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-hermes-shields-env-"));
const hermesHome = path.join(tmpDir, ".hermes");
const scriptPath = path.join(tmpDir, "run.sh");

fs.mkdirSync(hermesHome, { recursive: true });
if (opts.locked) {
fs.chmodSync(hermesHome, 0o755);
fs.writeFileSync(path.join(hermesHome, "config.yaml"), "model: test\n");
fs.writeFileSync(path.join(hermesHome, ".env"), "HERMES_TEST=1\n");
}

const src = fs.readFileSync(START_SCRIPT, "utf-8");
const statMock = opts.locked ? LOCKED_HERMES_CONFIG_STAT_MOCK : "";
const presetLine =
opts.presetValue === undefined
? "unset HERMES_KANBAN_DISPATCH_IN_GATEWAY"
: `export HERMES_KANBAN_DISPATCH_IN_GATEWAY=${shellQuote(opts.presetValue)}`;

fs.writeFileSync(
scriptPath,
[
"#!/usr/bin/env bash",
"set -uo pipefail",
extractShellFunctionFromSource(src, "hermes_config_path_is_locked"),
extractShellFunctionFromSource(src, "hermes_config_root_is_locked"),
extractShellFunctionFromSource(src, "apply_shields_up_runtime_env"),
`HERMES_DIR=${shellQuote(hermesHome)}`,
statMock,
presetLine,
"apply_shields_up_runtime_env",
'printf "KANBAN=%s\\n" "${HERMES_KANBAN_DISPATCH_IN_GATEWAY-<unset>}"',
].join("\n"),
{ mode: 0o700 },
);

try {
const result = spawnSync("bash", [scriptPath], {
encoding: "utf-8",
timeout: 5000,
env: process.env,
});
const match = result.stdout.match(/KANBAN=(.*)/);
return {
result,
kanbanValue: match ? match[1] : "",
};
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
}

describe("agents/hermes/start.sh shields-up kanban dispatcher override", () => {
it("disables the embedded Hermes kanban dispatcher when the config root is locked", () => {
const run = runShieldsUpRuntimeEnv({ locked: true });

expect(run.result.status).toBe(0);
expect(run.kanbanValue).toBe("0");
expect(run.result.stderr).toContain(
"Shields-up: HERMES_KANBAN_DISPATCH_IN_GATEWAY=0",
);
expect(run.result.stderr).toContain("embedded kanban dispatcher suspended");
});

it("leaves the Hermes kanban dispatcher untouched when shields are down", () => {
const run = runShieldsUpRuntimeEnv({ locked: false });

expect(run.result.status).toBe(0);
expect(run.kanbanValue).toBe("<unset>");
expect(run.result.stderr).not.toContain("HERMES_KANBAN_DISPATCH_IN_GATEWAY");
});

it("preserves a caller-supplied HERMES_KANBAN_DISPATCH_IN_GATEWAY value under shields-up", () => {
const run = runShieldsUpRuntimeEnv({ locked: true, presetValue: "1" });

expect(run.result.status).toBe(0);
expect(run.kanbanValue).toBe("1");
expect(run.result.stderr).not.toContain("HERMES_KANBAN_DISPATCH_IN_GATEWAY=0");
});
});

describe("agents/hermes/start.sh Tirith marker bootstrap", () => {
it("removes a retryable download_failed marker so Hermes runtime fallback can retry", () => {
const run = runTirithMarkerBootstrap({ markerReason: "download_failed" });
Expand Down
Loading