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 = [