atlas-iac/dockerfiles/Dockerfile.hermes-agent
2026-08-11 16:22:19 -03:00

660 lines
26 KiB
Docker

# 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 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";
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 = [
' 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");
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);
// 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
# 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", "atlas-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,
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
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 'await api.getSessions(1, 0' src/pages/ChatPage.tsx \
&& grep -Fq 'api.getSessions(1, 0' src/components/ChatSidebar.tsx \
&& grep -Fq 'if (unmounting) return;' src/pages/ChatPage.tsx \
&& grep -Fq 'resume:${resumeParam}' src/pages/ChatPage.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 \
&& 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 '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 '"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