fix(hermes): isolate consumer sessions
This commit is contained in:
parent
b08cca85d5
commit
4fe6dba491
@ -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
|
||||
|
||||
101
dockerfiles/hermes-session-migrate.py
Normal file
101
dockerfiles/hermes-session-migrate.py
Normal 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()
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user