From 1b6592f432b2c4b83dbfaf24c21bfb18c5644368 Mon Sep 17 00:00:00 2001 From: Ramy Barsoum Date: Wed, 13 May 2026 04:16:00 -0700 Subject: [PATCH 1/2] fix: preserve resolved env refs across gateway reloads --- cron/scheduler.py | 7 +-- gateway/platforms/api_server.py | 10 +++- hermes_cli/env_loader.py | 32 ++++++++++++ hermes_cli/gateway.py | 13 ++++- skills/productivity/google-workspace/SKILL.md | 2 +- .../google-workspace/scripts/google_api.py | 10 +++- .../google-workspace/scripts/setup.py | 50 +++++++++++++++++++ 7 files changed, 115 insertions(+), 9 deletions(-) diff --git a/cron/scheduler.py b/cron/scheduler.py index b585ef2e42ba..7b2f51c72401 100644 --- a/cron/scheduler.py +++ b/cron/scheduler.py @@ -37,6 +37,7 @@ from hermes_constants import get_hermes_home from hermes_cli.config import load_config, _expand_env_vars +from hermes_cli.env_loader import load_hermes_dotenv from hermes_time import now as _hermes_now logger = logging.getLogger(__name__) @@ -1268,11 +1269,7 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]: try: # Re-read .env and config.yaml fresh every run so provider/key # changes take effect without a gateway restart. - from dotenv import load_dotenv - try: - load_dotenv(str(_get_hermes_home() / ".env"), override=True, encoding="utf-8") - except UnicodeDecodeError: - load_dotenv(str(_get_hermes_home() / ".env"), override=True, encoding="latin-1") + load_hermes_dotenv(hermes_home=_get_hermes_home()) delivery_target = _resolve_delivery_target(job) if delivery_target: diff --git a/gateway/platforms/api_server.py b/gateway/platforms/api_server.py index 8b53db3a99f3..5f8221d2dab9 100644 --- a/gateway/platforms/api_server.py +++ b/gateway/platforms/api_server.py @@ -831,7 +831,15 @@ def _create_agent( user_config = _load_gateway_config() enabled_toolsets = sorted(_get_platform_tools(user_config, "api_server")) - max_iterations = int(os.getenv("HERMES_MAX_ITERATIONS", "90")) + raw_max_iterations = os.getenv("HERMES_MAX_ITERATIONS", "") + try: + max_iterations = int(raw_max_iterations or "90") + except (TypeError, ValueError): + agent_cfg = user_config.get("agent", {}) if isinstance(user_config, dict) else {} + try: + max_iterations = int(agent_cfg.get("max_turns", 90) or 90) + except (TypeError, ValueError): + max_iterations = 90 # Load fallback provider chain so the API server platform has the # same fallback behaviour as Telegram/Discord/Slack (fixes #4954). diff --git a/hermes_cli/env_loader.py b/hermes_cli/env_loader.py index 8040b73eb54c..a6cee5e4fc35 100644 --- a/hermes_cli/env_loader.py +++ b/hermes_cli/env_loader.py @@ -3,6 +3,7 @@ from __future__ import annotations import os +import re import sys from pathlib import Path @@ -22,6 +23,35 @@ _WARNED_KEYS: set[str] = set() +def _resolved_op_env_values(path: Path) -> dict[str, str]: + """Return resolved env values that should survive raw op:// dotenv refs. + + Max launches Hermes through `op run --env-file`, which resolves 1Password + references before Python starts. A later python-dotenv load with + override=True would otherwise clobber those resolved values back to raw + `op://...` strings from the same env file. + """ + if not path.exists(): + return {} + try: + lines = path.read_text(encoding="utf-8-sig", errors="replace").splitlines() + except OSError: + return {} + + preserved: dict[str, str] = {} + for line in lines: + match = re.match(r"^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.+?)\s*$", line) + if not match: + continue + key, raw_value = match.groups() + if not raw_value.strip().strip('"').strip("'").startswith("op://"): + continue + current = os.environ.get(key) + if current and not current.startswith("op://"): + preserved[key] = current + return preserved + + def _format_offending_chars(value: str, limit: int = 3) -> str: """Return a compact 'U+XXXX ('c'), ...' summary of non-ASCII codepoints.""" seen: list[str] = [] @@ -82,10 +112,12 @@ def _sanitize_loaded_credentials() -> None: def _load_dotenv_with_fallback(path: Path, *, override: bool) -> None: + preserved = _resolved_op_env_values(path) if override else {} try: load_dotenv(dotenv_path=path, override=override, encoding="utf-8") except UnicodeDecodeError: load_dotenv(dotenv_path=path, override=override, encoding="latin-1") + os.environ.update(preserved) # Strip non-ASCII characters from credential env vars that were just # loaded. API keys must be pure ASCII since they're sent as HTTP # header values (httpx encodes headers as ASCII). Non-ASCII chars diff --git a/hermes_cli/gateway.py b/hermes_cli/gateway.py index b0cb579daa8f..08cc590814c7 100644 --- a/hermes_cli/gateway.py +++ b/hermes_cli/gateway.py @@ -2769,12 +2769,23 @@ def generate_launchd_plist() -> str: dict.fromkeys(priority_dirs + [p for p in os.environ.get("PATH", "").split(":") if p]) ) - # Build ProgramArguments array, including --profile when using a named profile + wrapper_cfg = read_raw_config().get("launchd_wrapper") or {} + wrapper_command = wrapper_cfg.get("command") if isinstance(wrapper_cfg, dict) else None + wrapper_env_file = wrapper_cfg.get("env_file") if isinstance(wrapper_cfg, dict) else None + + # Build ProgramArguments array, including --profile when using a named profile. + # launchd_wrapper lets local installs source secrets before starting Hermes. prog_args = [ f"{python_path}", "-m", "hermes_cli.main", ] + if isinstance(wrapper_command, str) and wrapper_command.strip(): + prog_args = [f"{wrapper_command}"] + ( + [f"{wrapper_env_file}"] + if isinstance(wrapper_env_file, str) and wrapper_env_file.strip() + else [] + ) + prog_args if profile_arg: for part in profile_arg.split(): prog_args.append(f"{part}") diff --git a/skills/productivity/google-workspace/SKILL.md b/skills/productivity/google-workspace/SKILL.md index 5668d80f28a3..d36bc79f9dfb 100644 --- a/skills/productivity/google-workspace/SKILL.md +++ b/skills/productivity/google-workspace/SKILL.md @@ -161,7 +161,7 @@ Should print `AUTHENTICATED`. Setup is complete — token refreshes automaticall - Token is stored at `~/.hermes/google_token.json` and auto-refreshes. - Pending OAuth session state/verifier are stored temporarily at `~/.hermes/google_oauth_pending.json` until exchange completes. -- If `gws` is installed, `google_api.py` points it at the same `~/.hermes/google_token.json` credentials file. Users do not need to run a separate `gws auth login` flow. +- If `gws` is installed, `google_api.py` prefers the same `~/.hermes/google_token.json` credentials file. When that profile-scoped token does not exist, it falls back to the existing `gws` credential store (usually `~/.config/gws` + keyring). - To revoke: `$GSETUP --revoke` ## Usage diff --git a/skills/productivity/google-workspace/scripts/google_api.py b/skills/productivity/google-workspace/scripts/google_api.py index 7b8350ab34a2..d0a667acdedd 100644 --- a/skills/productivity/google-workspace/scripts/google_api.py +++ b/skills/productivity/google-workspace/scripts/google_api.py @@ -62,6 +62,9 @@ def _normalize_authorized_user_payload(payload: dict) -> dict: def _ensure_authenticated(): + if TOKEN_PATH.exists() or _gws_binary(): + return + if not TOKEN_PATH.exists(): print("Not authenticated. Run the setup script first:", file=sys.stderr) print(f" python {Path(__file__).parent / 'setup.py'}", file=sys.stderr) @@ -88,7 +91,12 @@ def _gws_binary() -> str | None: def _gws_env() -> dict[str, str]: env = os.environ.copy() - env["GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE"] = str(TOKEN_PATH) + if TOKEN_PATH.exists(): + env["GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE"] = str(TOKEN_PATH) + else: + # Let gws use its native store (usually ~/.config/gws + keyring) when + # Hermes has not created a profile-scoped OAuth token yet. + env.pop("GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE", None) return env diff --git a/skills/productivity/google-workspace/scripts/setup.py b/skills/productivity/google-workspace/scripts/setup.py index fbf91128bda7..db91181983e2 100644 --- a/skills/productivity/google-workspace/scripts/setup.py +++ b/skills/productivity/google-workspace/scripts/setup.py @@ -26,6 +26,7 @@ import argparse import json import os +import shutil import subprocess import sys from pathlib import Path @@ -130,12 +131,59 @@ def _ensure_deps(): sys.exit(1) +def _gws_binary() -> str | None: + override = os.getenv("HERMES_GWS_BIN") + if override: + return override + return shutil.which("gws") + + +def _gws_native_env() -> dict[str, str]: + env = os.environ.copy() + env.pop("GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE", None) + return env + + +def _check_gws_native_auth(quiet: bool = False) -> bool: + """Return True when the standalone gws CLI can make a real Workspace call.""" + binary = _gws_binary() + if not binary: + return False + + try: + result = subprocess.run( + [ + binary, + "calendar", + "calendarList", + "list", + "--params", + json.dumps({"maxResults": 1, "fields": "items(id),nextPageToken"}), + ], + capture_output=True, + text=True, + timeout=30, + env=_gws_native_env(), + ) + except Exception: + return False + + if result.returncode != 0: + return False + if not quiet: + print("AUTHENTICATED: gws CLI native auth works via its configured credential store.") + return True + + def check_auth_live(): """Check auth with a real API call to detect disabled_client/account issues.""" # quiet=True suppresses the "AUTHENTICATED" print from check_auth so the # final status line reflects the live-call outcome (OK or FAILED). if not check_auth(quiet=True): return False + if not TOKEN_PATH.exists(): + print("LIVE_CHECK_OK: gws CLI API call succeeded.") + return True try: from googleapiclient.discovery import build from google.oauth2.credentials import Credentials @@ -159,6 +207,8 @@ def check_auth_live(): def check_auth(quiet: bool = False): """Check if stored credentials are valid. Prints status, exits 0 or 1.""" if not TOKEN_PATH.exists(): + if _check_gws_native_auth(quiet=quiet): + return True print(f"NOT_AUTHENTICATED: No token at {TOKEN_PATH}") return False From 466b2e50f5fad9fd39fa54fccde185c90f291acb Mon Sep 17 00:00:00 2001 From: Ramy Barsoum Date: Wed, 13 May 2026 09:16:18 -0700 Subject: [PATCH 2/2] test: cover env reload and google workspace fallbacks --- tests/hermes_cli/test_env_loader.py | 21 +++++++++++++++++++++ tests/hermes_cli/test_gateway_service.py | 20 ++++++++++++++++++++ tests/skills/test_google_oauth_setup.py | 21 +++++++++++++++++++++ tests/skills/test_google_workspace_api.py | 18 ++++++++++++++++++ 4 files changed, 80 insertions(+) diff --git a/tests/hermes_cli/test_env_loader.py b/tests/hermes_cli/test_env_loader.py index f309dfd4c6a8..d97790c7edc4 100644 --- a/tests/hermes_cli/test_env_loader.py +++ b/tests/hermes_cli/test_env_loader.py @@ -70,6 +70,27 @@ def test_user_env_takes_precedence_over_project_env(tmp_path, monkeypatch): assert os.getenv("OPENAI_API_KEY") == "project-key" +def test_resolved_onepassword_values_survive_user_env_reload(tmp_path, monkeypatch): + """op run resolves op:// refs before startup; reloads must not undo that.""" + home = tmp_path / "hermes" + home.mkdir() + user_env = home / ".env" + user_env.write_text( + "OPENAI_API_KEY=op://vault/hermes/openai-api-key\n" + "HERMES_INFERENCE_PROVIDER=openai-codex\n", + encoding="utf-8", + ) + + monkeypatch.setenv("OPENAI_API_KEY", "sk-resolved-by-op-run") + monkeypatch.setenv("HERMES_INFERENCE_PROVIDER", "openrouter") + + loaded = load_hermes_dotenv(hermes_home=home) + + assert loaded == [user_env] + assert os.getenv("OPENAI_API_KEY") == "sk-resolved-by-op-run" + assert os.getenv("HERMES_INFERENCE_PROVIDER") == "openai-codex" + + def test_main_import_applies_user_env_over_shell_values(tmp_path, monkeypatch): home = tmp_path / "hermes" home.mkdir() diff --git a/tests/hermes_cli/test_gateway_service.py b/tests/hermes_cli/test_gateway_service.py index 6fb012ff8072..0eecaf312705 100644 --- a/tests/hermes_cli/test_gateway_service.py +++ b/tests/hermes_cli/test_gateway_service.py @@ -1649,6 +1649,26 @@ def test_launchd_plist_includes_profile(self, tmp_path, monkeypatch): assert "--profile" in plist assert "mybot" in plist + def test_launchd_plist_can_prefix_configured_wrapper(self, tmp_path, monkeypatch): + """Local launchd installs can run through a secrets/env wrapper.""" + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir(parents=True) + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + monkeypatch.setattr(gateway_cli, "get_hermes_home", lambda: hermes_home) + monkeypatch.setattr(gateway_cli, "read_raw_config", lambda: { + "launchd_wrapper": { + "command": "/Users/cole/RBrain/scripts/op-run-rbrain-agents.sh", + "env_file": "/Users/cole/RBrain/env/rbrain.env", + } + }) + + plist = gateway_cli.generate_launchd_plist() + + assert "/Users/cole/RBrain/scripts/op-run-rbrain-agents.sh" in plist + assert "/Users/cole/RBrain/env/rbrain.env" in plist + assert plist.index("op-run-rbrain-agents.sh") < plist.index("-m") + assert plist.index("rbrain.env") < plist.index("hermes_cli.main") + def test_launchd_plist_path_uses_real_user_home_not_profile_home(self, tmp_path, monkeypatch): profile_dir = tmp_path / ".hermes" / "profiles" / "orcha" profile_dir.mkdir(parents=True) diff --git a/tests/skills/test_google_oauth_setup.py b/tests/skills/test_google_oauth_setup.py index a7908bd76a1f..bb8ffd25bdda 100644 --- a/tests/skills/test_google_oauth_setup.py +++ b/tests/skills/test_google_oauth_setup.py @@ -148,6 +148,27 @@ def test_persists_state_and_code_verifier_for_later_exchange(self, setup_module, assert flow.authorization_kwargs == {"access_type": "offline", "prompt": "consent"} +class TestCheckAuth: + def test_uses_native_gws_auth_when_profile_token_missing(self, setup_module, monkeypatch, capsys): + calls = [] + monkeypatch.setattr(setup_module, "_gws_binary", lambda: "/usr/local/bin/gws") + + def fake_run(cmd, **kwargs): + calls.append((cmd, kwargs)) + return types.SimpleNamespace(returncode=0, stdout='{"items": []}', stderr="") + + monkeypatch.setattr(setup_module.subprocess, "run", fake_run) + assert not setup_module.TOKEN_PATH.exists() + + assert setup_module.check_auth() is True + + out = capsys.readouterr().out + assert "gws CLI native auth works" in out + cmd, kwargs = calls[0] + assert cmd[:4] == ["/usr/local/bin/gws", "calendar", "calendarList", "list"] + assert "GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE" not in kwargs["env"] + + class TestExchangeAuthCode: def test_reuses_saved_pkce_material_for_plain_code(self, setup_module): setup_module.PENDING_AUTH_PATH.write_text( diff --git a/tests/skills/test_google_workspace_api.py b/tests/skills/test_google_workspace_api.py index bbd51a35df0a..f4c8e21fcd4a 100644 --- a/tests/skills/test_google_workspace_api.py +++ b/tests/skills/test_google_workspace_api.py @@ -131,6 +131,24 @@ def capture_run(cmd, **kwargs): assert captured["cmd"] == ["gws", "gmail", "+triage"] +def test_api_gws_env_uses_native_store_when_profile_token_missing(api_module, monkeypatch): + monkeypatch.setenv("GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE", "/tmp/stale-token.json") + assert not api_module.TOKEN_PATH.exists() + + env = api_module._gws_env() + + assert "GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE" not in env + + +def test_api_gws_env_prefers_profile_token_when_present(api_module, monkeypatch): + monkeypatch.setenv("GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE", "/tmp/stale-token.json") + api_module.TOKEN_PATH.write_text("{}") + + env = api_module._gws_env() + + assert env["GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE"] == str(api_module.TOKEN_PATH) + + def test_api_calendar_list_uses_events_list(api_module): """calendar_list calls _run_gws with events list + params.""" captured = {}