mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-10 12:18:44 +08:00
Compare commits
1 Commits
fix/photon
...
hermes/her
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2774b48892 |
@@ -54,6 +54,22 @@ if [ "$(id -u)" = "0" ]; then
|
||||
chmod 640 "$HERMES_HOME/config.yaml" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Ensure pairing data is readable by the hermes runtime user. Without this,
|
||||
# `docker exec <container> hermes pairing approve …` (which defaults to
|
||||
# uid=0) writes 0600 root-owned files that the post-gosu gateway process
|
||||
# cannot read, silently leaving the approved user unauthorized (#10270).
|
||||
# The top-level recursive chown above is skipped on warm boots when
|
||||
# /opt/data is already hermes-owned, so the platforms/pairing/ subtree
|
||||
# has to be fixed unconditionally on every start. It's tiny — a handful
|
||||
# of small JSON files — so the cost is negligible.
|
||||
if [ -d "$HERMES_HOME/platforms/pairing" ]; then
|
||||
chown -R hermes:hermes "$HERMES_HOME/platforms/pairing" 2>/dev/null || true
|
||||
fi
|
||||
# Legacy location (pre-consolidated layout).
|
||||
if [ -d "$HERMES_HOME/pairing" ]; then
|
||||
chown -R hermes:hermes "$HERMES_HOME/pairing" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
echo "Dropping root privileges"
|
||||
exec gosu hermes "$0" "$@"
|
||||
fi
|
||||
|
||||
@@ -19,6 +19,7 @@ Storage: ~/.hermes/pairing/
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
import tempfile
|
||||
@@ -30,6 +31,8 @@ from typing import Optional
|
||||
from hermes_constants import get_hermes_dir
|
||||
from utils import atomic_replace
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Unambiguous alphabet -- excludes 0/O, 1/I to prevent confusion
|
||||
ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
||||
@@ -102,6 +105,27 @@ class PairingStore:
|
||||
if path.exists():
|
||||
try:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
except PermissionError as e:
|
||||
# Surface this loudly: a 0600 file owned by a different user
|
||||
# (classic Docker symptom: `docker exec` runs as root and writes
|
||||
# the file, then the gateway process — running as `hermes` after
|
||||
# gosu drop — can't read it) would otherwise be swallowed by
|
||||
# the generic OSError branch below, silently leaving the user
|
||||
# marked unauthorized. See issue #10270.
|
||||
try:
|
||||
st = path.stat()
|
||||
owner_info = f"owner_uid={st.st_uid} mode={oct(st.st_mode)[-4:]}"
|
||||
except OSError:
|
||||
owner_info = "<stat failed>"
|
||||
logger.warning(
|
||||
"Pairing file %s exists but is not readable as uid=%s (%s; %s). "
|
||||
"If you ran `docker exec <container> hermes pairing approve ...` as root, "
|
||||
"re-run with `docker exec -u hermes <container> ...` and "
|
||||
"chown the existing file to the hermes user, or restart the "
|
||||
"container so the entrypoint can fix ownership.",
|
||||
path, os.geteuid(), owner_info, e,
|
||||
)
|
||||
return {}
|
||||
except (json.JSONDecodeError, OSError):
|
||||
return {}
|
||||
return {}
|
||||
|
||||
@@ -390,3 +390,71 @@ class TestListAndClear:
|
||||
store.generate_code("discord", "user2")
|
||||
count = store.clear_pending()
|
||||
assert count == 2
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Unreadable approved-list file logs a warning instead of failing silently
|
||||
# (issue #10270: Docker `docker exec` writes root-owned 0600 files that the
|
||||
# post-gosu gateway can't read; the previous OSError swallow turned the bug
|
||||
# into a mystery "Unauthorized user" message)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestUnreadablePairingFile:
|
||||
def test_permission_error_logs_warning_and_returns_empty(self, tmp_path, caplog):
|
||||
import logging
|
||||
import builtins
|
||||
|
||||
approved_path = tmp_path / "weixin-approved.json"
|
||||
approved_path.write_text(
|
||||
'{"o9cq80fake@im.wechat": {"user_name": "x", "approved_at": 0}}'
|
||||
)
|
||||
|
||||
real_open = builtins.open
|
||||
|
||||
def fake_read_text(self, *a, **kw):
|
||||
# Path.read_text uses Path.open internally; raise PermissionError
|
||||
# to mimic a 0600 file owned by a different uid.
|
||||
raise PermissionError(13, "Permission denied", str(self))
|
||||
|
||||
with patch("gateway.pairing.PAIRING_DIR", tmp_path), \
|
||||
patch.object(Path, "read_text", fake_read_text), \
|
||||
caplog.at_level(logging.WARNING, logger="gateway.pairing"):
|
||||
store = PairingStore()
|
||||
result = store._load_json(approved_path)
|
||||
|
||||
assert result == {}, "should fall back to empty dict, not raise"
|
||||
assert any(
|
||||
"not readable" in rec.getMessage() and "#10270" not in rec.getMessage()
|
||||
or "not readable" in rec.getMessage()
|
||||
for rec in caplog.records
|
||||
), f"expected a warning about unreadable pairing file, got {caplog.records!r}"
|
||||
# And the warning should include actionable advice
|
||||
msgs = " ".join(rec.getMessage() for rec in caplog.records)
|
||||
assert "docker exec" in msgs
|
||||
assert "-u hermes" in msgs
|
||||
|
||||
def test_is_approved_returns_false_when_file_unreadable(self, tmp_path, caplog):
|
||||
"""End-to-end: an unreadable approved.json must not crash the gateway,
|
||||
and the affected user must stay unauthorized (the documented fallback
|
||||
behaviour) rather than triggering a 500."""
|
||||
import logging
|
||||
|
||||
approved_path = tmp_path / "weixin-approved.json"
|
||||
approved_path.write_text(
|
||||
'{"o9cq80fake@im.wechat": {"user_name": "x", "approved_at": 0}}'
|
||||
)
|
||||
|
||||
def fake_read_text(self, *a, **kw):
|
||||
raise PermissionError(13, "Permission denied", str(self))
|
||||
|
||||
with patch("gateway.pairing.PAIRING_DIR", tmp_path), \
|
||||
patch.object(Path, "read_text", fake_read_text), \
|
||||
caplog.at_level(logging.WARNING, logger="gateway.pairing"):
|
||||
store = PairingStore()
|
||||
ok = store.is_approved("weixin", "o9cq80fake@im.wechat")
|
||||
|
||||
assert ok is False
|
||||
# The warning must fire — otherwise this is the silent-failure bug.
|
||||
assert any(rec.levelno == logging.WARNING for rec in caplog.records), \
|
||||
"PermissionError on approved.json must produce a WARNING log line"
|
||||
|
||||
@@ -285,6 +285,24 @@ hermes pairing revoke telegram 123456789
|
||||
hermes pairing clear-pending
|
||||
```
|
||||
|
||||
:::tip Docker users: run pairing commands as the `hermes` user
|
||||
The official Docker image runs the gateway as the unprivileged `hermes` user
|
||||
(uid 10000) via `gosu`, but `docker exec` defaults to root. Approval files
|
||||
created by root are written with mode `0600 root:root` and the gateway
|
||||
cannot read them — the approval is silently ignored ([#10270][i10270]).
|
||||
|
||||
Always pass `-u hermes`:
|
||||
|
||||
```bash
|
||||
docker exec -u hermes hermes-agent hermes pairing approve telegram ABC12DEF
|
||||
```
|
||||
|
||||
If you already ran the command as root and the user is still unauthorized,
|
||||
restart the container — the entrypoint will fix ownership on the next start.
|
||||
|
||||
[i10270]: https://github.com/NousResearch/hermes-agent/issues/10270
|
||||
:::
|
||||
|
||||
**Storage:** Pairing data is stored in `~/.hermes/pairing/` with per-platform JSON files:
|
||||
- `{platform}-pending.json` — pending pairing requests
|
||||
- `{platform}-approved.json` — approved users
|
||||
|
||||
Reference in New Issue
Block a user