Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 2 additions & 5 deletions cron/scheduler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)
Expand Down Expand Up @@ -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:
Expand Down
10 changes: 9 additions & 1 deletion gateway/platforms/api_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
32 changes: 32 additions & 0 deletions hermes_cli/env_loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from __future__ import annotations

import os
import re
import sys
from pathlib import Path

Expand All @@ -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] = []
Expand Down Expand Up @@ -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
Expand Down
13 changes: 12 additions & 1 deletion hermes_cli/gateway.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"<string>{python_path}</string>",
"<string>-m</string>",
"<string>hermes_cli.main</string>",
]
if isinstance(wrapper_command, str) and wrapper_command.strip():
prog_args = [f"<string>{wrapper_command}</string>"] + (
[f"<string>{wrapper_env_file}</string>"]
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"<string>{part}</string>")
Expand Down
2 changes: 1 addition & 1 deletion skills/productivity/google-workspace/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 9 additions & 1 deletion skills/productivity/google-workspace/scripts/google_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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


Expand Down
50 changes: 50 additions & 0 deletions skills/productivity/google-workspace/scripts/setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
import argparse
import json
import os
import shutil
import subprocess
import sys
from pathlib import Path
Expand Down Expand Up @@ -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
Expand All @@ -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

Expand Down
21 changes: 21 additions & 0 deletions tests/hermes_cli/test_env_loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
20 changes: 20 additions & 0 deletions tests/hermes_cli/test_gateway_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -1649,6 +1649,26 @@ def test_launchd_plist_includes_profile(self, tmp_path, monkeypatch):
assert "<string>--profile</string>" in plist
assert "<string>mybot</string>" 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 "<string>/Users/cole/RBrain/scripts/op-run-rbrain-agents.sh</string>" in plist
assert "<string>/Users/cole/RBrain/env/rbrain.env</string>" in plist
assert plist.index("op-run-rbrain-agents.sh") < plist.index("<string>-m</string>")
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)
Expand Down
21 changes: 21 additions & 0 deletions tests/skills/test_google_oauth_setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
18 changes: 18 additions & 0 deletions tests/skills/test_google_workspace_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {}
Expand Down