From fff0561441d26f8056af5e64bf44c0a54cad5ecc Mon Sep 17 00:00:00 2001 From: Frowtek Date: Thu, 4 Jun 2026 01:36:42 +0300 Subject: [PATCH] fix(gateway): anchor Google Chat OAuth client secret to default Hermes root --- plugins/platforms/google_chat/oauth.py | 49 +++++++++++++++++-- tests/gateway/test_google_chat.py | 43 ++++++++++++++++ .../docs/user-guide/messaging/google_chat.md | 7 +++ 3 files changed, 95 insertions(+), 4 deletions(-) diff --git a/plugins/platforms/google_chat/oauth.py b/plugins/platforms/google_chat/oauth.py index d18aaab0c..3b1101106 100644 --- a/plugins/platforms/google_chat/oauth.py +++ b/plugins/platforms/google_chat/oauth.py @@ -50,8 +50,10 @@ Token storage layout ``${HERMES_HOME}/google_chat_user_oauth_pending/.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): + ``/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: diff --git a/tests/gateway/test_google_chat.py b/tests/gateway/test_google_chat.py index b75902785..03ab232eb 100644 --- a/tests/gateway/test_google_chat.py +++ b/tests/gateway/test_google_chat.py @@ -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" diff --git a/website/docs/user-guide/messaging/google_chat.md b/website/docs/user-guide/messaging/google_chat.md index d9565b154..d34ebbd2e 100644 --- a/website/docs/user-guide/messaging/google_chat.md +++ b/website/docs/user-guide/messaging/google_chat.md @@ -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 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: