From d27a9649cfa331d55a9b3b5ac31e6fd845bee939 Mon Sep 17 00:00:00 2001 From: jenkins Date: Sat, 15 Aug 2026 13:58:42 -0300 Subject: [PATCH] hermes: secure node access and expose live workers --- .../applications/hermes/kustomization.yaml | 4 + dockerfiles/Dockerfile.hermes-agent | 174 ++++++++++- dockerfiles/hermes-session-activity-panel.tsx | 273 ++++++++++++++++++ services/hermes/agent-deployment.yaml | 16 +- services/hermes/node-ssh-access.yaml | 25 +- .../scripts/patch_api_server_sessions.py | 50 +++- testing/tests/test_hermes_chat_quality.py | 25 +- testing/tests/test_hermes_cli_lanes.py | 72 ++++- 8 files changed, 608 insertions(+), 31 deletions(-) create mode 100644 dockerfiles/hermes-session-activity-panel.tsx diff --git a/clusters/atlas/flux-system/applications/hermes/kustomization.yaml b/clusters/atlas/flux-system/applications/hermes/kustomization.yaml index 57fed46e..3949282b 100644 --- a/clusters/atlas/flux-system/applications/hermes/kustomization.yaml +++ b/clusters/atlas/flux-system/applications/hermes/kustomization.yaml @@ -30,6 +30,10 @@ spec: kind: Deployment name: hermes-agent namespace: hermes + - apiVersion: apps/v1 + kind: DaemonSet + name: hermes-node-ssh-access + namespace: hermes - apiVersion: apps/v1 kind: Deployment name: hermes diff --git a/dockerfiles/Dockerfile.hermes-agent b/dockerfiles/Dockerfile.hermes-agent index 806a721a..6ece284a 100644 --- a/dockerfiles/Dockerfile.hermes-agent +++ b/dockerfiles/Dockerfile.hermes-agent @@ -685,8 +685,104 @@ for before, after, label in replacements: path.write_text(source) PY +# Make parent-linked API workers first-class live dashboard sessions. They stay +# active until the API runner closes them, even during a long final model call; +# normal interactive sessions retain the stock five-minute recency window. +RUN python - <<'PY' +from pathlib import Path + +path = Path("/opt/hermes/hermes_cli/web_server.py") +source = path.read_text() + +route_marker = '@app.get("/api/sessions")\n' +helper = '''def _dashboard_session_is_active(session: Dict[str, Any], now: float) -> bool: + """Return liveness for interactive sessions and durable API workers.""" + if session.get("ended_at") is not None: + return False + if session.get("source") == "api_server" and session.get("parent_session_id"): + return True + last_active = session.get("last_active") or session.get("started_at") or 0 + try: + return (now - float(last_active)) < 300 + except (TypeError, ValueError): + return False + + +''' +if source.count(route_marker) != 1: + raise SystemExit("Hermes sessions route marker changed") +source = source.replace(route_marker, helper + route_marker, 1) + +signature_before = ''' cwd_prefix: str = None, + profile: Optional[str] = None, +): +''' +signature_after = ''' cwd_prefix: str = None, + profile: Optional[str] = None, + include_children: bool = False, +): +''' +sessions_start = source.index('async def get_sessions(') +sessions_end = source.index('\n\n@app.get("/api/profiles/sessions")', sessions_start) +sessions_source = source[sessions_start:sessions_end] +if sessions_source.count(signature_before) != 1: + raise SystemExit("Hermes sessions signature changed") +sessions_source = sessions_source.replace(signature_before, signature_after, 1) +query_before = ''' offset=offset, + min_message_count=min_message_count, +''' +query_after = ''' offset=offset, + include_children=include_children, + min_message_count=min_message_count, +''' +if sessions_source.count(query_before) != 1: + raise SystemExit("Hermes sessions query changed") +sessions_source = sessions_source.replace(query_before, query_after, 1) +source = source[:sessions_start] + sessions_source + source[sessions_end:] + +status_before = ''' sessions = db.list_sessions_rich(limit=50) + now = time.time() + active_sessions = sum( + 1 for s in sessions + if s.get("ended_at") is None + and (now - s.get("last_active", s.get("started_at", 0))) < 300 + ) +''' +status_after = ''' sessions = db.list_sessions_rich( + limit=500, + include_children=True, + order_by_last_active=True, + ) + now = time.time() + active_sessions = sum( + 1 for session in sessions + if _dashboard_session_is_active(session, now) + ) +''' +if source.count(status_before) != 1: + raise SystemExit("Hermes active session status block changed") +source = source.replace(status_before, status_after, 1) + +row_before = ''' s["is_active"] = ( + s.get("ended_at") is None + and (now - s.get("last_active", s.get("started_at", 0))) < 300 + )''' +row_after = ''' s["is_active"] = _dashboard_session_is_active(s, now)''' +if source.count(row_before) != 2: + raise SystemExit( + "Hermes session activity rows changed: expected 2, " + f"found {source.count(row_before)}" + ) +source = source.replace(row_before, row_after) + +path.write_text(source) +PY + # Keep the browser terminal reliable across GPU context loss, make durable -# worker lineage visible, and expose the family Telegram-linking entry point. +# worker lineage visible, expose an accessible API-worker transcript, and +# expose the family Telegram-linking entry point. +COPY dockerfiles/hermes-session-activity-panel.tsx /opt/hermes/web/src/components/SessionActivityPanel.tsx + RUN node <<'NODE' const fs = require("node:fs"); @@ -704,6 +800,13 @@ function replaceOnce(source, before, after, label) { { const path = "/opt/hermes/web/src/pages/ChatPage.tsx"; let source = fs.readFileSync(path, "utf8"); + source = replaceOnce( + source, + 'import { ChatSessionList } from "@/components/ChatSessionList";\n', + 'import { ChatSessionList } from "@/components/ChatSessionList";\n' + + 'import { SessionActivityPanel } from "@/components/SessionActivityPanel";\n', + "accessible activity import", + ); source = replaceOnce( source, 'import { WebglAddon } from "@xterm/addon-webgl";\n', @@ -790,9 +893,62 @@ function replaceOnce(source, before, after, label) { `, "xterm repaint cleanup", ); + source = replaceOnce( + source, + `
+ + {/* NS-504:`, + `
+ + {resumeParam && ( + + )} + + {/* NS-504:`, + "accessible activity mount", + ); source = source.replace( "@xterm/xterm Terminal (WebGL renderer, Unicode 11 widths)", - "@xterm/xterm Terminal (repaintable canvas, Unicode 11 widths)", + "@xterm/xterm Terminal (repaintable canvas + accessible activity, Unicode 11 widths)", + ); + fs.writeFileSync(path, source); +} + +// Child API workers are hidden from the stock list by default. Let the chat +// tree opt into them without changing the Sessions page's root-only contract. +{ + const path = "/opt/hermes/web/src/lib/api.ts"; + let source = fs.readFileSync(path, "utf8"); + source = replaceOnce( + source, + ` order: "created" | "recent" = "created", + ) => + fetchJSON( + appendProfileParam( + \`/api/sessions?limit=\${limit}&offset=\${offset}&order=\${order}\`, + profile, + ), + ),`, + ` order: "created" | "recent" = "created", + includeChildren = false, + ) => + fetchJSON( + appendProfileParam( + \`/api/sessions?limit=\${limit}&offset=\${offset}&order=\${order}&include_children=\${includeChildren}\`, + profile, + ), + ),`, + "session child query option", ); fs.writeFileSync(path, source); } @@ -814,6 +970,12 @@ function replaceOnce(source, before, after, label) { "const SESSION_LIMIT = 100;", "session-tree list limit", ); + source = replaceOnce( + source, + '.getSessions(SESSION_LIMIT, 0, scopeKey, "recent")', + '.getSessions(SESSION_LIMIT, 0, scopeKey, "recent", true)', + "session-tree child query", + ); source = replaceOnce( source, " const [reloadNonce, setReloadNonce] = useState(0);\n", @@ -1053,6 +1215,14 @@ RUN cd /opt/hermes/web \ && grep -Fq 'scheduleTerminalPaint' src/pages/ChatPage.tsx \ && grep -Fq 'sessionTree.childrenByParent' \ src/components/ChatSessionList.tsx \ + && grep -Fq 'getSessions(SESSION_LIMIT, 0, scopeKey, "recent", true)' \ + src/components/ChatSessionList.tsx \ + && grep -Fq 'SessionActivityPanel' src/pages/ChatPage.tsx \ + && grep -Fq 'Poll-backed transcript; terminal rendering is not required.' \ + src/components/SessionActivityPanel.tsx \ + && grep -Fq 'include_children=${includeChildren}' src/lib/api.ts \ + && grep -Fq '_dashboard_session_is_active' \ + /opt/hermes/hermes_cli/web_server.py \ && grep -Fq 'Open Telegram setup' src/App.tsx \ && grep -Fq '"pre_turn_route"' /opt/hermes/hermes_cli/plugins.py \ && grep -Fq '"pre_internal_route"' /opt/hermes/hermes_cli/plugins.py \ diff --git a/dockerfiles/hermes-session-activity-panel.tsx b/dockerfiles/hermes-session-activity-panel.tsx new file mode 100644 index 00000000..130c8ac9 --- /dev/null +++ b/dockerfiles/hermes-session-activity-panel.tsx @@ -0,0 +1,273 @@ +/** Accessible, poll-backed transcript for externally started Hermes workers. */ + +import { Button } from "@nous-research/ui/ui/components/button"; +import { + Activity, + ChevronDown, + ChevronUp, + RefreshCw, + TerminalSquare, +} from "lucide-react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +import { + api, + type SessionInfo, + type SessionMessage, +} from "@/lib/api"; + +const POLL_MS = 2_000; + +interface SessionActivityPanelProps { + sessionId: string; + profile?: string; +} + +function messageLabel(message: SessionMessage): string { + if (message.role === "tool") { + return message.tool_name ? `Tool result: ${message.tool_name}` : "Tool result"; + } + if (message.tool_calls?.length) { + const names = message.tool_calls + .map((call) => call.function.name) + .filter(Boolean) + .join(", "); + return names ? `Tool calls: ${names}` : "Tool calls"; + } + return message.role === "assistant" ? "Hermes" : message.role; +} + +function latestTimestamp(messages: SessionMessage[]): number | null { + let latest: number | null = null; + for (const message of messages) { + if (typeof message.timestamp !== "number") continue; + latest = latest === null ? message.timestamp : Math.max(latest, message.timestamp); + } + return latest; +} + +function ActivityMessage({ + message, + index, +}: { + message: SessionMessage; + index: number; +}) { + const toolCalls = message.tool_calls ?? []; + const content = message.content ?? ""; + const isToolResult = message.role === "tool"; + + return ( +
+
+ {messageLabel(message)} + {typeof message.timestamp === "number" && ( + + )} +
+ + {toolCalls.map((call) => ( +
+ + {call.function.name || "tool"} arguments + +
+            {call.function.arguments}
+          
+
+ ))} + + {content && isToolResult ? ( +
+ + {content.length.toLocaleString()} characters of output + +
+            {content}
+          
+
+ ) : content ? ( +
+          {content}
+        
+ ) : toolCalls.length === 0 ? ( +
Waiting for the next recorded event…
+ ) : null} +
+ ); +} + +/** + * Show every persisted message and tool event without relying on xterm's + * canvas. API workers default to this view; the normal terminal remains one + * click away and continues running underneath it. + */ +export function SessionActivityPanel({ + sessionId, + profile, +}: SessionActivityPanelProps) { + const [detail, setDetail] = useState(null); + const [messages, setMessages] = useState([]); + const [expanded, setExpanded] = useState(true); + const [error, setError] = useState(null); + const [pollTime, setPollTime] = useState(Date.now()); + const requestRunning = useRef(false); + const scrollRef = useRef(null); + const stickToBottom = useRef(true); + + const load = useCallback(async () => { + if (requestRunning.current) return; + requestRunning.current = true; + try { + const [nextDetail, response] = await Promise.all([ + api.getSessionDetail(sessionId, profile ?? ""), + api.getSessionMessages(sessionId, profile ?? ""), + ]); + setDetail(nextDetail); + setMessages(response.messages); + setError(null); + setPollTime(Date.now()); + if (stickToBottom.current) { + requestAnimationFrame(() => { + const node = scrollRef.current; + if (node) node.scrollTop = node.scrollHeight; + }); + } + } catch (cause) { + setError(cause instanceof Error ? cause.message : "activity feed unavailable"); + } finally { + requestRunning.current = false; + } + }, [profile, sessionId]); + + useEffect(() => { + void load(); + const timer = window.setInterval(() => void load(), POLL_MS); + const onVisibility = () => { + if (!document.hidden) void load(); + }; + document.addEventListener("visibilitychange", onVisibility); + return () => { + window.clearInterval(timer); + document.removeEventListener("visibilitychange", onVisibility); + }; + }, [load]); + + const latest = useMemo(() => latestTimestamp(messages), [messages]); + const quietSeconds = latest === null ? null : Math.max(0, pollTime / 1000 - latest); + const running = detail?.ended_at == null; + const status = error + ? "Activity feed reconnecting" + : running && quietSeconds !== null && quietSeconds > 15 + ? "Working — waiting for the model or a tool result" + : running + ? "Working" + : "Run finished"; + + // Ordinary interactive TUI sessions keep their established terminal UI. + if (detail && detail.source !== "api_server") return null; + + if (!expanded) { + return ( + + ); + } + + return ( +
+
+
+
+ + Live worker activity +
+
+ {status} · {messages.length} recorded events +
+
+
+ + +
+
+ + {error && ( +
+ {error}. Retrying automatically. +
+ )} + +
{ + const node = event.currentTarget; + stickToBottom.current = + node.scrollHeight - node.scrollTop - node.clientHeight < 80; + }} + > + {messages.length === 0 ? ( +
+ +
+
Connecting to the worker transcript…
+
This view refreshes every two seconds.
+
+
+ ) : ( + messages.map((message, index) => ( + + )) + )} +
+ +
+ Poll-backed transcript; terminal rendering is not required. + + {expanded ? : } + Updated {new Date(pollTime).toLocaleTimeString()} + +
+
+ ); +} diff --git a/services/hermes/agent-deployment.yaml b/services/hermes/agent-deployment.yaml index 7270354b..c5beab82 100644 --- a/services/hermes/agent-deployment.yaml +++ b/services/hermes/agent-deployment.yaml @@ -48,6 +48,16 @@ spec: {{- with secret "kv/data/atlas/hermes/developer-ssh" -}} {{ .Data.data.private_key }} {{- end }} + vault.hashicorp.com/agent-inject-secret-node-ssh-config: kv/data/atlas/hermes/developer-ssh + vault.hashicorp.com/agent-inject-template-node-ssh-config: | + {{- with secret "kv/data/atlas/hermes/developer-ssh" -}} + {{ .Data.data.config }} + {{- end }} + vault.hashicorp.com/agent-inject-secret-node-ssh-known-hosts: kv/data/atlas/hermes/developer-ssh + vault.hashicorp.com/agent-inject-template-node-ssh-known-hosts: | + {{- with secret "kv/data/atlas/hermes/developer-ssh" -}} + {{ .Data.data.known_hosts }} + {{- end }} vault.hashicorp.com/agent-inject-secret-chat-relay-key: kv/data/atlas/hermes/chat-telegram vault.hashicorp.com/agent-inject-template-chat-relay-key: | {{- with secret "kv/data/atlas/hermes/chat-telegram" -}} @@ -124,8 +134,8 @@ spec: cp /config/SOUL.md /opt/data/SOUL.md cp /config/AGENTS.md /opt/data/workspace/AGENTS.md cp /config/START-HERE.md /opt/data/workspace/START-HERE.md - cp /config/ssh_config /opt/data/home/.ssh/config - cp /config/ssh_known_hosts /opt/data/home/.ssh/known_hosts + cp /vault/secrets/node-ssh-config /opt/data/home/.ssh/config + cp /vault/secrets/node-ssh-known-hosts /opt/data/home/.ssh/known_hosts if [ -s /vault/secrets/node-ssh-private-key ]; then cp /vault/secrets/node-ssh-private-key /opt/data/home/.ssh/id_ed25519_atlas_nodes chmod 0600 /opt/data/home/.ssh/id_ed25519_atlas_nodes @@ -522,6 +532,8 @@ spec: - {name: HERMES_DASHBOARD_FILES_ROOT, value: /opt/data/workspace} - {name: HERMES_DASHBOARD_PUBLIC_URL, value: https://agent.hermes.bstein.dev} - {name: HERMES_TUI_AGENT_INIT_TIMEOUT_S, value: "180"} + - {name: HERMES_STREAM_STALE_TIMEOUT, value: "600"} + - {name: HERMES_API_CALL_STALE_TIMEOUT, value: "600"} - {name: API_SERVER_ENABLED, value: "true"} - {name: API_SERVER_HOST, value: 0.0.0.0} - {name: API_SERVER_PORT, value: "8642"} diff --git a/services/hermes/node-ssh-access.yaml b/services/hermes/node-ssh-access.yaml index c1e3f1b1..0d834165 100644 --- a/services/hermes/node-ssh-access.yaml +++ b/services/hermes/node-ssh-access.yaml @@ -1,13 +1,11 @@ # services/hermes/node-ssh-access.yaml apiVersion: v1 -kind: ConfigMap +kind: ServiceAccount metadata: name: hermes-node-ssh-access namespace: hermes labels: app: hermes-node-ssh-access -data: - authorized_key: >- --- apiVersion: apps/v1 kind: DaemonSet @@ -24,8 +22,19 @@ spec: metadata: labels: app: hermes-node-ssh-access + annotations: + vault.hashicorp.com/agent-inject: "true" + vault.hashicorp.com/role: hermes-node-ssh + vault.hashicorp.com/agent-inject-secret-node-ssh-public-key: kv/data/atlas/hermes/developer-ssh + vault.hashicorp.com/agent-inject-template-node-ssh-public-key: | + {{- with secret "kv/data/atlas/hermes/developer-ssh" -}} + {{ .Data.data.public_key }} + {{- end }} + vault.hashicorp.com/agent-pre-populate-only: "true" + vault.hashicorp.com/agent-init-first: "true" spec: - automountServiceAccountToken: false + serviceAccountName: hermes-node-ssh-access + automountServiceAccountToken: true tolerations: - operator: Exists containers: @@ -36,7 +45,7 @@ spec: args: - | reconcile() { - key="$(cat /config/authorized_key)" + key="$(cat /vault/secrets/node-ssh-public-key)" found=0 for user in atlas oceanus; do home="/host-home/${user}" @@ -62,9 +71,6 @@ spec: seccompProfile: type: RuntimeDefault volumeMounts: - - name: access-config - mountPath: /config - readOnly: true - name: host-home mountPath: /host-home - name: tmp @@ -77,9 +83,6 @@ spec: cpu: 50m memory: 32Mi volumes: - - name: access-config - configMap: - name: hermes-node-ssh-access - name: host-home hostPath: path: /home diff --git a/services/hermes/scripts/patch_api_server_sessions.py b/services/hermes/scripts/patch_api_server_sessions.py index b6d3af08..3e3abf5f 100644 --- a/services/hermes/scripts/patch_api_server_sessions.py +++ b/services/hermes/scripts/patch_api_server_sessions.py @@ -88,7 +88,16 @@ RUNS_AFTER = ''' run_id = f"run_{uuid.uuid4().hex}" return web.json_response(_openai_error("Session database unavailable", code="session_db_unavailable"), status=503) if not db.get_session(parent_session_id): return web.json_response(_openai_error(f"Parent session not found: {parent_session_id}", code="parent_session_not_found"), status=404) - if not db.get_session(session_id): + existing_session = db.get_session(session_id) + if existing_session and existing_session.get("parent_session_id") != parent_session_id: + return web.json_response( + _openai_error( + "Session belongs to a different parent objective", + code="session_parent_conflict", + ), + status=409, + ) + if not existing_session: db.create_session( session_id, "api_server", @@ -101,10 +110,41 @@ RUNS_AFTER = ''' run_id = f"run_{uuid.uuid4().hex}" parts = incident.group(1).split("/") label = " · ".join(parts[1:3]) if len(parts) >= 3 else incident.group(1) db.set_session_title(session_id, f"Sonar · {label} · {session_id[-8:]}") + else: + # A deliberate continuation of the same visible worker should + # return to the active state until this run reaches a terminal + # status. Cross-parent reuse was rejected above. + db.reopen_session(session_id) # Approval queues gate host-side tool execution and must be isolated ''' +RUN_CLOSE_BEFORE = ''' finally: + # If the asyncio wrapper is cancelled (for example via +''' + +RUN_CLOSE_AFTER = ''' finally: + # Parent-linked API workers are durable dashboard sessions. + # Close them on every terminal run path so the dashboard can + # distinguish a long model wait from completed or failed work. + if parent_session_id: + try: + db = self._ensure_session_db() + if db is not None: + terminal_status = self._run_statuses.get(run_id, {}).get( + "status", "failed" + ) + db.end_session(session_id, f"api_run_{terminal_status}") + except Exception: + logger.exception( + "[api_server] failed to close session %s for run %s", + session_id, + run_id, + ) + + # If the asyncio wrapper is cancelled (for example via +''' + def patch(source: Path, destination: Path) -> None: """Apply the narrow session-lineage extension and fail on upstream drift.""" @@ -113,9 +153,15 @@ def patch(source: Path, destination: Path) -> None: raise RuntimeError("Hermes API session patch context changed") if RUNS_BEFORE not in content: raise RuntimeError("Hermes API runs patch context changed") + if RUN_CLOSE_BEFORE not in content: + raise RuntimeError("Hermes API run-close patch context changed") destination.parent.mkdir(parents=True, exist_ok=True) content = content.replace(BEFORE, AFTER, 1) - destination.write_text(content.replace(RUNS_BEFORE, RUNS_AFTER, 1), encoding="utf-8") + content = content.replace(RUNS_BEFORE, RUNS_AFTER, 1) + destination.write_text( + content.replace(RUN_CLOSE_BEFORE, RUN_CLOSE_AFTER, 1), + encoding="utf-8", + ) def main() -> int: diff --git a/testing/tests/test_hermes_chat_quality.py b/testing/tests/test_hermes_chat_quality.py index 78c918fb..0d96d9c8 100644 --- a/testing/tests/test_hermes_chat_quality.py +++ b/testing/tests/test_hermes_chat_quality.py @@ -164,6 +164,16 @@ def test_gateway_image_honors_ui_model_and_caps_reasoning(): assert "Anthropic API / direct OAuth (not used by Switchyard)" in dockerfile assert "scheduleTerminalPaint" in dockerfile assert "sessionTree.childrenByParent" in dockerfile + assert "SessionActivityPanel" in dockerfile + assert "include_children=${includeChildren}" in dockerfile + assert "_dashboard_session_is_active" in dockerfile + activity = ( + ROOT / "dockerfiles" / "hermes-session-activity-panel.tsx" + ).read_text() + assert "Live worker activity" in activity + assert "api.getSessionMessages" in activity + assert "aria-live=\"polite\"" in activity + assert "Poll-backed transcript; terminal rendering is not required." in activity assert "Open Telegram setup" in dockerfile @@ -466,6 +476,10 @@ def test_chat_image_generation_uses_private_owner_broker(): 'hermes/developer-ssh"' in vault_policy ) + assert ( + 'write_policy_and_role "hermes-node-ssh" "hermes" ' + '"hermes-node-ssh-access"' in vault_policy + ) def test_compact_image_edit_resolves_latest_tenant_artifact(tmp_path, monkeypatch): @@ -1024,7 +1038,13 @@ def test_api_session_patch_accepts_parent_lineage(tmp_path: Path): source = tmp_path / "api_server.py" destination = tmp_path / "patched.py" source.write_text( - "prefix\n" + module.BEFORE + "middle\n" + module.RUNS_BEFORE + "suffix\n", + "prefix\n" + + module.BEFORE + + "middle\n" + + module.RUNS_BEFORE + + "run body\n" + + module.RUN_CLOSE_BEFORE + + "suffix\n", encoding="utf-8", ) @@ -1036,6 +1056,9 @@ def test_api_session_patch_accepts_parent_lineage(tmp_path: Path): assert "Parent session not found" in patched assert "HERMES_API_DEFAULT_PARENT_MATCH_PREFIXES" in patched assert "user_message.startswith(default_prefixes)" in patched + assert "session_parent_conflict" in patched + assert "db.reopen_session(session_id)" in patched + assert 'db.end_session(session_id, f"api_run_{terminal_status}")' in patched def test_legacy_api_sessions_are_nested_idempotently(tmp_path: Path): diff --git a/testing/tests/test_hermes_cli_lanes.py b/testing/tests/test_hermes_cli_lanes.py index ac6b8bb8..8188ccad 100644 --- a/testing/tests/test_hermes_cli_lanes.py +++ b/testing/tests/test_hermes_cli_lanes.py @@ -686,6 +686,9 @@ def test_agent_root_is_stock_dashboard_and_terminal_is_a_separate_path(): assert "dashboard" not in containers hermes = containers["hermes"] + hermes_env = {item["name"]: item["value"] for item in hermes["env"]} + assert hermes_env["HERMES_STREAM_STALE_TIMEOUT"] == "600" + assert hermes_env["HERMES_API_CALL_STALE_TIMEOUT"] == "600" assert hermes["command"] == ["/bin/sh", "-ec"] startup = hermes["args"][0] assert ". /opt/data/.env" in startup @@ -828,6 +831,7 @@ def test_flux_health_checks_follow_the_owner_oauth_sidecar(): for item in flux["spec"]["healthChecks"] } assert ("Deployment", "hermes-agent") in checks + assert ("DaemonSet", "hermes-node-ssh-access") in checks assert ("Deployment", "oauth2-proxy-hermes-agent") not in checks @@ -924,6 +928,12 @@ def test_owner_agent_has_pinned_dedicated_node_ssh_access(): assert annotations[ "vault.hashicorp.com/agent-inject-secret-node-ssh-private-key" ] == "kv/data/atlas/hermes/developer-ssh" + assert annotations[ + "vault.hashicorp.com/agent-inject-secret-node-ssh-config" + ] == "kv/data/atlas/hermes/developer-ssh" + assert annotations[ + "vault.hashicorp.com/agent-inject-secret-node-ssh-known-hosts" + ] == "kv/data/atlas/hermes/developer-ssh" init = next( item @@ -931,21 +941,17 @@ def test_owner_agent_has_pinned_dedicated_node_ssh_access(): if item["name"] == "init-config" ) command = init["command"][2] - assert "cp /config/ssh_config /opt/data/home/.ssh/config" in command + assert "cp /vault/secrets/node-ssh-config /opt/data/home/.ssh/config" in command + assert ( + "cp /vault/secrets/node-ssh-known-hosts /opt/data/home/.ssh/known_hosts" + in command + ) assert "chmod 0600 /opt/data/home/.ssh/id_ed25519_atlas_nodes" in command config = yaml.safe_load((HERMES / "agent-configmap.yaml").read_text())["data"] - assert "IdentityFile /opt/data/home/.ssh/id_ed25519_atlas_nodes" in config[ - "ssh_config" - ] - assert "StrictHostKeyChecking yes" in config["ssh_config"] - assert "PasswordAuthentication no" in config["ssh_config"] - pinned_hosts = [ - line for line in config["ssh_known_hosts"].splitlines() if line.strip() - ] - assert len(pinned_hosts) == 21 - assert all(" ssh-ed25519 " in line for line in pinned_hosts) - assert any(line.startswith("[titan-23]:2277 ") for line in pinned_hosts) + assert "ssh_config" not in config + assert "ssh_known_hosts" not in config + assert "ssh-ed25519" not in (HERMES / "agent-configmap.yaml").read_text() resources = yaml.safe_load((HERMES / "kustomization.yaml").read_text())[ "resources" @@ -956,16 +962,56 @@ def test_owner_agent_has_pinned_dedicated_node_ssh_access(): for item in yaml.safe_load_all((HERMES / "node-ssh-access.yaml").read_text()) if item ] + service_account = next(item for item in access if item["kind"] == "ServiceAccount") + assert service_account["metadata"]["name"] == "hermes-node-ssh-access" daemonset = next(item for item in access if item["kind"] == "DaemonSet") pod = daemonset["spec"]["template"]["spec"] - assert pod["automountServiceAccountToken"] is False + assert pod["serviceAccountName"] == "hermes-node-ssh-access" + assert pod["automountServiceAccountToken"] is True + pod_annotations = daemonset["spec"]["template"]["metadata"]["annotations"] + assert pod_annotations["vault.hashicorp.com/role"] == "hermes-node-ssh" + assert pod_annotations[ + "vault.hashicorp.com/agent-inject-secret-node-ssh-public-key" + ] == "kv/data/atlas/hermes/developer-ssh" host_home = next(item for item in pod["volumes"] if item["name"] == "host-home") assert host_home["hostPath"] == {"path": "/home", "type": "Directory"} reconciler = pod["containers"][0]["args"][0] + assert "cat /vault/secrets/node-ssh-public-key" in reconciler assert "grep -qxF" in reconciler assert "for user in atlas oceanus" in reconciler +def test_owner_agent_tracks_no_ssh_identity_or_host_key_material(): + """Vault references may be tracked; SSH identities and trust data may not.""" + forbidden = ( + "BEGIN OPENSSH PRIVATE KEY", + "ssh-ed25519 AAAA", + "ssh-rsa AAAA", + "IdentityFile ", + "UserKnownHostsFile ", + "StrictHostKeyChecking ", + "ssh_config:", + "ssh_known_hosts:", + ) + text_suffixes = { + ".conf", + ".json", + ".md", + ".py", + ".sh", + ".toml", + ".yaml", + ".yml", + } + tracked = "\n".join( + path.read_text(encoding="utf-8") + for path in HERMES.rglob("*") + if path.is_file() and path.suffix in text_suffixes + ) + for marker in forbidden: + assert marker not in tracked + + def test_switchyard_has_a_dedicated_non_owner_identity_and_read_only_catalog(): """Routing must not inherit the owner agent's cluster-admin capability.""" service_accounts = [