fix(gateway): anchor Google Chat OAuth client secret to default Hermes root
This commit is contained in:
@ -50,8 +50,10 @@ Token storage layout
|
||||
``${HERMES_HOME}/google_chat_user_oauth_pending/<sanitized_email>.json``
|
||||
- Legacy pending state:
|
||||
``${HERMES_HOME}/google_chat_user_oauth_pending.json``
|
||||
- Shared OAuth client (one per host):
|
||||
``${HERMES_HOME}/google_chat_user_client_secret.json``
|
||||
- Shared OAuth client (one per host, anchored at the default Hermes root so
|
||||
every profile sees it; a profile-local copy under ``${HERMES_HOME}`` wins
|
||||
when present):
|
||||
``<default-root>/google_chat_user_client_secret.json`` (default ``~/.hermes``)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@ -75,7 +77,11 @@ logger = logging.getLogger("gateway.platforms.google_chat_user_oauth")
|
||||
# Use the project's HERMES_HOME helper so the token follows the user's
|
||||
# profile (e.g. tests can override via HERMES_HOME=/tmp/...).
|
||||
try:
|
||||
from hermes_constants import display_hermes_home, get_hermes_home
|
||||
from hermes_constants import (
|
||||
display_hermes_home,
|
||||
get_default_hermes_root,
|
||||
get_hermes_home,
|
||||
)
|
||||
except (ModuleNotFoundError, ImportError):
|
||||
# Fallback for environments where hermes_constants isn't importable
|
||||
# (mirrors the same fallback used by the google-workspace skill's
|
||||
@ -84,6 +90,24 @@ except (ModuleNotFoundError, ImportError):
|
||||
val = os.environ.get("HERMES_HOME", "").strip()
|
||||
return Path(val) if val else Path.home() / ".hermes"
|
||||
|
||||
def get_default_hermes_root() -> Path:
|
||||
# Mirror hermes_constants.get_default_hermes_root(): resolve the
|
||||
# profile root so host-wide files (the shared client secret) are
|
||||
# found regardless of which profile is active.
|
||||
native_home = Path.home() / ".hermes"
|
||||
env_home = os.environ.get("HERMES_HOME", "").strip()
|
||||
if not env_home:
|
||||
return native_home
|
||||
env_path = Path(env_home)
|
||||
try:
|
||||
env_path.resolve().relative_to(native_home.resolve())
|
||||
return native_home
|
||||
except ValueError:
|
||||
pass
|
||||
if env_path.parent.name == "profiles":
|
||||
return env_path.parent.parent
|
||||
return env_path
|
||||
|
||||
def display_hermes_home() -> str:
|
||||
home = get_hermes_home()
|
||||
try:
|
||||
@ -140,7 +164,24 @@ def _token_path(email: Optional[str] = None) -> Path:
|
||||
|
||||
|
||||
def _client_secret_path() -> Path:
|
||||
return _hermes_home() / "google_chat_user_client_secret.json"
|
||||
"""Path to the shared OAuth client secret (one per host).
|
||||
|
||||
The client secret identifies the OAuth *app*, not a user or a profile,
|
||||
so it is anchored at the default Hermes root (``~/.hermes`` — or the
|
||||
Docker root) rather than the active profile's ``HERMES_HOME``. That way
|
||||
the one-time ``--client-secret`` host setup is visible to gateways
|
||||
running under any named profile, exactly as the docs describe ("one
|
||||
file per host is enough no matter how many users authorize later").
|
||||
|
||||
A profile-local secret (``$HERMES_HOME/google_chat_user_client_secret.json``)
|
||||
still takes precedence when present, for installs that seeded one under
|
||||
the previous profile-scoped behavior or that deliberately run a separate
|
||||
OAuth app per profile.
|
||||
"""
|
||||
profile_local = _hermes_home() / "google_chat_user_client_secret.json"
|
||||
if profile_local.exists():
|
||||
return profile_local
|
||||
return get_default_hermes_root() / "google_chat_user_client_secret.json"
|
||||
|
||||
|
||||
def _pending_auth_path(email: Optional[str] = None) -> Path:
|
||||
|
||||
@ -16,6 +16,7 @@ import json
|
||||
import os
|
||||
import sys
|
||||
import types
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
@ -1650,6 +1651,48 @@ class TestUserOAuthHelper:
|
||||
},
|
||||
)
|
||||
|
||||
def test_client_secret_is_shared_across_profiles(self, tmp_path, monkeypatch):
|
||||
"""The OAuth client secret is host-wide infra: a secret seeded at the
|
||||
default root by the documented one-time `--client-secret` host step
|
||||
must be visible to a gateway running under a named profile.
|
||||
|
||||
Regression: `_client_secret_path()` used to scope to the active
|
||||
HERMES_HOME, so a profile gateway reported 'No client credentials
|
||||
stored on the host' even after the host setup had been run.
|
||||
"""
|
||||
root = tmp_path / ".hermes"
|
||||
profile_home = root / "profiles" / "bot1"
|
||||
profile_home.mkdir(parents=True)
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
|
||||
# Seed the secret at the default root, as the host setup does.
|
||||
secret = root / "google_chat_user_client_secret.json"
|
||||
secret.write_text("{}", encoding="utf-8")
|
||||
|
||||
# Resolve from inside a named profile.
|
||||
monkeypatch.setenv("HERMES_HOME", str(profile_home))
|
||||
from plugins.platforms.google_chat.oauth import _client_secret_path
|
||||
assert _client_secret_path() == secret
|
||||
assert _client_secret_path().exists()
|
||||
|
||||
def test_profile_local_client_secret_takes_precedence(self, tmp_path, monkeypatch):
|
||||
"""A profile-local secret (separate OAuth app per bot, or a legacy
|
||||
profile-scoped seed) overrides the host-wide default when present."""
|
||||
root = tmp_path / ".hermes"
|
||||
profile_home = root / "profiles" / "bot1"
|
||||
profile_home.mkdir(parents=True)
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
monkeypatch.setenv("HERMES_HOME", str(profile_home))
|
||||
|
||||
(root / "google_chat_user_client_secret.json").write_text(
|
||||
"{}", encoding="utf-8"
|
||||
)
|
||||
profile_secret = profile_home / "google_chat_user_client_secret.json"
|
||||
profile_secret.write_text("{}", encoding="utf-8")
|
||||
|
||||
from plugins.platforms.google_chat.oauth import _client_secret_path
|
||||
assert _client_secret_path() == profile_secret
|
||||
|
||||
def test_store_client_secret_writes_private_json(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
src = tmp_path / "client_secret.json"
|
||||
|
||||
@ -247,6 +247,13 @@ That writes `~/.hermes/google_chat_user_client_secret.json`. This is shared
|
||||
infrastructure — it identifies the OAuth *app*, not any individual user. One
|
||||
file per host is enough no matter how many users authorize later.
|
||||
|
||||
This file lives at the default Hermes root, so a gateway running under a named
|
||||
profile (`hermes -p <name> gateway …`) finds the same host-wide secret — you do
|
||||
**not** re-run this step per profile. To deliberately use a separate OAuth app
|
||||
for one profile, drop a `google_chat_user_client_secret.json` inside that
|
||||
profile's `HERMES_HOME` and it takes precedence. Per-user tokens always stay
|
||||
scoped to the active profile.
|
||||
|
||||
### Per-user authorization (in chat)
|
||||
|
||||
Each user runs the flow once, in their own DM with the bot:
|
||||
|
||||
Reference in New Issue
Block a user