fix(hermes): isolate consumer sessions

This commit is contained in:
jenkins 2026-08-02 16:46:21 -03:00
parent b08cca85d5
commit 4fe6dba491
4 changed files with 211 additions and 23 deletions

View File

@ -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

View File

@ -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()

View File

@ -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

View File

@ -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