hermes: secure node access and expose live workers
This commit is contained in:
parent
86298cb9dc
commit
d27a9649cf
@ -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
|
||||
|
||||
@ -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 \
|
||||
|
||||
273
dockerfiles/hermes-session-activity-panel.tsx
Normal file
273
dockerfiles/hermes-session-activity-panel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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"}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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 = [
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user