# syntax=docker/dockerfile:1 # dockerfiles/Dockerfile.hermes-agent FROM nousresearch/hermes-agent@sha256:9c841866021c54c4596849f6135717e8a4d52ba510b7f52c50aef1de1a283973 USER root # Codex uses the distribution bubblewrap package when a bounded sandbox is # requested. tmux keeps the stock Hermes TUI alive across browser reconnects; # 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 # 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 path = "/opt/hermes/web/src/pages/ChatPage.tsx"; let source = fs.readFileSync(path, "utf8"); const socketBefore = [ ' const url = await api.buildWsUrl("/api/pty", params);', ' const ws = new WebSocket(url);', ].join("\n"); const socketAfter = [ ' const url = await api.buildWsUrl("/api/pty", params);', ' if (unmounting) return;', ' const ws = new WebSocket(url);', ].join("\n"); const attachBefore = ' params.attach = ptyAttachToken(forceFresh);'; const attachAfter = [ ' const attachScope = resumeParam', ' ? `resume:${resumeParam}:${scopedProfile ?? ""}`', ' : `fresh:${scopedProfile ?? ""}`;', ' params.attach = `${ptyAttachToken(forceFresh)}:${attachScope}`;', ].join("\n"); if (!source.includes(socketBefore)) { throw new Error("Hermes ChatPage WebSocket patch context changed"); } 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); NODE # 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. RUN python - <<'PY' from pathlib import Path path = Path("/opt/hermes/hermes_cli/dashboard_auth/middleware.py") 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 "" ''' 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, ) ''' 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) PY # 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)) PY # 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") allowed_providers = {"openai-codex", "anthropic"} 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, ) -> 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"), ) ''' 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), ): 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 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 COPY dockerfiles/hermes-session-migrate.py /opt/hermes/bin/hermes-session-migrate RUN cd /opt/hermes/web \ && npm run build \ && grep -Fq 'if (unmounting) return;' src/pages/ChatPage.tsx \ && grep -Fq 'resume:${resumeParam}' src/pages/ChatPage.tsx \ && 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 '"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/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' \ && chmod 0755 /opt/hermes/bin/hermes-session-migrate