mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-23 10:42:00 +00:00
* feat(memory): OAuth token storage and refresh for the Honcho provider * feat(memory): refresh the Honcho OAuth token in the client and session * feat(memory): zero-CLI loopback OAuth authorization flow * feat(memory): generic memory-provider OAuth connect endpoints * feat(desktop): memory-provider OAuth connect link * feat(memory): CLI OAuth sign-in with source-tagged authorize links * fix(memory): IP-literal loopback redirect and consent config_path on the authorize link * fix(memory): profile-scope the memory-provider OAuth endpoints * refactor(desktop): generic memory-provider OAuth client functions * docs(memory): trim OAuth module docstrings to the invariants * docs(memory): document OAuth connect as an optional auth method * fix(memory): send home-relative display path to consent, not the absolute path * perf(memory): cache OAuth token expiry in memory to skip the hot-path disk read * fix(memory): log OAuth refresh failures at warning, not debug * feat(memory): fall back to an OS-assigned loopback port when 8765 is taken * test(memory): cover the desktop Connect launcher, status, and provider dispatch * fix(desktop): keep the memory-provider dropdown one size regardless of connect state * fix(desktop): move the memory connect link to the description line, leaving the dropdown untouched * refactor(memory): move OAuth connect routes out of web_server into a memory-layer router * refactor(desktop): import MemoryConnect directly, drop the single-export barrel * fix(memory): launch CLI OAuth sign-in right after the auth choice, not after the wizard * fix(desktop): auto-clear the OAuth error state instead of leaving it sticky * test(honcho): isolate auth-method prompt from deployment-shape wizard tests main's wizard suite scripts the cloud prompts without the OAuth auth-method step; auto-answer it in the shared helper so the answer lists stay shape-only. * docs(honcho): document query-adaptive reasoning level (reasoningHeuristic) README never mentioned reasoningHeuristic and listed reasoningLevelCap as an orphaned cap with the wrong default (— vs "high"). Add the query-adaptive scaling note + the reasoningHeuristic/reasoningLevelCap rows (grouped under Dialectic & Reasoning), matching the wording already on the hosted honcho.md page, and add a pointer from the memory-providers overview. * fix(honcho): default the CLI peer prompt to the OAuth consent name The CLI runs the grant with apply_config=False, so the peerName the user just entered at consent was dropped and the wizard's 'Your name' prompt fell back to $USER. Surface it as a transient OAuthCredential.consent_peer_name (set even when config isn't merged) and seed the prompt default from it. * feat(honcho): split OAuth client_id by surface (cli=hermes-agent, desktop=hermes-desktop) resolve_endpoints now picks the client_id from the initiating surface and threads it through authorize -> token exchange -> persisted grant -> refresh, so the CLI and desktop register as distinct OAuth clients. Surface-specific env overrides (HONCHO_OAUTH_CLIENT_ID_CLI/_DESKTOP) win over the generic HONCHO_OAUTH_CLIENT_ID, which still overrides every surface. * feat(honcho): show OAuth vs API key in status; detect existing OAuth in setup status now prints 'Auth: OAuth (clientId, token valid Xm/expired)' instead of masking the OAuth access token as a generic API key; setup notes an existing OAuth grant when re-run. * docs(honcho): drop 'shared pool' wording from unified observation mode help * fix(honcho): cross-process lock around OAuth refresh to prevent grant revocation The in-process threading lock can't stop a sibling process (another profile or the desktop app sharing honcho.json) from replaying the single-use refresh token and tripping reuse-detection, which revokes the whole grant. Guard the read-refresh-persist section with an OS file lock on <config>.lock so only one process rotates at a time; the others re-read the freshly-persisted token. Best-effort: platforms without flock degrade to in-process serialization. * refactor(honcho): one OAuth client (hermes-agent) for all surfaces Collapse the per-surface client_id split. CLI and desktop now use a single client_id (hermes-agent); consent branding/UI still adapt via the source query param. One grant identity means no clientId-vs-refresh-token desync that could get the grant revoked. HONCHO_OAUTH_CLIENT_ID still overrides for self-hosting. * fix(honcho): per-session resolves to session_id, never remapped by title Reorder resolve_session_name so stable identifiers win over labels: gateway per-chat key first, then the per-session session_id, then the cwd map / title. A (possibly auto-generated) title can no longer remap a live per-session conversation onto a second Honcho session mid-stream — fixes the desktop, which is per-conversation via session_id. Consequence: a gateway's per-chat key now also wins over a title (titles never remap a stable id).
254 lines
11 KiB
Python
254 lines
11 KiB
Python
"""Tests for plugins/memory/honcho/oauth.py — OAuth grant storage + refresh."""
|
|
|
|
import json
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from plugins.memory.honcho import oauth
|
|
from plugins.memory.honcho.oauth import OAuthCredential
|
|
|
|
|
|
def _host_block(refresh="hch-rt-old", expires_at=10_000):
|
|
return {
|
|
"apiKey": "hch-at-old",
|
|
"oauth": {
|
|
"refreshToken": refresh,
|
|
"expiresAt": expires_at,
|
|
"clientId": "hermes-desktop",
|
|
"tokenEndpoint": "http://localhost:8000/oauth/token",
|
|
"scope": "write",
|
|
"tokenType": "Bearer",
|
|
},
|
|
}
|
|
|
|
|
|
def _write(path: Path, raw: dict) -> None:
|
|
path.write_text(json.dumps(raw), encoding="utf-8")
|
|
|
|
|
|
class TestTokenDetection:
|
|
def test_access_token_prefix(self):
|
|
assert oauth.is_oauth_access_token("hch-at-abc")
|
|
assert not oauth.is_oauth_access_token("hch-v3-abc")
|
|
assert not oauth.is_oauth_access_token("hch-rt-abc")
|
|
assert not oauth.is_oauth_access_token(None)
|
|
|
|
|
|
class TestCredentialModel:
|
|
def test_roundtrip(self):
|
|
cred = OAuthCredential.from_host_block(_host_block())
|
|
assert cred is not None
|
|
block = cred.oauth_block()
|
|
assert block["refreshToken"] == "hch-rt-old"
|
|
assert block["expiresAt"] == 10_000
|
|
assert block["clientId"] == "hermes-desktop"
|
|
|
|
def test_incomplete_block_returns_none(self):
|
|
# plain API key (no oauth sub-block)
|
|
assert OAuthCredential.from_host_block({"apiKey": "hch-v3-x"}) is None
|
|
# oauth block missing refreshToken
|
|
bad = _host_block()
|
|
del bad["oauth"]["refreshToken"]
|
|
assert OAuthCredential.from_host_block(bad) is None
|
|
|
|
def test_is_expired_respects_skew(self):
|
|
cred = OAuthCredential.from_host_block(_host_block(expires_at=1000))
|
|
assert not cred.is_expired(now=800, skew=120) # 1000-120=880 > 800
|
|
assert cred.is_expired(now=900, skew=120) # 900 >= 880
|
|
|
|
|
|
class TestEnsureFreshToken:
|
|
def test_no_oauth_credential_is_noop(self, tmp_path):
|
|
path = tmp_path / "honcho.json"
|
|
_write(path, {"hosts": {"hermes": {"apiKey": "hch-v3-static"}}})
|
|
token, refreshed = oauth.ensure_fresh_token(path, "hermes", now=0)
|
|
assert token is None and refreshed is False
|
|
|
|
def test_fresh_token_skips_refresh(self, tmp_path, monkeypatch):
|
|
path = tmp_path / "honcho.json"
|
|
_write(path, {"hosts": {"hermes": _host_block(expires_at=10_000)}})
|
|
monkeypatch.setattr(
|
|
oauth, "_http_post_form",
|
|
lambda *a, **k: pytest.fail("refresh must not be called when fresh"),
|
|
)
|
|
token, refreshed = oauth.ensure_fresh_token(path, "hermes", now=0)
|
|
assert token == "hch-at-old" and refreshed is False
|
|
|
|
def test_fresh_token_served_from_cache_without_disk(self, tmp_path, monkeypatch):
|
|
path = tmp_path / "honcho.json"
|
|
_write(path, {"hosts": {"hermes": _host_block(expires_at=10_000)}})
|
|
oauth._expiry_cache.clear()
|
|
# First call seeds the cache from disk.
|
|
oauth.ensure_fresh_token(path, "hermes", now=0)
|
|
# Second call must not touch disk while the token is well clear of expiry.
|
|
monkeypatch.setattr(
|
|
oauth, "_read_config",
|
|
lambda *a, **k: pytest.fail("disk must not be read while token is fresh"),
|
|
)
|
|
token, refreshed = oauth.ensure_fresh_token(path, "hermes", now=100)
|
|
assert token == "hch-at-old" and refreshed is False
|
|
|
|
def test_expired_token_refreshes_and_persists_rotation(self, tmp_path, monkeypatch):
|
|
path = tmp_path / "honcho.json"
|
|
_write(path, {"hosts": {"hermes": _host_block(expires_at=100)}})
|
|
|
|
def fake_post(url, data, timeout):
|
|
assert data["grant_type"] == "refresh_token"
|
|
assert data["refresh_token"] == "hch-rt-old"
|
|
assert data["client_id"] == "hermes-desktop"
|
|
return {
|
|
"access_token": "hch-at-new",
|
|
"refresh_token": "hch-rt-new",
|
|
"expires_in": 3600,
|
|
"scope": "write",
|
|
"token_type": "Bearer",
|
|
}
|
|
|
|
monkeypatch.setattr(oauth, "_http_post_form", fake_post)
|
|
token, refreshed = oauth.ensure_fresh_token(path, "hermes", now=1000)
|
|
assert token == "hch-at-new" and refreshed is True
|
|
|
|
# Rotated refresh token + new access token + absolute expiry persisted.
|
|
saved = json.loads(path.read_text())["hosts"]["hermes"]
|
|
assert saved["apiKey"] == "hch-at-new"
|
|
assert saved["oauth"]["refreshToken"] == "hch-rt-new"
|
|
assert saved["oauth"]["expiresAt"] == 1000 + 3600
|
|
|
|
def test_refresh_failure_fails_open(self, tmp_path, monkeypatch):
|
|
path = tmp_path / "honcho.json"
|
|
_write(path, {"hosts": {"hermes": _host_block(expires_at=100)}})
|
|
|
|
def boom(*a, **k):
|
|
raise RuntimeError("network down")
|
|
|
|
monkeypatch.setattr(oauth, "_http_post_form", boom)
|
|
token, refreshed = oauth.ensure_fresh_token(path, "hermes", now=1000)
|
|
# Stale token returned, no crash, file untouched.
|
|
assert token == "hch-at-old" and refreshed is False
|
|
assert json.loads(path.read_text())["hosts"]["hermes"]["apiKey"] == "hch-at-old"
|
|
|
|
def test_double_check_uses_disk_when_already_rotated(self, tmp_path, monkeypatch):
|
|
# Simulates a concurrent thread that rotated the token on disk after our
|
|
# stale in-memory snapshot: the locked re-read must skip the HTTP call.
|
|
path = tmp_path / "honcho.json"
|
|
_write(path, {"hosts": {"hermes": _host_block(refresh="hch-rt-fresh", expires_at=10_000)}})
|
|
stale_raw = {"hosts": {"hermes": _host_block(refresh="hch-rt-old", expires_at=100)}}
|
|
stale_raw["hosts"]["hermes"]["apiKey"] = "hch-at-stale"
|
|
monkeypatch.setattr(
|
|
oauth, "_http_post_form",
|
|
lambda *a, **k: pytest.fail("must not refresh; disk token is fresh"),
|
|
)
|
|
token, refreshed = oauth.ensure_fresh_token(path, "hermes", stale_raw, now=1000)
|
|
assert token == "hch-at-old" # the on-disk fresh credential's access token
|
|
|
|
def test_refresh_holds_cross_process_lock(self, tmp_path, monkeypatch):
|
|
# A second opener must not grab <config>.lock mid-refresh — proving the
|
|
# rotation is serialized machine-wide so peers can't replay the token.
|
|
fcntl = pytest.importorskip("fcntl")
|
|
path = tmp_path / "honcho.json"
|
|
_write(path, {"hosts": {"hermes": _host_block(expires_at=100)}})
|
|
seen = {}
|
|
|
|
def fake_post(url, data, timeout):
|
|
with open(f"{path}.lock", "a+b") as other:
|
|
try:
|
|
fcntl.flock(other.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
fcntl.flock(other.fileno(), fcntl.LOCK_UN)
|
|
seen["held"] = False
|
|
except OSError:
|
|
seen["held"] = True
|
|
return {"access_token": "hch-at-new", "refresh_token": "hch-rt-new",
|
|
"expires_in": 3600, "scope": "write", "token_type": "Bearer"}
|
|
|
|
monkeypatch.setattr(oauth, "_http_post_form", fake_post)
|
|
token, refreshed = oauth.ensure_fresh_token(path, "hermes", now=1000)
|
|
assert refreshed is True and seen.get("held") is True
|
|
# Released afterward: a non-blocking acquire now succeeds.
|
|
with open(f"{path}.lock", "a+b") as fh:
|
|
fcntl.flock(fh.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
fcntl.flock(fh.fileno(), fcntl.LOCK_UN)
|
|
|
|
def test_refresh_degrades_when_lock_unavailable(self, tmp_path, monkeypatch):
|
|
# No flock (unsupported FS/platform) must not block refresh — it falls
|
|
# back to in-process serialization only.
|
|
fcntl = pytest.importorskip("fcntl")
|
|
path = tmp_path / "honcho.json"
|
|
_write(path, {"hosts": {"hermes": _host_block(expires_at=100)}})
|
|
|
|
def no_flock(*a, **k):
|
|
raise OSError("flock unsupported")
|
|
|
|
monkeypatch.setattr(fcntl, "flock", no_flock)
|
|
monkeypatch.setattr(
|
|
oauth, "_http_post_form",
|
|
lambda *a, **k: {"access_token": "hch-at-new", "refresh_token": "hch-rt-new",
|
|
"expires_in": 3600, "scope": "write", "token_type": "Bearer"},
|
|
)
|
|
token, refreshed = oauth.ensure_fresh_token(path, "hermes", now=1000)
|
|
assert token == "hch-at-new" and refreshed is True
|
|
|
|
|
|
class TestInstallGrant:
|
|
def test_deep_merges_config_and_preserves_other_hosts(self, tmp_path):
|
|
path = tmp_path / "honcho.json"
|
|
_write(path, {
|
|
"apiKey": "hch-v3-root", # root static key preserved
|
|
"hosts": {
|
|
"obsidian": {"workspace": "obsidian"},
|
|
"hermes": {"workspace": "hermes", "saveMessages": False},
|
|
},
|
|
})
|
|
grant = {
|
|
"access_token": "hch-at-fresh",
|
|
"refresh_token": "hch-rt-fresh",
|
|
"expires_in": 3600,
|
|
"scope": "write",
|
|
"config": {
|
|
"environment": "production",
|
|
"hosts": {"hermes": {"saveMessages": True, "recallMode": "hybrid"}},
|
|
},
|
|
}
|
|
cred = oauth.install_grant(
|
|
path, "hermes", grant,
|
|
client_id="hermes-desktop",
|
|
token_endpoint="http://localhost:8000/oauth/token",
|
|
now=1000,
|
|
)
|
|
assert cred.expires_at == 1000 + 3600
|
|
|
|
saved = json.loads(path.read_text())
|
|
assert saved["apiKey"] == "hch-v3-root" # untouched
|
|
assert saved["hosts"]["obsidian"] == {"workspace": "obsidian"} # untouched
|
|
h = saved["hosts"]["hermes"]
|
|
assert h["apiKey"] == "hch-at-fresh"
|
|
assert h["oauth"]["refreshToken"] == "hch-rt-fresh"
|
|
assert h["saveMessages"] is True # grant config won the deep-merge
|
|
assert h["recallMode"] == "hybrid" # new key added
|
|
assert h["workspace"] == "hermes" # pre-existing key preserved
|
|
assert saved["environment"] == "production" # root key from grant
|
|
|
|
def test_rejects_grant_without_tokens(self, tmp_path):
|
|
path = tmp_path / "honcho.json"
|
|
_write(path, {})
|
|
with pytest.raises(ValueError):
|
|
oauth.install_grant(
|
|
path, "hermes", {"access_token": "hch-at-x"}, # no refresh_token
|
|
client_id="c", token_endpoint="e",
|
|
)
|
|
|
|
|
|
class TestApplyTokenToClient:
|
|
def test_mutates_live_bearer(self):
|
|
class FakeHttp:
|
|
api_key = "hch-at-old"
|
|
|
|
class FakeClient:
|
|
_http = FakeHttp()
|
|
|
|
client = FakeClient()
|
|
assert oauth.apply_token_to_client(client, "hch-at-new") is True
|
|
assert client._http.api_key == "hch-at-new"
|
|
|
|
def test_returns_false_when_shape_unknown(self):
|
|
assert oauth.apply_token_to_client(object(), "hch-at-new") is False
|