hermes: secure node access and expose live workers

This commit is contained in:
jenkins 2026-08-15 13:58:42 -03:00
parent 86298cb9dc
commit d27a9649cf
8 changed files with 608 additions and 31 deletions

View File

@ -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

View File

@ -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,
` <div
ref={hostRef}
className="hermes-chat-xterm-host min-h-0 min-w-0 flex-1"
/>
{/* NS-504:`,
` <div
ref={hostRef}
className="hermes-chat-xterm-host min-h-0 min-w-0 flex-1"
/>
{resumeParam && (
<SessionActivityPanel
key={resumeParam + "\\0" + (scopedProfile ?? "")}
sessionId={resumeParam}
profile={scopedProfile}
/>
)}
{/* 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<PaginatedSessions>(
appendProfileParam(
\`/api/sessions?limit=\${limit}&offset=\${offset}&order=\${order}\`,
profile,
),
),`,
` order: "created" | "recent" = "created",
includeChildren = false,
) =>
fetchJSON<PaginatedSessions>(
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 \

View File

@ -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 (
<article
className="rounded border border-white/15 bg-white/5 px-3 py-2 text-xs text-white/85"
data-message-index={index}
>
<div className="mb-1 flex flex-wrap items-center justify-between gap-2 text-[0.6875rem] font-semibold uppercase tracking-wider text-white/60">
<span>{messageLabel(message)}</span>
{typeof message.timestamp === "number" && (
<time dateTime={new Date(message.timestamp * 1000).toISOString()}>
{new Date(message.timestamp * 1000).toLocaleTimeString()}
</time>
)}
</div>
{toolCalls.map((call) => (
<details key={call.id} className="my-1 rounded border border-white/10 px-2 py-1">
<summary className="cursor-pointer select-none text-white/75">
{call.function.name || "tool"} arguments
</summary>
<pre className="mt-2 max-h-72 overflow-auto whitespace-pre-wrap break-words text-[0.6875rem] text-white/70">
{call.function.arguments}
</pre>
</details>
))}
{content && isToolResult ? (
<details className="mt-1" open={content.length < 1_500}>
<summary className="cursor-pointer select-none text-white/75">
{content.length.toLocaleString()} characters of output
</summary>
<pre className="mt-2 max-h-96 overflow-auto whitespace-pre-wrap break-words text-[0.6875rem] leading-relaxed text-white/75">
{content}
</pre>
</details>
) : content ? (
<pre className="whitespace-pre-wrap break-words font-sans leading-relaxed text-white/90">
{content}
</pre>
) : toolCalls.length === 0 ? (
<div className="text-white/50">Waiting for the next recorded event…</div>
) : null}
</article>
);
}
/**
* 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<SessionInfo | null>(null);
const [messages, setMessages] = useState<SessionMessage[]>([]);
const [expanded, setExpanded] = useState(true);
const [error, setError] = useState<string | null>(null);
const [pollTime, setPollTime] = useState(Date.now());
const requestRunning = useRef(false);
const scrollRef = useRef<HTMLDivElement | null>(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 (
<Button
onClick={() => setExpanded(true)}
prefix={<Activity className="h-4 w-4" />}
aria-expanded="false"
className="absolute left-4 top-4 z-30 bg-black/90 text-white"
>
Show live activity
</Button>
);
}
return (
<section
className="absolute inset-0 z-30 flex min-h-0 flex-col bg-black text-white"
aria-label={`Live activity for ${detail?.title || sessionId}`}
>
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-white/15 px-3 py-2">
<div className="min-w-0">
<div className="flex items-center gap-2 text-sm font-semibold">
<Activity className="h-4 w-4 text-warning" aria-hidden />
<span className="truncate">Live worker activity</span>
</div>
<div
className="mt-0.5 text-[0.6875rem] text-white/65"
aria-live="polite"
aria-atomic="true"
>
{status} · {messages.length} recorded events
</div>
</div>
<div className="flex items-center gap-2">
<Button
ghost
onClick={() => void load()}
prefix={<RefreshCw className="h-3.5 w-3.5" />}
aria-label="Refresh worker activity"
className="text-white"
>
Refresh
</Button>
<Button
ghost
onClick={() => setExpanded(false)}
prefix={<TerminalSquare className="h-3.5 w-3.5" />}
aria-expanded="true"
className="text-white"
>
Terminal view
</Button>
</div>
</div>
{error && (
<div className="border-b border-warning/40 bg-warning/10 px-3 py-2 text-xs text-warning">
{error}. Retrying automatically.
</div>
)}
<div
ref={scrollRef}
className="min-h-0 flex-1 space-y-2 overflow-y-auto p-3"
tabIndex={0}
onScroll={(event) => {
const node = event.currentTarget;
stickToBottom.current =
node.scrollHeight - node.scrollTop - node.clientHeight < 80;
}}
>
{messages.length === 0 ? (
<div className="flex min-h-full flex-col items-center justify-center gap-3 text-center text-sm text-white/65">
<Activity className="h-7 w-7 animate-pulse text-warning" aria-hidden />
<div>
<div className="font-medium text-white/85">Connecting to the worker transcript…</div>
<div className="mt-1 text-xs">This view refreshes every two seconds.</div>
</div>
</div>
) : (
messages.map((message, index) => (
<ActivityMessage
key={`${message.timestamp ?? "untimed"}-${index}`}
message={message}
index={index}
/>
))
)}
</div>
<div className="flex items-center justify-between border-t border-white/15 px-3 py-1.5 text-[0.625rem] text-white/50">
<span>Poll-backed transcript; terminal rendering is not required.</span>
<span className="flex items-center gap-1">
{expanded ? <ChevronUp className="h-3 w-3" /> : <ChevronDown className="h-3 w-3" />}
Updated {new Date(pollTime).toLocaleTimeString()}
</span>
</div>
</section>
);
}

View File

@ -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"}

View File

@ -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

View File

@ -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:

View File

@ -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):

View File

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