123 lines
4.4 KiB
Docker
123 lines
4.4 KiB
Docker
# 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
|