From 4fe6dba491b7f18328deaa2e604378f90168eb2e Mon Sep 17 00:00:00 2001 From: jenkins Date: Sun, 2 Aug 2026 16:46:21 -0300 Subject: [PATCH] fix(hermes): isolate consumer sessions --- dockerfiles/Dockerfile.hermes-agent | 70 +++++++++++++++--- dockerfiles/hermes-session-migrate.py | 101 ++++++++++++++++++++++++++ services/hermes-chat/deployment.yaml | 59 ++++++++++++--- services/hermes/deployment.yaml | 4 +- 4 files changed, 211 insertions(+), 23 deletions(-) create mode 100644 dockerfiles/hermes-session-migrate.py diff --git a/dockerfiles/Dockerfile.hermes-agent b/dockerfiles/Dockerfile.hermes-agent index 186852c6c..48c016dce 100644 --- a/dockerfiles/Dockerfile.hermes-agent +++ b/dockerfiles/Dockerfile.hermes-agent @@ -4,29 +4,81 @@ FROM nousresearch/hermes-agent@sha256:9c841866021c54c4596849f6135717e8a4d52ba510 USER root -# Ignore an async WebSocket-ticket result after React has already disposed the -# connection attempt. Without this guard, a stale socket can supersede the -# visible chat socket and leave the dashboard in a reconnect loop. +# 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"; -const source = fs.readFileSync(path, "utf8"); -const before = [ +let source = fs.readFileSync(path, "utf8"); +const socketBefore = [ ' const url = await api.buildWsUrl("/api/pty", params);', ' const ws = new WebSocket(url);', ].join("\n"); -const after = [ +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(before)) { +if (!source.includes(socketBefore)) { throw new Error("Hermes ChatPage WebSocket patch context changed"); } -fs.writeFileSync(path, source.replace(before, after)); +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 self-hosted OIDC plugin authenticates users but deliberately +# treats the dashboard as one shared workstation. Allow a deployment to narrow +# that workstation to an explicit OIDC subject without changing default +# behavior for the operator instance. +RUN python - <<'PY' +from pathlib import Path + +path = Path("/opt/hermes/plugins/dashboard_auth/self_hosted/__init__.py") +source = path.read_text() +before = ''' if not user_id: + raise ProviderError("ID token missing 'sub' (user_id) claim") + + email = str(claims.get("email", "") or "") +''' +after = ''' if not user_id: + raise ProviderError("ID token missing 'sub' (user_id) claim") + + allowed_user_ids = { + value.strip() + for value in os.environ.get( + "HERMES_DASHBOARD_OIDC_ALLOWED_USER_IDS", "" + ).split(",") + if value.strip() + } + if allowed_user_ids and user_id not in allowed_user_ids: + raise ProviderError("This account is not authorized for this dashboard") + + email = str(claims.get("email", "") or "") +''' +if before not in source: + raise SystemExit("Hermes self-hosted OIDC patch context changed") +path.write_text(source.replace(before, after)) +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 '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/plugins/dashboard_auth/self_hosted/__init__.py \ + && chmod 0755 /opt/hermes/bin/hermes-session-migrate diff --git a/dockerfiles/hermes-session-migrate.py b/dockerfiles/hermes-session-migrate.py new file mode 100644 index 000000000..2ba69b329 --- /dev/null +++ b/dockerfiles/hermes-session-migrate.py @@ -0,0 +1,101 @@ +#!/usr/bin/env python3 +"""Copy selected legacy Hermes sessions into an isolated user home.""" + +from __future__ import annotations + +import os +from pathlib import Path +import shutil +import sqlite3 + +from hermes_state import SessionDB + + +def _copy_if_missing(source: Path, target: Path) -> None: + """Copy one credential/config file without overwriting user state.""" + if source.is_file() and not target.exists(): + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(source, target) + + +def _copy_session( + source: sqlite3.Connection, + target: sqlite3.Connection, + session_id: str, + user_id: str, +) -> bool: + """Copy a session and its messages, preserving original timestamps.""" + source.row_factory = sqlite3.Row + session = source.execute( + "SELECT * FROM sessions WHERE id = ?", (session_id,) + ).fetchone() + if session is None: + return False + + columns = list(session.keys()) + values = [user_id if column == "user_id" else session[column] for column in columns] + placeholders = ", ".join("?" for _ in columns) + target.execute( + f"INSERT OR IGNORE INTO sessions ({', '.join(columns)}) " + f"VALUES ({placeholders})", + values, + ) + + message_columns = [ + row[1] for row in target.execute("PRAGMA table_info(messages)").fetchall() + ] + selected_columns = [ + column + for column in message_columns + if column in { + row[1] + for row in source.execute("PRAGMA table_info(messages)").fetchall() + } + ] + column_sql = ", ".join(selected_columns) + target.execute( + f"INSERT OR IGNORE INTO messages ({column_sql}) " + f"SELECT {column_sql} FROM source_db.messages WHERE session_id = ?", + (session_id,), + ) + return True + + +def main() -> None: + """Create the isolated home and perform the idempotent session copy.""" + source_home = Path(os.environ["HERMES_MIGRATE_SOURCE_HOME"]) + target_home = Path(os.environ["HERMES_HOME"]) + user_id = os.environ["HERMES_MIGRATE_USER_ID"].strip() + session_ids = [ + value.strip() + for value in os.environ.get("HERMES_MIGRATE_SESSION_IDS", "").split(",") + if value.strip() + ] + + target_home.mkdir(parents=True, exist_ok=True) + (target_home / "home" / ".local" / "bin").mkdir(parents=True, exist_ok=True) + (target_home / "workspace" / "skills").mkdir(parents=True, exist_ok=True) + (target_home / "logs").mkdir(parents=True, exist_ok=True) + for filename in (".env", "auth.json"): + _copy_if_missing(source_home / filename, target_home / filename) + + source_db = source_home / "state.db" + target_db = target_home / "state.db" + SessionDB(db_path=target_db).close() + copied = 0 + if source_db.is_file() and session_ids: + source = sqlite3.connect(f"file:{source_db}?mode=ro", uri=True) + target = sqlite3.connect(target_db) + target.execute("ATTACH DATABASE ? AS source_db", (str(source_db),)) + try: + with target: + for session_id in session_ids: + copied += int(_copy_session(source, target, session_id, user_id)) + finally: + target.close() + source.close() + print(f"isolated Hermes home ready; migrated_sessions={copied}") + + +if __name__ == "__main__": + main() diff --git a/services/hermes-chat/deployment.yaml b/services/hermes-chat/deployment.yaml index f63a235a1..60b417f49 100644 --- a/services/hermes-chat/deployment.yaml +++ b/services/hermes-chat/deployment.yaml @@ -24,7 +24,8 @@ spec: ai.bstein.dev/role: personal-chat-research ai.bstein.dev/isolation: observer-only Kubernetes RBAC, private-service egress denied ai.bstein.dev/session-reset: "20260802-consumer-pty-recovery" - ai.bstein.dev/frontend-fix: discard disposed WebSocket ticket attempts + ai.bstein.dev/frontend-fix: scope PTY attachment by selected conversation + ai.bstein.dev/user-boundary: one OIDC subject with an isolated PVC subdirectory spec: serviceAccountName: hermes-chat automountServiceAccountToken: true @@ -75,6 +76,36 @@ spec: values: - rpi4 initContainers: + - name: migrate-user-sessions + image: registry.bstein.dev/bstein/hermes-agent@sha256:ba3e81a1cefcc178729891a7af31269e65b94a72d880f0cb144d3ddbbceaeaba + imagePullPolicy: IfNotPresent + command: + - /opt/hermes/bin/hermes-session-migrate + env: + - name: HERMES_HOME + value: /storage/users/4794ab8284a14d421eaeea3e + - name: HERMES_MIGRATE_SOURCE_HOME + value: /storage + - name: HERMES_MIGRATE_USER_ID + value: 26b56113-0ec5-40b0-be7d-c2cd862f9bbc + - name: HERMES_MIGRATE_SESSION_IDS + value: 20260802_182723_9f53f1,20260802_173527_9dc51b + securityContext: + allowPrivilegeEscalation: false + runAsUser: 10000 + runAsGroup: 10000 + seccompProfile: + type: RuntimeDefault + volumeMounts: + - name: home + mountPath: /storage + resources: + requests: + cpu: 25m + memory: 64Mi + limits: + cpu: 250m + memory: 256Mi - name: init-config image: busybox:1.37 imagePullPolicy: IfNotPresent @@ -83,17 +114,18 @@ spec: - -c - | set -eu - mkdir -p /opt/data/workspace/skills /opt/data/home/.local/bin /opt/data/logs - cp /config/config.yaml /opt/data/config.yaml - cp /config/SOUL.md /opt/data/SOUL.md - cp /config/AGENTS.md /opt/data/workspace/AGENTS.md - touch /opt/data/.env - if ! grep -q '^API_SERVER_KEY=' /opt/data/.env; then + user_home=/storage/users/4794ab8284a14d421eaeea3e + mkdir -p "${user_home}/workspace/skills" "${user_home}/home/.local/bin" "${user_home}/logs" + cp /config/config.yaml "${user_home}/config.yaml" + cp /config/SOUL.md "${user_home}/SOUL.md" + cp /config/AGENTS.md "${user_home}/workspace/AGENTS.md" + touch "${user_home}/.env" + if ! grep -q '^API_SERVER_KEY=' "${user_home}/.env"; then api_key="$(dd if=/dev/urandom bs=32 count=1 2>/dev/null | od -An -tx1 | tr -d ' \n')" - printf '\nAPI_SERVER_KEY=%s\n' "${api_key}" >> /opt/data/.env + printf '\nAPI_SERVER_KEY=%s\n' "${api_key}" >> "${user_home}/.env" fi - chmod 0600 /opt/data/.env - chown -R 10000:10000 /opt/data + chmod 0600 "${user_home}/.env" + chown -R 10000:10000 "${user_home}" securityContext: allowPrivilegeEscalation: false runAsUser: 0 @@ -102,7 +134,7 @@ spec: type: RuntimeDefault volumeMounts: - name: home - mountPath: /opt/data + mountPath: /storage - name: config mountPath: /config readOnly: true @@ -142,7 +174,7 @@ spec: memory: 64Mi containers: - name: hermes-chat - image: registry.bstein.dev/bstein/hermes-agent@sha256:61dddd6f2f2716cc474b3d81255694de0b8f8062f059541a72fa19c780d892b4 + image: registry.bstein.dev/bstein/hermes-agent@sha256:ba3e81a1cefcc178729891a7af31269e65b94a72d880f0cb144d3ddbbceaeaba imagePullPolicy: IfNotPresent args: - gateway @@ -172,6 +204,8 @@ spec: value: hermes-chat-dashboard - name: HERMES_DASHBOARD_OIDC_SCOPES value: openid profile email groups + - name: HERMES_DASHBOARD_OIDC_ALLOWED_USER_IDS + value: 26b56113-0ec5-40b0-be7d-c2cd862f9bbc - name: API_SERVER_ENABLED value: "true" - name: API_SERVER_HOST @@ -183,6 +217,7 @@ spec: volumeMounts: - name: home mountPath: /opt/data + subPath: users/4794ab8284a14d421eaeea3e - name: tools mountPath: /usr/local/bin/kubectl subPath: kubectl diff --git a/services/hermes/deployment.yaml b/services/hermes/deployment.yaml index 32784e4e0..aa3e5c283 100644 --- a/services/hermes/deployment.yaml +++ b/services/hermes/deployment.yaml @@ -20,7 +20,7 @@ spec: labels: app: hermes annotations: - ai.bstein.dev/frontend-fix: discard disposed WebSocket ticket attempts + ai.bstein.dev/frontend-fix: scope PTY attachment by selected conversation ai.bstein.dev/model: openai-codex/gpt-5.6-terra with local gpt-oss:20b fallback ai.bstein.dev/role: testing-triage ai.bstein.dev/placement: arm64 gateway lane (rpi5 preferred) @@ -134,7 +134,7 @@ spec: memory: 64Mi containers: - name: hermes - image: registry.bstein.dev/bstein/hermes-agent@sha256:61dddd6f2f2716cc474b3d81255694de0b8f8062f059541a72fa19c780d892b4 + image: registry.bstein.dev/bstein/hermes-agent@sha256:ba3e81a1cefcc178729891a7af31269e65b94a72d880f0cb144d3ddbbceaeaba imagePullPolicy: IfNotPresent args: - gateway