atlas-iac/dockerfiles/Dockerfile.hermes-agent

1658 lines
65 KiB
Docker
Raw Normal View History

# syntax=docker/dockerfile:1
# dockerfiles/Dockerfile.hermes-agent
2026-08-15 22:32:08 -03:00
FROM nousresearch/hermes-agent@sha256:47d4bd4cc420b70e40ed75efdade373e45b86b7382d4013a054208982bb6ba08
USER root
# Codex uses the distribution bubblewrap package when a bounded sandbox is
# requested. tmux keeps the stock Hermes TUI alive across browser reconnects;
2026-08-10 17:05:14 -03:00
# durable coding workers are dispatched by native Hermes Kanban lanes.
RUN apt-get update \
&& apt-get install -y --no-install-recommends bubblewrap tmux \
&& rm -rf /var/lib/apt/lists/*
# Keep a credential-free search provider available for private chat tenants.
# Paid/provider-backed search remains selectable through normal Hermes config.
RUN uv pip install --python /opt/hermes/.venv/bin/python ddgs==9.14.4
ARG HERMES_KANIKO_HEREDOC_COMPAT=0
COPY dockerfiles/Dockerfile.hermes-agent /tmp/hermes-agent.Dockerfile
COPY dockerfiles/hermes-kaniko-heredoc-runner.py /tmp/hermes-kaniko-heredoc-runner.py
2026-08-02 16:46:21 -03:00
# Keep dashboard chat sockets tied to the intended React mount and conversation.
# A resumed conversation needs a different PTY attachment key from a fresh chat;
# reconnects to that same conversation must keep using the same key.
RUN node <<'NODE'
const fs = require("node:fs");
const apiPath = "/opt/hermes/web/src/lib/api.ts";
let apiSource = fs.readFileSync(apiPath, "utf8");
const sessionHeaderBefore = [
'function setSessionHeader(headers: Headers, token: string): void {',
' if (!headers.has(SESSION_HEADER)) {',
' headers.set(SESSION_HEADER, token);',
' }',
'}',
].join("\n");
const sessionHeaderAfter = [
sessionHeaderBefore,
'',
'/**',
' * Recover an expired outer OAuth2 Proxy session without losing the SPA route.',
' * Background fetch redirects cannot complete an OIDC browser flow, so API',
' * routes deliberately return 401 and the dashboard performs this top-level',
' * navigation once instead.',
' */',
'function redirectToProxyLogin<T>(): Promise<T> {',
' const rd =',
' window.location.pathname + window.location.search + window.location.hash;',
' try {',
' sessionStorage.setItem("hermes.lastLocation", rd);',
' } catch {',
' /* privacy mode — the rd query parameter remains authoritative */',
' }',
' window.location.assign(',
' `${BASE}/oauth2/start?rd=${encodeURIComponent(rd)}`,',
' );',
' return new Promise<T>(() => {});',
'}',
].join("\n");
const plainUnauthorizedBefore = [
' if (!window.__HERMES_AUTH_REQUIRED__ && !options?.allowUnauthorized) {',
].join("\n");
const plainUnauthorizedAfter = [
' // OAuth2 Proxy API routes intentionally return a plain 401 when its',
' // session expires. A background fetch cannot complete OIDC, so promote',
' // it to a top-level navigation and return to this exact dashboard route.',
' if (window.__HERMES_AUTH_REQUIRED__ && !options?.allowUnauthorized) {',
' return redirectToProxyLogin<T>();',
' }',
'',
plainUnauthorizedBefore,
].join("\n");
const ticketFailureBefore = [
' if (!res.ok) {',
' throw new Error(`/api/auth/ws-ticket: HTTP ${res.status}`);',
' }',
].join("\n");
const ticketFailureAfter = [
' if (res.status === 401) {',
' return redirectToProxyLogin<{ ticket: string; ttl_seconds: number }>();',
' }',
ticketFailureBefore,
].join("\n");
for (const [before, after, label] of [
[sessionHeaderBefore, sessionHeaderAfter, "proxy login helper"],
[plainUnauthorizedBefore, plainUnauthorizedAfter, "plain 401 recovery"],
[ticketFailureBefore, ticketFailureAfter, "ticket 401 recovery"],
]) {
if (!apiSource.includes(before)) {
throw new Error(`Hermes API ${label} patch context changed`);
}
apiSource = apiSource.replace(before, after);
}
fs.writeFileSync(apiPath, apiSource);
const path = "/opt/hermes/web/src/pages/ChatPage.tsx";
2026-08-02 16:46:21 -03:00
let source = fs.readFileSync(path, "utf8");
const socketBefore = [
' const url = await api.buildWsUrl("/api/pty", params);',
' const ws = new WebSocket(url);',
].join("\n");
2026-08-02 16:46:21 -03:00
const socketAfter = [
' let url: string;',
' try {',
' // WebSocket upgrades cannot expose an HTTP 401 reliably through',
' // every proxy. Probe a protected REST route first so fetchJSON can',
' // detect a rotated loopback token and reload this SPA route once.',
' await api.getSessions(1, 0, scopedProfile ?? "");',
' url = await api.buildWsUrl("/api/pty", params);',
' } catch {',
' if (!unmounting) scheduleReconnect(1006);',
' return;',
' }',
' if (unmounting) return;',
' const ws = new WebSocket(url);',
].join("\n");
2026-08-02 16:46:21 -03:00
const attachBefore = ' params.attach = ptyAttachToken(forceFresh);';
const attachAfter = [
' const attachScope = resumeParam',
' ? `resume:${resumeParam}:${scopedProfile ?? ""}`',
' : `fresh:${scopedProfile ?? ""}`;',
' params.attach = `${ptyAttachToken(forceFresh)}:${attachScope}`;',
].join("\n");
2026-08-02 16:46:21 -03:00
if (!source.includes(socketBefore)) {
throw new Error("Hermes ChatPage WebSocket patch context changed");
}
2026-08-02 16:46:21 -03:00
if (!source.includes(attachBefore)) {
throw new Error("Hermes ChatPage PTY attachment patch context changed");
}
source = source.replace(socketBefore, socketAfter);
source = source.replace(attachBefore, attachAfter);
fs.writeFileSync(path, source);
// The terminal socket already reconnects indefinitely after a transient
// outage. The sidebar's events/gateway sockets did not: one deployment or
// network flap left the page in a permanent manual-Reconnect state. Retry the
// whole sidebar connection set with capped backoff while keeping auth/origin
// rejections fail-closed.
const sidebarPath = "/opt/hermes/web/src/components/ChatSidebar.tsx";
let sidebar = fs.readFileSync(sidebarPath, "utf8");
const retryStateBefore = ' const [version, setVersion] = useState(0);';
const retryStateAfter = [
retryStateBefore,
' const eventsRetryAttempt = useRef(0);',
].join("\n");
const socketStateBefore = [
' let unmounting = false;',
' let ws: WebSocket | null = null;',
].join("\n");
const socketStateAfter = [
' let unmounting = false;',
' let ws: WebSocket | null = null;',
' let reconnectTimer: ReturnType<typeof setTimeout> | null = null;',
].join("\n");
const ticketBefore = [
' const url = await buildWsUrl("/api/events", { channel });',
' if (unmounting) {',
' return;',
' }',
' ws = new WebSocket(url);',
].join("\n");
const ticketAfter = [
' let url: string;',
' try {',
' // Run the shared HTTP auth recovery before each socket attempt.',
' // A stale injected token otherwise appears as opaque WS code 1006',
' // and this reconnect loop can never obtain the replacement token.',
' await api.getSessions(1, 0, profile ?? "");',
' url = await buildWsUrl("/api/events", { channel });',
' } catch {',
' if (unmounting) return;',
' const attempt = Math.min(eventsRetryAttempt.current + 1, 6);',
' eventsRetryAttempt.current = attempt;',
' const delay = Math.min(500 * 2 ** (attempt - 1), 5000);',
' setError("events feed disconnected — tool calls may not appear; reconnecting…");',
' reconnectTimer = setTimeout(() => {',
' reconnectTimer = null;',
' if (!unmounting) setVersion((v) => v + 1);',
' }, delay);',
' return;',
' }',
' if (unmounting) return;',
' ws = new WebSocket(url);',
].join("\n");
const handlersBefore = [
' ws.addEventListener("error", () => surface(DISCONNECTED));',
'',
' ws.addEventListener("close", (ev) => {',
' if (ev.code === 4401 || ev.code === 4403) {',
' surface(`events feed rejected (${ev.code}) — reload the page`);',
' } else if (ev.code !== 1000) {',
' surface(DISCONNECTED);',
' }',
' });',
].join("\n");
const handlersAfter = [
' ws.addEventListener("open", () => {',
' eventsRetryAttempt.current = 0;',
' setError(null);',
' });',
'',
' ws.addEventListener("error", () => surface(DISCONNECTED));',
'',
' ws.addEventListener("close", (ev) => {',
' if (unmounting || ev.code === 1000) return;',
' if (ev.code === 4401 || ev.code === 4403) {',
' surface(`events feed rejected (${ev.code}) — reload the page`);',
' return;',
' }',
' const attempt = Math.min(eventsRetryAttempt.current + 1, 6);',
' eventsRetryAttempt.current = attempt;',
' const delay = Math.min(500 * 2 ** (attempt - 1), 5000);',
' surface(`${DISCONNECTED}; reconnecting…`);',
' reconnectTimer = setTimeout(() => {',
' reconnectTimer = null;',
' if (!unmounting) setVersion((v) => v + 1);',
' }, delay);',
' });',
].join("\n");
const cleanupBefore = [
' return () => {',
' unmounting = true;',
' ws?.close();',
' };',
].join("\n");
const cleanupAfter = [
' return () => {',
' unmounting = true;',
' if (reconnectTimer) clearTimeout(reconnectTimer);',
' ws?.close();',
' };',
].join("\n");
const gatewayBefore = [
' gw.connect()',
' .then(() => {',
].join("\n");
const gatewayAfter = [
' // Give the ordinary HTTP client first chance to recover an expired',
' // OAuth session or a dashboard token rotated by a server restart.',
' api.getSessions(1, 0, profile ?? "")',
' .then(() => gw.connect())',
' .then(() => {',
].join("\n");
for (const [before, after, label] of [
[retryStateBefore, retryStateAfter, "retry state"],
[socketStateBefore, socketStateAfter, "socket state"],
[ticketBefore, ticketAfter, "ticket retry"],
[handlersBefore, handlersAfter, "socket handlers"],
[cleanupBefore, cleanupAfter, "socket cleanup"],
[gatewayBefore, gatewayAfter, "gateway auth preflight"],
]) {
if (!sidebar.includes(before)) {
throw new Error(`Hermes ChatSidebar ${label} patch context changed`);
}
sidebar = sidebar.replace(before, after);
}
fs.writeFileSync(sidebarPath, sidebar);
NODE
RUN case "${HERMES_KANIKO_HEREDOC_COMPAT}" in 0) ;; 1) python /tmp/hermes-kaniko-heredoc-runner.py --dockerfile /tmp/hermes-agent.Dockerfile --block-index 1 ;; *) exit 2 ;; esac
# The upstream OIDC gate authenticates users but deliberately treats the
# dashboard as one shared workstation. Allow a deployment to narrow that
# workstation to explicit OIDC subjects. Enforce this after normal provider
# verification so a denied account is a 403, not a misleading provider 503.
2026-08-02 16:46:21 -03:00
RUN python - <<'PY'
from pathlib import Path
path = Path("/opt/hermes/hermes_cli/dashboard_auth/middleware.py")
2026-08-02 16:46:21 -03:00
source = path.read_text()
helper_before = '''def _client_ip(request: Request) -> str:
fwd = request.headers.get("x-forwarded-for", "")
if fwd:
return fwd.split(",")[0].strip()
return request.client.host if request.client else ""
2026-08-02 16:46:21 -03:00
'''
helper_after = helper_before + '''def _dashboard_user_allowed(session) -> bool:
"""Apply an optional deployment-level OIDC-subject allowlist."""
import os
allowed = {
value.strip()
for value in os.environ.get(
"HERMES_DASHBOARD_OIDC_ALLOWED_USER_IDS", ""
).split(",")
if value.strip()
}
return not allowed or session.user_id in allowed
def _user_forbidden_response() -> Response:
"""Return an authorization failure without exposing identities."""
return JSONResponse(
{
"error": "forbidden",
"detail": "This Atlas account is not authorized for this dashboard.",
},
status_code=403,
)
2026-08-02 16:46:21 -03:00
'''
refresh_before = ''' new_session, refreshing_provider = refreshed
request.state.session = new_session
response = await call_next(request)
'''
refresh_after = ''' new_session, refreshing_provider = refreshed
if not _dashboard_user_allowed(new_session):
return _user_forbidden_response()
request.state.session = new_session
response = await call_next(request)
'''
final_before = ''' request.state.session = session
return await call_next(request)
'''
final_after = ''' if not _dashboard_user_allowed(session):
return _user_forbidden_response()
request.state.session = session
return await call_next(request)
'''
for before, after, label in (
(helper_before, helper_after, "allowlist helper"),
(refresh_before, refresh_after, "refreshed session"),
(final_before, final_after, "verified session"),
):
if before not in source:
raise SystemExit(f"Hermes dashboard auth {label} patch context changed")
source = source.replace(before, after, 1)
path.write_text(source)
2026-08-02 16:46:21 -03:00
PY
RUN case "${HERMES_KANIKO_HEREDOC_COMPAT}" in 0) ;; 1) python /tmp/hermes-kaniko-heredoc-runner.py --dockerfile /tmp/hermes-agent.Dockerfile --block-index 2 ;; *) exit 2 ;; esac
2026-08-02 16:46:21 -03:00
# Give trusted plugins a pre-turn routing hook. It runs after fallback runtime
# restoration but before Hermes builds its provider-specific system prompt.
RUN python - <<'PY'
from pathlib import Path
plugins_path = Path("/opt/hermes/hermes_cli/plugins.py")
plugins = plugins_path.read_text()
hooks_before = ''' "pre_llm_call",
"post_llm_call",
'''
hooks_after = ''' "pre_llm_call",
"pre_turn_route",
"pre_internal_route",
"pre_subagent_route",
"post_llm_call",
'''
if plugins.count(hooks_before) != 1:
raise SystemExit(
"Hermes pre-turn hook registry context changed: expected 1, "
f"found {plugins.count(hooks_before)}"
)
plugins_path.write_text(plugins.replace(hooks_before, hooks_after, 1))
turn_path = Path("/opt/hermes/agent/turn_context.py")
turn = turn_path.read_text()
turn_before = ''' agent._restore_primary_runtime()
'''
turn_after = turn_before + '''
# Trusted coordinator plugins may select the provider/model/effort for this
# turn. Run this before system-prompt restoration so the prompt and runtime
# always describe the same selected provider.
try:
from hermes_cli.plugins import has_hook, invoke_hook
if has_hook("pre_turn_route"):
invoke_hook(
"pre_turn_route",
agent=agent,
user_message=user_message,
conversation_history=list(conversation_history or []),
session_id=agent.session_id or "",
platform=agent.platform or "",
)
except Exception:
logger.warning("pre_turn_route hook failed", exc_info=True)
'''
if turn.count(turn_before) != 1:
raise SystemExit(
"Hermes pre-turn routing context changed: expected 1, "
f"found {turn.count(turn_before)}"
)
turn_path.write_text(turn.replace(turn_before, turn_after, 1))
loop_path = Path("/opt/hermes/agent/conversation_loop.py")
loop = loop_path.read_text()
loop_before = ''' # Prepare messages for API call
'''
loop_after = ''' # Reclassify every internal tool-loop continuation before provider-
# specific prompt construction. The first request was already routed by
# pre_turn_route; later requests include the tool evidence accumulated
# since that decision.
if api_call_count > 1:
try:
from hermes_cli.plugins import has_hook, invoke_hook
if has_hook("pre_internal_route"):
invoke_hook(
"pre_internal_route",
agent=agent,
user_message=original_user_message,
conversation_history=list(messages),
session_id=agent.session_id or "",
platform=agent.platform or "",
api_call_count=api_call_count,
)
except Exception:
logger.warning("pre_internal_route hook failed", exc_info=True)
# Prepare messages for API call
'''
if loop.count(loop_before) != 1:
raise SystemExit(
"Hermes internal routing context changed: expected 1, "
f"found {loop.count(loop_before)}"
)
loop_path.write_text(loop.replace(loop_before, loop_after, 1))
delegate_path = Path("/opt/hermes/tools/delegate_tool.py")
delegate = delegate_path.read_text()
delegate_before = ''' # Override with correct parent tool names (before child construction mutated global)
child._delegate_saved_tool_names = _parent_tool_names
children.append((i, t, child))
'''
delegate_after = ''' # Route each bounded child independently before its first LLM call.
# This keeps one multi-part objective from pinning every leaf to
# the parent coordinator's provider/model/effort.
try:
from hermes_cli.plugins import has_hook, invoke_hook
if has_hook("pre_subagent_route"):
invoke_hook(
"pre_subagent_route",
agent=child,
parent_agent=parent_agent,
goal=t["goal"],
context=t.get("context"),
task_index=i,
task_count=n_tasks,
)
except Exception:
logger.warning("pre_subagent_route hook failed", exc_info=True)
# Override with correct parent tool names (before child construction mutated global)
child._delegate_saved_tool_names = _parent_tool_names
children.append((i, t, child))
'''
if delegate.count(delegate_before) != 1:
raise SystemExit(
"Hermes subagent routing context changed: expected 1, "
f"found {delegate.count(delegate_before)}"
)
delegate_path.write_text(delegate.replace(delegate_before, delegate_after, 1))
delegate = delegate_path.read_text()
background_before = ''' is_subagent = getattr(parent_agent, "_delegate_depth", 0) > 0
return not is_subagent
'''
background_after = ''' is_subagent = getattr(parent_agent, "_delegate_depth", 0) > 0
# A one-shot process has no durable event loop to receive a detached
# child's result after its parent exits. Keep delegation synchronous so
# the child verdict is returned to—and persisted by—the parent turn.
if bool(getattr(parent_agent, "_hermes_oneshot", False)):
return False
return not is_subagent
'''
if delegate.count(background_before) != 1:
raise SystemExit(
"Hermes oneshot delegation context changed: expected 1, "
f"found {delegate.count(background_before)}"
)
delegate_path.write_text(delegate.replace(background_before, background_after, 1))
oneshot_path = Path("/opt/hermes/hermes_cli/oneshot.py")
oneshot = oneshot_path.read_text()
oneshot_before = ''' # Belt-and-braces: make sure AIAgent doesn't invoke any streaming
# display callbacks that would bypass our stdout capture.
agent.suppress_status_output = True
'''
oneshot_after = ''' # Preserve a caller's explicit --model route across every plugin routing
# boundary. Config/env defaults remain automatic policy inputs.
agent._hermes_explicit_model_pick = bool((model or "").strip())
# Detached delegation cannot deliver back into a process that exits after
# this turn; delegate_tool uses this marker to keep children synchronous.
agent._hermes_oneshot = True
# Belt-and-braces: make sure AIAgent doesn't invoke any streaming
# display callbacks that would bypass our stdout capture.
agent.suppress_status_output = True
'''
if oneshot.count(oneshot_before) != 1:
raise SystemExit(
"Hermes oneshot explicit-model context changed: expected 1, "
f"found {oneshot.count(oneshot_before)}"
)
oneshot_path.write_text(oneshot.replace(oneshot_before, oneshot_after, 1))
PY
RUN case "${HERMES_KANIKO_HEREDOC_COMPAT}" in 0) ;; 1) python /tmp/hermes-kaniko-heredoc-runner.py --dockerfile /tmp/hermes-agent.Dockerfile --block-index 3 ;; *) exit 2 ;; esac
# Hermes WebUI sends its model/provider/reasoning selection on /v1/runs.
# Upstream currently applies only statically declared model_routes there, so
# the UI can display one model while the gateway silently runs another. Honor
# trusted first-party provider selections and cap all chat reasoning at xhigh.
RUN python - <<'PY'
from pathlib import Path
path = Path("/opt/hermes/gateway/platforms/api_server.py")
source = path.read_text()
route_before = ''' def _resolve_route(self, model_alias: Any) -> Optional[Dict[str, Any]]:
"""Return the model_routes entry for *model_alias*, or None."""
if not self._model_routes or not isinstance(model_alias, str):
return None
return self._model_routes.get(model_alias)
'''
route_after = route_before + ''' def _resolve_request_route(self, body: Dict[str, Any]) -> Optional[Dict[str, Any]]:
"""Resolve a static route or a trusted WebUI provider/model selection."""
route = self._resolve_route(body.get("model"))
if route is not None:
return route
provider = body.get("provider")
model = body.get("model")
# Every WebUI selection is a public Switchyard route. Direct provider
# picks would create a second routing control plane and bypass failover.
allowed_providers = {"atlas-switchyard"}
if provider not in allowed_providers or not isinstance(model, str):
return None
model = model.strip()
if not model or len(model) > 128 or any(
char not in "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789._:/+-"
for char in model
):
return None
return {"provider": provider, "model": model}
'''
signature_before = ''' gateway_session_key: Optional[str] = None,
route: Optional[Dict[str, Any]] = None,
) -> Any:
'''
signature_after = ''' gateway_session_key: Optional[str] = None,
route: Optional[Dict[str, Any]] = None,
reasoning_effort: Any = None,
routing_priority: Any = None,
explicit_model_pick: Any = None,
explicit_reasoning_effort: Any = None,
) -> Any:
'''
reasoning_before = ''' runtime_kwargs = _resolve_runtime_agent_kwargs()
reasoning_config = GatewayRunner._load_reasoning_config()
model = _resolve_gateway_model()
'''
reasoning_after = ''' runtime_kwargs = _resolve_runtime_agent_kwargs()
reasoning_config = GatewayRunner._load_reasoning_config()
from hermes_constants import parse_reasoning_effort
requested_reasoning = parse_reasoning_effort(reasoning_effort)
if requested_reasoning is not None:
reasoning_config = requested_reasoning
if reasoning_config and reasoning_config.get("effort") == "max":
reasoning_config = {"enabled": True, "effort": "xhigh"}
model = _resolve_gateway_model()
'''
runs_route_before = ''' # Per-client model routing for /v1/runs (see model_routes).
route = self._resolve_route(body.get("model"))
'''
runs_route_after = ''' # Honor both static routes and the WebUI's trusted provider/model pick.
route = self._resolve_request_route(body)
'''
runs_agent_before = ''' gateway_session_key=gateway_session_key,
route=route,
)
'''
runs_agent_after = ''' gateway_session_key=gateway_session_key,
route=route,
reasoning_effort=body.get("reasoning_effort"),
routing_priority=body.get("routing_priority"),
explicit_model_pick=body.get("explicit_model_pick"),
explicit_reasoning_effort=body.get("explicit_reasoning_effort"),
)
'''
agent_controls_before = ''' gateway_session_key=gateway_session_key,
)
return agent
'''
agent_controls_after = ''' gateway_session_key=gateway_session_key,
)
priority = str(routing_priority or "").strip().lower()
if priority not in {"fast", "balanced", "deep", "maximum"}:
priority = ""
explicit_effort = str(explicit_reasoning_effort or "").strip().lower()
if explicit_effort not in {"none", "minimal", "low", "medium", "high", "xhigh"}:
explicit_effort = ""
agent._hermes_routing_priority = priority
agent._hermes_explicit_model_pick = bool(explicit_model_pick)
agent._hermes_explicit_reasoning_effort = explicit_effort
return agent
'''
for before, after, label, count in (
(route_before, route_after, "request route resolver", 1),
(signature_before, signature_after, "agent reasoning argument", 1),
(reasoning_before, reasoning_after, "reasoning clamp", 1),
(runs_route_before, runs_route_after, "runs route", 1),
(agent_controls_before, agent_controls_after, "request routing controls", 1),
):
if source.count(before) != count:
raise SystemExit(
f"Hermes API {label} patch context changed: expected {count}, "
f"found {source.count(before)}"
)
source = source.replace(before, after, count)
# The same argument tail appears in other handlers. Restrict replacement to
# the /v1/runs section so non-WebUI API surfaces retain upstream behavior.
runs_start = source.index(" async def _handle_runs(")
runs_source = source[runs_start:]
if runs_source.count(runs_agent_before) != 1:
raise SystemExit(
"Hermes API /v1/runs agent-call patch context changed: expected 1, "
f"found {runs_source.count(runs_agent_before)}"
)
runs_source = runs_source.replace(runs_agent_before, runs_agent_after, 1)
source = source[:runs_start] + runs_source
path.write_text(source)
PY
RUN case "${HERMES_KANIKO_HEREDOC_COMPAT}" in 0) ;; 1) python /tmp/hermes-kaniko-heredoc-runner.py --dockerfile /tmp/hermes-agent.Dockerfile --block-index 4 ;; *) exit 2 ;; esac
hermes: harden worker isolation and blocked-task semantics Three narrowly scoped Hermes reliability fixes backed by live evidence from the Cassandra/titan-iac proof run. Worker concurrency. Three simultaneous direct CLI workers on the 4-core hermes-agent node drove load to ~45 and made the hermes and oauth2-proxy containers fail their probes, leaving the pod 8/10 Ready; two workers stayed at 10/10. Cap HERMES_CLI_LANE_CONCURRENCY at 2 and lower the cli-lane-runner CPU limit from 3 to 2 so the dashboard and auth sidecars keep a guaranteed share of the node. Requests are unchanged: the pod still asks for 745m total, so placement does not move. Service links. Kubernetes injects a service-link variable pair for every service in the namespace, and hermes-claude-broker produces HERMES_CLAUDE_BROKER_PORT=tcp://10.43.31.76:9006 — a value the broker parses as an int. That contaminated worker and test environments even though the deployment already addresses every service by DNS name. Set enableServiceLinks: false on the hermes-agent pod spec. Blocked-task scheduling. create_task(initial_status="blocked") records a created event carrying status=blocked but never a blocked event, while _has_sticky_block() only inspects blocked/unblocked events. recompute_ready() considers blocked tasks, so an explicitly parked task with no incomplete parent auto-promoted on the next dispatcher cycle. Teach _has_sticky_block() to also recognize a created event whose payload status is blocked, which covers tasks created before this image patch without adding a persisted field. Dependency-driven promotion and the circuit-breaker failure-limit guard are untouched; unblock_task() still releases either kind of block. hermes-kanban-blocked-regression.py runs against the real upstream kanban_db API during the image build, so the build fails if any of these semantics regress. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 20:53:22 +00:00
# ``create_task(initial_status="blocked")`` is an explicit operator park, but
# upstream only treats later block_task() events as sticky. Recognize the
# existing created(status=blocked) event too. This also protects tasks created
# before this image patch without making dependency or circuit-breaker blocks
# depend on a new persisted field.
RUN python - <<'PY'
from pathlib import Path
db_path = Path("/opt/hermes/hermes_cli/kanban_db.py")
db_source = db_path.read_text()
hermes: fail closed on kanban created-event producer drift The sticky-block gate added in the previous commit classifies a task from the `created` event payload that upstream `create_task` writes. That producer is code we do not own, so trusting it silently was the gap: if upstream renamed the key, dropped it, or stopped deriving it from `initial_status`, the image would still build and ship a consumer that mis-classifies every task it reads. Anchor the producer contract at build time, before the regression suite runs, with three assert-only preconditions: the `initial_status="blocked"` park resolves `task_status` to `"blocked"`, every non-park creation resolves it to something else, and the `created` event carries that same variable under `"status"`. None of them rewrite the producer. Textual anchors cannot see dataflow, so add the runtime net the reviewer asked for. The suite now drives the real API: create + claim an ordinary task, trip the circuit breaker once at failure_limit=1 so it parks with a `gave_up` event (leaving its own `created` event as the most recent create/block/unblock row), then recompute at failure_limit=2 and require promotion to ready. That case is red under an unconditional-true created predicate and red under producer drift that labels every created event blocked, while the explicit block/unblock, dependency-promotion and circuit-breaker-at-current-limit cases stay green. Non-blocked and malformed created payloads are pinned as controls, and the gate now rejects non-dict payloads rather than trusting `.get`. Also make the live placement correction durable: titan-04 is cordoned after repeated kernel undervoltage and kubelet failure and titan-19 was probe/Longhorn unstable under worker load, so both join the hard NotIn list; titan-05 is healthy but sits at 3592m/3600m requested CPU, so the main hermes container gives back 50m (350m -> 300m) to schedule there. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 22:42:18 +00:00
# The rewritten sticky-block gate below classifies a task from the ``created``
# event payload that ``create_task`` writes. That producer is upstream code we
# do not own, so anchor the exact contract the consumer depends on instead of
# trusting it: an ``initial_status="blocked"`` park must resolve ``task_status``
# to ``"blocked"``, every other creation must resolve it to something else, and
# the ``created`` event must carry that same variable under the ``"status"``
# key. If upstream renames the key, drops it, hardcodes it, or stops deriving it
# from ``initial_status``, fail the build here rather than ship a consumer that
# silently mis-classifies every task it reads. These assert only -- they
# deliberately do not rewrite the producer. The regression suite copied in below
# is the second net: it proves the same contract against the real create_task,
# for the drift these textual anchors cannot see.
producer_park_anchor = ''' if initial_status == "blocked":
task_status = "blocked"
'''
if db_source.count(producer_park_anchor) != 1:
raise SystemExit(
"Hermes Kanban create_task park semantics changed: expected 1, "
f"found {db_source.count(producer_park_anchor)}"
)
# The mirror of the park branch: every non-park creation must resolve to a
# status the gate does *not* read as a park. Without this, drift that widened
# the ladder to park ordinary work would leave both other anchors intact.
producer_unparked_anchor = ''' elif triage:
task_status = "triage"
else:
task_status = "ready"
'''
if db_source.count(producer_unparked_anchor) != 1:
raise SystemExit(
"Hermes Kanban create_task non-park status ladder changed: expected 1, "
f"found {db_source.count(producer_unparked_anchor)}"
)
producer_event_anchor = ''' _append_event(
conn,
task_id,
"created",
{
"assignee": assignee,
"status": task_status,
'''
if db_source.count(producer_event_anchor) != 1:
raise SystemExit(
"Hermes Kanban created-event status payload changed: expected 1, "
f"found {db_source.count(producer_event_anchor)}"
)
hermes: harden worker isolation and blocked-task semantics Three narrowly scoped Hermes reliability fixes backed by live evidence from the Cassandra/titan-iac proof run. Worker concurrency. Three simultaneous direct CLI workers on the 4-core hermes-agent node drove load to ~45 and made the hermes and oauth2-proxy containers fail their probes, leaving the pod 8/10 Ready; two workers stayed at 10/10. Cap HERMES_CLI_LANE_CONCURRENCY at 2 and lower the cli-lane-runner CPU limit from 3 to 2 so the dashboard and auth sidecars keep a guaranteed share of the node. Requests are unchanged: the pod still asks for 745m total, so placement does not move. Service links. Kubernetes injects a service-link variable pair for every service in the namespace, and hermes-claude-broker produces HERMES_CLAUDE_BROKER_PORT=tcp://10.43.31.76:9006 — a value the broker parses as an int. That contaminated worker and test environments even though the deployment already addresses every service by DNS name. Set enableServiceLinks: false on the hermes-agent pod spec. Blocked-task scheduling. create_task(initial_status="blocked") records a created event carrying status=blocked but never a blocked event, while _has_sticky_block() only inspects blocked/unblocked events. recompute_ready() considers blocked tasks, so an explicitly parked task with no incomplete parent auto-promoted on the next dispatcher cycle. Teach _has_sticky_block() to also recognize a created event whose payload status is blocked, which covers tasks created before this image patch without adding a persisted field. Dependency-driven promotion and the circuit-breaker failure-limit guard are untouched; unblock_task() still releases either kind of block. hermes-kanban-blocked-regression.py runs against the real upstream kanban_db API during the image build, so the build fails if any of these semantics regress. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 20:53:22 +00:00
sticky_doc_before = ''' The cheapest signal that distinguishes the two is the most recent
``"blocked"`` / ``"unblocked"`` event for the task. If the most
recent one is ``"blocked"`` (or there is a ``"blocked"`` event and
no ``"unblocked"`` event has fired since), the task is sticky and
``recompute_ready`` must *not* auto-promote it.
Returns ``False`` when there is no such event at all (e.g. the task
'''
sticky_doc_after = ''' The cheapest signal that distinguishes the two is the most recent
``"created"`` / ``"blocked"`` / ``"unblocked"`` event for the task.
A ``"created"`` event whose payload has ``status="blocked"`` is the
explicit park requested by ``create_task(initial_status="blocked")``.
A later ``"unblocked"`` event clears either kind of sticky block.
Returns ``False`` when there is no such event at all (e.g. the task
'''
if db_source.count(sticky_doc_before) != 1:
raise SystemExit(
"Hermes Kanban sticky-block documentation changed: expected 1, "
f"found {db_source.count(sticky_doc_before)}"
)
db_source = db_source.replace(sticky_doc_before, sticky_doc_after, 1)
sticky_before = ''' row = conn.execute(
"SELECT kind FROM task_events "
"WHERE task_id = ? AND kind IN ('blocked', 'unblocked') "
"ORDER BY id DESC LIMIT 1",
(task_id,),
).fetchone()
return bool(row) and row["kind"] == "blocked"
'''
sticky_after = ''' row = conn.execute(
"SELECT kind, payload FROM task_events "
"WHERE task_id = ? AND kind IN ('created', 'blocked', 'unblocked') "
"ORDER BY id DESC LIMIT 1",
(task_id,),
).fetchone()
if not row or row["kind"] == "unblocked":
return False
if row["kind"] == "blocked":
return True
try:
payload = json.loads(row["payload"] or "{}")
except (TypeError, ValueError):
return False
hermes: fail closed on kanban created-event producer drift The sticky-block gate added in the previous commit classifies a task from the `created` event payload that upstream `create_task` writes. That producer is code we do not own, so trusting it silently was the gap: if upstream renamed the key, dropped it, or stopped deriving it from `initial_status`, the image would still build and ship a consumer that mis-classifies every task it reads. Anchor the producer contract at build time, before the regression suite runs, with three assert-only preconditions: the `initial_status="blocked"` park resolves `task_status` to `"blocked"`, every non-park creation resolves it to something else, and the `created` event carries that same variable under `"status"`. None of them rewrite the producer. Textual anchors cannot see dataflow, so add the runtime net the reviewer asked for. The suite now drives the real API: create + claim an ordinary task, trip the circuit breaker once at failure_limit=1 so it parks with a `gave_up` event (leaving its own `created` event as the most recent create/block/unblock row), then recompute at failure_limit=2 and require promotion to ready. That case is red under an unconditional-true created predicate and red under producer drift that labels every created event blocked, while the explicit block/unblock, dependency-promotion and circuit-breaker-at-current-limit cases stay green. Non-blocked and malformed created payloads are pinned as controls, and the gate now rejects non-dict payloads rather than trusting `.get`. Also make the live placement correction durable: titan-04 is cordoned after repeated kernel undervoltage and kubelet failure and titan-19 was probe/Longhorn unstable under worker load, so both join the hard NotIn list; titan-05 is healthy but sits at 3592m/3600m requested CPU, so the main hermes container gives back 50m (350m -> 300m) to schedule there. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 22:42:18 +00:00
if not isinstance(payload, dict):
return False
hermes: harden worker isolation and blocked-task semantics Three narrowly scoped Hermes reliability fixes backed by live evidence from the Cassandra/titan-iac proof run. Worker concurrency. Three simultaneous direct CLI workers on the 4-core hermes-agent node drove load to ~45 and made the hermes and oauth2-proxy containers fail their probes, leaving the pod 8/10 Ready; two workers stayed at 10/10. Cap HERMES_CLI_LANE_CONCURRENCY at 2 and lower the cli-lane-runner CPU limit from 3 to 2 so the dashboard and auth sidecars keep a guaranteed share of the node. Requests are unchanged: the pod still asks for 745m total, so placement does not move. Service links. Kubernetes injects a service-link variable pair for every service in the namespace, and hermes-claude-broker produces HERMES_CLAUDE_BROKER_PORT=tcp://10.43.31.76:9006 — a value the broker parses as an int. That contaminated worker and test environments even though the deployment already addresses every service by DNS name. Set enableServiceLinks: false on the hermes-agent pod spec. Blocked-task scheduling. create_task(initial_status="blocked") records a created event carrying status=blocked but never a blocked event, while _has_sticky_block() only inspects blocked/unblocked events. recompute_ready() considers blocked tasks, so an explicitly parked task with no incomplete parent auto-promoted on the next dispatcher cycle. Teach _has_sticky_block() to also recognize a created event whose payload status is blocked, which covers tasks created before this image patch without adding a persisted field. Dependency-driven promotion and the circuit-breaker failure-limit guard are untouched; unblock_task() still releases either kind of block. hermes-kanban-blocked-regression.py runs against the real upstream kanban_db API during the image build, so the build fails if any of these semantics regress. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 20:53:22 +00:00
return payload.get("status") == "blocked"
'''
if db_source.count(sticky_before) != 1:
raise SystemExit(
"Hermes Kanban sticky-block gate changed: expected 1, "
f"found {db_source.count(sticky_before)}"
)
db_source = db_source.replace(sticky_before, sticky_after, 1)
# A manual evidence-based close must not expose a parked task to the dispatcher
# between separate `unblock` and `complete` commands. Upstream already permits
# direct completion from `blocked`; extend the same atomic path to `scheduled`
# and make that contract visible in CLI help so agents do not create a ready
# window that can launch redundant work.
state_before = ''' WHERE id = ?
AND status IN ('running', 'ready', 'blocked')
'''
state_after = ''' WHERE id = ?
AND status IN ('running', 'ready', 'blocked', 'scheduled')
'''
if db_source.count(state_before) != 2:
raise SystemExit(
"Hermes Kanban completion state gate changed: expected 2, "
f"found {db_source.count(state_before)}"
)
# Only the manual path lacks an expected run id. A worker-owned completion must
# continue to match its live run and may not revive an already scheduled run.
db_path.write_text(db_source.replace(state_before, state_after, 1))
cli_path = Path("/opt/hermes/hermes_cli/kanban.py")
cli_source = cli_path.read_text()
help_before = 'p_complete = sub.add_parser("complete", help="Mark one or more tasks done")'
help_after = (
'p_complete = sub.add_parser('
'"complete", '
'help="Atomically mark running, ready, blocked, or scheduled tasks done"'
')'
)
if cli_source.count(help_before) != 1:
raise SystemExit(
"Hermes Kanban complete help changed: expected 1, "
f"found {cli_source.count(help_before)}"
)
cli_path.write_text(cli_source.replace(help_before, help_after, 1))
PY
RUN case "${HERMES_KANIKO_HEREDOC_COMPAT}" in 0) ;; 1) python /tmp/hermes-kaniko-heredoc-runner.py --dockerfile /tmp/hermes-agent.Dockerfile --block-index 5 ;; *) exit 2 ;; esac
hermes: harden worker isolation and blocked-task semantics Three narrowly scoped Hermes reliability fixes backed by live evidence from the Cassandra/titan-iac proof run. Worker concurrency. Three simultaneous direct CLI workers on the 4-core hermes-agent node drove load to ~45 and made the hermes and oauth2-proxy containers fail their probes, leaving the pod 8/10 Ready; two workers stayed at 10/10. Cap HERMES_CLI_LANE_CONCURRENCY at 2 and lower the cli-lane-runner CPU limit from 3 to 2 so the dashboard and auth sidecars keep a guaranteed share of the node. Requests are unchanged: the pod still asks for 745m total, so placement does not move. Service links. Kubernetes injects a service-link variable pair for every service in the namespace, and hermes-claude-broker produces HERMES_CLAUDE_BROKER_PORT=tcp://10.43.31.76:9006 — a value the broker parses as an int. That contaminated worker and test environments even though the deployment already addresses every service by DNS name. Set enableServiceLinks: false on the hermes-agent pod spec. Blocked-task scheduling. create_task(initial_status="blocked") records a created event carrying status=blocked but never a blocked event, while _has_sticky_block() only inspects blocked/unblocked events. recompute_ready() considers blocked tasks, so an explicitly parked task with no incomplete parent auto-promoted on the next dispatcher cycle. Teach _has_sticky_block() to also recognize a created event whose payload status is blocked, which covers tasks created before this image patch without adding a persisted field. Dependency-driven promotion and the circuit-breaker failure-limit guard are untouched; unblock_task() still releases either kind of block. hermes-kanban-blocked-regression.py runs against the real upstream kanban_db API during the image build, so the build fails if any of these semantics regress. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 20:53:22 +00:00
COPY dockerfiles/hermes-kanban-blocked-regression.py /tmp/hermes-kanban-blocked-regression.py
RUN /opt/hermes/.venv/bin/python /tmp/hermes-kanban-blocked-regression.py \
&& rm /tmp/hermes-kanban-blocked-regression.py
COPY dockerfiles/hermes-python-sandbox-tool.py /opt/hermes/tools/python_sandbox_tool.py
COPY dockerfiles/hermes-public-extract/__init__.py /opt/hermes/plugins/web/public_extract/__init__.py
COPY dockerfiles/hermes-public-extract/plugin.yaml /opt/hermes/plugins/web/public_extract/plugin.yaml
COPY dockerfiles/hermes-public-extract/provider.py /opt/hermes/plugins/web/public_extract/provider.py
# Per-capability custom backends are resolved before upstream discovers web
# plugins, causing extract_backend to fall through to the shared search-only
# backend. Discover bundled plugins before checking a custom capability name.
RUN python - <<'PY'
from pathlib import Path
path = Path("/opt/hermes/tools/web_tools.py")
source = path.read_text()
before = ''' cfg = _load_web_config()
specific = (cfg.get(f"{capability}_backend") or "").lower().strip()
if specific and _is_backend_available(specific):
return specific
'''
after = ''' cfg = _load_web_config()
specific = (cfg.get(f"{capability}_backend") or "").lower().strip()
if specific and specific not in _LEGACY_WEB_BACKENDS:
_ensure_web_plugins_loaded()
if specific and _is_backend_available(specific):
return specific
'''
if source.count(before) != 1:
raise SystemExit(
"Hermes custom web capability patch context changed: expected 1, "
f"found {source.count(before)}"
)
path.write_text(source.replace(before, after, 1))
PY
RUN case "${HERMES_KANIKO_HEREDOC_COMPAT}" in 0) ;; 1) python /tmp/hermes-kaniko-heredoc-runner.py --dockerfile /tmp/hermes-agent.Dockerfile --block-index 6 ;; *) exit 2 ;; esac
# Keep the dashboard's account cards aligned with the managed provider lanes.
# Switchyard receives Claude Code subscription OAuth through the environment;
# that credential must not be presented as a metered Anthropic API key.
RUN python - <<'PY'
from pathlib import Path
path = Path("/opt/hermes/hermes_cli/web_server.py")
source = path.read_text()
registry_before = ''' try:
from hermes_cli.auth import PROVIDER_REGISTRY
env_var_order = PROVIDER_REGISTRY["anthropic"].api_key_env_vars
except (ImportError, KeyError):
pass
'''
registry_after = ''' try:
from hermes_cli.auth import PROVIDER_REGISTRY
env_var_order = PROVIDER_REGISTRY["anthropic"].api_key_env_vars
except (ImportError, KeyError):
pass
# Claude Code subscription OAuth is reported by its dedicated card below.
env_var_order = tuple(
var for var in env_var_order if var != "CLAUDE_CODE_OAUTH_TOKEN"
)
'''
managed_before = ''' try:
from agent.anthropic_adapter import read_claude_code_credentials
creds = read_claude_code_credentials()
'''
managed_after = ''' managed_token = os.getenv("CLAUDE_CODE_OAUTH_TOKEN")
if managed_token:
return {
"logged_in": True,
"source": "managed_claude_code_subscription",
"source_label": "Switchyard-managed Claude Code subscription",
"token_preview": _truncate_token(managed_token),
"expires_at": None,
"has_refresh_token": False,
}
try:
from agent.anthropic_adapter import read_claude_code_credentials
creds = read_claude_code_credentials()
'''
replacements = (
(registry_before, registry_after, "Anthropic credential separation"),
(managed_before, managed_after, "managed Claude Code status"),
('"name": "Anthropic API Key",',
'"name": "Anthropic API / direct OAuth (not used by Switchyard)",',
"Anthropic account label"),
('"name": "Anthropic OAuth: Required Extra Usage Credits to Use Subscription",',
'"name": "Claude Code subscription (Switchyard)",',
"Claude Code account label"),
)
for before, after, label in replacements:
if source.count(before) != 1:
raise SystemExit(
f"Hermes {label} patch context changed: expected 1, "
f"found {source.count(before)}"
)
source = source.replace(before, after, 1)
path.write_text(source)
PY
RUN case "${HERMES_KANIKO_HEREDOC_COMPAT}" in 0) ;; 1) python /tmp/hermes-kaniko-heredoc-runner.py --dockerfile /tmp/hermes-agent.Dockerfile --block-index 7 ;; *) exit 2 ;; esac
# 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
RUN case "${HERMES_KANIKO_HEREDOC_COMPAT}" in 0) ;; 1) python /tmp/hermes-kaniko-heredoc-runner.py --dockerfile /tmp/hermes-agent.Dockerfile --block-index 8 ;; *) exit 2 ;; esac
# Keep the browser terminal reliable across GPU context loss, make durable
# 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");
function replaceOnce(source, before, after, label) {
const count = source.split(before).length - 1;
if (count !== 1) {
throw new Error(`${label} patch context changed: expected 1, found ${count}`);
}
return source.replace(before, after);
}
// The dashboard bundle takes a few seconds to hydrate on slower clients. A
// resumed worker must announce that it is connecting instead of exposing an
// unexplained black terminal canvas during that interval.
{
const path = "/opt/hermes/web/index.html";
let source = fs.readFileSync(path, "utf8");
source = replaceOnce(
source,
' <body>\n <div id="root"></div>',
` <body>
<div
id="hermes-resume-bootstrap"
role="status"
aria-live="polite"
aria-busy="true"
hidden
style="position:fixed;inset:0;z-index:2147483647;display:flex;align-items:center;justify-content:center;background:#000;color:#f4f0df;font:14px/1.5 ui-monospace,monospace;text-align:center"
>
<div>
<strong style="display:block;margin-bottom:8px">Opening Hermes worker activity…</strong>
<span>Connecting to the accessible, durable transcript.</span>
</div>
</div>
<script>
(() => {
const params = new URLSearchParams(window.location.search);
const loading = document.getElementById("hermes-resume-bootstrap");
if (loading && window.location.pathname === "/chat" && params.has("resume")) {
loading.hidden = false;
}
})();
</script>
<div id="root"></div>`,
"resumed activity bootstrap",
);
fs.writeFileSync(path, source);
}
// A WebGL context can be discarded while a browser tab sleeps or loses its
// GPU process. xterm's canvas renderer is fast enough for this single PTY and
// can be repainted deterministically from the server-side replay buffer.
{
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',
"",
"xterm WebGL import",
);
source = replaceOnce(
source,
' const resumeParam = searchParams.get("resume");\n',
' const resumeParam = searchParams.get("resume");\n' +
' const lineageRoot = searchParams.get("lineage_root") || resumeParam;\n',
"durable lineage root",
);
source = replaceOnce(
source,
String.raw` useEffect(() => {
if (!resumeParam) return;
let cancelled = false;
api
.getSessionLatestDescendant(resumeParam, scopedProfile)
.then((res) => {
if (cancelled || !res.session_id || res.session_id === resumeParam) {
return;
}
const next = new URLSearchParams(searchParams);
next.set("resume", res.session_id);
setSearchParams(next, { replace: true });
})
.catch(() => {
// Best-effort: old servers or missing sessions should not block chat.
});
return () => {
cancelled = true;
};
}, [resumeParam, scopedProfile, searchParams, setSearchParams]);
`,
String.raw` useEffect(() => {
if (!lineageRoot) return;
let cancelled = false;
const followActiveLineage = async () => {
try {
const res = await api.getSessionLatestDescendant(
lineageRoot,
scopedProfile,
);
if (cancelled || !res.session_id) return;
setSearchParams(
(previous) => {
const next = new URLSearchParams(previous);
const current = next.get("resume");
if (current === res.session_id) return previous;
next.set("lineage_root", lineageRoot);
next.set("resume", res.session_id);
return next;
},
{ replace: true },
);
} catch {
// Best-effort: old servers or missing sessions should not block chat.
}
};
void followActiveLineage();
const timer = window.setInterval(() => void followActiveLineage(), 2_000);
return () => {
cancelled = true;
window.clearInterval(timer);
};
}, [lineageRoot, scopedProfile, setSearchParams]);
`,
"active lineage follower",
);
source = replaceOnce(
source,
' next.delete("resume");\n',
' next.delete("resume");\n next.delete("lineage_root");\n',
"fresh chat lineage reset",
);
const webglStart = source.indexOf(" // WebGL draws from");
const webglEnd = source.indexOf(
"\n\n // Initial fit + resize observer",
webglStart,
);
if (webglStart < 0 || webglEnd < 0) {
throw new Error("xterm WebGL setup patch context changed");
}
source =
source.slice(0, webglStart) +
` // Use xterm's canvas renderer. It survives browser sleep and GPU-process
// resets, while the server-side PTY replay restores any missed output.
` +
source.slice(webglEnd + 2);
source = replaceOnce(
source,
" term.open(host);\n",
` term.open(host);
// Coalesce terminal repaints so buffered replay and visibility changes
// cannot leave a valid xterm buffer hidden behind a stale renderer frame.
let paintRaf = 0;
const scheduleTerminalPaint = () => {
if (paintRaf) return;
paintRaf = requestAnimationFrame(() => {
paintRaf = 0;
if (!host.isConnected || term.rows <= 0) return;
try {
term.refresh(0, term.rows - 1);
} catch {
/* terminal disposed during a navigation */
}
});
};
const refreshVisibleTerminal = () => {
if (document.hidden) return;
syncMetricsRef.current?.();
scheduleTerminalPaint();
};
document.addEventListener("visibilitychange", refreshVisibleTerminal);
`,
"xterm repaint scheduler",
);
source = replaceOnce(
source,
' ws.send(`\\x1b[RESIZE:${term.cols};${term.rows}]`);\n',
' ws.send(`\\x1b[RESIZE:${term.cols};${term.rows}]`);\n' +
" scheduleTerminalPaint();\n",
"xterm open repaint",
);
source = replaceOnce(
source,
` ws.onmessage = (ev) => {
if (typeof ev.data === "string") {
term.write(ev.data);
} else {
term.write(new Uint8Array(ev.data as ArrayBuffer));
}
};
`,
` ws.onmessage = (ev) => {
if (typeof ev.data === "string") {
term.write(ev.data, scheduleTerminalPaint);
} else {
term.write(new Uint8Array(ev.data as ArrayBuffer), scheduleTerminalPaint);
}
};
`,
"xterm replay repaint",
);
source = replaceOnce(
source,
" ro.disconnect();\n",
` ro.disconnect();
document.removeEventListener("visibilitychange", refreshVisibleTerminal);
if (paintRaf) cancelAnimationFrame(paintRaf);
`,
"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 + 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",
);
source = replaceOnce(
source,
`export interface SessionMessagesResponse {
session_id: string;
messages: SessionMessage[];
}`,
`export interface SessionMessagesResponse {
session_id: string;
messages: SessionMessage[];
total_messages?: number;
}`,
"session message total",
);
source = replaceOnce(
source,
` getSessionMessages: (id: string, profile = getManagementProfile()) =>
fetchJSON<SessionMessagesResponse>(
appendProfileParam(\`/api/sessions/\${encodeURIComponent(id)}/messages\`, profile),
),`,
` getSessionMessages: (
id: string,
profile = getManagementProfile(),
limit?: number,
) =>
fetchJSON<SessionMessagesResponse>(
appendProfileParam(
\`/api/sessions/\${encodeURIComponent(id)}/messages\${
limit ? \`?limit=\${Math.max(1, Math.floor(limit))}\` : ""
}\`,
profile,
),
),`,
"bounded session message query",
);
fs.writeFileSync(path, source);
}
// The API already stores parent_session_id. Present it as a collapsible tree
// instead of making durable Codex/Claude/API workers look like root chats.
{
const path = "/opt/hermes/web/src/components/ChatSessionList.tsx";
let source = fs.readFileSync(path, "utf8");
source = replaceOnce(
source,
'import { AlertCircle, MessageSquarePlus, RefreshCw } from "lucide-react";',
'import { AlertCircle, ChevronDown, ChevronRight, GitBranch, MessageSquarePlus, RefreshCw } from "lucide-react";',
"session-tree icons",
);
source = replaceOnce(
source,
"const SESSION_LIMIT = 30;",
"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,
' next.set("resume", id);\n',
' next.delete("lineage_root");\n next.set("resume", id);\n',
"manual session lineage reset",
);
source = replaceOnce(
source,
' next.delete("resume");\n',
' next.delete("resume");\n next.delete("lineage_root");\n',
"new session lineage reset",
);
source = replaceOnce(
source,
" const [reloadNonce, setReloadNonce] = useState(0);\n",
` const [reloadNonce, setReloadNonce] = useState(0);
const [expandedParents, setExpandedParents] = useState<Set<string>>(
() => new Set(),
);
`,
"session-tree expansion state",
);
source = replaceOnce(
source,
" const content = useMemo(() => {\n",
` const sessionTree = useMemo(() => {
const listed = sessions ?? [];
const sessionIds = new Set(listed.map((session) => session.id));
const childrenByParent = new Map<string, SessionInfo[]>();
const roots: SessionInfo[] = [];
for (const session of listed) {
const parent = session.parent_session_id;
if (parent && sessionIds.has(parent)) {
const children = childrenByParent.get(parent) ?? [];
children.push(session);
childrenByParent.set(parent, children);
} else {
roots.push(session);
}
}
return { childrenByParent, roots };
}, [sessions]);
useEffect(() => {
if (!activeSessionId) return;
const active = sessions?.find((session) => session.id === activeSessionId);
if (!active?.parent_session_id) return;
setExpandedParents((previous) => {
if (previous.has(active.parent_session_id!)) return previous;
const next = new Set(previous);
next.add(active.parent_session_id!);
return next;
});
}, [activeSessionId, sessions]);
const toggleParent = useCallback((id: string) => {
setExpandedParents((previous) => {
const next = new Set(previous);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}, []);
const content = useMemo(() => {
`,
"session-tree derivation",
);
const listStart = source.indexOf(" {sessions.map((s) => {");
const listEndMarker = " })}\n </div>";
const listEnd = source.indexOf(listEndMarker, listStart);
if (listStart < 0 || listEnd < 0) {
throw new Error("session-tree list rendering patch context changed");
}
const listReplacement = String.raw` {sessionTree.roots.flatMap((root) => {
const children = sessionTree.childrenByParent.get(root.id) ?? [];
const rows: Array<{ session: SessionInfo; nested: boolean }> = [
{ session: root, nested: false },
];
if (expandedParents.has(root.id)) {
rows.push(...children.map((session) => ({ session, nested: true })));
}
return rows.map(({ session: s, nested }) => {
const isActive = s.id === activeSessionId;
const childCount = sessionTree.childrenByParent.get(s.id)?.length ?? 0;
const expanded = expandedParents.has(s.id);
return (
<div
key={s.id}
className={cn(
"flex min-w-0 items-stretch gap-0.5",
nested && "ml-4 border-l border-current/15 pl-1",
)}
>
{childCount > 0 ? (
<button
type="button"
aria-label={expanded ? "Collapse worker sessions" : "Expand worker sessions"}
aria-expanded={expanded}
title={childCount + " worker session" + (childCount === 1 ? "" : "s")}
onClick={() => toggleParent(s.id)}
className="flex w-5 shrink-0 items-center justify-center text-text-tertiary hover:text-foreground"
>
{expanded ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
</button>
) : nested ? (
<span className="flex w-5 shrink-0 items-center justify-center text-text-tertiary" aria-hidden>
<GitBranch className="h-3 w-3" />
</span>
) : (
<span className="w-5 shrink-0" aria-hidden />
)}
<ListItem
onClick={() => pick(s.id)}
aria-current={isActive ? "true" : undefined}
className={cn(
"min-w-0 flex-1 flex-col items-start gap-0.5 rounded px-2 py-1.5",
"normal-case tracking-normal",
isActive
? "bg-primary/10 text-foreground border-l-2 border-primary"
: "text-text-secondary hover:bg-midground/5 hover:text-foreground",
)}
>
<span className="flex w-full items-center gap-1 truncate text-sm font-medium">
<span className="truncate">{rowLabel(s, t.sessions.untitledSession)}</span>
{childCount > 0 && (
<span className="shrink-0 text-[0.625rem] text-text-tertiary">
{childCount} workers
</span>
)}
</span>
<span className="flex w-full items-center gap-1.5 text-[0.6875rem] text-text-tertiary">
<span>{timeAgo(s.last_active)}</span>
{s.message_count > 0 && (
<>
<span aria-hidden>·</span>
<span>{s.message_count} msgs</span>
</>
)}
{s.source && s.source !== "cli" && (
<>
<span aria-hidden>·</span>
<span className="truncate">{s.source}</span>
</>
)}
</span>
</ListItem>
</div>
);
});
})}`;
source =
source.slice(0, listStart) +
listReplacement +
source.slice(listEnd + " })}".length);
source = replaceOnce(
source,
" }, [activeSessionId, error, loading, pick, reload, sessions, t]);",
" }, [activeSessionId, error, expandedParents, loading, pick, reload, sessionTree, sessions, t, toggleParent]);",
"session-tree memo dependencies",
);
fs.writeFileSync(path, source);
}
// Telegram has one operator-managed bot, but every Keycloak user links their
// own account. Put that workflow in normal navigation instead of a floating
// button or a URL users have to remember.
{
const path = "/opt/hermes/web/src/App.tsx";
let source = fs.readFileSync(path, "utf8");
source = replaceOnce(
source,
"function RootRedirect() {\n",
`function TelegramSetupPage() {
return (
<div className="mx-auto flex w-full max-w-3xl flex-col gap-5 p-6 lg:p-10">
<div>
<h2 className="text-2xl font-bold">Telegram</h2>
<p className="mt-2 text-sm text-text-secondary">
Atlas uses one operator-managed Hermes bot. Each Keycloak user links
their own Telegram account once; conversations remain attached to
that user's isolated family-chat workspace.
</p>
</div>
<div className="rounded border border-current/20 bg-midground/5 p-5">
<h3 className="text-sm font-bold tracking-wider">Link this account</h3>
<p className="mt-2 text-sm text-text-secondary">
Open the family-chat linking page, create a one-time code, then send
it to the shared Hermes bot from your Telegram account.
</p>
<a
href="https://chat.bstein.dev/telegram"
className="mt-4 inline-flex rounded border border-current/30 px-4 py-2 text-sm font-medium text-midground hover:bg-midground/10"
>
Open Telegram setup
</a>
</div>
</div>
);
}
function RootRedirect() {
`,
"Telegram setup page",
);
source = replaceOnce(
source,
' "/channels": ChannelsPage,\n',
' "/channels": ChannelsPage,\n "/telegram": TelegramSetupPage,\n',
"Telegram setup route",
);
source = replaceOnce(
source,
' { path: "/channels", label: "Channels", icon: Radio },\n',
' { path: "/channels", label: "Channels", icon: Radio },\n { path: "/telegram", label: "Telegram", icon: MessageSquare },\n',
"Telegram setup navigation",
);
fs.writeFileSync(path, source);
}
NODE
RUN case "${HERMES_KANIKO_HEREDOC_COMPAT}" in 0) ;; 1) python /tmp/hermes-kaniko-heredoc-runner.py --dockerfile /tmp/hermes-agent.Dockerfile --block-index 9 ;; *) exit 2 ;; esac
COPY dockerfiles/patch-hermes-execution-safety.py /tmp/patch-hermes-execution-safety.py
COPY dockerfiles/hermes-execution-safety-regression.py /tmp/hermes-execution-safety-regression.py
COPY dockerfiles/hermes_execution_patch_support.py /tmp/hermes_execution_patch_support.py
COPY dockerfiles/patch_hermes_run_safety.py /tmp/patch_hermes_run_safety.py
COPY dockerfiles/patch_hermes_decomposition_safety.py /tmp/patch_hermes_decomposition_safety.py
COPY dockerfiles/hermes_execution_regression_support.py /tmp/hermes_execution_regression_support.py
COPY dockerfiles/hermes_run_safety_regression.py /tmp/hermes_run_safety_regression.py
COPY dockerfiles/hermes_decomposition_safety_regression.py /tmp/hermes_decomposition_safety_regression.py
2026-08-17 08:16:35 -03:00
COPY dockerfiles/hermes_lane_compatibility_regression.py /tmp/hermes_lane_compatibility_regression.py
COPY services/hermes/scripts/cli_lane_*.py /tmp/hermes-lane-regression/
2026-08-17 08:16:35 -03:00
RUN HERMES_CLI_LANE_SOURCE=/tmp/hermes-lane-regression \
HERMES_COMPATIBILITY_MODE=legacy \
/opt/hermes/.venv/bin/python /tmp/hermes_lane_compatibility_regression.py \
&& /opt/hermes/.venv/bin/python /tmp/patch-hermes-execution-safety.py \
&& HERMES_CLI_LANE_SOURCE=/tmp/hermes-lane-regression \
HERMES_COMPATIBILITY_MODE=patched \
/opt/hermes/.venv/bin/python /tmp/hermes_lane_compatibility_regression.py \
&& HERMES_CLI_LANE_SOURCE=/tmp/hermes-lane-regression \
/opt/hermes/.venv/bin/python /tmp/hermes-execution-safety-regression.py \
&& rm /tmp/patch-hermes-execution-safety.py \
/tmp/hermes-execution-safety-regression.py \
/tmp/hermes_execution_patch_support.py \
/tmp/patch_hermes_run_safety.py \
/tmp/patch_hermes_decomposition_safety.py \
/tmp/hermes_execution_regression_support.py \
/tmp/hermes_run_safety_regression.py \
/tmp/hermes_decomposition_safety_regression.py \
2026-08-17 08:16:35 -03:00
/tmp/hermes_lane_compatibility_regression.py \
&& find /tmp/hermes-lane-regression -depth -delete
2026-08-02 16:46:21 -03:00
COPY dockerfiles/hermes-session-migrate.py /opt/hermes/bin/hermes-session-migrate
RUN rm -f /tmp/hermes-agent.Dockerfile /tmp/hermes-kaniko-heredoc-runner.py
RUN cd /opt/hermes/web \
&& npm run build \
&& grep -Fq 'await api.getSessions(1, 0' src/pages/ChatPage.tsx \
&& grep -Fq 'api.getSessions(1, 0' src/components/ChatSidebar.tsx \
2026-08-02 16:46:21 -03:00
&& grep -Fq 'if (unmounting) return;' src/pages/ChatPage.tsx \
&& grep -Fq 'resume:${resumeParam}' src/pages/ChatPage.tsx \
&& grep -Fq 'followActiveLineage' src/pages/ChatPage.tsx \
&& grep -Fq 'lineage_root' src/components/ChatSessionList.tsx \
&& grep -Fq 'eventsRetryAttempt.current' src/components/ChatSidebar.tsx \
&& grep -Fq 'reconnecting…' src/components/ChatSidebar.tsx \
&& grep -Fq 'redirectToProxyLogin' src/lib/api.ts \
&& grep -Fq '/oauth2/start?rd=' src/lib/api.ts \
2026-08-02 16:46:21 -03:00
&& grep -Fq 'HERMES_DASHBOARD_OIDC_ALLOWED_USER_IDS' \
/opt/hermes/hermes_cli/dashboard_auth/middleware.py \
&& grep -Fq '_resolve_request_route' \
/opt/hermes/gateway/platforms/api_server.py \
&& grep -Fq 'reasoning_effort=body.get("reasoning_effort")' \
/opt/hermes/gateway/platforms/api_server.py \
&& grep -Fq "status IN ('running', 'ready', 'blocked', 'scheduled')" \
/opt/hermes/hermes_cli/kanban_db.py \
&& grep -Fq 'Atomically mark running, ready, blocked, or scheduled tasks done' \
/opt/hermes/hermes_cli/kanban.py \
&& grep -Fq 'routing_priority=body.get("routing_priority")' \
/opt/hermes/gateway/platforms/api_server.py \
&& grep -Fq 'agent._hermes_explicit_model_pick' \
/opt/hermes/gateway/platforms/api_server.py \
&& grep -Fq 'managed_claude_code_subscription' \
/opt/hermes/hermes_cli/web_server.py \
&& grep -Fq 'Claude Code subscription (Switchyard)' \
/opt/hermes/hermes_cli/web_server.py \
&& ! grep -Fq 'WebglAddon' src/pages/ChatPage.tsx \
&& 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 \
&& grep -Fq '"pre_subagent_route"' /opt/hermes/hermes_cli/plugins.py \
&& grep -Fq 'invoke_hook(' /opt/hermes/agent/turn_context.py \
&& grep -Fq 'pre_internal_route hook failed' \
/opt/hermes/agent/conversation_loop.py \
&& grep -Fq 'pre_subagent_route hook failed' \
/opt/hermes/tools/delegate_tool.py \
&& /opt/hermes/.venv/bin/python -m py_compile \
/opt/hermes/gateway/platforms/api_server.py \
/opt/hermes/agent/turn_context.py \
/opt/hermes/agent/conversation_loop.py \
/opt/hermes/hermes_cli/oneshot.py \
/opt/hermes/hermes_cli/kanban_decompose.py \
/opt/hermes/gateway/kanban_watchers.py \
/opt/hermes/tools/delegate_tool.py \
/opt/hermes/tools/web_tools.py \
/opt/hermes/tools/python_sandbox_tool.py \
/opt/hermes/plugins/web/public_extract/provider.py \
&& /opt/hermes/.venv/bin/python -c 'import ddgs' \
2026-08-02 16:46:21 -03:00
&& chmod 0755 /opt/hermes/bin/hermes-session-migrate