# syntax=docker/dockerfile:1 # dockerfiles/Dockerfile.hermes-agent FROM nousresearch/hermes-agent@sha256:9c841866021c54c4596849f6135717e8a4d52ba510b7f52c50aef1de1a283973 USER root # 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 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 \ && chmod 0755 /opt/hermes/bin/hermes-session-migrate