From 05bec0ac79ad525997d569f17e47dcd39c401ed3 Mon Sep 17 00:00:00 2001 From: Austin Pickett Date: Mon, 4 May 2026 12:53:09 -0400 Subject: [PATCH 01/25] fix: pluralization --- hermes_cli/claw.py | 11 +++++++++-- hermes_cli/doctor.py | 11 +++++++++-- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/hermes_cli/claw.py b/hermes_cli/claw.py index f6e2521eb01..9b02916a554 100644 --- a/hermes_cli/claw.py +++ b/hermes_cli/claw.py @@ -677,10 +677,17 @@ def _cmd_cleanup(args): # Summary print() if dry_run: - print_info(f"Dry run complete. {len(dirs_to_check)} directory(ies) would be archived.") + _n_dirs = len(dirs_to_check) + print_info( + f"Dry run complete. {_n_dirs} " + f"{'directory' if _n_dirs == 1 else 'directories'} would be archived." + ) print_info("Run without --dry-run to archive them.") elif total_archived: - print_success(f"Cleaned up {total_archived} OpenClaw directory(ies).") + print_success( + f"Cleaned up {total_archived} OpenClaw " + f"{'directory' if total_archived == 1 else 'directories'}." + ) print_info("Directories were renamed, not deleted. You can undo by renaming them back.") else: print_info("No directories were archived.") diff --git a/hermes_cli/doctor.py b/hermes_cli/doctor.py index 446f576a612..8887b44e9a7 100644 --- a/hermes_cli/doctor.py +++ b/hermes_cli/doctor.py @@ -985,9 +985,16 @@ def run_doctor(args): f"{label} deps", f"({critical} critical, {high} high, {moderate} moderate — run: cd {npm_dir} && npm audit fix)" ) - issues.append(f"{label} has {total} npm vulnerability(ies)") + issues.append( + f"{label} has {total} npm " + f"{'vulnerability' if total == 1 else 'vulnerabilities'}" + ) else: - check_ok(f"{label} deps", f"({moderate} moderate vulnerability(ies))") + check_ok( + f"{label} deps", + f"({moderate} moderate " + f"{'vulnerability' if moderate == 1 else 'vulnerabilities'})", + ) except Exception: pass From b162f9ef9a923dc5765ae1c24c0a32f0a1f5be5e Mon Sep 17 00:00:00 2001 From: Austin Pickett Date: Mon, 4 May 2026 13:41:08 -0400 Subject: [PATCH 02/25] fix(nix): refresh hermes-tui npmDepsHash for ui-tui lockfile Co-authored-by: Cursor --- nix/tui.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nix/tui.nix b/nix/tui.nix index 4d27dde798e..45ea1aa1190 100644 --- a/nix/tui.nix +++ b/nix/tui.nix @@ -4,7 +4,7 @@ let src = ../ui-tui; npmDeps = pkgs.fetchNpmDeps { inherit src; - hash = "sha256-a/HGI9OgVcTnZrMXA7xFMGnFoVxyHe95fulVz+WNYB0="; + hash = "sha256-tmKv51gGIHzfT6HqB3zR3mrRIfkmngrW1ad3Gg6n2aE="; }; npm = hermesNpmLib.mkNpmPassthru { folder = "ui-tui"; attr = "tui"; pname = "hermes-tui"; }; From 7338e5d9ba94c1d90a644d0588ac003d1aaee350 Mon Sep 17 00:00:00 2001 From: kshitij <82637225+kshitijk4poor@users.noreply.github.com> Date: Fri, 8 May 2026 01:58:54 -0700 Subject: [PATCH 03/25] fix(model-switch): prevent stale Ollama credentials after provider switch (#21703) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When switching from a custom local provider (e.g. ollama-launch) to a cloud provider, two bugs caused the CLI to misbehave: 1. _explicit_api_key/_explicit_base_url were only updated when the switch result had non-empty values (guarded by `if result.api_key:` etc.). If the previous provider set these to Ollama values ("ollama", "http://127.0.0.1:11434/v1"), those stale values leaked into the next turn's _ensure_runtime_credentials() call and were forwarded to the new provider's API endpoint, causing authentication/routing failures. Fix: unconditionally write result.api_key/base_url into the explicit fields after every successful switch. An empty string is the correct sentinel — it tells _ensure_runtime_credentials to re-resolve from the auth store / config rather than forwarding a stale override. 2. In AIAgent.switch_model(), `self.base_url = base_url or self.base_url` kept the old Ollama localhost URL whenever the incoming base_url was an empty string. For providers that use a native SDK (not an OpenAI-compat endpoint), the caller passes base_url="" and expects the agent to clear the field — not silently inherit Ollama's address. Fix: only update self.base_url when base_url is truthy. 3. _handle_model_picker_selection() was called from the prompt_toolkit Enter key binding without any exception guard. Any unexpected error in the model-selection code path propagated through prompt_toolkit's key-binding dispatcher and caused the entire TUI to exit — which the user sees as "the terminal exits when I switch providers". Fix: wrap the call in try/except and close the picker on failure. --- cli.py | 20 +++++++++++++++----- run_agent.py | 8 +++++++- 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/cli.py b/cli.py index 08a9bb94ced..531716885b3 100644 --- a/cli.py +++ b/cli.py @@ -5804,12 +5804,15 @@ class HermesCLI: self.model = result.new_model self.provider = result.target_provider self.requested_provider = result.target_provider + # Always overwrite explicit overrides so stale credentials from the + # previous provider (e.g. Ollama api_key/base_url) don't leak into + # the new provider's credential resolution on the next turn. + self._explicit_api_key = result.api_key + self._explicit_base_url = result.base_url if result.api_key: self.api_key = result.api_key - self._explicit_api_key = result.api_key if result.base_url: self.base_url = result.base_url - self._explicit_base_url = result.base_url if result.api_mode: self.api_mode = result.api_mode @@ -6027,12 +6030,15 @@ class HermesCLI: self.model = result.new_model self.provider = result.target_provider self.requested_provider = result.target_provider + # Always overwrite explicit overrides so stale credentials from the + # previous provider (e.g. Ollama api_key/base_url) don't leak into + # the new provider's credential resolution on the next turn. + self._explicit_api_key = result.api_key + self._explicit_base_url = result.base_url if result.api_key: self.api_key = result.api_key - self._explicit_api_key = result.api_key if result.base_url: self.base_url = result.base_url - self._explicit_base_url = result.base_url if result.api_mode: self.api_mode = result.api_mode @@ -10443,7 +10449,11 @@ class HermesCLI: # --- /model picker modal --- if self._model_picker_state: - self._handle_model_picker_selection() + try: + self._handle_model_picker_selection() + except Exception as _exc: + _cprint(f" ✗ Model selection failed: {_exc}") + self._close_model_picker() event.app.current_buffer.reset() event.app.invalidate() return diff --git a/run_agent.py b/run_agent.py index 403dba4e785..2646301b3e3 100644 --- a/run_agent.py +++ b/run_agent.py @@ -2386,7 +2386,13 @@ class AIAgent: # ── Swap core runtime fields ── self.model = new_model self.provider = new_provider - self.base_url = base_url or self.base_url + # Use new base_url when provided; only fall back to current when the + # new provider genuinely has no endpoint (e.g. native SDK providers). + # Without this guard the old provider's URL (e.g. Ollama's localhost + # address) would persist silently after switching to a cloud provider + # that returns an empty base_url string. + if base_url: + self.base_url = base_url self.api_mode = api_mode # Invalidate transport cache — new api_mode may need a different transport if hasattr(self, "_transport_cache"): From 5d1bdf11b61d559b6d1d2b6e7626fa7d71a6f860 Mon Sep 17 00:00:00 2001 From: Isaac Huang Date: Wed, 6 May 2026 14:23:59 -0700 Subject: [PATCH 04/25] Add AUTHOR_MAP entry for Isaac Huang --- scripts/release.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/release.py b/scripts/release.py index ce94fd16629..47b65078dde 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -696,6 +696,7 @@ AUTHOR_MAP = { "mike@mikewaters.net": "mikewaters", "65117428+WadydX@users.noreply.github.com": "WadydX", "216480837+isaachuangGMICLOUD@users.noreply.github.com": "isaachuangGMICLOUD", + "isaac.h@gmicloud.ai": "isaachuangGMICLOUD", "nukuom976228@gmail.com": "hsy5571616", "11462216+Nan93@users.noreply.github.com": "Nan93", "l973401489@126.com": "zhouxiaoya12", From 81928f03ab5841362e526df011e3eb74159aea8b Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Fri, 8 May 2026 12:42:56 +0530 Subject: [PATCH 05/25] refactor(gmi): move User-Agent to profile.default_headers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous revision of this PR added six GMI-specific branches (`elif base_url_host_matches(..., 'api.gmi-serving.com')`) across run_agent.py and agent/auxiliary_client.py, plus a _HERMES_UA_HEADERS constant in auxiliary_client.py. ProviderProfile already has a `default_headers: dict[str, str]` field commented as 'Client-level quirks (set once at client construction)'. Other plugins (ai-gateway, kimi-coding) already use it. Two of the four auxiliary_client sites we previously patched already had a generic `else: profile.default_headers` fallback that picked it up (so did both run_agent sites). This revision: * Sets `default_headers={'User-Agent': 'HermesAgent/'}` on the GMI profile in plugins/model-providers/gmi/__init__.py. * Reverts all six GMI-specific branches in run_agent.py and auxiliary_client.py. * Adds the generic profile-fallback `else` block to the two auxiliary_client sites (`_to_async_client`, `resolve_provider_client`) that didn't have it yet. This benefits every provider whose profile declares default_headers, not just GMI — e.g. Vercel AI Gateway's HTTP-Referer/X-Title now flow through the async client path too. * Replaces the GMI-specific URL-branch tests with a profile-level assertion and keeps the run_agent integration test (with `provider='gmi'` so the fallback picks up the profile). Net diff vs main: +82/-0 across 5 files, touching only the GMI plugin, two generic fallback blocks in auxiliary_client.py, AUTHOR_MAP, and tests. No core files change. Based on #20907 by @isaachuangGMICLOUD. --- agent/auxiliary_client.py | 36 +++++++++++++++++++ plugins/model-providers/gmi/__init__.py | 5 +++ tests/hermes_cli/test_gmi_provider.py | 16 +++++++++ .../test_provider_attribution_headers.py | 25 +++++++++++++ 4 files changed, 82 insertions(+) diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index bd4e6be4579..00f461e77ef 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -2141,6 +2141,20 @@ def _to_async_client(sync_client, model: str, is_vision: bool = False): ) elif base_url_host_matches(sync_base_url, "api.kimi.com"): async_kwargs["default_headers"] = {"User-Agent": "claude-code/0.1.0"} + else: + # Fall back to profile.default_headers for providers that declare + # client-level headers on their ProviderProfile (e.g. attribution + # User-Agent strings). Provider is inferred from the hostname. + try: + from agent.model_metadata import _infer_provider_from_url + from providers import get_provider_profile as _gpf_async + _inferred = _infer_provider_from_url(sync_base_url) + if _inferred: + _ph_async = _gpf_async(_inferred) + if _ph_async and _ph_async.default_headers: + async_kwargs["default_headers"] = dict(_ph_async.default_headers) + except Exception: + pass return AsyncOpenAI(**async_kwargs), model @@ -2368,6 +2382,16 @@ def resolve_provider_client( extra["default_headers"] = copilot_request_headers( is_agent_turn=True, is_vision=is_vision ) + else: + # Fall back to profile.default_headers for providers that + # declare client-level attribution headers on their profile. + try: + from providers import get_provider_profile as _gpf_custom + _ph_custom = _gpf_custom(provider) + if _ph_custom and _ph_custom.default_headers: + extra["default_headers"] = dict(_ph_custom.default_headers) + except Exception: + pass client = OpenAI(api_key=custom_key, base_url=_clean_base, **extra) client = _wrap_if_needed(client, final_model, custom_base, custom_key) return (_to_async_client(client, final_model, is_vision=is_vision) if async_mode @@ -2556,6 +2580,18 @@ def resolve_provider_client( headers.update(copilot_request_headers( is_agent_turn=True, is_vision=is_vision )) + else: + # Fall back to profile.default_headers for providers that declare + # client-level attribution headers on their profile (e.g. GMI + # User-Agent for traffic identification, Vercel AI Gateway + # Referer/Title for analytics). + try: + from providers import get_provider_profile as _gpf_main + _ph_main = _gpf_main(provider) + if _ph_main and _ph_main.default_headers: + headers.update(_ph_main.default_headers) + except Exception: + pass client = OpenAI(api_key=api_key, base_url=base_url, **({"default_headers": headers} if headers else {})) diff --git a/plugins/model-providers/gmi/__init__.py b/plugins/model-providers/gmi/__init__.py index a7cc32e552f..fb022070803 100644 --- a/plugins/model-providers/gmi/__init__.py +++ b/plugins/model-providers/gmi/__init__.py @@ -1,5 +1,6 @@ """GMI Cloud provider profile.""" +from hermes_cli import __version__ as _HERMES_VERSION from providers import register_provider from providers.base import ProviderProfile @@ -12,6 +13,10 @@ gmi = ProviderProfile( env_vars=("GMI_API_KEY", "GMI_BASE_URL"), base_url="https://api.gmi-serving.com/v1", auth_type="api_key", + # Attribution so GMI can identify traffic from Hermes Agent. + # The generic profile.default_headers fallback in run_agent.py and + # agent/auxiliary_client.py picks this up at client construction time. + default_headers={"User-Agent": f"HermesAgent/{_HERMES_VERSION}"}, default_aux_model="google/gemini-3.1-flash-lite-preview", fallback_models=( "zai-org/GLM-5.1-FP8", diff --git a/tests/hermes_cli/test_gmi_provider.py b/tests/hermes_cli/test_gmi_provider.py index 0b9363e6753..06863b66826 100644 --- a/tests/hermes_cli/test_gmi_provider.py +++ b/tests/hermes_cli/test_gmi_provider.py @@ -284,6 +284,22 @@ class TestGmiAuxiliary: assert model == "google/gemini-3.1-flash-lite-preview" assert mock_openai.call_args.kwargs["api_key"] == "gmi-test-key" assert mock_openai.call_args.kwargs["base_url"] == "https://api.gmi-serving.com/v1" + # GMI profile declares default_headers with a HermesAgent User-Agent + # for traffic attribution. The generic profile-fallback branch in + # resolve_provider_client should carry it through to the OpenAI client. + headers = mock_openai.call_args.kwargs.get("default_headers", {}) + assert headers.get("User-Agent", "").startswith("HermesAgent/") + + def test_gmi_profile_declares_hermes_user_agent(self): + """The GMI plugin sets a HermesAgent/ User-Agent on its profile.""" + from providers import get_provider_profile + + profile = get_provider_profile("gmi") + assert profile is not None + ua = profile.default_headers.get("User-Agent", "") + assert ua.startswith("HermesAgent/"), ( + f"expected GMI profile User-Agent to start with 'HermesAgent/', got {ua!r}" + ) def test_resolve_provider_client_accepts_gmi_alias(self, monkeypatch): monkeypatch.setenv("GMI_API_KEY", "gmi-test-key") diff --git a/tests/run_agent/test_provider_attribution_headers.py b/tests/run_agent/test_provider_attribution_headers.py index 673a906cfbc..2a1d9088c46 100644 --- a/tests/run_agent/test_provider_attribution_headers.py +++ b/tests/run_agent/test_provider_attribution_headers.py @@ -65,6 +65,31 @@ def test_routermint_base_url_applies_user_agent_header(mock_openai): assert headers["User-Agent"].startswith("HermesAgent/") +@patch("run_agent.OpenAI") +def test_gmi_base_url_picks_up_profile_user_agent(mock_openai): + """GMI declares User-Agent on its ProviderProfile.default_headers. + + The ``_apply_client_headers_for_base_url`` else-branch looks up the + provider profile and applies its default_headers, so no GMI-specific + branch is needed in run_agent. + """ + mock_openai.return_value = MagicMock() + agent = AIAgent( + api_key="test-key", + base_url="https://api.gmi-serving.com/v1", + model="test/model", + provider="gmi", + quiet_mode=True, + skip_context_files=True, + skip_memory=True, + ) + + agent._apply_client_headers_for_base_url("https://api.gmi-serving.com/v1") + + headers = agent._client_kwargs["default_headers"] + assert headers["User-Agent"].startswith("HermesAgent/") + + @patch("run_agent.OpenAI") def test_unknown_base_url_clears_default_headers(mock_openai): mock_openai.return_value = MagicMock() From 486b14b423e85120691e445df7bfc57f093459a0 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Fri, 8 May 2026 04:17:21 -0700 Subject: [PATCH 06/25] =?UTF-8?q?feat(cron):=20routing=20intent=20?= =?UTF-8?q?=E2=80=94=20deliver=3Dall=20fans=20out=20to=20every=20connected?= =?UTF-8?q?=20channel=20(#21495)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds one reserved token to the cron `deliver` field: - `all` — expand to every platform with a configured home channel Resolves at fire time, not create time, so a job created before Telegram was wired up picks it up once `TELEGRAM_HOME_CHANNEL` is set. Composes with existing targets: `origin,all`, `all,telegram:-100:17`. Inspired by Vellum Assistant's reminder routing-intent system. ## Changes - cron/scheduler.py: _expand_routing_tokens + integrate into _resolve_delivery_targets - tools/cronjob_tools.py: schema description updated - tests/cron/test_scheduler.py: TestRoutingIntents (5 cases) - website/docs/user-guide/features/cron.md: docs + table rows ## Validation - tests/cron/test_scheduler.py -k 'Routing or Deliver' → 57 passed --- cron/scheduler.py | 44 +++++++++++- tests/cron/test_scheduler.py | 89 ++++++++++++++++++++++++ tools/cronjob_tools.py | 2 +- website/docs/user-guide/features/cron.md | 11 +++ 4 files changed, 143 insertions(+), 3 deletions(-) diff --git a/cron/scheduler.py b/cron/scheduler.py index 97d0567300e..0eccd458ff1 100644 --- a/cron/scheduler.py +++ b/cron/scheduler.py @@ -360,12 +360,52 @@ def _normalize_deliver_value(deliver) -> str: return str(deliver) +# Routing intent tokens — resolved at fire time, not create time, so a +# job created before Telegram was wired up will pick up Telegram once it +# comes online. ``all`` expands into the set of connected platforms +# (those with a configured home chat_id) in _expand_routing_tokens. +_ROUTING_TOKENS = frozenset({"all"}) + + +def _expand_routing_tokens(part: str) -> List[str]: + """Expand a routing-intent token to concrete platform names. + + ``all`` expands to every platform in ``_iter_home_target_platforms()`` + that has a configured home chat_id right now. Unknown / non-token + values pass through unchanged as a single-element list, so the caller + can treat every token uniformly. + """ + token = part.lower() + if token not in _ROUTING_TOKENS: + return [part] + expanded: List[str] = [] + for platform_name in _iter_home_target_platforms(): + if _get_home_target_chat_id(platform_name): + expanded.append(platform_name) + return expanded + + def _resolve_delivery_targets(job: dict) -> List[dict]: - """Resolve all concrete auto-delivery targets for a cron job (supports comma-separated deliver).""" + """Resolve all concrete auto-delivery targets for a cron job. + + Accepts the legacy comma-separated ``deliver`` string plus the + ``all`` routing-intent token, which expands to every platform with + a configured home channel. Tokens may be combined with explicit + targets: ``origin,all`` and ``all,telegram:-100:17`` both work. + Duplicate (platform, chat_id, thread_id) tuples are collapsed by the + existing dedup pass. + """ deliver = _normalize_deliver_value(job.get("deliver", "local")) if deliver == "local": return [] - parts = [p.strip() for p in deliver.split(",") if p.strip()] + + raw_parts = [p.strip() for p in deliver.split(",") if p.strip()] + + # Expand routing intents. + parts: List[str] = [] + for raw in raw_parts: + parts.extend(_expand_routing_tokens(raw)) + seen = set() targets = [] for part in parts: diff --git a/tests/cron/test_scheduler.py b/tests/cron/test_scheduler.py index 2182a1b17dc..ce213a9f396 100644 --- a/tests/cron/test_scheduler.py +++ b/tests/cron/test_scheduler.py @@ -351,6 +351,95 @@ class TestResolveDeliveryTarget: assert _resolve_delivery_targets({"deliver": []}) == [] +class TestRoutingIntents: + """``all`` routing intent expands at fire time.""" + + def test_all_expands_to_every_connected_home_channel(self, monkeypatch): + """deliver='all' fans out to every platform with a configured home channel.""" + from cron.scheduler import _resolve_delivery_targets + + monkeypatch.setenv("TELEGRAM_HOME_CHANNEL", "-111") + monkeypatch.setenv("DISCORD_HOME_CHANNEL", "-222") + monkeypatch.setenv("SLACK_HOME_CHANNEL", "C333") + # Sanity: platforms without the env var must NOT appear in the expansion. + monkeypatch.delenv("SIGNAL_HOME_CHANNEL", raising=False) + monkeypatch.delenv("MATRIX_HOME_ROOM", raising=False) + + targets = _resolve_delivery_targets({"deliver": "all", "origin": None}) + platforms = sorted(t["platform"] for t in targets) + + assert "telegram" in platforms + assert "discord" in platforms + assert "slack" in platforms + assert "signal" not in platforms + assert "matrix" not in platforms + + def test_all_combines_with_explicit_target_and_dedups(self, monkeypatch): + """'telegram:-999,all' yields every home channel + the explicit target without dupes.""" + from cron.scheduler import _resolve_delivery_targets + + monkeypatch.setenv("TELEGRAM_HOME_CHANNEL", "-111") + monkeypatch.setenv("DISCORD_HOME_CHANNEL", "-222") + + # Explicit telegram target precedes 'all'. Expansion adds discord; + # the dedup pass collapses any (platform, chat_id, thread_id) repeats. + job = {"deliver": "telegram:-999,all", "origin": None} + targets = _resolve_delivery_targets(job) + + platforms = sorted(t["platform"].lower() for t in targets) + assert "telegram" in platforms + assert "discord" in platforms + # Every target is unique on (platform, chat_id, thread_id). + keys = [(t["platform"].lower(), str(t["chat_id"]), t.get("thread_id")) for t in targets] + assert len(keys) == len(set(keys)) + + def test_all_with_no_connected_channels_returns_empty(self, monkeypatch): + """deliver='all' with nothing connected returns [] — delivery is recorded as failed upstream.""" + from cron.scheduler import _resolve_delivery_targets + + for var in ("TELEGRAM_HOME_CHANNEL", "DISCORD_HOME_CHANNEL", "SLACK_HOME_CHANNEL", + "SIGNAL_HOME_CHANNEL", "MATRIX_HOME_ROOM", "MATTERMOST_HOME_CHANNEL", + "SMS_HOME_CHANNEL", "EMAIL_HOME_ADDRESS", "DINGTALK_HOME_CHANNEL", + "FEISHU_HOME_CHANNEL", "WECOM_HOME_CHANNEL", "WEIXIN_HOME_CHANNEL", + "BLUEBUBBLES_HOME_CHANNEL", "QQBOT_HOME_CHANNEL", "QQ_HOME_CHANNEL"): + monkeypatch.delenv(var, raising=False) + + assert _resolve_delivery_targets({"deliver": "all", "origin": None}) == [] + + def test_origin_comma_all_preserves_origin_first(self, monkeypatch): + """'origin,all' delivers to the origin platform plus every other home channel.""" + from cron.scheduler import _resolve_delivery_targets + + monkeypatch.setenv("TELEGRAM_HOME_CHANNEL", "-111") + monkeypatch.setenv("DISCORD_HOME_CHANNEL", "-222") + + job = { + "deliver": "origin,all", + "origin": {"platform": "discord", "chat_id": "888"}, + } + targets = _resolve_delivery_targets(job) + platforms = sorted(t["platform"].lower() for t in targets) + assert "telegram" in platforms + assert "discord" in platforms + + # The origin's explicit chat_id (888) wins the dedup race over the + # discord home channel (-222) because origin is resolved first. + discord = next(t for t in targets if t["platform"].lower() == "discord") + assert discord["chat_id"] == "888" + + def test_all_token_case_insensitive(self, monkeypatch): + """'ALL' / 'All' / 'all' are all recognized.""" + from cron.scheduler import _resolve_delivery_targets + + monkeypatch.setenv("TELEGRAM_HOME_CHANNEL", "-111") + monkeypatch.setenv("DISCORD_HOME_CHANNEL", "-222") + + for token in ("ALL", "All", "all"): + targets = _resolve_delivery_targets({"deliver": token, "origin": None}) + platforms = sorted(t["platform"].lower() for t in targets) + assert platforms == ["discord", "telegram"], f"token={token!r} -> {platforms}" + + class TestDeliverResultWrapping: """Verify that cron deliveries are wrapped with header/footer and no longer mirrored.""" diff --git a/tools/cronjob_tools.py b/tools/cronjob_tools.py index 5e9ffa51ead..b4cc4f69ecc 100644 --- a/tools/cronjob_tools.py +++ b/tools/cronjob_tools.py @@ -541,7 +541,7 @@ Important safety rule: cron-run sessions should not recursively schedule more cr }, "deliver": { "type": "string", - "description": "Omit this parameter to auto-deliver back to the current chat and topic (recommended). Auto-detection preserves thread/topic context. Only set explicitly when the user asks to deliver somewhere OTHER than the current conversation. Values: 'origin' (same as omitting), 'local' (no delivery, save only), or platform:chat_id:thread_id for a specific destination. Examples: 'telegram:-1001234567890:17585', 'discord:#engineering', 'sms:+15551234567'. WARNING: 'platform:chat_id' without :thread_id loses topic targeting." + "description": "Omit this parameter to auto-deliver back to the current chat and topic (recommended). Auto-detection preserves thread/topic context. Only set explicitly when the user asks to deliver somewhere OTHER than the current conversation. Values: 'origin' (same as omitting), 'local' (no delivery, save only), 'all' (fan out to every connected home channel), or platform:chat_id:thread_id for a specific destination. Combine with comma: 'origin,all' delivers to the origin plus every other connected channel. Examples: 'telegram:-1001234567890:17585', 'discord:#engineering', 'sms:+15551234567', 'all'. WARNING: 'platform:chat_id' without :thread_id loses topic targeting. 'all' resolves at fire time, so a job created before a channel was wired up will pick it up automatically once connected." }, "skills": { "type": "array", diff --git a/website/docs/user-guide/features/cron.md b/website/docs/user-guide/features/cron.md index f02b13934f9..c2c67df8a2a 100644 --- a/website/docs/user-guide/features/cron.md +++ b/website/docs/user-guide/features/cron.md @@ -240,9 +240,20 @@ When scheduling jobs, you specify where the output goes: | `"weixin"` | Weixin (WeChat) | | | `"bluebubbles"` | BlueBubbles (iMessage) | | | `"qqbot"` | QQ Bot (Tencent QQ) | | +| `"all"` | Fan out to every connected home channel | Resolved at fire time | +| `"telegram,discord"` | Fan out to a specific set of channels | Comma-separated list | +| `"origin,all"` | Deliver to the origin **plus** every other connected channel | Combine any tokens | The agent's final response is automatically delivered. You do not need to call `send_message` in the cron prompt. +### Routing intent (`all`) + +`all` lets you ship one cron job to every messaging channel you have configured, without having to enumerate them by name. It is **resolved at fire time**, so a job created before you wired up Telegram will pick up Telegram on the next tick after you set `TELEGRAM_HOME_CHANNEL`. + +Semantics: `all` expands to every platform with a configured home channel. Zero is fine; the job simply produces no delivery targets and is recorded as a delivery failure upstream. + +`all` composes with explicit targets. `origin,all` delivers to the origin chat *plus* every other connected home channel, de-duplicating by `(platform, chat_id, thread_id)`. + ### Response wrapping By default, delivered cron output is wrapped with a header and footer so the recipient knows it came from a scheduled task: From b32461f6e864dddcd9c7e0a8976b4e4ca50616db Mon Sep 17 00:00:00 2001 From: Shannon Sands Date: Fri, 8 May 2026 10:24:34 +1000 Subject: [PATCH 07/25] fix(auth): send Nous refresh token via header --- hermes_cli/auth.py | 2 +- tests/hermes_cli/test_auth_nous_provider.py | 40 ++++++++++++++++++++- 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index 3fa726d6a7e..425ffb6f25e 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -3117,10 +3117,10 @@ def _refresh_access_token( ) -> Dict[str, Any]: response = client.post( f"{portal_base_url}/api/oauth/token", + headers={"x-nous-refresh-token": refresh_token}, data={ "grant_type": "refresh_token", "client_id": client_id, - "refresh_token": refresh_token, }, ) diff --git a/tests/hermes_cli/test_auth_nous_provider.py b/tests/hermes_cli/test_auth_nous_provider.py index 136265c7e48..1af26cc9ff6 100644 --- a/tests/hermes_cli/test_auth_nous_provider.py +++ b/tests/hermes_cli/test_auth_nous_provider.py @@ -1,7 +1,6 @@ """Regression tests for Nous OAuth refresh + agent-key mint interactions.""" import json -import os from datetime import datetime, timezone from pathlib import Path @@ -862,6 +861,45 @@ def test_refresh_token_reuse_detection_surfaces_actionable_message(): assert exc_info.value.relogin_required is True +def test_refresh_token_exchange_sends_refresh_token_header(): + """Nous refresh tokens must be sent in a header so sandbox proxies can + substitute placeholder credentials without parsing form bodies. + """ + from hermes_cli.auth import _refresh_access_token + + class _FakeResponse: + status_code = 200 + + def json(self): + return {"access_token": "access-2", "refresh_token": "refresh-2"} + + class _FakeClient: + def __init__(self): + self.kwargs = None + + def post(self, *args, **kwargs): + del args + self.kwargs = kwargs + return _FakeResponse() + + client = _FakeClient() + + payload = _refresh_access_token( + client=client, + portal_base_url="https://portal.nousresearch.com", + client_id="hermes-cli", + refresh_token="refresh-1", + ) + + assert payload["access_token"] == "access-2" + assert client.kwargs is not None + assert client.kwargs["headers"]["x-nous-refresh-token"] == "refresh-1" + assert client.kwargs["data"] == { + "grant_type": "refresh_token", + "client_id": "hermes-cli", + } + + def test_refresh_non_reuse_error_keeps_original_description(): """Non-reuse invalid_grant errors must keep their original description untouched. From 80775d758562821c4bd5ad6e2f26afa3d5223d5d Mon Sep 17 00:00:00 2001 From: Shannon Sands Date: Fri, 8 May 2026 12:04:31 +1000 Subject: [PATCH 08/25] test(auth): assert Nous refresh rotation payload --- tests/hermes_cli/test_auth_nous_provider.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/hermes_cli/test_auth_nous_provider.py b/tests/hermes_cli/test_auth_nous_provider.py index 1af26cc9ff6..bd6098d3746 100644 --- a/tests/hermes_cli/test_auth_nous_provider.py +++ b/tests/hermes_cli/test_auth_nous_provider.py @@ -892,6 +892,7 @@ def test_refresh_token_exchange_sends_refresh_token_header(): ) assert payload["access_token"] == "access-2" + assert payload["refresh_token"] == "refresh-2" assert client.kwargs is not None assert client.kwargs["headers"]["x-nous-refresh-token"] == "refresh-1" assert client.kwargs["data"] == { From 5fa493a2ca6a5899acc40026283d3f47303f5937 Mon Sep 17 00:00:00 2001 From: ygd58 Date: Mon, 4 May 2026 11:42:44 +0200 Subject: [PATCH 09/25] fix(google-workspace): detect disabled_client in --check and add --check-live setup.py --check only validated token shape/expiry but did not detect when Google had disabled the OAuth client or account. Users got AUTHENTICATED even when actual API calls failed with disabled_client. Changes: - Catch disabled_client and invalid_client in check_auth() refresh path with actionable guidance (check Cloud Console, check account status, do not retry) - Add check_auth_live() that performs a real Calendar API call to detect disabled_client errors that survive token refresh - Add --check-live CLI flag backed by check_auth_live() Fixes #19570 --- .../google-workspace/scripts/setup.py | 44 ++++++++++++++++++- 1 file changed, 43 insertions(+), 1 deletion(-) diff --git a/skills/productivity/google-workspace/scripts/setup.py b/skills/productivity/google-workspace/scripts/setup.py index ac48b65c7cf..e80b7a2e81a 100644 --- a/skills/productivity/google-workspace/scripts/setup.py +++ b/skills/productivity/google-workspace/scripts/setup.py @@ -130,6 +130,31 @@ def _ensure_deps(): sys.exit(1) +def check_auth_live(): + """Check auth with a real API call to detect disabled_client/account issues."""" + if not check_auth(): + return False + _ensure_deps() + try: + from googleapiclient.discovery import build + from google.oauth2.credentials import Credentials + creds = Credentials.from_authorized_user_file(str(TOKEN_PATH)) + service = build("calendar", "v3", credentials=creds) + service.calendarList().list(maxResults=1).execute() + print("LIVE_CHECK_OK: Real API call succeeded.") + return True + except Exception as e: + err_str = str(e).lower() + if "disabled_client" in err_str or "invalid_client" in err_str: + print(f"LIVE_CHECK_FAILED: OAuth client or account disabled: {e}") + print(" 1. Check Google Cloud Console for disabled OAuth client") + print(" 2. Check myaccount.google.com for account status") + print(" 3. Do NOT retry with a disabled account") + else: + print(f"LIVE_CHECK_FAILED: {e}") + return False + + def check_auth(): """Check if stored credentials are valid. Prints status, exits 0 or 1.""" if not TOKEN_PATH.exists(): @@ -177,7 +202,21 @@ def check_auth(): print(f"AUTHENTICATED: Token refreshed at {TOKEN_PATH}") return True except Exception as e: - print(f"REFRESH_FAILED: {e}") + err_str = str(e).lower() + if "disabled_client" in err_str or "invalid_client" in err_str: + print(f"OAUTH_CLIENT_DISABLED: {e}") + print(" The OAuth client or Google account has been disabled.") + print(" Steps to resolve:") + print(" 1. Check your Google Cloud Console — verify the OAuth client is not disabled") + print(" 2. Check if your Google account itself has been disabled at myaccount.google.com") + print(" 3. If the account is disabled, you can appeal at accounts.google.com/signin/recovery") + print(" 4. Do NOT retry API calls with a disabled account — this may worsen the situation") + print(" 5. If the OAuth client is disabled, create a new one in Google Cloud Console") + elif "token_revoked" in err_str or "invalid_grant" in err_str: + print(f"TOKEN_REVOKED: {e}") + print(" Re-run setup to re-authenticate.") + else: + print(f"REFRESH_FAILED: {e}") return False print("TOKEN_INVALID: Re-run setup.") @@ -384,6 +423,7 @@ def main(): parser = argparse.ArgumentParser(description="Google Workspace OAuth setup for Hermes") group = parser.add_mutually_exclusive_group(required=True) group.add_argument("--check", action="store_true", help="Check if auth is valid (exit 0=yes, 1=no)") + group.add_argument("--check-live", action="store_true", help="Check auth with a real API call (detects disabled_client)") group.add_argument("--client-secret", metavar="PATH", help="Store OAuth client_secret.json") group.add_argument("--auth-url", action="store_true", help="Print OAuth URL for user to visit") group.add_argument("--auth-code", metavar="CODE", help="Exchange auth code for token") @@ -393,6 +433,8 @@ def main(): if args.check: sys.exit(0 if check_auth() else 1) + if getattr(args, "check_live", False): + sys.exit(0 if check_auth_live() else 1) elif args.client_secret: store_client_secret(args.client_secret) elif args.auth_url: From 617ac0535b191998b96979a48c7df2268670087c Mon Sep 17 00:00:00 2001 From: ygd58 Date: Mon, 4 May 2026 11:59:30 +0200 Subject: [PATCH 10/25] fix: correct docstring syntax error in check_auth_live --- skills/productivity/google-workspace/scripts/setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/skills/productivity/google-workspace/scripts/setup.py b/skills/productivity/google-workspace/scripts/setup.py index e80b7a2e81a..8504d180cd6 100644 --- a/skills/productivity/google-workspace/scripts/setup.py +++ b/skills/productivity/google-workspace/scripts/setup.py @@ -131,7 +131,7 @@ def _ensure_deps(): def check_auth_live(): - """Check auth with a real API call to detect disabled_client/account issues."""" + """Check auth with a real API call to detect disabled_client/account issues.""" if not check_auth(): return False _ensure_deps() From 83c23e88617c97ab5d3663ee8895eeda258a1eb9 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Fri, 8 May 2026 04:20:00 -0700 Subject: [PATCH 11/25] fix(google-workspace): cleanup for --check-live salvage Small follow-ups on top of #19643: - check_auth() takes quiet kwarg to suppress its AUTHENTICATED print when called from check_auth_live(), so the final status line reflects the live-call outcome only. - Drop redundant _ensure_deps() call in check_auth_live() (check_auth() already calls it). - Add AUTHOR_MAP entry for ygd58 so release attribution script works. --- scripts/release.py | 1 + .../productivity/google-workspace/scripts/setup.py | 13 ++++++++----- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/scripts/release.py b/scripts/release.py index 47b65078dde..c5ceac0a9f5 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -47,6 +47,7 @@ AUTHOR_MAP = { "qiyin.zuo@pcitc.com": "qiyin-code", "oleksii.lisikh@gmail.com": "olisikh", "leone.parise@gmail.com": "leoneparise", + "buraysandro9@gmail.com": "ygd58", "teknium@nousresearch.com": "teknium1", "piyushvp1@gmail.com": "thelumiereguy", "harish.kukreja@gmail.com": "counterposition", diff --git a/skills/productivity/google-workspace/scripts/setup.py b/skills/productivity/google-workspace/scripts/setup.py index 8504d180cd6..8d798f8a67b 100644 --- a/skills/productivity/google-workspace/scripts/setup.py +++ b/skills/productivity/google-workspace/scripts/setup.py @@ -132,9 +132,10 @@ def _ensure_deps(): def check_auth_live(): """Check auth with a real API call to detect disabled_client/account issues.""" - if not check_auth(): + # 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 - _ensure_deps() try: from googleapiclient.discovery import build from google.oauth2.credentials import Credentials @@ -155,7 +156,7 @@ def check_auth_live(): return False -def check_auth(): +def check_auth(quiet: bool = False): """Check if stored credentials are valid. Prints status, exits 0 or 1.""" if not TOKEN_PATH.exists(): print(f"NOT_AUTHENTICATED: No token at {TOKEN_PATH}") @@ -182,7 +183,8 @@ def check_auth(): print(f"AUTHENTICATED (partial): Token valid but missing {len(missing_scopes)} scopes:") for s in missing_scopes: print(f" - {s}") - print(f"AUTHENTICATED: Token valid at {TOKEN_PATH}") + if not quiet: + print(f"AUTHENTICATED: Token valid at {TOKEN_PATH}") return True if creds.expired and creds.refresh_token: @@ -199,7 +201,8 @@ def check_auth(): print(f"AUTHENTICATED (partial): Token refreshed but missing {len(missing_scopes)} scopes:") for s in missing_scopes: print(f" - {s}") - print(f"AUTHENTICATED: Token refreshed at {TOKEN_PATH}") + if not quiet: + print(f"AUTHENTICATED: Token refreshed at {TOKEN_PATH}") return True except Exception as e: err_str = str(e).lower() From 7190e20e0b84c581fe182b5038ade7483482e69e Mon Sep 17 00:00:00 2001 From: Siddharth Balyan <52913345+alt-glitch@users.noreply.github.com> Date: Fri, 8 May 2026 17:36:38 +0530 Subject: [PATCH 12/25] fix: include terminal backend in quick setup wizard (#21842) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The quick setup flow (recommended for first-time users) silently defaulted terminal.backend to 'local' without ever presenting the choice. This meant new users who wanted Docker, SSH, Modal, Daytona, or any other backend had to know about 'hermes setup terminal' — which most wouldn't discover until later. Now the quick setup flow is: 1. Provider selection 2. API key 3. Terminal backend (local/Docker/Modal/SSH/Daytona/Vercel/Singularity) 4. Messaging platform 5. Done The terminal backend is a foundational decision (where ALL commands run) and belongs in the onboarding path alongside provider selection. --- hermes_cli/setup.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/hermes_cli/setup.py b/hermes_cli/setup.py index f5b8b6c160f..d39df8b3b10 100644 --- a/hermes_cli/setup.py +++ b/hermes_cli/setup.py @@ -3240,22 +3240,23 @@ def _offer_launch_chat(): def _run_first_time_quick_setup(config: dict, hermes_home, is_existing: bool): - """Streamlined first-time setup: provider + model only. + """Streamlined first-time setup: provider, model, terminal & messaging. - Applies sensible defaults for TTS (Edge), terminal (local), agent - settings, and tools — the user can customize later via - ``hermes setup
``. + Applies sensible defaults for TTS (Edge), agent settings, and tools — + the user can customize later via ``hermes setup
``. """ # Step 1: Model & Provider (essential — skips rotation/vision/TTS) setup_model_provider(config, quick=True) - # Step 2: Apply defaults for everything else + # Step 2: Terminal Backend — where commands run is a core decision + setup_terminal_backend(config) + + # Step 3: Apply defaults for everything else _apply_default_agent_settings(config) - config.setdefault("terminal", {}).setdefault("backend", "local") save_config(config) - # Step 3: Offer messaging gateway setup + # Step 4: Offer messaging gateway setup print() gateway_choice = prompt_choice( "Connect a messaging platform? (Telegram, Discord, etc.)", From 42f9234da34e59e456240cb3ddb8bad1995427a4 Mon Sep 17 00:00:00 2001 From: brooklyn! Date: Fri, 8 May 2026 05:12:09 -0700 Subject: [PATCH 13/25] feat(tui): segment turns with rule above non-first user msgs; trim ticker dead space (#21846) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Multi-turn transcripts ran together visually because every user message got the same vertical rhythm regardless of position. Adds a short ─── in the border colour above every user message after the first, so each turn reads as its own block. Height estimator gains a `withSeparator` flag so virtual scrolling pre-allocates the extra two rows (rule + top margin) and avoids a jump on first measurement. While in the area: the busy-indicator duration was padded with `padStart(7)`, leaving five visible spaces between `·` and the digits (`⠋ · 2s`) — especially loud under the verb-less `unicode` style. Drop the padding entirely (`⠋ · 2s`); the model label now shifts a few columns as the duration grows, which is the right trade-off for the minimal indicator styles. The verb-padding test stays; the duration-padding test is removed alongside the function it covered. --- ui-tui/src/__tests__/statusBarTicker.test.ts | 11 +---------- ui-tui/src/__tests__/virtualHeights.test.ts | 8 ++++++++ ui-tui/src/app/useMainApp.ts | 10 ++++++++-- ui-tui/src/components/appChrome.tsx | 4 +--- ui-tui/src/components/appLayout.tsx | 15 +++++++++++++++ ui-tui/src/lib/virtualHeights.ts | 18 ++++++++++++++++-- 6 files changed, 49 insertions(+), 17 deletions(-) diff --git a/ui-tui/src/__tests__/statusBarTicker.test.ts b/ui-tui/src/__tests__/statusBarTicker.test.ts index 6dff476ba0a..4f3369bfa33 100644 --- a/ui-tui/src/__tests__/statusBarTicker.test.ts +++ b/ui-tui/src/__tests__/statusBarTicker.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' -import { DURATION_PAD_LEN, padTickerDuration, padVerb, VERB_PAD_LEN } from '../components/appChrome.js' +import { padVerb, VERB_PAD_LEN } from '../components/appChrome.js' import { VERBS } from '../content/verbs.js' describe('FaceTicker verb padding', () => { @@ -16,12 +16,3 @@ describe('FaceTicker verb padding', () => { } }) }) - -describe('FaceTicker duration padding', () => { - it('keeps elapsed segment width stable across second/minute boundaries', () => { - const samples = [9000, 10000, 59000, 60000, 61000, 3599000] - const lens = samples.map(ms => padTickerDuration(ms).length) - - expect(new Set(lens)).toEqual(new Set([DURATION_PAD_LEN])) - }) -}) diff --git a/ui-tui/src/__tests__/virtualHeights.test.ts b/ui-tui/src/__tests__/virtualHeights.test.ts index f407976db35..ee60286297e 100644 --- a/ui-tui/src/__tests__/virtualHeights.test.ts +++ b/ui-tui/src/__tests__/virtualHeights.test.ts @@ -31,4 +31,12 @@ describe('virtual height estimates', () => { estimatedMsgHeight(msg, 80, { compact: false, details: false }) ) }) + + it('reserves two extra rows for the inter-turn separator on non-first user messages', () => { + const msg: Msg = { role: 'user', text: 'follow-up question' } + const base = estimatedMsgHeight(msg, 80, { compact: false, details: false }) + const withSep = estimatedMsgHeight(msg, 80, { compact: false, details: false, withSeparator: true }) + + expect(withSep).toBe(base + 2) + }) }) diff --git a/ui-tui/src/app/useMainApp.ts b/ui-tui/src/app/useMainApp.ts index 874eca50a21..648cc1b69a0 100644 --- a/ui-tui/src/app/useMainApp.ts +++ b/ui-tui/src/app/useMainApp.ts @@ -264,15 +264,21 @@ export function useMainApp(gw: GatewayClient) { return cache }, [heightCacheKey]) + // Index of the first user-role message — separator-rendering in + // appLayout.tsx skips this row, so the height estimator must skip it + // too. -1 when no user message exists yet (no row will gate true). + const firstUserIdx = useMemo(() => virtualRows.findIndex(r => r.msg.role === 'user'), [virtualRows]) + const estimateRowHeight = useCallback( (index: number) => estimatedMsgHeight(virtualRows[index]!.msg, cols, { compact: ui.compact, details: detailsVisible, limitHistory: index < virtualRows.length - FULL_RENDER_TAIL_ITEMS, - userPrompt: ui.theme.brand.prompt + userPrompt: ui.theme.brand.prompt, + withSeparator: virtualRows[index]!.msg.role === 'user' && firstUserIdx >= 0 && index > firstUserIdx }), - [cols, detailsVisible, ui.compact, ui.theme.brand.prompt, virtualRows] + [cols, detailsVisible, firstUserIdx, ui.compact, ui.theme.brand.prompt, virtualRows] ) const syncHeightCache = useCallback( diff --git a/ui-tui/src/components/appChrome.tsx b/ui-tui/src/components/appChrome.tsx index e5724c99baa..c961f4c2731 100644 --- a/ui-tui/src/components/appChrome.tsx +++ b/ui-tui/src/components/appChrome.tsx @@ -23,9 +23,7 @@ const HEART_COLORS = ['#ff5fa2', '#ff4d6d'] // Keep verb segment width stable so status-bar content to the right doesn't // jitter when the ticker rotates between short/long verbs. export const VERB_PAD_LEN = VERBS.reduce((max, v) => Math.max(max, v.length), 0) + 1 // + ellipsis -export const DURATION_PAD_LEN = 7 // e.g. " 9s", "1m 05s", "59m 59s" export const padVerb = (verb: string) => `${verb}…`.padEnd(VERB_PAD_LEN, ' ') -export const padTickerDuration = (ms: number) => fmtDuration(ms).padStart(DURATION_PAD_LEN, ' ') // Compact alternates for the `emoji` and `ascii` indicator styles. // Each entry is a fixed-width (display-width) glyph. @@ -114,7 +112,7 @@ function FaceTicker({ color, startedAt }: { color: string; startedAt?: null | nu // verb segment is hidden (e.g. `unicode` spinner style). When the verb // IS shown, its trailing padding already provides the gap, so the extra // space is harmless. - const durationSegment = startedAt ? ` · ${padTickerDuration(now - startedAt)}` : '' + const durationSegment = startedAt ? ` · ${fmtDuration(now - startedAt)}` : '' return ( diff --git a/ui-tui/src/components/appLayout.tsx b/ui-tui/src/components/appLayout.tsx index ec60726ed3b..475ad237dc0 100644 --- a/ui-tui/src/components/appLayout.tsx +++ b/ui-tui/src/components/appLayout.tsx @@ -76,6 +76,15 @@ const TranscriptPane = memo(function TranscriptPane({ return -1 }, [transcript.historyItems]) + // Index of the first user-role message; every later user message gets a + // small dash above it so multi-turn transcripts visually segment by + // turn. -1 when no user message has been sent yet → no separator ever + // renders. + const firstUserIdx = useMemo( + () => transcript.historyItems.findIndex(m => m.role === 'user'), + [transcript.historyItems] + ) + return ( <> ( + {row.msg.role === 'user' && firstUserIdx >= 0 && row.index > firstUserIdx && ( + + ─── + + )} + {row.msg.kind === 'intro' ? ( diff --git a/ui-tui/src/lib/virtualHeights.ts b/ui-tui/src/lib/virtualHeights.ts index e9439d42dd5..9a74b929579 100644 --- a/ui-tui/src/lib/virtualHeights.ts +++ b/ui-tui/src/lib/virtualHeights.ts @@ -43,8 +43,15 @@ export const estimatedMsgHeight = ( compact, details, limitHistory = false, - userPrompt = '' - }: { compact: boolean; details: boolean; limitHistory?: boolean; userPrompt?: string } + userPrompt = '', + withSeparator = false + }: { + compact: boolean + details: boolean + limitHistory?: boolean + userPrompt?: string + withSeparator?: boolean + } ) => { if (msg.kind === 'intro') { return msg.info?.version ? 9 : 5 @@ -80,5 +87,12 @@ export const estimatedMsgHeight = ( h++ } + // Inter-turn separator above non-first user messages (1 rule row + 1 + // top-margin row). The render-side gate is in appLayout.tsx; we trust + // the caller to pass `withSeparator` only when it matches that gate. + if (withSeparator) { + h += 2 + } + return Math.max(1, h) } From f4e621f7d834fe8dc879dd4f4fbf3e14d3d986cf Mon Sep 17 00:00:00 2001 From: hekaru-agent Date: Fri, 8 May 2026 06:20:35 -0700 Subject: [PATCH 14/25] fix(cron): clean up job output dir in remove_job remove_job() deletes the job from cron/jobs.json but leaves the per-job output directory at ~/.hermes/cron/output/{job_id}/ behind. Over time this accumulates orphaned dirs that never get reclaimed. Adopted from #13510 by @hekaru-agent; the honcho RLock half of that PR was already salvaged in commit dad021745 so this lands the remaining cron cleanup hunk on its own. --- cron/jobs.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/cron/jobs.py b/cron/jobs.py index 93ad4c17fbe..566db1e6dbc 100644 --- a/cron/jobs.py +++ b/cron/jobs.py @@ -8,6 +8,7 @@ Output is saved to ~/.hermes/cron/output/{job_id}/{timestamp}.md import copy import json import logging +import shutil import tempfile import threading import os @@ -696,6 +697,10 @@ def remove_job(job_id: str) -> bool: jobs = [j for j in jobs if j["id"] != job_id] if len(jobs) < original_len: save_jobs(jobs) + # Clean up output directory to prevent orphaned dirs accumulating + job_output_dir = OUTPUT_DIR / job_id + if job_output_dir.exists(): + shutil.rmtree(job_output_dir) return True return False From 5643c297901312d817713a8cc870a28a439e3114 Mon Sep 17 00:00:00 2001 From: pefontana Date: Wed, 6 May 2026 17:56:19 -0300 Subject: [PATCH 15/25] feat(docker): bootstrap auth.json from env on first boot Lets orchestrators (e.g. an account-management service provisioning a Hermes VPS) seed an OAuth refresh credential non-interactively instead of walking the user through `hermes setup` + the device-flow login dance. Matches the existing first-boot-only pattern used for .env, config.yaml, and SOUL.md. If HERMES_AUTH_JSON_BOOTSTRAP is set and $HERMES_HOME/auth.json doesn't already exist, write the env var's contents to auth.json with mode 600. The `[ ! -f ... ]` guard is critical: it ensures that on container restart the rotated refresh token Hermes wrote back to the persistent volume is never clobbered by the now-stale value the orchestrator originally seeded. Generic name (not Nous-specific) so the feature is reusable by any future orchestrator. --- docker/entrypoint.sh | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 65386e53dd5..288ae2614bb 100755 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -81,6 +81,20 @@ if [ ! -f "$HERMES_HOME/SOUL.md" ]; then cp "$INSTALL_DIR/docker/SOUL.md" "$HERMES_HOME/SOUL.md" fi +# auth.json: bootstrap from env on first boot only. Used by orchestrators +# (e.g. provisioning a Hermes VPS from an account-management service) that +# need to seed the OAuth refresh credential non-interactively, instead of +# walking the user through `hermes setup` + the device-flow login dance. +# Subsequent token rotations write back to the same file, which lives on a +# persistent volume — so this env var is consumed exactly once at first +# boot. The `[ ! -f ... ]` guard is critical: without it, a container +# restart would clobber a rotated refresh token with the now-stale value +# the orchestrator originally seeded. +if [ ! -f "$HERMES_HOME/auth.json" ] && [ -n "$HERMES_AUTH_JSON_BOOTSTRAP" ]; then + printf '%s' "$HERMES_AUTH_JSON_BOOTSTRAP" > "$HERMES_HOME/auth.json" + chmod 600 "$HERMES_HOME/auth.json" +fi + # Sync bundled skills (manifest-based so user edits are preserved) if [ -d "$INSTALL_DIR/skills" ]; then python3 "$INSTALL_DIR/tools/skills_sync.py" From 674fad14832006bfd742c5e3183f34c24018e43a Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Fri, 8 May 2026 06:53:13 -0700 Subject: [PATCH 16/25] fix(goals): Ctrl+C during /goal loop auto-pauses the goal (#21888) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reported: Ctrl+C during an active /goal loop felt like it did nothing — the agent would interrupt the current turn, then immediately queue another continuation and keep going until the session ended or the 20-turn budget ran out. Root cause: cli.py's _maybe_continue_goal_after_turn() ran in the finally: block around self.chat(...) unconditionally. Whether the turn completed normally, got interrupted, or returned an empty string, the judge ran on whatever was in conversation_history and — because the judge is fail-open — a "continue" verdict pushed another CONTINUATION_PROMPT onto _pending_input. Ctrl+C was invisible to the hook. Fix: - chat() now captures result['interrupted'] onto self._last_turn_interrupted (resets to False at entry so early-returns don't leak prior state). - _maybe_continue_goal_after_turn() checks the flag first: on interrupt, auto-pause via mgr.pause(reason='user-interrupted (Ctrl+C)') and print a one-liner pointing the user at /goal resume or /goal clear. No judge call, no continuation enqueued. - Also added an empty-response guard that mirrors gateway/run.py's _handle_message logic (empty reply → transient failure → skip judging so we don't trip the consecutive-parse-failures backstop unnecessarily). The goal stays in the DB as paused, so /goal resume recovers it after the user has sorted out whatever made them cancel. /goal clear still works as before for a full stop. Tests: tests/cli/test_cli_goal_interrupt.py covers: - interrupted turn pauses + doesn't queue + judge is NOT called - paused goal is resumable - empty / whitespace / missing assistant reply skips judging - healthy turn still enqueues continuation / marks done - chat() resets _last_turn_interrupted at entry (anti-leak guard) All 55 existing goal tests still pass. --- cli.py | 52 ++++++- tests/cli/test_cli_goal_interrupt.py | 221 +++++++++++++++++++++++++++ 2 files changed, 272 insertions(+), 1 deletion(-) create mode 100644 tests/cli/test_cli_goal_interrupt.py diff --git a/cli.py b/cli.py index 531716885b3..c7c33bce322 100644 --- a/cli.py +++ b/cli.py @@ -2414,6 +2414,11 @@ class HermesCLI: self._agent_running = False self._pending_input = queue.Queue() self._interrupt_queue = queue.Queue() + # Tracks whether the turn that just finished was interrupted via + # Ctrl+C. Consumed by _maybe_continue_goal_after_turn so /goal loops + # don't auto-queue another continuation on top of a user-cancelled + # turn (which would make Ctrl+C feel like it did nothing). + self._last_turn_interrupted = False self._should_exit = False self._last_ctrl_c_time = 0 self._clarify_state = None @@ -7523,6 +7528,15 @@ class HermesCLI: priority and we'll re-judge after that turn). If judge says done, mark it done and tell the user. If judge says continue and we're under budget, push the continuation prompt onto the queue. + + Interrupt handling: if the turn was user-cancelled (Ctrl+C), we + AUTO-PAUSE the goal instead of judging + re-queuing. Otherwise + Ctrl+C feels like it did nothing — the judge runs on whatever + partial output landed, almost always says "continue", and the + loop keeps going. Auto-pause keeps the goal recoverable via + ``/goal resume`` once the user has sorted out what they want. + The empty-response skip mirrors the gateway guard at + ``_handle_message`` in ``gateway/run.py``. """ mgr = self._get_goal_manager() if mgr is None or not mgr.is_active(): @@ -7537,6 +7551,22 @@ class HermesCLI: except Exception: pass + # If the turn was user-interrupted (Ctrl+C), auto-pause the goal + # and bail. The judge call would almost always return "continue" + # on the partial output and immediately re-queue another turn, + # which is exactly what the user cancelled. Pausing (rather than + # silently skipping) is the observable, recoverable behavior. + if getattr(self, "_last_turn_interrupted", False): + try: + mgr.pause(reason="user-interrupted (Ctrl+C)") + except Exception as exc: + logging.debug("goal pause-on-interrupt failed: %s", exc) + _cprint( + f" {_DIM}⏸ Goal paused — turn was interrupted. " + f"Use /goal resume to continue, or /goal clear to stop.{_RST}" + ) + return + # Extract the agent's final response for this turn. last_response = "" try: @@ -7558,6 +7588,13 @@ class HermesCLI: except Exception: last_response = "" + # Skip judging on empty/whitespace-only responses. These are almost + # always transient failures (API error, empty stream) where the + # judge would say "continue" and trip the consecutive-parse-failures + # backstop unnecessarily. Mirrors the gateway guard. + if not last_response.strip(): + return + decision = mgr.evaluate_after_turn(last_response, user_initiated=True) msg = decision.get("message") or "" if msg: @@ -9432,6 +9469,12 @@ class HermesCLI: # register secure secret capture here as well. set_secret_capture_callback(self._secret_capture_callback) + # Reset the per-turn interrupt flag. Any subsequent path that + # discovers an interrupt (below, after run_conversation) will flip + # this to True. Early returns (credential refresh failure, etc.) + # leave it False, which is correct — those aren't user interrupts. + self._last_turn_interrupted = False + # Refresh provider credentials if needed (handles key rotation transparently) if not self._ensure_runtime_credentials(): return None @@ -9855,7 +9898,11 @@ class HermesCLI: # Handle interrupt - check if we were interrupted pending_message = None - if result and result.get("interrupted"): + _interrupted_this_turn = bool(result and result.get("interrupted")) + # Expose the flag for post-turn hooks (e.g. goal continuation) + # so they can skip themselves when the turn was user-cancelled. + self._last_turn_interrupted = _interrupted_this_turn + if _interrupted_this_turn: pending_message = result.get("interrupt_message") or interrupt_msg # Add indicator that we were interrupted if response and pending_message: @@ -10335,6 +10382,9 @@ class HermesCLI: self._agent_running = False self._pending_input = queue.Queue() # For normal input (commands + new queries) self._interrupt_queue = queue.Queue() # For messages typed while agent is running + # See constructor note. Mirrored here for the run() path that skips + # the earlier __init__ branch. + self._last_turn_interrupted = False self._should_exit = False self._last_ctrl_c_time = 0 # Track double Ctrl+C for force exit diff --git a/tests/cli/test_cli_goal_interrupt.py b/tests/cli/test_cli_goal_interrupt.py new file mode 100644 index 00000000000..851b87e856b --- /dev/null +++ b/tests/cli/test_cli_goal_interrupt.py @@ -0,0 +1,221 @@ +"""Tests for CLI goal-continuation interrupt handling. + +Covers: +- Ctrl+C during a /goal turn auto-pauses the goal (no more continuations). +- Empty/whitespace-only responses skip the judge (no phantom continuations). +- Clean response without interrupt still drives the judge + enqueues. + +These tests exercise ``_maybe_continue_goal_after_turn`` directly on a +minimal ``HermesCLI`` stub (pattern used elsewhere in tests/cli). +""" + +from __future__ import annotations + +import queue +import sys +import uuid +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + + +# ────────────────────────────────────────────────────────────────────── +# Fixtures +# ────────────────────────────────────────────────────────────────────── + + +@pytest.fixture +def hermes_home(tmp_path, monkeypatch): + """Isolated HERMES_HOME so SessionDB.state_meta writes stay hermetic.""" + home = tmp_path / ".hermes" + home.mkdir() + monkeypatch.setattr(Path, "home", lambda: tmp_path) + monkeypatch.setenv("HERMES_HOME", str(home)) + + # Bust the goal module's DB cache so it re-resolves HERMES_HOME each test. + from hermes_cli import goals + goals._DB_CACHE.clear() + yield home + goals._DB_CACHE.clear() + + +def _make_cli_with_goal(session_id: str, goal_text: str = "build a thing"): + """Build a minimal HermesCLI stub with an active goal wired in.""" + from cli import HermesCLI + from hermes_cli.goals import GoalManager + + cli = HermesCLI.__new__(HermesCLI) + # State the hook + helpers touch directly. + cli._pending_input = queue.Queue() + cli._last_turn_interrupted = False + cli.conversation_history = [] + # `_get_goal_manager()` reads `self.session_id` directly, not + # `self.agent.session_id`. Match the production lookup. + cli.session_id = session_id + cli.agent = MagicMock() + cli.agent.session_id = session_id + + mgr = GoalManager(session_id=session_id, default_max_turns=5) + mgr.set(goal_text) + cli._goal_manager = mgr + return cli, mgr + + +# ────────────────────────────────────────────────────────────────────── +# Tests +# ────────────────────────────────────────────────────────────────────── + + +class TestInterruptAutoPause: + def test_interrupted_turn_pauses_goal_and_skips_continuation(self, hermes_home): + """Ctrl+C mid-turn must auto-pause the goal, not queue another round.""" + sid = f"sid-interrupt-{uuid.uuid4().hex}" + cli, mgr = _make_cli_with_goal(sid) + # Simulate an interrupted turn with a partial assistant reply. + cli._last_turn_interrupted = True + cli.conversation_history = [ + {"role": "user", "content": "kickoff"}, + {"role": "assistant", "content": "starting work..."}, + ] + + # Judge MUST NOT run on an interrupted turn. If it does, we've + # regressed — fail loudly instead of silently querying a mock. + with patch("hermes_cli.goals.judge_goal") as judge_mock: + judge_mock.side_effect = AssertionError( + "judge_goal called on an interrupted turn" + ) + cli._maybe_continue_goal_after_turn() + + # Pending input must NOT contain a continuation prompt. + assert cli._pending_input.empty(), ( + "Interrupted turn should not enqueue a continuation prompt" + ) + + # Goal should be paused, not active. + state = mgr.state + assert state is not None + assert state.status == "paused" + assert "interrupt" in (state.paused_reason or "").lower() + + def test_interrupted_turn_is_resumable(self, hermes_home): + """After auto-pause from Ctrl+C, /goal resume puts it back to active.""" + sid = f"sid-resume-{uuid.uuid4().hex}" + cli, mgr = _make_cli_with_goal(sid) + cli._last_turn_interrupted = True + cli.conversation_history = [ + {"role": "assistant", "content": "partial"}, + ] + with patch("hermes_cli.goals.judge_goal"): + cli._maybe_continue_goal_after_turn() + assert mgr.state.status == "paused" + + mgr.resume() + assert mgr.state.status == "active" + + +class TestEmptyResponseSkip: + def test_empty_response_does_not_invoke_judge(self, hermes_home): + """Whitespace-only replies skip judging (transient failure guard).""" + sid = f"sid-empty-{uuid.uuid4().hex}" + cli, mgr = _make_cli_with_goal(sid) + cli._last_turn_interrupted = False + cli.conversation_history = [ + {"role": "user", "content": "go"}, + {"role": "assistant", "content": " \n\n "}, + ] + + with patch("hermes_cli.goals.judge_goal") as judge_mock: + judge_mock.side_effect = AssertionError( + "judge_goal called on an empty response" + ) + cli._maybe_continue_goal_after_turn() + + # No continuation queued; goal still active (neither paused nor done). + assert cli._pending_input.empty() + assert mgr.state.status == "active" + + def test_no_assistant_message_skipped(self, hermes_home): + """Conversation with zero assistant replies must not trip the judge.""" + sid = f"sid-noassistant-{uuid.uuid4().hex}" + cli, mgr = _make_cli_with_goal(sid) + cli._last_turn_interrupted = False + cli.conversation_history = [ + {"role": "user", "content": "go"}, + ] + + with patch("hermes_cli.goals.judge_goal") as judge_mock: + judge_mock.side_effect = AssertionError( + "judge_goal called without an assistant response" + ) + cli._maybe_continue_goal_after_turn() + + assert cli._pending_input.empty() + assert mgr.state.status == "active" + + +class TestHealthyTurnStillRuns: + def test_clean_response_enqueues_continuation_when_judge_says_continue( + self, hermes_home, + ): + """Sanity check: the hook still works in the happy path.""" + sid = f"sid-healthy-{uuid.uuid4().hex}" + cli, mgr = _make_cli_with_goal(sid) + cli._last_turn_interrupted = False + cli.conversation_history = [ + {"role": "user", "content": "go"}, + {"role": "assistant", "content": "did some work, more to do"}, + ] + + # Force the judge to say "continue" without touching the network. + with patch( + "hermes_cli.goals.judge_goal", + return_value=("continue", "needs more steps", False), + ): + cli._maybe_continue_goal_after_turn() + + # Continuation prompt must be queued. + assert not cli._pending_input.empty() + queued = cli._pending_input.get_nowait() + assert "Continuing toward your standing goal" in queued + assert mgr.state.status == "active" + + def test_clean_response_marks_done_when_judge_says_done(self, hermes_home): + sid = f"sid-done-{uuid.uuid4().hex}" + cli, mgr = _make_cli_with_goal(sid) + cli._last_turn_interrupted = False + cli.conversation_history = [ + {"role": "assistant", "content": "all finished, here's the result"}, + ] + + with patch( + "hermes_cli.goals.judge_goal", + return_value=("done", "goal satisfied", False), + ): + cli._maybe_continue_goal_after_turn() + + assert cli._pending_input.empty() + assert mgr.state.status == "done" + + +class TestInterruptFlagLifecycle: + def test_chat_resets_flag_at_entry(self, hermes_home): + """chat() must reset _last_turn_interrupted at the top of each turn. + + This guards against stale flag state: if turn N was interrupted and + turn N+1 runs clean, the hook must not see True from N. + """ + # We can't run chat() end-to-end here, but we can assert the reset + # is the first thing after the secret-capture registration by + # inspecting the source shape. + from cli import HermesCLI + import inspect + + src = inspect.getsource(HermesCLI.chat) + # Look for an explicit reset near the top of chat(). + head = src.split("if not self._ensure_runtime_credentials", 1)[0] + assert "self._last_turn_interrupted = False" in head, ( + "chat() must reset _last_turn_interrupted before run_conversation " + "runs — otherwise a prior turn's interrupt state leaks into the " + "next turn's goal hook decision." + ) From e43d2fe5205ef3a2027924f14380a6af08bda35e Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Fri, 8 May 2026 07:27:32 -0700 Subject: [PATCH 17/25] feat(google-workspace): Drive write ops + Docs/Sheets create/append (#21895) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expand the google-workspace skill beyond read-only access to Drive and Docs. Sheets already had full scope — just adds the missing create verb. New subcommands: - drive get : metadata for a single file - drive upload : upload a local file (auto MIME detection) - drive download : download or export (Docs/Sheets/Slides export to pdf/csv/pdf by default) - drive create-folder - drive share : user/group/domain/anyone + reader/writer/etc. - drive delete : default trashes (reversible); --permanent skips the trash - sheets create : new spreadsheet with optional first-tab name - docs create : new doc, optional initial body - docs append : append text at end of an existing doc Scope changes: - drive.readonly -> drive - documents.readonly -> documents Existing users with old tokens will hit the existing partial-scope warning path (AUTHENTICATED (partial) ...) — the troubleshooting table now points them at $GSETUP --revoke + redo steps 3-5 to pick up the write scopes. --- skills/productivity/google-workspace/SKILL.md | 54 ++- .../google-workspace/scripts/google_api.py | 363 +++++++++++++++++- .../google-workspace/scripts/setup.py | 4 +- 3 files changed, 415 insertions(+), 6 deletions(-) diff --git a/skills/productivity/google-workspace/SKILL.md b/skills/productivity/google-workspace/SKILL.md index b141afe3973..79ac7051dbd 100644 --- a/skills/productivity/google-workspace/SKILL.md +++ b/skills/productivity/google-workspace/SKILL.md @@ -1,7 +1,7 @@ --- name: google-workspace description: "Gmail, Calendar, Drive, Docs, Sheets via gws CLI or Python." -version: 1.0.1 +version: 1.1.0 author: Nous Research license: MIT required_credential_files: @@ -216,8 +216,36 @@ $GAPI calendar delete EVENT_ID ### Drive ```bash +# Search existing files $GAPI drive search "quarterly report" --max 10 $GAPI drive search "mimeType='application/pdf'" --raw-query --max 5 + +# Get metadata for a single file +$GAPI drive get FILE_ID + +# Upload a local file (auto-detects MIME type) +$GAPI drive upload /path/to/report.pdf +$GAPI drive upload /path/to/image.png --name "Logo.png" --parent FOLDER_ID + +# Download (binary files download as-is; Google-native files export to a +# sensible default — Docs→pdf, Sheets→csv, Slides→pdf, Drawings→png) +$GAPI drive download FILE_ID +$GAPI drive download DOC_ID --output ~/doc.pdf +$GAPI drive download DOC_ID --export-mime text/plain --output ~/doc.txt + +# Create a folder +$GAPI drive create-folder "Reports" +$GAPI drive create-folder "Q4" --parent FOLDER_ID + +# Share +$GAPI drive share FILE_ID --email alice@example.com --role reader +$GAPI drive share FILE_ID --email alice@example.com --role writer --notify +$GAPI drive share FILE_ID --type anyone --role reader # anyone with link +$GAPI drive share FILE_ID --type domain --domain example.com --role reader + +# Delete — defaults to trash (reversible). Use --permanent to skip the trash. +$GAPI drive delete FILE_ID +$GAPI drive delete FILE_ID --permanent ``` ### Contacts @@ -229,6 +257,10 @@ $GAPI contacts list --max 20 ### Sheets ```bash +# Create a new spreadsheet +$GAPI sheets create --title "Q4 Budget" +$GAPI sheets create --title "Inventory" --sheet-name "Stock" + # Read $GAPI sheets get SHEET_ID "Sheet1!A1:D10" @@ -242,7 +274,15 @@ $GAPI sheets append SHEET_ID "Sheet1!A:C" --values '[["new","row","data"]]' ### Docs ```bash +# Read $GAPI docs get DOC_ID + +# Create a new Doc (optionally seeded with body text) +$GAPI docs create --title "Meeting Notes" +$GAPI docs create --title "Draft" --body "First paragraph..." + +# Append text to the end of an existing Doc +$GAPI docs append DOC_ID --text "Additional content to append" ``` ## Output Format @@ -255,12 +295,21 @@ All commands return JSON. Parse with `jq` or read directly. Key fields: - **Calendar list**: `[{id, summary, start, end, location, description, htmlLink}]` - **Calendar create**: `{status: "created", id, summary, htmlLink}` - **Drive search**: `[{id, name, mimeType, modifiedTime, webViewLink}]` +- **Drive get**: `{id, name, mimeType, modifiedTime, size, webViewLink, parents, owners}` +- **Drive upload**: `{status: "uploaded", id, name, mimeType, webViewLink}` +- **Drive download**: `{status: "downloaded", id, name, path, mimeType}` +- **Drive create-folder**: `{status: "created", id, name, webViewLink}` +- **Drive share**: `{status: "shared", permissionId, fileId, role, type}` +- **Drive delete**: `{status: "trashed" | "deleted", fileId, permanent}` - **Contacts list**: `[{name, emails: [...], phones: [...]}]` - **Sheets get**: `[[cell, cell, ...], ...]` +- **Sheets create**: `{status: "created", spreadsheetId, title, spreadsheetUrl}` +- **Docs create**: `{status: "created", documentId, title, url}` +- **Docs append**: `{status: "appended", documentId, inserted_at, characters}` ## Rules -1. **Never send email or create/delete events without confirming with the user first.** Show the draft content and ask for approval. +1. **Never send email, create/delete calendar events, delete Drive files, share files, or modify Docs/Sheets without confirming with the user first.** Show what will be done (recipients, file IDs, content, share role) and ask for approval. For `drive delete`, prefer the default trash (reversible) over `--permanent`. 2. **Check auth before first use** — run `setup.py --check`. If it fails, guide the user through setup. 3. **Use the Gmail search syntax reference** for complex queries — load it with `skill_view("google-workspace", file_path="references/gmail-search-syntax.md")`. 4. **Calendar times must include timezone** — always use ISO 8601 with offset (e.g., `2026-03-01T10:00:00-06:00`) or UTC (`Z`). @@ -273,6 +322,7 @@ All commands return JSON. Parse with `jq` or read directly. Key fields: | `NOT_AUTHENTICATED` | Run setup Steps 2-5 above | | `REFRESH_FAILED` | Token revoked or expired — redo Steps 3-5 | | `HttpError 403: Insufficient Permission` | Missing API scope — `$GSETUP --revoke` then redo Steps 3-5 | +| `AUTHENTICATED (partial)` or "Token missing scopes" | New write capabilities (Drive write/delete, Docs create/edit) require re-authorization. `$GSETUP --revoke` then redo Steps 3-5 to grant the upgraded scopes. | | `HttpError 403: Access Not Configured` | API not enabled — user needs to enable it in Google Cloud Console | | `ModuleNotFoundError` | Run `$GSETUP --install-deps` | | Advanced Protection blocks auth | Workspace admin must allowlist the OAuth client ID | diff --git a/skills/productivity/google-workspace/scripts/google_api.py b/skills/productivity/google-workspace/scripts/google_api.py index 0c39e091f88..7b8350ab34a 100644 --- a/skills/productivity/google-workspace/scripts/google_api.py +++ b/skills/productivity/google-workspace/scripts/google_api.py @@ -47,10 +47,10 @@ SCOPES = [ "https://www.googleapis.com/auth/gmail.send", "https://www.googleapis.com/auth/gmail.modify", "https://www.googleapis.com/auth/calendar", - "https://www.googleapis.com/auth/drive.readonly", + "https://www.googleapis.com/auth/drive", "https://www.googleapis.com/auth/contacts.readonly", "https://www.googleapis.com/auth/spreadsheets", - "https://www.googleapis.com/auth/documents.readonly", + "https://www.googleapis.com/auth/documents", ] @@ -587,6 +587,213 @@ def drive_search(args): print(json.dumps(files, indent=2, ensure_ascii=False)) +def drive_get(args): + """Get metadata for a single Drive file by ID.""" + fields = "id, name, mimeType, modifiedTime, size, webViewLink, parents, owners(emailAddress)" + if _gws_binary(): + result = _run_gws( + ["drive", "files", "get"], + params={"fileId": args.file_id, "fields": fields}, + ) + print(json.dumps(result, indent=2, ensure_ascii=False)) + return + + service = build_service("drive", "v3") + result = service.files().get(fileId=args.file_id, fields=fields).execute() + print(json.dumps(result, indent=2, ensure_ascii=False)) + + +def drive_upload(args): + """Upload a local file to Drive. Falls through to Python client even when gws + is installed, because gws doesn't do multipart uploads.""" + import mimetypes + from googleapiclient.http import MediaFileUpload + + local_path = Path(args.path).expanduser() + if not local_path.exists(): + print(f"ERROR: file not found: {local_path}", file=sys.stderr) + sys.exit(1) + + mime = args.mime_type or mimetypes.guess_type(str(local_path))[0] or "application/octet-stream" + metadata = {"name": args.name or local_path.name} + if args.parent: + metadata["parents"] = [args.parent] + + service = build_service("drive", "v3") + media = MediaFileUpload(str(local_path), mimetype=mime, resumable=True) + result = service.files().create( + body=metadata, + media_body=media, + fields="id, name, mimeType, webViewLink", + ).execute() + print(json.dumps({ + "status": "uploaded", + "id": result["id"], + "name": result.get("name", ""), + "mimeType": result.get("mimeType", ""), + "webViewLink": result.get("webViewLink", ""), + }, indent=2, ensure_ascii=False)) + + +def drive_download(args): + """Download a Drive file to a local path. Google-native files (Docs/Sheets/Slides) + must be exported; binary files are downloaded as-is.""" + import io + from googleapiclient.http import MediaIoBaseDownload + + service = build_service("drive", "v3") + + # Look up the file to decide download vs export. + meta = service.files().get(fileId=args.file_id, fields="id, name, mimeType").execute() + mime = meta.get("mimeType", "") + name = meta.get("name", args.file_id) + + # Map Google-native MIME types to a sensible export default. + native_export_map = { + "application/vnd.google-apps.document": ("application/pdf", ".pdf"), + "application/vnd.google-apps.spreadsheet": ("text/csv", ".csv"), + "application/vnd.google-apps.presentation": ("application/pdf", ".pdf"), + "application/vnd.google-apps.drawing": ("image/png", ".png"), + } + + out_path = Path(args.output).expanduser() if args.output else Path.cwd() / name + + if mime in native_export_map: + export_mime = args.export_mime or native_export_map[mime][0] + default_ext = native_export_map[mime][1] + if not args.output and not out_path.suffix: + out_path = out_path.with_suffix(default_ext) + request = service.files().export_media(fileId=args.file_id, mimeType=export_mime) + else: + request = service.files().get_media(fileId=args.file_id) + + out_path.parent.mkdir(parents=True, exist_ok=True) + fh = io.FileIO(str(out_path), "wb") + downloader = MediaIoBaseDownload(fh, request) + done = False + while not done: + _, done = downloader.next_chunk() + fh.close() + + print(json.dumps({ + "status": "downloaded", + "id": args.file_id, + "name": name, + "path": str(out_path), + "mimeType": mime, + }, indent=2, ensure_ascii=False)) + + +def drive_create_folder(args): + body = { + "name": args.name, + "mimeType": "application/vnd.google-apps.folder", + } + if args.parent: + body["parents"] = [args.parent] + + if _gws_binary(): + result = _run_gws( + ["drive", "files", "create"], + params={"fields": "id, name, webViewLink"}, + body=body, + ) + print(json.dumps({ + "status": "created", + "id": result["id"], + "name": result.get("name", ""), + "webViewLink": result.get("webViewLink", ""), + }, indent=2, ensure_ascii=False)) + return + + service = build_service("drive", "v3") + result = service.files().create(body=body, fields="id, name, webViewLink").execute() + print(json.dumps({ + "status": "created", + "id": result["id"], + "name": result.get("name", ""), + "webViewLink": result.get("webViewLink", ""), + }, indent=2, ensure_ascii=False)) + + +def drive_share(args): + permission = { + "type": args.type, + "role": args.role, + } + if args.type in ("user", "group"): + if not args.email: + print("ERROR: --email is required for type=user or type=group", file=sys.stderr) + sys.exit(1) + permission["emailAddress"] = args.email + elif args.type == "domain": + if not args.domain: + print("ERROR: --domain is required for type=domain", file=sys.stderr) + sys.exit(1) + permission["domain"] = args.domain + + if _gws_binary(): + result = _run_gws( + ["drive", "permissions", "create"], + params={ + "fileId": args.file_id, + "sendNotificationEmail": args.notify, + }, + body=permission, + ) + print(json.dumps({ + "status": "shared", + "permissionId": result.get("id", ""), + "fileId": args.file_id, + "role": permission["role"], + "type": permission["type"], + }, indent=2, ensure_ascii=False)) + return + + service = build_service("drive", "v3") + result = service.permissions().create( + fileId=args.file_id, + body=permission, + sendNotificationEmail=args.notify, + fields="id", + ).execute() + print(json.dumps({ + "status": "shared", + "permissionId": result.get("id", ""), + "fileId": args.file_id, + "role": permission["role"], + "type": permission["type"], + }, indent=2, ensure_ascii=False)) + + +def drive_delete(args): + """Trash or permanently delete a Drive file. Defaults to trash (reversible).""" + if args.permanent: + if _gws_binary(): + _run_gws(["drive", "files", "delete"], params={"fileId": args.file_id}) + print(json.dumps({"status": "deleted", "fileId": args.file_id, "permanent": True})) + return + service = build_service("drive", "v3") + service.files().delete(fileId=args.file_id).execute() + print(json.dumps({"status": "deleted", "fileId": args.file_id, "permanent": True})) + return + + # Trash (reversible). Use files.update with trashed=True. + body = {"trashed": True} + if _gws_binary(): + _run_gws( + ["drive", "files", "update"], + params={"fileId": args.file_id}, + body=body, + ) + print(json.dumps({"status": "trashed", "fileId": args.file_id, "permanent": False})) + return + + service = build_service("drive", "v3") + service.files().update(fileId=args.file_id, body=body).execute() + print(json.dumps({"status": "trashed", "fileId": args.file_id, "permanent": False})) + + # ========================================================================= # Contacts # ========================================================================= @@ -708,6 +915,34 @@ def sheets_append(args): print(json.dumps({"updatedCells": result.get("updates", {}).get("updatedCells", 0)}, indent=2)) +def sheets_create(args): + """Create a new spreadsheet. Returns the new spreadsheet ID and URL.""" + body = {"properties": {"title": args.title}} + if args.sheet_name: + body["sheets"] = [{"properties": {"title": args.sheet_name}}] + + if _gws_binary(): + result = _run_gws(["sheets", "spreadsheets", "create"], body=body) + print(json.dumps({ + "status": "created", + "spreadsheetId": result.get("spreadsheetId", ""), + "title": result.get("properties", {}).get("title", ""), + "spreadsheetUrl": result.get("spreadsheetUrl", ""), + }, indent=2, ensure_ascii=False)) + return + + service = build_service("sheets", "v4") + result = service.spreadsheets().create( + body=body, fields="spreadsheetId,properties,spreadsheetUrl", + ).execute() + print(json.dumps({ + "status": "created", + "spreadsheetId": result.get("spreadsheetId", ""), + "title": result.get("properties", {}).get("title", ""), + "spreadsheetUrl": result.get("spreadsheetUrl", ""), + }, indent=2, ensure_ascii=False)) + + # ========================================================================= # Docs # ========================================================================= @@ -734,6 +969,79 @@ def docs_get(args): print(json.dumps(result, indent=2, ensure_ascii=False)) +def docs_create(args): + """Create a new Doc. Optionally seed it with initial body text.""" + body = {"title": args.title} + + if _gws_binary(): + doc = _run_gws(["docs", "documents", "create"], body=body) + else: + service = build_service("docs", "v1") + doc = service.documents().create(body=body).execute() + + doc_id = doc.get("documentId", "") + + if args.body and doc_id: + _docs_insert_text(doc_id, args.body, index=1) + + print(json.dumps({ + "status": "created", + "documentId": doc_id, + "title": doc.get("title", ""), + "url": f"https://docs.google.com/document/d/{doc_id}/edit" if doc_id else "", + }, indent=2, ensure_ascii=False)) + + +def docs_append(args): + """Append text to the end of an existing Doc.""" + if _gws_binary(): + doc = _run_gws(["docs", "documents", "get"], params={"documentId": args.doc_id}) + else: + service = build_service("docs", "v1") + doc = service.documents().get(documentId=args.doc_id).execute() + + # The end-of-body index is one less than the segment endIndex of the body + # (trailing newline is always at length-1). Docs indexes are 1-based; use + # endIndex - 1 to insert before the final newline. + content = doc.get("body", {}).get("content", []) + end_index = 1 + for element in content: + ei = element.get("endIndex") + if isinstance(ei, int) and ei > end_index: + end_index = ei + insert_index = max(end_index - 1, 1) + + text = args.text if args.text.endswith("\n") else args.text + "\n" + _docs_insert_text(args.doc_id, text, index=insert_index) + + print(json.dumps({ + "status": "appended", + "documentId": args.doc_id, + "inserted_at": insert_index, + "characters": len(text), + }, indent=2, ensure_ascii=False)) + + +def _docs_insert_text(doc_id: str, text: str, index: int) -> None: + """Send a batchUpdate with a single insertText request.""" + requests = [{ + "insertText": { + "location": {"index": index}, + "text": text, + } + }] + if _gws_binary(): + _run_gws( + ["docs", "documents", "batchUpdate"], + params={"documentId": doc_id}, + body={"requests": requests}, + ) + return + + service = build_service("docs", "v1") + service.documents().batchUpdate(documentId=doc_id, body={"requests": requests}).execute() + + # ========================================================================= # CLI parser # ========================================================================= @@ -817,6 +1125,42 @@ def main(): p.add_argument("--raw-query", action="store_true", help="Use query as raw Drive API query") p.set_defaults(func=drive_search) + p = drv_sub.add_parser("get") + p.add_argument("file_id") + p.set_defaults(func=drive_get) + + p = drv_sub.add_parser("upload") + p.add_argument("path", help="Local file path to upload") + p.add_argument("--name", default="", help="Override file name in Drive (defaults to local filename)") + p.add_argument("--parent", default="", help="Parent folder ID") + p.add_argument("--mime-type", default="", help="Override MIME type (auto-detected if omitted)") + p.set_defaults(func=drive_upload) + + p = drv_sub.add_parser("download") + p.add_argument("file_id") + p.add_argument("--output", default="", help="Local output path (defaults to ./ in cwd)") + p.add_argument("--export-mime", default="", help="Export MIME for Google-native files (overrides defaults: pdf for Docs/Slides, csv for Sheets, png for Drawings)") + p.set_defaults(func=drive_download) + + p = drv_sub.add_parser("create-folder") + p.add_argument("name") + p.add_argument("--parent", default="", help="Parent folder ID (defaults to root)") + p.set_defaults(func=drive_create_folder) + + p = drv_sub.add_parser("share") + p.add_argument("file_id") + p.add_argument("--role", default="reader", choices=["reader", "commenter", "writer", "fileOrganizer", "organizer", "owner"]) + p.add_argument("--type", default="user", choices=["user", "group", "domain", "anyone"]) + p.add_argument("--email", default="", help="Email address (required for type=user or type=group)") + p.add_argument("--domain", default="", help="Domain (required for type=domain)") + p.add_argument("--notify", action="store_true", help="Send notification email") + p.set_defaults(func=drive_share) + + p = drv_sub.add_parser("delete") + p.add_argument("file_id") + p.add_argument("--permanent", action="store_true", help="Permanently delete (default is trash, which is reversible)") + p.set_defaults(func=drive_delete) + # --- Contacts --- con = sub.add_parser("contacts") con_sub = con.add_subparsers(dest="action", required=True) @@ -846,6 +1190,11 @@ def main(): p.add_argument("--values", required=True, help="JSON array of arrays") p.set_defaults(func=sheets_append) + p = sh_sub.add_parser("create") + p.add_argument("--title", required=True, help="Spreadsheet title") + p.add_argument("--sheet-name", default="", help="Name of the first tab (defaults to 'Sheet1')") + p.set_defaults(func=sheets_create) + # --- Docs --- docs = sub.add_parser("docs") docs_sub = docs.add_subparsers(dest="action", required=True) @@ -854,6 +1203,16 @@ def main(): p.add_argument("doc_id") p.set_defaults(func=docs_get) + p = docs_sub.add_parser("create") + p.add_argument("--title", required=True, help="Document title") + p.add_argument("--body", default="", help="Initial body text (optional)") + p.set_defaults(func=docs_create) + + p = docs_sub.add_parser("append") + p.add_argument("doc_id") + p.add_argument("--text", required=True, help="Text to append to the end of the document") + p.set_defaults(func=docs_append) + args = parser.parse_args() args.func(args) diff --git a/skills/productivity/google-workspace/scripts/setup.py b/skills/productivity/google-workspace/scripts/setup.py index 8d798f8a67b..fbf91128bda 100644 --- a/skills/productivity/google-workspace/scripts/setup.py +++ b/skills/productivity/google-workspace/scripts/setup.py @@ -47,10 +47,10 @@ SCOPES = [ "https://www.googleapis.com/auth/gmail.send", "https://www.googleapis.com/auth/gmail.modify", "https://www.googleapis.com/auth/calendar", - "https://www.googleapis.com/auth/drive.readonly", + "https://www.googleapis.com/auth/drive", "https://www.googleapis.com/auth/contacts.readonly", "https://www.googleapis.com/auth/spreadsheets", - "https://www.googleapis.com/auth/documents.readonly", + "https://www.googleapis.com/auth/documents", ] REQUIRED_PACKAGES = ["google-api-python-client", "google-auth-oauthlib", "google-auth-httplib2"] From 526c0e018a2087303cf31b25b949a64a029d0718 Mon Sep 17 00:00:00 2001 From: Zhicheng Han Date: Tue, 5 May 2026 18:34:58 +0200 Subject: [PATCH 18/25] feat(api-server): expose run approval events --- gateway/platforms/api_server.py | 185 +++++++++++++++++++++++++- tests/gateway/test_api_server_runs.py | 93 +++++++++++++ tools/approval.py | 26 +++- 3 files changed, 295 insertions(+), 9 deletions(-) diff --git a/gateway/platforms/api_server.py b/gateway/platforms/api_server.py index 3b0375ff03d..cde78136236 100644 --- a/gateway/platforms/api_server.py +++ b/gateway/platforms/api_server.py @@ -11,7 +11,8 @@ Exposes an HTTP server with endpoints: - POST /v1/runs — start a run, returns run_id immediately (202) - GET /v1/runs/{run_id} — retrieve current run status - GET /v1/runs/{run_id}/events — SSE stream of structured lifecycle events -- POST /v1/runs/{run_id}/stop — interrupt a running agent +- POST /v1/runs/{run_id}/approval — resolve a pending run approval +- POST /v1/runs/{run_id}/stop — interrupt a running agent - GET /health — health check - GET /health/detailed — rich status for cross-container dashboard probing @@ -605,6 +606,10 @@ class APIServerAdapter(BasePlatformAdapter): self._active_run_tasks: Dict[str, "asyncio.Task"] = {} # Pollable run status for dashboards and external control-plane UIs. self._run_statuses: Dict[str, Dict[str, Any]] = {} + # Active approval session key for each run_id. The approval core + # resolves requests by session key, while API clients address the + # in-flight run by run_id. + self._run_approval_sessions: Dict[str, str] = {} self._session_db: Optional[Any] = None # Lazy-init SessionDB for session continuity @staticmethod @@ -936,7 +941,9 @@ class APIServerAdapter(BasePlatformAdapter): "run_status": True, "run_events_sse": True, "run_stop": True, + "run_approval_response": True, "tool_progress_events": True, + "approval_events": True, "session_continuity_header": "X-Hermes-Session-Id", "session_key_header": "X-Hermes-Session-Key", "cors": bool(self._cors_origins), @@ -950,6 +957,7 @@ class APIServerAdapter(BasePlatformAdapter): "runs": {"method": "POST", "path": "/v1/runs"}, "run_status": {"method": "GET", "path": "/v1/runs/{run_id}"}, "run_events": {"method": "GET", "path": "/v1/runs/{run_id}/events"}, + "run_approval": {"method": "POST", "path": "/v1/runs/{run_id}/approval"}, "run_stop": {"method": "POST", "path": "/v1/runs/{run_id}/stop"}, }, }) @@ -2821,12 +2829,14 @@ class APIServerAdapter(BasePlatformAdapter): run_id = f"run_{uuid.uuid4().hex}" session_id = body.get("session_id") or stored_session_id or run_id + approval_session_key = gateway_session_key or session_id or run_id ephemeral_system_prompt = instructions loop = asyncio.get_running_loop() q: "asyncio.Queue[Optional[Dict]]" = asyncio.Queue() created_at = time.time() self._run_streams[run_id] = q self._run_streams_created[run_id] = created_at + self._run_approval_sessions[run_id] = approval_session_key event_cb = self._make_run_event_callback(run_id, loop) @@ -2863,13 +2873,66 @@ class APIServerAdapter(BasePlatformAdapter): gateway_session_key=gateway_session_key, ) self._active_run_agents[run_id] = agent - def _run_sync(): - effective_task_id = session_id or run_id - r = agent.run_conversation( - user_message=user_message, - conversation_history=conversation_history, - task_id=effective_task_id, + + def _approval_notify(approval_data: Dict[str, Any]) -> None: + event = dict(approval_data or {}) + event.update({ + "event": "approval.request", + "run_id": run_id, + "timestamp": time.time(), + "choices": ["once", "session", "always", "deny"], + }) + self._set_run_status( + run_id, + "waiting_for_approval", + last_event="approval.request", ) + try: + loop.call_soon_threadsafe(q.put_nowait, event) + except Exception: + pass + + def _run_sync(): + from gateway.session_context import clear_session_vars, set_session_vars + from tools.approval import ( + register_gateway_notify, + reset_current_session_key, + set_current_session_key, + unregister_gateway_notify, + ) + + effective_task_id = session_id or run_id + approval_token = None + session_tokens = [] + try: + # Bind approval/session identity for this API run via + # contextvars so concurrent runs do not share process + # environment state. + approval_token = set_current_session_key(approval_session_key) + session_tokens = set_session_vars( + platform="api_server", + session_key=approval_session_key, + ) + register_gateway_notify(approval_session_key, _approval_notify) + r = agent.run_conversation( + user_message=user_message, + conversation_history=conversation_history, + task_id=effective_task_id, + ) + finally: + try: + unregister_gateway_notify(approval_session_key) + finally: + if approval_token is not None: + try: + reset_current_session_key(approval_token) + except Exception: + pass + if session_tokens: + try: + clear_session_vars(session_tokens) + except Exception: + pass u = { "input_tokens": getattr(agent, "session_prompt_tokens", 0) or 0, "output_tokens": getattr(agent, "session_completion_tokens", 0) or 0, @@ -2944,6 +3007,17 @@ class APIServerAdapter(BasePlatformAdapter): except Exception: pass finally: + # If the asyncio wrapper is cancelled (for example via + # /stop), the executor thread can still be blocked waiting + # on an approval Event. Unregistering here releases those + # waits immediately; the in-thread unregister is harmlessly + # idempotent on normal completion. + try: + from tools.approval import unregister_gateway_notify + + unregister_gateway_notify(approval_session_key) + except Exception: + pass # Sentinel: signal SSE stream to close try: q.put_nowait(None) @@ -2951,6 +3025,7 @@ class APIServerAdapter(BasePlatformAdapter): pass self._active_run_agents.pop(run_id, None) self._active_run_tasks.pop(run_id, None) + self._run_approval_sessions.pop(run_id, None) task = asyncio.create_task(_run_and_close()) self._active_run_tasks[run_id] = task @@ -3034,6 +3109,92 @@ class APIServerAdapter(BasePlatformAdapter): return response + + async def _handle_run_approval(self, request: "web.Request") -> "web.Response": + """POST /v1/runs/{run_id}/approval — resolve a pending run approval.""" + auth_err = self._check_auth(request) + if auth_err: + return auth_err + + run_id = request.match_info["run_id"] + status = self._run_statuses.get(run_id) + if status is None: + return web.json_response( + _openai_error(f"Run not found: {run_id}", code="run_not_found"), + status=404, + ) + + try: + body = await request.json() + except Exception: + return web.json_response(_openai_error("Invalid JSON"), status=400) + + raw_choice = str(body.get("choice", "")).strip().lower() + aliases = {"approve": "once", "approved": "once", "allow": "once"} + choice = aliases.get(raw_choice, raw_choice) + allowed = {"once", "session", "always", "deny"} + if choice not in allowed: + return web.json_response( + _openai_error( + "Invalid approval choice; expected one of: once, session, always, deny", + code="invalid_approval_choice", + ), + status=400, + ) + + approval_session_key = self._run_approval_sessions.get(run_id) + if not approval_session_key: + return web.json_response( + _openai_error( + f"Run has no active approval session: {run_id}", + code="approval_not_active", + ), + status=409, + ) + + resolve_all = bool(body.get("all") or body.get("resolve_all")) + try: + from tools.approval import resolve_gateway_approval + + resolved = resolve_gateway_approval( + approval_session_key, + choice, + resolve_all=resolve_all, + ) + except Exception as exc: + logger.exception("[api_server] approval resolution failed for run %s", run_id) + return web.json_response(_openai_error(str(exc)), status=500) + + if resolved <= 0: + return web.json_response( + _openai_error( + f"Run has no pending approval: {run_id}", + code="approval_not_pending", + ), + status=409, + ) + + self._set_run_status(run_id, "running", last_event="approval.responded") + q = self._run_streams.get(run_id) + if q is not None: + try: + q.put_nowait({ + "event": "approval.responded", + "run_id": run_id, + "timestamp": time.time(), + "choice": choice, + "resolved": resolved, + }) + except Exception: + pass + + return web.json_response({ + "object": "hermes.run.approval_response", + "run_id": run_id, + "choice": choice, + "resolved": resolved, + }) + async def _handle_stop_run(self, request: "web.Request") -> "web.Response": """POST /v1/runs/{run_id}/stop — interrupt a running agent.""" auth_err = self._check_auth(request) @@ -3086,10 +3247,19 @@ class APIServerAdapter(BasePlatformAdapter): ] for run_id in stale: logger.debug("[api_server] sweeping orphaned run %s", run_id) + try: + from tools.approval import unregister_gateway_notify + + approval_session_key = self._run_approval_sessions.get(run_id) + if approval_session_key: + unregister_gateway_notify(approval_session_key) + except Exception: + pass self._run_streams.pop(run_id, None) self._run_streams_created.pop(run_id, None) self._active_run_agents.pop(run_id, None) self._active_run_tasks.pop(run_id, None) + self._run_approval_sessions.pop(run_id, None) stale_statuses = [ run_id @@ -3136,6 +3306,7 @@ class APIServerAdapter(BasePlatformAdapter): self._app.router.add_post("/v1/runs", self._handle_runs) self._app.router.add_get("/v1/runs/{run_id}", self._handle_get_run) self._app.router.add_get("/v1/runs/{run_id}/events", self._handle_run_events) + self._app.router.add_post("/v1/runs/{run_id}/approval", self._handle_run_approval) self._app.router.add_post("/v1/runs/{run_id}/stop", self._handle_stop_run) # Start background sweep to clean up orphaned (unconsumed) run streams sweep_task = asyncio.create_task(self._sweep_orphaned_runs()) diff --git a/tests/gateway/test_api_server_runs.py b/tests/gateway/test_api_server_runs.py index 6ce67db9231..f47060d0689 100644 --- a/tests/gateway/test_api_server_runs.py +++ b/tests/gateway/test_api_server_runs.py @@ -49,6 +49,7 @@ def _create_runs_app(adapter: APIServerAdapter) -> web.Application: app.router.add_post("/v1/runs", adapter._handle_runs) app.router.add_get("/v1/runs/{run_id}", adapter._handle_get_run) app.router.add_get("/v1/runs/{run_id}/events", adapter._handle_run_events) + app.router.add_post("/v1/runs/{run_id}/approval", adapter._handle_run_approval) app.router.add_post("/v1/runs/{run_id}/stop", adapter._handle_stop_run) return app @@ -305,6 +306,98 @@ class TestRunEvents: assert "run.completed" in body assert "Hello!" in body + + @pytest.mark.asyncio + async def test_approval_request_event_and_response_unblock_run(self, adapter): + """Dangerous-command approvals should surface on the run SSE stream.""" + app = _create_runs_app(adapter) + async with TestClient(TestServer(app)) as cli: + with patch.object(adapter, "_create_agent") as mock_create: + guard_result = {} + + mock_agent = MagicMock() + + def _run_with_approval(user_message=None, conversation_history=None, task_id=None): + from tools.approval import check_all_command_guards + + result = check_all_command_guards("git reset --hard HEAD", "local") + guard_result.update(result) + return {"final_response": "approved" if result.get("approved") else "blocked"} + + mock_agent.run_conversation.side_effect = _run_with_approval + mock_agent.session_prompt_tokens = 0 + mock_agent.session_completion_tokens = 0 + mock_agent.session_total_tokens = 0 + mock_create.return_value = mock_agent + + resp = await cli.post("/v1/runs", json={"input": "needs approval"}) + assert resp.status == 202 + data = await resp.json() + run_id = data["run_id"] + + events_resp = await cli.get(f"/v1/runs/{run_id}/events") + assert events_resp.status == 200 + + approval_event = None + for _ in range(20): + line = await asyncio.wait_for(events_resp.content.readline(), timeout=3.0) + text = line.decode() + if not text.startswith("data: "): + continue + event = json.loads(text[len("data: "):]) + if event.get("event") == "approval.request": + approval_event = event + break + + assert approval_event is not None + assert approval_event["run_id"] == run_id + assert approval_event["command"] == "git reset --hard HEAD" + assert approval_event["pattern_key"] + assert "pattern_keys" in approval_event + assert approval_event["choices"] == ["once", "session", "always", "deny"] + + approval_resp = await cli.post( + f"/v1/runs/{run_id}/approval", + json={"choice": "once"}, + ) + assert approval_resp.status == 200 + approval_data = await approval_resp.json() + assert approval_data["resolved"] == 1 + assert approval_data["choice"] == "once" + + body = await events_resp.text() + assert "approval.responded" in body + assert "run.completed" in body + + assert guard_result.get("approved") is True + + @pytest.mark.asyncio + async def test_approval_response_without_pending_returns_409(self, adapter): + app = _create_runs_app(adapter) + async with TestClient(TestServer(app)) as cli: + with patch.object(adapter, "_create_agent") as mock_create: + mock_agent = MagicMock() + mock_agent.run_conversation.return_value = {"final_response": "done"} + mock_agent.session_prompt_tokens = 0 + mock_agent.session_completion_tokens = 0 + mock_agent.session_total_tokens = 0 + mock_create.return_value = mock_agent + + resp = await cli.post("/v1/runs", json={"input": "hello"}) + data = await resp.json() + run_id = data["run_id"] + + approval_resp = await cli.post( + f"/v1/runs/{run_id}/approval", + json={"choice": "once"}, + ) + assert approval_resp.status == 409 + approval_data = await approval_resp.json() + assert approval_data["error"]["code"] in { + "approval_not_active", + "approval_not_pending", + } + @pytest.mark.asyncio async def test_events_not_found_returns_404(self, adapter): app = _create_runs_app(adapter) diff --git a/tools/approval.py b/tools/approval.py index a7faaff21f2..1322098ebca 100644 --- a/tools/approval.py +++ b/tools/approval.py @@ -83,6 +83,28 @@ def get_current_session_key(default: str = "default") -> str: from gateway.session_context import get_session_env return get_session_env("HERMES_SESSION_KEY", default) + +def _get_session_platform() -> str: + """Return the current gateway platform from contextvars/env fallback.""" + try: + from gateway.session_context import get_session_env + + return get_session_env("HERMES_SESSION_PLATFORM", "") or "" + except Exception: + return os.getenv("HERMES_SESSION_PLATFORM", "") or "" + + +def _is_gateway_approval_context() -> bool: + """True when this call is inside a gateway/API session. + + Legacy gateway integrations set HERMES_GATEWAY_SESSION in process env. + Newer concurrent gateway paths bind HERMES_SESSION_PLATFORM via + contextvars so approval mode does not depend on process-global flags. + """ + if os.getenv("HERMES_GATEWAY_SESSION"): + return True + return bool(_get_session_platform()) + # Sensitive write targets that should trigger approval even when referenced # via shell expansions like $HOME or $HERMES_HOME. _SSH_SENSITIVE_PATH = r'(?:~|\$home|\$\{home\})/\.ssh(?:/|$)' @@ -829,7 +851,7 @@ def check_dangerous_command(command: str, env_type: str, return {"approved": True, "message": None} is_cli = os.getenv("HERMES_INTERACTIVE") - is_gateway = os.getenv("HERMES_GATEWAY_SESSION") + is_gateway = _is_gateway_approval_context() if not is_cli and not is_gateway: # Cron sessions: respect cron_mode config @@ -946,7 +968,7 @@ def check_all_command_guards(command: str, env_type: str, return {"approved": True, "message": None} is_cli = os.getenv("HERMES_INTERACTIVE") - is_gateway = os.getenv("HERMES_GATEWAY_SESSION") + is_gateway = _is_gateway_approval_context() is_ask = os.getenv("HERMES_EXEC_ASK") # Preserve the existing non-interactive behavior: outside CLI/gateway/ask From 839cdd1b054a75ff1b581199a83488c8e0f2f788 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Fri, 8 May 2026 07:01:15 -0700 Subject: [PATCH 19/25] fix(approval): cron jobs must not be treated as gateway context MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The new _is_gateway_approval_context() widened the gateway classification to any call with HERMES_SESSION_PLATFORM bound via contextvars. But cron/scheduler.py binds that same contextvar for delivery routing on cron jobs that originate from a gateway platform (telegram/discord/etc.), so those jobs were getting routed through submit_pending with no listener — blocking indefinitely instead of honoring approvals.cron_mode. Short-circuit on HERMES_CRON_SESSION before any gateway check. Cron is always governed by cron_mode config, regardless of where the job was scheduled from. Adds regression coverage in TestCronWithGatewayOrigin and records the contributor email mapping for scripts/release.py. --- scripts/release.py | 1 + tests/tools/test_cron_approval_mode.py | 74 ++++++++++++++++++++++++++ tools/approval.py | 9 ++++ 3 files changed, 84 insertions(+) diff --git a/scripts/release.py b/scripts/release.py index c5ceac0a9f5..bb943595ab1 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -905,6 +905,7 @@ AUTHOR_MAP = { "montbra@gmail.com": "Montbra", # PR #20897 salvage of #16189 (TUI voice PTT) "promptsiren@gmail.com": "firefly", # PR #18123 salvage of #16660 (ContextVars) "wtyopenclaw@gmail.com": "WuTianyi123", # PR #20275 salvage of #13723 (feishu markdown) + "zhicheng.han@mathematik.uni-goettingen.de": "hanzckernel", # PR #20311 (api-server approval events) # pander: empty email, salvaged via PR #19665 from #16126 by @ms-alan } diff --git a/tests/tools/test_cron_approval_mode.py b/tests/tools/test_cron_approval_mode.py index abd730ca3ae..3826813157a 100644 --- a/tests/tools/test_cron_approval_mode.py +++ b/tests/tools/test_cron_approval_mode.py @@ -256,3 +256,77 @@ class TestCronModeInteractions: result = check_dangerous_command("rm -rf /tmp/stuff", "local") assert result["approved"] + + +class TestCronWithGatewayOrigin: + """Cron jobs originating from a gateway platform must NOT be treated as gateway. + + cron/scheduler.py binds HERMES_SESSION_PLATFORM via contextvars for + delivery routing (so cron output lands back in the origin chat). The + API-server approvals work (PR #20311) made check_dangerous_command treat + any contextvar-bound platform as a gateway session. That would route + cron-from-telegram/discord/etc. through submit_pending with no listener, + hanging the job instead of respecting approvals.cron_mode. + """ + + def test_cron_with_telegram_origin_uses_cron_mode_not_gateway(self, monkeypatch): + """Cron + contextvar platform=telegram + cron_mode=deny → BLOCKED, not pending.""" + monkeypatch.setenv("HERMES_CRON_SESSION", "1") + monkeypatch.delenv("HERMES_INTERACTIVE", raising=False) + monkeypatch.delenv("HERMES_GATEWAY_SESSION", raising=False) + monkeypatch.delenv("HERMES_YOLO_MODE", raising=False) + monkeypatch.delenv("HERMES_EXEC_ASK", raising=False) + + from gateway.session_context import set_session_vars, clear_session_vars + tokens = set_session_vars(platform="telegram", chat_id="123") + try: + from unittest.mock import patch as mock_patch + with mock_patch("tools.approval._get_cron_approval_mode", return_value="deny"): + result = check_dangerous_command("rm -rf /tmp/stuff", "local") + # Cron-mode path: BLOCKED message, NOT pending/approval_required. + assert not result["approved"] + assert "BLOCKED" in result["message"] + assert "cron_mode" in result["message"] + assert result.get("status") != "approval_required" + finally: + clear_session_vars(tokens) + + def test_cron_with_telegram_origin_approve_mode_allows(self, monkeypatch): + """Cron + contextvar platform=telegram + cron_mode=approve → allowed via cron path.""" + monkeypatch.setenv("HERMES_CRON_SESSION", "1") + monkeypatch.delenv("HERMES_INTERACTIVE", raising=False) + monkeypatch.delenv("HERMES_GATEWAY_SESSION", raising=False) + monkeypatch.delenv("HERMES_YOLO_MODE", raising=False) + monkeypatch.delenv("HERMES_EXEC_ASK", raising=False) + + from gateway.session_context import set_session_vars, clear_session_vars + tokens = set_session_vars(platform="discord", chat_id="456") + try: + from unittest.mock import patch as mock_patch + with mock_patch("tools.approval._get_cron_approval_mode", return_value="approve"): + result = check_dangerous_command("rm -rf /tmp/stuff", "local") + assert result["approved"] + # Should NOT be a gateway-approval response. + assert result.get("status") != "approval_required" + finally: + clear_session_vars(tokens) + + def test_cron_with_telegram_origin_combined_guard_uses_cron_mode(self, monkeypatch): + """check_all_command_guards must also honor cron_mode over gateway classification.""" + monkeypatch.setenv("HERMES_CRON_SESSION", "1") + monkeypatch.delenv("HERMES_INTERACTIVE", raising=False) + monkeypatch.delenv("HERMES_GATEWAY_SESSION", raising=False) + monkeypatch.delenv("HERMES_YOLO_MODE", raising=False) + monkeypatch.delenv("HERMES_EXEC_ASK", raising=False) + + from gateway.session_context import set_session_vars, clear_session_vars + tokens = set_session_vars(platform="telegram", chat_id="789") + try: + from unittest.mock import patch as mock_patch + with mock_patch("tools.approval._get_cron_approval_mode", return_value="deny"): + result = check_all_command_guards("rm -rf /tmp/stuff", "local") + assert not result["approved"] + assert "BLOCKED" in result["message"] + assert result.get("status") != "approval_required" + finally: + clear_session_vars(tokens) diff --git a/tools/approval.py b/tools/approval.py index 1322098ebca..068748f6854 100644 --- a/tools/approval.py +++ b/tools/approval.py @@ -100,7 +100,16 @@ def _is_gateway_approval_context() -> bool: Legacy gateway integrations set HERMES_GATEWAY_SESSION in process env. Newer concurrent gateway paths bind HERMES_SESSION_PLATFORM via contextvars so approval mode does not depend on process-global flags. + + Cron jobs are NEVER gateway-approval contexts even when they originate + from a gateway platform (cron binds HERMES_SESSION_PLATFORM via + contextvars for delivery routing). Cron approvals are governed by + ``approvals.cron_mode`` config, not interactive resolve — letting cron + fall through to the gateway branch would submit a pending approval + with no listener and block the job indefinitely. """ + if os.getenv("HERMES_CRON_SESSION"): + return False if os.getenv("HERMES_GATEWAY_SESSION"): return True return bool(_get_session_platform()) From ea8e608821b18f1cfa2f45c65542f7bc6c2f7b96 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Fri, 8 May 2026 09:27:15 -0700 Subject: [PATCH 20/25] =?UTF-8?q?feat(skills):=20watchers=20skill=20?= =?UTF-8?q?=E2=80=94=20poll=20RSS=20/=20HTTP=20JSON=20/=20GitHub=20via=20c?= =?UTF-8?q?ron=20no-agent=20(#21881)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(skills): watchers skill — poll RSS / HTTP JSON / GitHub via cron no-agent Ships three reusable polling scripts plus a shared watermark helper as an optional skill. Users wire them into the existing cron (no_agent=True) mode rather than learning a new subsystem. Supersedes the closed PR #21497 (parallel watcher subsystem). Same value, zero new core surface. ## What ships - optional-skills/devops/watchers/SKILL.md: pattern + three example cron commands - optional-skills/devops/watchers/scripts/_watermark.py: shared helper (atomic state writes, bounded ID set, first-run baseline) - optional-skills/devops/watchers/scripts/watch_rss.py: RSS 2.0 + Atom - optional-skills/devops/watchers/scripts/watch_http_json.py: any JSON endpoint with configurable id_field / items_path / headers - optional-skills/devops/watchers/scripts/watch_github.py: issues / pulls / releases / commits (uses GITHUB_TOKEN if present) ## Invariants enforced by the shared helper - First run records baseline, emits nothing (never replays existing feed) - Watermark file is /.json, atomic replace on write - Bounded to 500 IDs (configurable) - Empty stdout when no new items — cron treats that as silent delivery ## Validation - watch_rss.py against news.ycombinator.com/rss first run → empty stdout, watermark populated - Removed one seen-id, second run → emitted exactly that item - No DeprecationWarnings (ET element truth-value footgun dodged explicitly) End-user pattern: 'hermes cron create my-feed --schedule "*/15 * * * *" --no-agent --script $HERMES_HOME/skills/devops/watchers/scripts/watch_rss.py --script-args "--name hn --url https://news.ycombinator.com/rss" --deliver telegram' * docs(skills/watchers): tighten description to match peer optional skills * docs(skills/watchers): align frontmatter + structure with peer optional skills * docs(skills/watchers): gate to linux/macos (shell syntax in examples) --- optional-skills/devops/watchers/SKILL.md | 112 ++++++++++++ .../devops/watchers/scripts/_watermark.py | 148 +++++++++++++++ .../devops/watchers/scripts/watch_github.py | 168 ++++++++++++++++++ .../watchers/scripts/watch_http_json.py | 131 ++++++++++++++ .../devops/watchers/scripts/watch_rss.py | 121 +++++++++++++ 5 files changed, 680 insertions(+) create mode 100644 optional-skills/devops/watchers/SKILL.md create mode 100755 optional-skills/devops/watchers/scripts/_watermark.py create mode 100755 optional-skills/devops/watchers/scripts/watch_github.py create mode 100755 optional-skills/devops/watchers/scripts/watch_http_json.py create mode 100755 optional-skills/devops/watchers/scripts/watch_rss.py diff --git a/optional-skills/devops/watchers/SKILL.md b/optional-skills/devops/watchers/SKILL.md new file mode 100644 index 00000000000..628f340b4c8 --- /dev/null +++ b/optional-skills/devops/watchers/SKILL.md @@ -0,0 +1,112 @@ +--- +name: watchers +description: Poll RSS, JSON APIs, and GitHub with watermark dedup. +version: 1.0.0 +author: Hermes Agent +license: MIT +platforms: [linux, macos] +metadata: + hermes: + tags: [cron, polling, rss, github, http, automation, monitoring] + category: devops + requires_toolsets: [terminal] + related_skills: [] +--- + +# Watchers + +Poll external sources on an interval and react only to new items. Three ready-made scripts plus a shared watermark helper; wire them into a cron job (or run them ad-hoc from the terminal). + +## When to Use + +- User wants to watch an RSS/Atom feed and be notified of new entries +- User wants to watch a GitHub repo's issues / pulls / releases / commits +- User wants to poll an arbitrary JSON endpoint and get notified on new items +- User asks for "a watcher for X" or "notify me when X changes" + +## Mental model + +A watcher is just a script that: + +1. Fetches data from the external source +2. Compares against a watermark file of previously-seen IDs +3. Writes the new watermark back +4. Prints new items to stdout (or nothing on no-change) + +The scripts below handle all three. The agent runs them via the terminal tool — from a cron job, a webhook, or an interactive chat — and reports what's new. + +## Ready-made scripts + +All three live in `$HERMES_HOME/skills/devops/watchers/scripts/` once the skill is installed. Each reads `WATCHER_STATE_DIR` (defaults to `$HERMES_HOME/watcher-state/`) for its state file, keyed by the `--name` argument. + +| Script | What it watches | Dedup key | +|---|---|---| +| `watch_rss.py` | RSS 2.0 or Atom feed URL | `` / `` | +| `watch_http_json.py` | Any JSON endpoint returning a list of objects | Configurable id field | +| `watch_github.py` | GitHub issues / pulls / releases / commits for a repo | `id` / `sha` | + +All three: + +- First run records a baseline — never replays existing feed +- Watermark is a bounded ID set (max 500) to cap memory +- Output format: `## \n<url>\n\n<optional body>` per item +- Empty stdout on no-new — the caller treats that as silent +- Non-zero exit on fetch errors + +## Usage + +Run a watcher directly from the terminal tool: + +```bash +python $HERMES_HOME/skills/devops/watchers/scripts/watch_rss.py \ + --name hn --url https://news.ycombinator.com/rss --max 5 +``` + +Watch a GitHub repo (set `GITHUB_TOKEN` in `~/.hermes/.env` to avoid the 60 req/hr anonymous rate limit): + +```bash +python $HERMES_HOME/skills/devops/watchers/scripts/watch_github.py \ + --name hermes-issues --repo NousResearch/hermes-agent --scope issues +``` + +Poll an arbitrary JSON API: + +```bash +python $HERMES_HOME/skills/devops/watchers/scripts/watch_http_json.py \ + --name api --url https://api.example.com/events \ + --id-field event_id --items-path data.events +``` + +## Wiring into cron + +Ask the agent to schedule a cron job with a prompt like: + +> Every 15 minutes, run `watch_rss.py --name hn --url https://news.ycombinator.com/rss`. If it prints anything, summarize the headlines and deliver them. If it prints nothing, stay silent. + +The agent invokes the script via the terminal tool inside the cron job's agent loop; no changes to cron's built-in `--script` flag are needed. + +## State files + +Every watcher writes `$HERMES_HOME/watcher-state/<name>.json`. Inspect: + +```bash +cat $HERMES_HOME/watcher-state/hn.json +``` + +Force a replay (next run treated as first poll): + +```bash +rm $HERMES_HOME/watcher-state/hn.json +``` + +## Writing your own + +All three scripts use the same template: load watermark, fetch, diff, save, emit. `scripts/_watermark.py` is the shared helper; import it to get atomic writes + bounded ID set + first-run baseline for free. See any of the three reference scripts for how little boilerplate it takes. + +## Common Pitfalls + +1. **Printing a "no new items" header every tick.** Callers rely on empty stdout = silent. If you print anything on an empty delta, you spam the channel. The shipped scripts handle this; custom scripts must too. +2. **Expecting the first run to emit items.** It won't — first run records a baseline. If you need an initial digest, delete the state file after the first run or add a `--prime-with-latest N` flag in your own script. +3. **Unbounded watermark growth.** The shared helper caps at 500 IDs. Raise it for high-churn feeds; lower it on constrained filesystems. +4. **Putting the state dir where the agent's sandbox can't write.** `$HERMES_HOME/watcher-state/` is always writable. Docker/Modal backends may not see arbitrary host paths. + diff --git a/optional-skills/devops/watchers/scripts/_watermark.py b/optional-skills/devops/watchers/scripts/_watermark.py new file mode 100755 index 00000000000..719b6804eb1 --- /dev/null +++ b/optional-skills/devops/watchers/scripts/_watermark.py @@ -0,0 +1,148 @@ +"""Shared watermark helper used by the three watcher scripts. + +A watermark is just a JSON file that records the IDs we've seen on previous +runs, so the next run only emits items we haven't seen before. + +Contract: +- First run: record all IDs from the fetched batch, emit nothing. +- Subsequent runs: emit items whose ID isn't in the stored set. +- Bounded: keep at most `max_seen` IDs (default 500). +- Atomic: write to a .tmp file and rename, so a crashed script can't + leave a half-written state file that permanently breaks dedup. + +Import and use from any custom watcher script: + + from _watermark import Watermark + + wm = Watermark.load("my-feed-name") + new_items = wm.filter_new(fetched_items, id_key="id") + wm.save() +""" + +from __future__ import annotations + +import json +import os +from pathlib import Path +from typing import Any, Dict, Iterable, List, Optional + + +def _state_dir() -> Path: + """Where watermark files live — respects WATCHER_STATE_DIR override.""" + override = os.environ.get("WATCHER_STATE_DIR") + if override: + return Path(override) + # Default: $HERMES_HOME/watcher-state/, falling back to ~/.hermes/watcher-state/. + hermes_home = os.environ.get("HERMES_HOME") or str(Path.home() / ".hermes") + return Path(hermes_home) / "watcher-state" + + +class Watermark: + """Per-watcher state. Persisted to <state_dir>/<name>.json.""" + + def __init__(self, name: str, *, max_seen: int = 500) -> None: + if not name or not name.replace("-", "").replace("_", "").isalnum(): + raise ValueError( + f"watermark name must be alphanumeric + '-'/'_' (got {name!r})" + ) + self.name = name + self.max_seen = max_seen + self._path = _state_dir() / f"{name}.json" + self._data: Dict[str, Any] = {"seen_ids": [], "first_run": True} + + @classmethod + def load(cls, name: str, *, max_seen: int = 500) -> "Watermark": + wm = cls(name, max_seen=max_seen) + if wm._path.exists(): + try: + wm._data = json.loads(wm._path.read_text(encoding="utf-8")) + wm._data.setdefault("seen_ids", []) + wm._data["first_run"] = False + except (OSError, json.JSONDecodeError): + # Corrupt state file — treat as a first run but don't crash. + wm._data = {"seen_ids": [], "first_run": True} + return wm + + @property + def is_first_run(self) -> bool: + return bool(self._data.get("first_run", True)) + + @property + def seen(self) -> List[str]: + return list(self._data.get("seen_ids", [])) + + def filter_new( + self, items: Iterable[Dict[str, Any]], *, id_key: str = "id" + ) -> List[Dict[str, Any]]: + """Return items whose id isn't in the stored set. + + Side effect: updates the in-memory seen set with every id in the + batch (so save() persists the full new watermark). On first run, + records every id but returns an empty list (baseline, no replay). + """ + existing = set(str(x) for x in self._data.get("seen_ids", [])) + was_first_run = self.is_first_run + + new_items: List[Dict[str, Any]] = [] + batch_ids: List[str] = [] + for item in items: + ident = item.get(id_key) + if ident is None: + continue + ident_str = str(ident) + batch_ids.append(ident_str) + if ident_str in existing: + continue + if was_first_run: + continue # record but don't emit + new_items.append(item) + + combined = list(existing) + [i for i in batch_ids if i not in existing] + if len(combined) > self.max_seen: + combined = combined[-self.max_seen:] + self._data["seen_ids"] = combined + self._data["first_run"] = False + return new_items + + def save(self) -> None: + self._path.parent.mkdir(parents=True, exist_ok=True) + tmp = self._path.with_suffix(".tmp") + tmp.write_text( + json.dumps(self._data, indent=2, sort_keys=True), + encoding="utf-8", + ) + os.replace(tmp, self._path) + + +def format_items_as_markdown( + items: List[Dict[str, Any]], + *, + title_key: str = "title", + url_key: str = "url", + body_key: Optional[str] = None, + max_body_chars: int = 500, +) -> str: + """Render a list of items as Markdown for cron delivery. + + One heading per item + its URL + optional snippet of body. Output is + empty string when items is empty — cron will then treat stdout as + silent and skip delivery (existing behavior). + """ + if not items: + return "" + lines: List[str] = [] + for item in items: + title = (item.get(title_key) or "(no title)").strip() + url = (item.get(url_key) or "").strip() + lines.append(f"## {title}") + if url: + lines.append(url) + if body_key: + body = (item.get(body_key) or "").strip() + if body: + if len(body) > max_body_chars: + body = body[:max_body_chars].rstrip() + "…" + lines.append("") + lines.append(body) + lines.append("") + return "\n".join(lines).rstrip() + "\n" diff --git a/optional-skills/devops/watchers/scripts/watch_github.py b/optional-skills/devops/watchers/scripts/watch_github.py new file mode 100755 index 00000000000..bb4a3ca6f30 --- /dev/null +++ b/optional-skills/devops/watchers/scripts/watch_github.py @@ -0,0 +1,168 @@ +#!/usr/bin/env python3 +"""Watch GitHub activity — issues, pulls, releases, or commits — with dedup. + +Usage (via cron with --no-agent): + + hermes cron create hermes-issues \\ + --schedule "*/5 * * * *" --no-agent \\ + --script "$HERMES_HOME/skills/devops/watchers/scripts/watch_github.py" \\ + --script-args "--name hermes-issues --repo NousResearch/hermes-agent --scope issues" + +Set GITHUB_TOKEN (or GH_TOKEN) in ~/.hermes/.env to avoid the 60 req/hr +anonymous rate limit. + +Scopes: issues | pulls | releases | commits. Or pass --search QUERY to +use the /search/issues endpoint instead of /repos/:owner/:repo/:scope. +""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import sys +import urllib.error +import urllib.parse +import urllib.request +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from _watermark import Watermark, format_items_as_markdown # type: ignore + + +VALID_SCOPES = ("issues", "pulls", "releases", "commits") + + +def _flatten_commit(item): + """Commit objects nest title/author/date under 'commit' — flatten for rendering.""" + commit = item.get("commit") or {} + msg = (commit.get("message") or "").strip().splitlines() + title = msg[0] if msg else "" + body = "\n".join(msg[1:]).strip() if len(msg) > 1 else "" + author = (item.get("author") or {}).get("login") or (commit.get("author") or {}).get("name", "") + date = (commit.get("author") or {}).get("date", "") + return { + "id": item.get("sha", ""), + "title": f"{title} ({author})" if author else title, + "url": item.get("html_url"), + "body": body, + "created_at": date, + } + + +def _flatten_issue_or_release(item): + return { + "id": str(item.get("id", "")), + "title": item.get("title") or item.get("name") or "", + "url": item.get("html_url") or item.get("url"), + "body": (item.get("body") or "").strip(), + "state": item.get("state"), + "author": (item.get("user") or {}).get("login") + or (item.get("author") or {}).get("login"), + "created_at": item.get("created_at"), + } + + +def main() -> int: + p = argparse.ArgumentParser(description="Watch GitHub issues / pulls / releases / commits.") + p.add_argument("--name", required=True, help="Watcher name (used for state file)") + p.add_argument("--repo", default="", + help="owner/name of the repo (one of --repo or --search is required)") + p.add_argument("--scope", default="issues", choices=VALID_SCOPES, + help="What to poll (default: issues)") + p.add_argument("--search", default="", + help="GitHub issues search query (alternative to --repo/--scope)") + p.add_argument("--per-page", type=int, default=30, + help="Results per page (default: 30, max: 100)") + p.add_argument("--max", type=int, default=20, + help="Max new items to emit per tick (default: 20)") + p.add_argument("--with-body", action="store_true", + help="Include issue/commit body as a snippet under each item") + p.add_argument("--timeout", type=float, default=30.0, + help="HTTP timeout in seconds (default: 30)") + args = p.parse_args() + + if not args.repo and not args.search: + print("watch_github: one of --repo or --search is required", file=sys.stderr) + return 2 + if args.repo and not re.fullmatch(r"[A-Za-z0-9._-]+/[A-Za-z0-9._-]+", args.repo): + print(f"watch_github: --repo must be owner/name (got {args.repo!r})", file=sys.stderr) + return 2 + + # URL + flattening strategy. + if args.search: + url = ( + "https://api.github.com/search/issues" + f"?q={urllib.parse.quote(args.search)}&per_page={args.per_page}" + ) + flatten = _flatten_issue_or_release + items_path = "items" + elif args.scope == "commits": + url = f"https://api.github.com/repos/{args.repo}/commits?per_page={args.per_page}" + flatten = _flatten_commit + items_path = "" + else: + url = ( + f"https://api.github.com/repos/{args.repo}/{args.scope}" + f"?per_page={args.per_page}&state=all" + ) + flatten = _flatten_issue_or_release + items_path = "" + + headers = { + "Accept": "application/vnd.github+json", + "User-Agent": "Hermes-Watcher/1.0", + } + token = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN") + if token: + headers["Authorization"] = f"Bearer {token}" + + req = urllib.request.Request(url) + for k, v in headers.items(): + req.add_header(k, v) + + try: + with urllib.request.urlopen(req, timeout=args.timeout) as resp: + raw = resp.read() + except urllib.error.HTTPError as e: + print(f"watch_github: HTTP {e.code} from {url}", file=sys.stderr) + return 2 + except (urllib.error.URLError, TimeoutError, OSError) as e: + print(f"watch_github: network error: {e}", file=sys.stderr) + return 2 + + try: + data = json.loads(raw.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError) as e: + print(f"watch_github: response is not valid JSON: {e}", file=sys.stderr) + return 2 + + # Drill into items_path if needed (search endpoint returns {"items":[...]}). + if items_path: + data = data.get(items_path) if isinstance(data, dict) else None + if not isinstance(data, list): + print(f"watch_github: expected a list of items; got {type(data).__name__}", + file=sys.stderr) + return 2 + + items = [flatten(i) for i in data if isinstance(i, dict)] + # Drop any items that flattened without an ID (defensive). + items = [i for i in items if i.get("id")] + + wm = Watermark.load(args.name) + new_items = wm.filter_new(items, id_key="id") + wm.save() + + if args.max > 0: + new_items = new_items[: args.max] + + body_key = "body" if args.with_body else None + output = format_items_as_markdown(new_items, body_key=body_key) + if output: + sys.stdout.write(output) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/optional-skills/devops/watchers/scripts/watch_http_json.py b/optional-skills/devops/watchers/scripts/watch_http_json.py new file mode 100755 index 00000000000..6d8be8c5413 --- /dev/null +++ b/optional-skills/devops/watchers/scripts/watch_http_json.py @@ -0,0 +1,131 @@ +#!/usr/bin/env python3 +"""Watch any JSON endpoint that returns a list of objects; dedup by ID field. + +Usage (via cron with --no-agent): + + hermes cron create api-events \\ + --schedule "*/1 * * * *" --no-agent \\ + --script "$HERMES_HOME/skills/devops/watchers/scripts/watch_http_json.py" \\ + --script-args "--name api --url https://api.example.com/events \\ + --id-field event_id --items-path data.events" + +The response can be: + - a top-level JSON list (default), or + - a JSON object with a dotted ``--items-path`` pointing to the list. + +Each item is deduped by ``--id-field`` (default "id"). + +Optional ``--header KEY:VALUE`` flags pass HTTP headers (repeatable). +""" + +from __future__ import annotations + +import argparse +import json +import sys +import urllib.error +import urllib.request +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from _watermark import Watermark, format_items_as_markdown # type: ignore + + +def _dig(obj, path: str): + """Dotted-path lookup: _dig({'a':{'b':[1,2]}}, 'a.b') → [1,2].""" + if not path: + return obj + cur = obj + for part in path.split("."): + if isinstance(cur, dict) and part in cur: + cur = cur[part] + else: + return None + return cur + + +def _parse_header(s: str): + if ":" not in s: + raise argparse.ArgumentTypeError( + f"--header expects 'KEY: VALUE' (got {s!r})" + ) + k, v = s.split(":", 1) + return (k.strip(), v.strip()) + + +def main() -> int: + p = argparse.ArgumentParser(description="Poll a JSON endpoint.") + p.add_argument("--name", required=True, help="Watcher name (used for state file)") + p.add_argument("--url", required=True, help="JSON endpoint URL") + p.add_argument("--id-field", default="id", + help="Field used to dedup items (default: 'id')") + p.add_argument("--items-path", default="", + help="Dotted path to the list inside the JSON response (e.g. 'data.events')") + p.add_argument("--title-field", default="title", + help="Field used as the item title in the rendered output (default: 'title')") + p.add_argument("--url-field", default="url", + help="Field used as the item URL in the rendered output (default: 'url')") + p.add_argument("--body-field", default="", + help="Optional body field to include as a snippet under each item") + p.add_argument("--max", type=int, default=20, + help="Max new items to emit per tick (default: 20)") + p.add_argument("--header", action="append", type=_parse_header, default=[], + metavar="KEY: VALUE", + help="HTTP header (repeatable)") + p.add_argument("--timeout", type=float, default=20.0, + help="HTTP timeout in seconds (default: 20)") + args = p.parse_args() + + req = urllib.request.Request(args.url, headers={"User-Agent": "Hermes-Watcher/1.0"}) + for k, v in args.header: + req.add_header(k, v) + + try: + with urllib.request.urlopen(req, timeout=args.timeout) as resp: + raw = resp.read() + except urllib.error.HTTPError as e: + print(f"watch_http_json: HTTP {e.code} from {args.url}", file=sys.stderr) + return 2 + except (urllib.error.URLError, TimeoutError, OSError) as e: + print(f"watch_http_json: network error: {e}", file=sys.stderr) + return 2 + + try: + data = json.loads(raw.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError) as e: + print(f"watch_http_json: response is not valid JSON: {e}", file=sys.stderr) + return 2 + + items = _dig(data, args.items_path) if args.items_path else data + if not isinstance(items, list): + print( + f"watch_http_json: items_path={args.items_path!r} did not resolve to a list " + f"(got {type(items).__name__})", + file=sys.stderr, + ) + return 2 + + # Keep only dicts — skip any bare strings / numbers so filter_new doesn't crash. + items = [i for i in items if isinstance(i, dict)] + + wm = Watermark.load(args.name) + new_items = wm.filter_new(items, id_key=args.id_field) + wm.save() + + if args.max > 0: + new_items = new_items[: args.max] + + body_key = args.body_field or None + output = format_items_as_markdown( + new_items, + title_key=args.title_field, + url_key=args.url_field, + body_key=body_key, + ) + if output: + sys.stdout.write(output) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/optional-skills/devops/watchers/scripts/watch_rss.py b/optional-skills/devops/watchers/scripts/watch_rss.py new file mode 100755 index 00000000000..cc729f91b13 --- /dev/null +++ b/optional-skills/devops/watchers/scripts/watch_rss.py @@ -0,0 +1,121 @@ +#!/usr/bin/env python3 +"""Watch an RSS 2.0 or Atom feed; print new items to stdout, silent on empty. + +Usage (via cron with --no-agent): + + hermes cron create my-feed \\ + --schedule "*/15 * * * *" --no-agent \\ + --script "$HERMES_HOME/skills/devops/watchers/scripts/watch_rss.py" \\ + --script-args "--name hn --url https://news.ycombinator.com/rss" + +First run records a baseline (emits nothing). Subsequent runs emit only +items whose <guid> / <id> isn't in the watermark. +""" + +from __future__ import annotations + +import argparse +import sys +import urllib.error +import urllib.request +from pathlib import Path +from xml.etree import ElementTree as ET + +sys.path.insert(0, str(Path(__file__).parent)) +from _watermark import Watermark, format_items_as_markdown # type: ignore + + +def _strip_ns(tag: str) -> str: + return tag.split("}", 1)[1] if "}" in tag else tag + + +def _parse_feed(xml_bytes: bytes): + """Return a list of {id, title, url, summary} dicts. + + Handles both RSS 2.0 ``<item>`` and Atom ``<entry>``. + """ + try: + root = ET.fromstring(xml_bytes) + except ET.ParseError as e: + print(f"watch_rss: invalid XML: {e}", file=sys.stderr) + sys.exit(2) + + entries = [] + for item in root.iter(): + tag = _strip_ns(item.tag) + if tag not in ("item", "entry"): + continue + # ElementTree Elements without children are *falsy* — use `is not None`. + children = {_strip_ns(c.tag): c for c in item} + + guid_el = children.get("guid") + if guid_el is None: + guid_el = children.get("id") + link_el = children.get("link") + if link_el is not None: + href = link_el.attrib.get("href") or (link_el.text or "").strip() + else: + href = "" + guid = (guid_el.text or "").strip() if guid_el is not None else "" + guid = guid or href + if not guid: + continue + + title_el = children.get("title") + title = (title_el.text or "").strip() if title_el is not None else "" + + summ_el = children.get("description") + if summ_el is None: + summ_el = children.get("summary") + summary = (summ_el.text or "").strip() if summ_el is not None else "" + + entries.append( + {"id": guid, "title": title, "url": href, "summary": summary} + ) + return entries + + +def main() -> int: + p = argparse.ArgumentParser(description="Watch an RSS/Atom feed.") + p.add_argument("--name", required=True, help="Watcher name (used for state file)") + p.add_argument("--url", required=True, help="Feed URL") + p.add_argument("--max", type=int, default=10, + help="Max new items to emit per tick (default: 10)") + p.add_argument("--with-summary", action="store_true", + help="Include <description>/<summary> snippet under each item") + p.add_argument("--timeout", type=float, default=20.0, + help="HTTP timeout in seconds (default: 20)") + args = p.parse_args() + + try: + req = urllib.request.Request(args.url, headers={"User-Agent": "Hermes-Watcher/1.0"}) + with urllib.request.urlopen(req, timeout=args.timeout) as resp: + xml_bytes = resp.read() + except urllib.error.HTTPError as e: + print(f"watch_rss: HTTP {e.code} from {args.url}", file=sys.stderr) + return 2 + except (urllib.error.URLError, TimeoutError, OSError) as e: + print(f"watch_rss: network error: {e}", file=sys.stderr) + return 2 + + entries = _parse_feed(xml_bytes) + + wm = Watermark.load(args.name) + new_items = wm.filter_new(entries, id_key="id") + wm.save() + + # Cap emitted items (watermark still records all seen IDs so we don't + # re-emit them next tick). + if args.max > 0: + new_items = new_items[: args.max] + + body_key = "summary" if args.with_summary else None + output = format_items_as_markdown(new_items, body_key=body_key) + if output: + sys.stdout.write(output) + # Empty stdout on no-new — cron treats that as silent. + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From a152c706b7bbde3efc921e86f302c75fdaef99a2 Mon Sep 17 00:00:00 2001 From: Dilee <uzmpsk.dilekakbas@gmail.com> Date: Thu, 7 May 2026 16:25:19 +0300 Subject: [PATCH 21/25] feat(msgraph): add auth and client foundation --- tests/tools/test_microsoft_graph_auth.py | 149 ++++++++++ tests/tools/test_microsoft_graph_client.py | 152 ++++++++++ tools/microsoft_graph_auth.py | 245 +++++++++++++++ tools/microsoft_graph_client.py | 327 +++++++++++++++++++++ 4 files changed, 873 insertions(+) create mode 100644 tests/tools/test_microsoft_graph_auth.py create mode 100644 tests/tools/test_microsoft_graph_client.py create mode 100644 tools/microsoft_graph_auth.py create mode 100644 tools/microsoft_graph_client.py diff --git a/tests/tools/test_microsoft_graph_auth.py b/tests/tools/test_microsoft_graph_auth.py new file mode 100644 index 00000000000..b969afe47c4 --- /dev/null +++ b/tests/tools/test_microsoft_graph_auth.py @@ -0,0 +1,149 @@ +"""Tests for tools/microsoft_graph_auth.py.""" + +from __future__ import annotations + +import httpx +import pytest + +from tools.microsoft_graph_auth import ( + DEFAULT_GRAPH_SCOPE, + GraphCredentials, + MicrosoftGraphConfigError, + MicrosoftGraphTokenError, + MicrosoftGraphTokenProvider, +) + + +class TestGraphCredentials: + def test_from_env_raises_for_missing_required_values(self): + with pytest.raises(MicrosoftGraphConfigError) as exc: + GraphCredentials.from_env({}) + assert "MSGRAPH_TENANT_ID" in str(exc.value) + assert "MSGRAPH_CLIENT_ID" in str(exc.value) + assert "MSGRAPH_CLIENT_SECRET" in str(exc.value) + + def test_from_env_optional_returns_none_when_not_configured(self): + assert GraphCredentials.from_env({}, required=False) is None + + def test_from_env_builds_normalized_credentials(self): + creds = GraphCredentials.from_env( + { + "MSGRAPH_TENANT_ID": "tenant-123", + "MSGRAPH_CLIENT_ID": "client-456", + "MSGRAPH_CLIENT_SECRET": "secret-789", + } + ) + assert creds is not None + assert creds.scope == DEFAULT_GRAPH_SCOPE + assert creds.token_url.endswith("/tenant-123/oauth2/v2.0/token") + + +@pytest.mark.anyio +class TestMicrosoftGraphTokenProvider: + async def test_reuses_cached_token_until_expiry(self): + calls: list[int] = [] + + def handler(request: httpx.Request) -> httpx.Response: + calls.append(1) + return httpx.Response( + 200, + json={ + "access_token": f"token-{len(calls)}", + "expires_in": 3600, + "token_type": "Bearer", + }, + ) + + provider = MicrosoftGraphTokenProvider( + GraphCredentials("tenant", "client", "secret"), + transport=httpx.MockTransport(handler), + ) + + first = await provider.get_access_token() + second = await provider.get_access_token() + + assert first == "token-1" + assert second == "token-1" + assert len(calls) == 1 + + async def test_refreshes_when_cached_token_is_expired(self): + calls: list[int] = [] + + def handler(request: httpx.Request) -> httpx.Response: + calls.append(1) + expires_in = 0 if len(calls) == 1 else 3600 + return httpx.Response( + 200, + json={ + "access_token": f"token-{len(calls)}", + "expires_in": expires_in, + "token_type": "Bearer", + }, + ) + + provider = MicrosoftGraphTokenProvider( + GraphCredentials("tenant", "client", "secret"), + transport=httpx.MockTransport(handler), + skew_seconds=0, + ) + + first = await provider.get_access_token() + second = await provider.get_access_token() + + assert first == "token-1" + assert second == "token-2" + assert len(calls) == 2 + + async def test_force_refresh_bypasses_cache(self): + calls: list[int] = [] + + def handler(request: httpx.Request) -> httpx.Response: + calls.append(1) + return httpx.Response( + 200, + json={ + "access_token": f"token-{len(calls)}", + "expires_in": 3600, + }, + ) + + provider = MicrosoftGraphTokenProvider( + GraphCredentials("tenant", "client", "secret"), + transport=httpx.MockTransport(handler), + ) + + first = await provider.get_access_token() + second = await provider.get_access_token(force_refresh=True) + + assert first == "token-1" + assert second == "token-2" + assert len(calls) == 2 + + async def test_invalid_token_response_raises(self): + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(200, json={"expires_in": 3600}) + + provider = MicrosoftGraphTokenProvider( + GraphCredentials("tenant", "client", "secret"), + transport=httpx.MockTransport(handler), + ) + + with pytest.raises(MicrosoftGraphTokenError) as exc: + await provider.get_access_token() + assert "access_token" in str(exc.value) + + async def test_http_error_includes_server_message(self): + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response( + 401, + json={"error": "invalid_client", "error_description": "bad secret"}, + ) + + provider = MicrosoftGraphTokenProvider( + GraphCredentials("tenant", "client", "secret"), + transport=httpx.MockTransport(handler), + ) + + with pytest.raises(MicrosoftGraphTokenError) as exc: + await provider.get_access_token() + assert "bad secret" in str(exc.value) diff --git a/tests/tools/test_microsoft_graph_client.py b/tests/tools/test_microsoft_graph_client.py new file mode 100644 index 00000000000..e788856ff82 --- /dev/null +++ b/tests/tools/test_microsoft_graph_client.py @@ -0,0 +1,152 @@ +"""Tests for tools/microsoft_graph_client.py.""" + +from __future__ import annotations + +from pathlib import Path + +import httpx +import pytest + +from tools.microsoft_graph_auth import GraphCredentials, MicrosoftGraphTokenProvider +from tools.microsoft_graph_client import ( + MicrosoftGraphAPIError, + MicrosoftGraphClient, + MicrosoftGraphClientError, +) + + +def _make_provider() -> MicrosoftGraphTokenProvider: + provider = MicrosoftGraphTokenProvider(GraphCredentials("tenant", "client", "secret")) + provider._cached_token = type( # type: ignore[attr-defined] + "Token", + (), + { + "access_token": "cached-token", + "is_expired": lambda self, skew_seconds=0: False, + "expires_in_seconds": 3600, + }, + )() + return provider + + +@pytest.mark.anyio +class TestMicrosoftGraphClient: + async def test_attaches_bearer_token_header(self): + captured_auth: list[str] = [] + + def handler(request: httpx.Request) -> httpx.Response: + captured_auth.append(request.headers["Authorization"]) + return httpx.Response(200, json={"ok": True}) + + client = MicrosoftGraphClient( + _make_provider(), + transport=httpx.MockTransport(handler), + ) + payload = await client.get_json("/me") + assert payload == {"ok": True} + assert captured_auth == ["Bearer cached-token"] + + async def test_retries_on_rate_limit_and_uses_retry_after(self): + calls: list[int] = [] + sleeps: list[float] = [] + + def handler(request: httpx.Request) -> httpx.Response: + calls.append(1) + if len(calls) == 1: + return httpx.Response( + 429, + json={"error": {"code": "TooManyRequests", "message": "slow down"}}, + headers={"Retry-After": "3"}, + ) + return httpx.Response(200, json={"ok": True}) + + async def fake_sleep(delay: float) -> None: + sleeps.append(delay) + + client = MicrosoftGraphClient( + _make_provider(), + transport=httpx.MockTransport(handler), + sleep=fake_sleep, + max_retries=2, + ) + + payload = await client.get_json("/me") + + assert payload == {"ok": True} + assert len(calls) == 2 + assert sleeps == [3.0] + + async def test_raises_api_error_after_retry_budget_exhausted(self): + sleeps: list[float] = [] + + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(503, json={"error": {"message": "unavailable"}}) + + async def fake_sleep(delay: float) -> None: + sleeps.append(delay) + + client = MicrosoftGraphClient( + _make_provider(), + transport=httpx.MockTransport(handler), + sleep=fake_sleep, + max_retries=1, + ) + + with pytest.raises(MicrosoftGraphAPIError) as exc: + await client.get_json("/me") + assert exc.value.status_code == 503 + assert sleeps == [0.5] + + async def test_collect_paginated_flattens_value_arrays(self): + def handler(request: httpx.Request) -> httpx.Response: + if str(request.url).endswith("/items"): + return httpx.Response( + 200, + json={ + "value": [{"id": "1"}], + "@odata.nextLink": "https://graph.microsoft.com/v1.0/items?page=2", + }, + ) + return httpx.Response(200, json={"value": [{"id": "2"}]}) + + client = MicrosoftGraphClient( + _make_provider(), + transport=httpx.MockTransport(handler), + ) + items = await client.collect_paginated("/items") + assert items == [{"id": "1"}, {"id": "2"}] + + async def test_download_to_file_writes_binary_content(self, tmp_path: Path): + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response( + 200, + content=b"meeting-recording", + headers={"content-type": "video/mp4"}, + ) + + client = MicrosoftGraphClient( + _make_provider(), + transport=httpx.MockTransport(handler), + ) + destination = tmp_path / "recording.mp4" + result = await client.download_to_file("/drive/item/content", destination) + + assert destination.read_bytes() == b"meeting-recording" + assert result["content_type"] == "video/mp4" + assert result["size_bytes"] == len(b"meeting-recording") + + async def test_invalid_json_response_raises_client_error(self): + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response( + 200, + content=b"not-json", + headers={"content-type": "application/json"}, + ) + + client = MicrosoftGraphClient( + _make_provider(), + transport=httpx.MockTransport(handler), + ) + + with pytest.raises(MicrosoftGraphClientError): + await client.get_json("/me") diff --git a/tools/microsoft_graph_auth.py b/tools/microsoft_graph_auth.py new file mode 100644 index 00000000000..46e3aa38753 --- /dev/null +++ b/tools/microsoft_graph_auth.py @@ -0,0 +1,245 @@ +"""Microsoft Graph app-only authentication helpers.""" + +from __future__ import annotations + +import asyncio +import os +import time +from dataclasses import dataclass +from typing import Any + +import httpx + + +DEFAULT_GRAPH_SCOPE = "https://graph.microsoft.com/.default" +DEFAULT_GRAPH_AUTHORITY_URL = "https://login.microsoftonline.com" +DEFAULT_TOKEN_SKEW_SECONDS = 120 + + +class MicrosoftGraphAuthError(RuntimeError): + """Base class for Microsoft Graph auth failures.""" + + +class MicrosoftGraphConfigError(MicrosoftGraphAuthError): + """Raised when Graph credentials are missing or invalid.""" + + +class MicrosoftGraphTokenError(MicrosoftGraphAuthError): + """Raised when token acquisition fails.""" + + +@dataclass(frozen=True) +class GraphCredentials: + """Normalized Microsoft Graph app-only credentials.""" + + tenant_id: str + client_id: str + client_secret: str + scope: str = DEFAULT_GRAPH_SCOPE + authority_url: str = DEFAULT_GRAPH_AUTHORITY_URL + + @property + def token_url(self) -> str: + base = self.authority_url.rstrip("/") + tenant = self.tenant_id.strip().strip("/") + return f"{base}/{tenant}/oauth2/v2.0/token" + + @classmethod + def from_env( + cls, + environ: dict[str, str] | None = None, + *, + required: bool = True, + ) -> "GraphCredentials | None": + env = environ if environ is not None else os.environ + tenant_id = (env.get("MSGRAPH_TENANT_ID") or "").strip() + client_id = (env.get("MSGRAPH_CLIENT_ID") or "").strip() + client_secret = (env.get("MSGRAPH_CLIENT_SECRET") or "").strip() + scope = (env.get("MSGRAPH_SCOPE") or DEFAULT_GRAPH_SCOPE).strip() + authority_url = ( + env.get("MSGRAPH_AUTHORITY_URL") or DEFAULT_GRAPH_AUTHORITY_URL + ).strip() + + missing = [ + name + for name, value in ( + ("MSGRAPH_TENANT_ID", tenant_id), + ("MSGRAPH_CLIENT_ID", client_id), + ("MSGRAPH_CLIENT_SECRET", client_secret), + ) + if not value + ] + if missing: + if not required: + return None + raise MicrosoftGraphConfigError( + f"Missing Microsoft Graph configuration: {', '.join(missing)}" + ) + + return cls( + tenant_id=tenant_id, + client_id=client_id, + client_secret=client_secret, + scope=scope, + authority_url=authority_url, + ) + + +@dataclass +class CachedAccessToken: + """Cached app-only Graph access token.""" + + access_token: str + expires_at: float + token_type: str = "Bearer" + + def is_expired(self, *, skew_seconds: int = DEFAULT_TOKEN_SKEW_SECONDS) -> bool: + return self.expires_at <= (time.time() + max(0, int(skew_seconds))) + + @property + def expires_in_seconds(self) -> int: + return max(0, int(self.expires_at - time.time())) + + +class MicrosoftGraphTokenProvider: + """Acquire and cache Microsoft Graph app-only access tokens.""" + + def __init__( + self, + credentials: GraphCredentials, + *, + timeout: float = 20.0, + skew_seconds: int = DEFAULT_TOKEN_SKEW_SECONDS, + transport: httpx.AsyncBaseTransport | None = None, + ) -> None: + self.credentials = credentials + self.timeout = timeout + self.skew_seconds = max(0, int(skew_seconds)) + self._transport = transport + self._cached_token: CachedAccessToken | None = None + self._lock = asyncio.Lock() + + @classmethod + def from_env( + cls, + environ: dict[str, str] | None = None, + **kwargs: Any, + ) -> "MicrosoftGraphTokenProvider": + credentials = GraphCredentials.from_env(environ) + return cls(credentials, **kwargs) + + def clear_cache(self) -> None: + self._cached_token = None + + def inspect_token_health(self) -> dict[str, Any]: + cached = self._cached_token + return { + "configured": True, + "tenant_id": self.credentials.tenant_id, + "client_id": self.credentials.client_id, + "scope": self.credentials.scope, + "authority_url": self.credentials.authority_url, + "token_url": self.credentials.token_url, + "cached": bool(cached), + "expires_in_seconds": cached.expires_in_seconds if cached else None, + "is_expired": cached.is_expired(skew_seconds=0) if cached else None, + "refresh_skew_seconds": self.skew_seconds, + } + + async def get_access_token(self, *, force_refresh: bool = False) -> str: + cached = self._cached_token + if not force_refresh and cached and not cached.is_expired( + skew_seconds=self.skew_seconds + ): + return cached.access_token + + async with self._lock: + cached = self._cached_token + if not force_refresh and cached and not cached.is_expired( + skew_seconds=self.skew_seconds + ): + return cached.access_token + + token = await self._fetch_access_token() + self._cached_token = token + return token.access_token + + async def _fetch_access_token(self) -> CachedAccessToken: + data = { + "grant_type": "client_credentials", + "client_id": self.credentials.client_id, + "client_secret": self.credentials.client_secret, + "scope": self.credentials.scope, + } + headers = {"Content-Type": "application/x-www-form-urlencoded"} + + async with httpx.AsyncClient( + timeout=httpx.Timeout(self.timeout), + transport=self._transport, + ) as client: + response = await client.post( + self.credentials.token_url, + data=data, + headers=headers, + ) + + if response.status_code >= 400: + detail = _extract_error_detail(response) + raise MicrosoftGraphTokenError( + "Microsoft Graph token request failed with HTTP " + f"{response.status_code}: {detail}" + ) + + try: + payload = response.json() + except ValueError as exc: + raise MicrosoftGraphTokenError( + "Microsoft Graph token response was not valid JSON." + ) from exc + + access_token = str(payload.get("access_token") or "").strip() + token_type = str(payload.get("token_type") or "Bearer").strip() or "Bearer" + expires_in = payload.get("expires_in") + + if not access_token: + raise MicrosoftGraphTokenError( + "Microsoft Graph token response did not include access_token." + ) + + try: + expires_in_seconds = int(expires_in) + except (TypeError, ValueError) as exc: + raise MicrosoftGraphTokenError( + "Microsoft Graph token response did not include a valid expires_in." + ) from exc + + return CachedAccessToken( + access_token=access_token, + token_type=token_type, + expires_at=time.time() + max(0, expires_in_seconds), + ) + + +def _extract_error_detail(response: httpx.Response) -> str: + try: + payload = response.json() + except ValueError: + text = response.text.strip() + return text or "unknown error" + + if isinstance(payload, dict): + if isinstance(payload.get("error_description"), str): + return payload["error_description"] + error = payload.get("error") + if isinstance(error, dict): + message = error.get("message") + code = error.get("code") + if message and code: + return f"{code}: {message}" + if message: + return str(message) + if code: + return str(code) + if isinstance(error, str): + return error + return str(payload) diff --git a/tools/microsoft_graph_client.py b/tools/microsoft_graph_client.py new file mode 100644 index 00000000000..f92ba66c545 --- /dev/null +++ b/tools/microsoft_graph_client.py @@ -0,0 +1,327 @@ +"""Reusable Microsoft Graph REST client helpers.""" + +from __future__ import annotations + +import asyncio +import os +from pathlib import Path +from typing import Any, AsyncIterator, Awaitable, Callable + +import httpx + +from tools.microsoft_graph_auth import GraphCredentials, MicrosoftGraphTokenProvider + + +DEFAULT_GRAPH_BASE_URL = "https://graph.microsoft.com/v1.0" + + +class MicrosoftGraphClientError(RuntimeError): + """Base class for Graph client failures.""" + + +class MicrosoftGraphAPIError(MicrosoftGraphClientError): + """Raised when a Graph API request fails.""" + + def __init__( + self, + status_code: int, + method: str, + url: str, + message: str, + *, + retry_after_seconds: float | None = None, + payload: Any = None, + ) -> None: + self.status_code = status_code + self.method = method + self.url = url + self.retry_after_seconds = retry_after_seconds + self.payload = payload + super().__init__( + f"Microsoft Graph API error {status_code} for {method} {url}: {message}" + ) + + +class MicrosoftGraphClient: + """Minimal async Microsoft Graph client with retries and pagination.""" + + def __init__( + self, + token_provider: MicrosoftGraphTokenProvider, + *, + base_url: str = DEFAULT_GRAPH_BASE_URL, + timeout: float = 60.0, + max_retries: int = 3, + transport: httpx.AsyncBaseTransport | None = None, + sleep: Callable[[float], Awaitable[None]] | None = None, + user_agent: str = "Hermes-Agent/graph-client", + ) -> None: + self.token_provider = token_provider + self.base_url = base_url.rstrip("/") + self.timeout = timeout + self.max_retries = max(0, int(max_retries)) + self._transport = transport + self._sleep = sleep or asyncio.sleep + self.user_agent = user_agent + + @classmethod + def from_env(cls, **kwargs: Any) -> "MicrosoftGraphClient": + credentials = GraphCredentials.from_env() + provider = MicrosoftGraphTokenProvider(credentials) + return cls(provider, **kwargs) + + async def get_json( + self, + path: str, + *, + params: dict[str, Any] | None = None, + headers: dict[str, str] | None = None, + ) -> Any: + response = await self._request("GET", path, params=params, headers=headers) + return self._decode_json(response) + + async def post_json( + self, + path: str, + *, + json_body: Any | None = None, + headers: dict[str, str] | None = None, + ) -> Any: + response = await self._request("POST", path, json_body=json_body, headers=headers) + return self._decode_json(response) + + async def patch_json( + self, + path: str, + *, + json_body: Any | None = None, + headers: dict[str, str] | None = None, + ) -> Any: + response = await self._request("PATCH", path, json_body=json_body, headers=headers) + if response.status_code == 204 or not response.content: + return {} + return self._decode_json(response) + + async def delete( + self, + path: str, + *, + headers: dict[str, str] | None = None, + ) -> dict[str, Any]: + response = await self._request("DELETE", path, headers=headers) + if response.status_code == 204 or not response.content: + return {"deleted": True, "status_code": response.status_code} + return self._decode_json(response) + + async def iterate_pages( + self, + path: str, + *, + params: dict[str, Any] | None = None, + headers: dict[str, str] | None = None, + ) -> AsyncIterator[dict[str, Any]]: + next_url: str | None = self._resolve_url(path) + next_params = dict(params or {}) + while next_url: + response = await self._request( + "GET", + next_url, + params=next_params or None, + headers=headers, + ) + payload = self._decode_json(response) + if not isinstance(payload, dict): + raise MicrosoftGraphClientError( + f"Expected paginated Graph response dict, got {type(payload).__name__}." + ) + yield payload + next_url = payload.get("@odata.nextLink") + next_params = {} + + async def collect_paginated( + self, + path: str, + *, + params: dict[str, Any] | None = None, + headers: dict[str, str] | None = None, + ) -> list[Any]: + items: list[Any] = [] + async for page in self.iterate_pages(path, params=params, headers=headers): + value = page.get("value") + if isinstance(value, list): + items.extend(value) + return items + + async def download_to_file( + self, + path: str, + destination: str | Path, + *, + headers: dict[str, str] | None = None, + chunk_size: int = 65536, + ) -> dict[str, Any]: + response = await self._request("GET", path, headers=headers) + target = Path(destination) + target.parent.mkdir(parents=True, exist_ok=True) + tmp_target = target.with_suffix(target.suffix + ".part") + with tmp_target.open("wb") as handle: + async for chunk in response.aiter_bytes(chunk_size=chunk_size): + if chunk: + handle.write(chunk) + os.replace(tmp_target, target) + return { + "path": str(target), + "size_bytes": target.stat().st_size, + "content_type": response.headers.get("content-type"), + } + + async def _request( + self, + method: str, + path_or_url: str, + *, + params: dict[str, Any] | None = None, + json_body: Any | None = None, + headers: dict[str, str] | None = None, + ) -> httpx.Response: + url = self._resolve_url(path_or_url) + attempt = 0 + last_error: Exception | None = None + + while attempt <= self.max_retries: + token = await self.token_provider.get_access_token( + force_refresh=attempt > 0 and self._should_refresh_token(last_error) + ) + request_headers = { + "Authorization": f"Bearer {token}", + "Accept": "application/json", + "User-Agent": self.user_agent, + } + if json_body is not None: + request_headers["Content-Type"] = "application/json" + if headers: + request_headers.update(headers) + + try: + async with httpx.AsyncClient( + timeout=httpx.Timeout(self.timeout), + transport=self._transport, + ) as client: + response = await client.request( + method, + url, + params=params, + json=json_body, + headers=request_headers, + ) + except httpx.HTTPError as exc: + last_error = exc + if attempt >= self.max_retries: + raise MicrosoftGraphClientError( + f"Microsoft Graph request failed for {method} {url}: {exc}" + ) from exc + await self._sleep(self._retry_delay(None, attempt)) + attempt += 1 + continue + + if response.status_code < 400: + return response + + api_error = self._build_api_error(method, url, response) + last_error = api_error + + if response.status_code == 401 and attempt < self.max_retries: + self.token_provider.clear_cache() + await self._sleep(self._retry_delay(response, attempt)) + attempt += 1 + continue + + if self._should_retry(response) and attempt < self.max_retries: + await self._sleep(self._retry_delay(response, attempt)) + attempt += 1 + continue + + raise api_error + + raise MicrosoftGraphClientError( + f"Microsoft Graph request exhausted retries for {method} {url}." + ) + + def _resolve_url(self, path_or_url: str) -> str: + if path_or_url.startswith(("http://", "https://")): + return path_or_url + path = path_or_url if path_or_url.startswith("/") else f"/{path_or_url}" + return f"{self.base_url}{path}" + + @staticmethod + def _decode_json(response: httpx.Response) -> Any: + try: + return response.json() + except ValueError as exc: + raise MicrosoftGraphClientError( + "Microsoft Graph response was not valid JSON for " + f"{response.request.method} {response.request.url}" + ) from exc + + @staticmethod + def _should_retry(response: httpx.Response | None) -> bool: + if response is None: + return True + return response.status_code == 429 or 500 <= response.status_code < 600 + + @staticmethod + def _should_refresh_token(error: Exception | None) -> bool: + return isinstance(error, MicrosoftGraphAPIError) and error.status_code == 401 + + @staticmethod + def _retry_delay(response: httpx.Response | None, attempt: int) -> float: + if response is not None: + retry_after = response.headers.get("Retry-After") + if retry_after: + try: + return max(0.0, float(retry_after)) + except ValueError: + pass + return min(8.0, 0.5 * (2 ** attempt)) + + @staticmethod + def _build_api_error( + method: str, + url: str, + response: httpx.Response, + ) -> MicrosoftGraphAPIError: + payload: Any = None + message = response.text.strip() or "unknown error" + try: + payload = response.json() + except ValueError: + payload = None + + if isinstance(payload, dict): + error = payload.get("error") + if isinstance(error, dict): + code = error.get("code") + inner_message = error.get("message") + if code and inner_message: + message = f"{code}: {inner_message}" + elif inner_message: + message = str(inner_message) + elif isinstance(error, str): + message = error + + retry_after: float | None = None + header_value = response.headers.get("Retry-After") + if header_value: + try: + retry_after = float(header_value) + except ValueError: + retry_after = None + + return MicrosoftGraphAPIError( + response.status_code, + method, + url, + message, + retry_after_seconds=retry_after, + payload=payload, + ) From b878f89f669cefbea3d24ba49b39aaf22c640469 Mon Sep 17 00:00:00 2001 From: Dilee <uzmpsk.dilekakbas@gmail.com> Date: Thu, 7 May 2026 17:10:50 +0300 Subject: [PATCH 22/25] test(msgraph): cover concurrent token cache reuse --- tests/tools/test_microsoft_graph_auth.py | 30 ++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/tests/tools/test_microsoft_graph_auth.py b/tests/tools/test_microsoft_graph_auth.py index b969afe47c4..4c45ca2c29e 100644 --- a/tests/tools/test_microsoft_graph_auth.py +++ b/tests/tools/test_microsoft_graph_auth.py @@ -2,10 +2,13 @@ from __future__ import annotations +import asyncio + import httpx import pytest from tools.microsoft_graph_auth import ( + CachedAccessToken, DEFAULT_GRAPH_SCOPE, GraphCredentials, MicrosoftGraphConfigError, @@ -66,6 +69,33 @@ class TestMicrosoftGraphTokenProvider: assert second == "token-1" assert len(calls) == 1 + async def test_concurrent_calls_share_one_token_fetch(self): + calls: list[int] = [] + + provider = MicrosoftGraphTokenProvider( + GraphCredentials("tenant", "client", "secret"), + ) + + async def _fake_fetch(): + calls.append(1) + await asyncio.sleep(0) + return CachedAccessToken( + access_token="token-1", + token_type="Bearer", + expires_at=9_999_999_999, + ) + + provider._fetch_access_token = _fake_fetch # type: ignore[method-assign] + + first, second = await asyncio.gather( + provider.get_access_token(), + provider.get_access_token(), + ) + + assert first == "token-1" + assert second == "token-1" + assert len(calls) == 1 + async def test_refreshes_when_cached_token_is_expired(self): calls: list[int] = [] From 45d860d424ffbfd143c66ce0ce266c321cd89006 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Fri, 8 May 2026 08:00:45 -0700 Subject: [PATCH 23/25] fix(msgraph): stream download_to_file body instead of buffering The prior implementation routed download_to_file through the shared _request() path, which uses httpx.AsyncClient.request() inside a context manager that closes before aiter_bytes() iterates. The body was read into memory first and the chunked write loop replayed it from buffer. On small test payloads this was invisible; on real Teams meeting recordings (hundreds of MB) it would force the full artifact into RAM per download. Rewrites download_to_file to open its own AsyncClient and use client.stream(), keeping the context open across the aiter_bytes iteration so the body is actually streamed chunk-by-chunk to disk. Retry/token-refresh/Retry-After semantics are preserved by handling them inline on the stream path. Partial .part files are cleaned up on transport errors and on exhausted retries. Adds three tests: large-payload streaming verifies the chunk loop runs multiple times (discriminator: 512 KiB at chunk_size=65536 yields 8 chunks under streaming, 1 under buffering), transient-5xx retry recovers after a single retry, and exhausted-retry cleans up the partial file. --- tests/tools/test_microsoft_graph_client.py | 105 +++++++++++++++++++++ tools/microsoft_graph_client.py | 103 +++++++++++++++++--- 2 files changed, 197 insertions(+), 11 deletions(-) diff --git a/tests/tools/test_microsoft_graph_client.py b/tests/tools/test_microsoft_graph_client.py index e788856ff82..b0f6ba31e3a 100644 --- a/tests/tools/test_microsoft_graph_client.py +++ b/tests/tools/test_microsoft_graph_client.py @@ -135,6 +135,111 @@ class TestMicrosoftGraphClient: assert result["content_type"] == "video/mp4" assert result["size_bytes"] == len(b"meeting-recording") + async def test_download_to_file_streams_large_payload_in_chunks( + self, tmp_path: Path, monkeypatch + ): + """Recordings can be hundreds of MB; verify the body is streamed. + + Uses a payload larger than the chunk size and counts how many + ``aiter_bytes`` iterations the download loop performs. If the + response were buffered in memory before the loop ran, only one + non-empty chunk would be yielded. + """ + payload = b"x" * (512 * 1024) # 512 KiB + + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response( + 200, + content=payload, + headers={"content-type": "video/mp4"}, + ) + + chunk_calls: list[int] = [] + original_aiter_bytes = httpx.Response.aiter_bytes + + async def counting_aiter_bytes(self, chunk_size: int | None = None): + async for chunk in original_aiter_bytes(self, chunk_size): + chunk_calls.append(len(chunk)) + yield chunk + + monkeypatch.setattr(httpx.Response, "aiter_bytes", counting_aiter_bytes) + + client = MicrosoftGraphClient( + _make_provider(), + transport=httpx.MockTransport(handler), + ) + destination = tmp_path / "big-recording.mp4" + result = await client.download_to_file( + "/drive/item/content", destination, chunk_size=65536 + ) + + assert destination.read_bytes() == payload + assert result["size_bytes"] == len(payload) + assert len(chunk_calls) >= 2, ( + "Expected multiple chunks; got a single chunk " + f"which suggests the body was buffered: {chunk_calls}" + ) + assert not (tmp_path / "big-recording.mp4.part").exists() + + async def test_download_to_file_retries_on_transient_server_error( + self, tmp_path: Path + ): + calls: list[int] = [] + sleeps: list[float] = [] + + def handler(request: httpx.Request) -> httpx.Response: + calls.append(1) + if len(calls) == 1: + return httpx.Response( + 503, json={"error": {"message": "unavailable"}} + ) + return httpx.Response( + 200, + content=b"payload", + headers={"content-type": "application/octet-stream"}, + ) + + async def fake_sleep(delay: float) -> None: + sleeps.append(delay) + + client = MicrosoftGraphClient( + _make_provider(), + transport=httpx.MockTransport(handler), + sleep=fake_sleep, + max_retries=2, + ) + destination = tmp_path / "artifact.bin" + result = await client.download_to_file("/drive/item/content", destination) + + assert destination.read_bytes() == b"payload" + assert result["size_bytes"] == len(b"payload") + assert len(calls) == 2 + assert sleeps == [0.5] + assert not (tmp_path / "artifact.bin.part").exists() + + async def test_download_to_file_cleans_partial_file_on_exhausted_retries( + self, tmp_path: Path + ): + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(503, json={"error": {"message": "unavailable"}}) + + async def fake_sleep(delay: float) -> None: + return None + + client = MicrosoftGraphClient( + _make_provider(), + transport=httpx.MockTransport(handler), + sleep=fake_sleep, + max_retries=1, + ) + destination = tmp_path / "artifact.bin" + + with pytest.raises(MicrosoftGraphAPIError): + await client.download_to_file("/drive/item/content", destination) + + assert not destination.exists() + assert not (tmp_path / "artifact.bin.part").exists() + async def test_invalid_json_response_raises_client_error(self): def handler(request: httpx.Request) -> httpx.Response: return httpx.Response( diff --git a/tools/microsoft_graph_client.py b/tools/microsoft_graph_client.py index f92ba66c545..dbdf211f6e4 100644 --- a/tools/microsoft_graph_client.py +++ b/tools/microsoft_graph_client.py @@ -160,20 +160,101 @@ class MicrosoftGraphClient: headers: dict[str, str] | None = None, chunk_size: int = 65536, ) -> dict[str, Any]: - response = await self._request("GET", path, headers=headers) + """Download a Graph resource to disk, streaming the response body. + + The body is written chunk-by-chunk via ``response.aiter_bytes`` with + the ``httpx.AsyncClient`` kept open for the duration of the iteration, + so recordings and other large artifacts do not need to fit in memory. + """ + url = self._resolve_url(path) target = Path(destination) target.parent.mkdir(parents=True, exist_ok=True) tmp_target = target.with_suffix(target.suffix + ".part") - with tmp_target.open("wb") as handle: - async for chunk in response.aiter_bytes(chunk_size=chunk_size): - if chunk: - handle.write(chunk) - os.replace(tmp_target, target) - return { - "path": str(target), - "size_bytes": target.stat().st_size, - "content_type": response.headers.get("content-type"), - } + + attempt = 0 + last_error: Exception | None = None + + while attempt <= self.max_retries: + token = await self.token_provider.get_access_token( + force_refresh=attempt > 0 and self._should_refresh_token(last_error) + ) + request_headers = { + "Authorization": f"Bearer {token}", + "Accept": "application/json", + "User-Agent": self.user_agent, + } + if headers: + request_headers.update(headers) + + try: + async with httpx.AsyncClient( + timeout=httpx.Timeout(self.timeout), + transport=self._transport, + ) as client: + async with client.stream( + "GET", + url, + headers=request_headers, + ) as response: + if response.status_code >= 400: + # Materialize error body so we can surface a meaningful + # message; error bodies are small. + await response.aread() + api_error = self._build_api_error("GET", url, response) + last_error = api_error + + if ( + response.status_code == 401 + and attempt < self.max_retries + ): + self.token_provider.clear_cache() + await self._sleep( + self._retry_delay(response, attempt) + ) + attempt += 1 + continue + + if ( + self._should_retry(response) + and attempt < self.max_retries + ): + await self._sleep( + self._retry_delay(response, attempt) + ) + attempt += 1 + continue + + raise api_error + + content_type = response.headers.get("content-type") + with tmp_target.open("wb") as handle: + async for chunk in response.aiter_bytes( + chunk_size=chunk_size + ): + if chunk: + handle.write(chunk) + except httpx.HTTPError as exc: + last_error = exc + tmp_target.unlink(missing_ok=True) + if attempt >= self.max_retries: + raise MicrosoftGraphClientError( + f"Microsoft Graph download failed for GET {url}: {exc}" + ) from exc + await self._sleep(self._retry_delay(None, attempt)) + attempt += 1 + continue + + os.replace(tmp_target, target) + return { + "path": str(target), + "size_bytes": target.stat().st_size, + "content_type": content_type, + } + + tmp_target.unlink(missing_ok=True) + raise MicrosoftGraphClientError( + f"Microsoft Graph download exhausted retries for GET {url}." + ) async def _request( self, From cf648a9b7e4f3a346451d543648ce76922971e1a Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Fri, 8 May 2026 08:18:16 -0700 Subject: [PATCH 24/25] docs(msgraph): add Azure app registration walkthrough + env var reference Foundation docs shipped alongside the Graph auth/client code so users have a working path from zero to a verified token from the moment this PR lands. - website/docs/guides/microsoft-graph-app-registration.md: new page walking through app registration, client secret, the exact minimum Graph API permissions per pipeline capability (transcript-first, recording fallback, Graph-mode delivery), admin consent, optional Application Access Policy for tenant-scoping, token-flow smoke test with the shipped MicrosoftGraphTokenProvider, and a troubleshooting table for common AADSTS errors. Includes secret-rotation procedure. - website/docs/reference/environment-variables.md: new Microsoft Graph subsection in Messaging documenting MSGRAPH_TENANT_ID, MSGRAPH_CLIENT_ID, MSGRAPH_CLIENT_SECRET, MSGRAPH_SCOPE (default .default), MSGRAPH_AUTHORITY_URL (with sovereign-cloud override note for GCC High etc.). - website/sidebars.ts: wire the guide into Guides Tutorials. The guide pages that cover the webhook listener, pipeline runtime, operator CLI, and outbound delivery land with their matching PRs. This one is the standalone prereq that's safe to verify in advance. Verified via npm run build: no new warnings or errors; page routes correctly at /docs/guides/microsoft-graph-app-registration. --- .../microsoft-graph-app-registration.md | 180 ++++++++++++++++++ .../docs/reference/environment-variables.md | 12 ++ website/sidebars.ts | 1 + 3 files changed, 193 insertions(+) create mode 100644 website/docs/guides/microsoft-graph-app-registration.md diff --git a/website/docs/guides/microsoft-graph-app-registration.md b/website/docs/guides/microsoft-graph-app-registration.md new file mode 100644 index 00000000000..70de0498cfe --- /dev/null +++ b/website/docs/guides/microsoft-graph-app-registration.md @@ -0,0 +1,180 @@ +--- +title: "Register a Microsoft Graph Application" +description: "Azure portal walkthrough for creating the app registration that powers the Teams meeting pipeline" +--- + +# Register a Microsoft Graph Application + +The Teams meeting pipeline reads meeting transcripts, recordings, and related artifacts from Microsoft Graph using **app-only** (daemon) authentication — no user sign-in, no interactive consent per meeting. That requires an Azure AD application registration with admin-consented application permissions. + +This guide walks through: + +1. Creating the app registration +2. Creating a client secret +3. Granting the Graph API permissions the pipeline needs +4. Admin-consenting those permissions +5. (Optional) Scoping the app to specific users with an Application Access Policy + +You need **tenant admin rights** (or an admin to grant consent on your behalf) to finish this. Bookmark the values you collect — they go into `~/.hermes/.env` at the end. + +## Prerequisites + +- A Microsoft 365 tenant with Teams Premium or Teams licenses that produce meeting transcripts and recordings +- Admin access to the Azure portal at [entra.microsoft.com](https://entra.microsoft.com) +- A publicly reachable HTTPS endpoint for Graph change notifications (set up later, in the webhook listener step) + +## Step 1: Create the App Registration + +1. Sign in to [entra.microsoft.com](https://entra.microsoft.com) as a tenant admin. +2. Navigate to **Identity → Applications → App registrations**. +3. Click **New registration**. +4. Fill in: + - **Name:** `Hermes Teams Meeting Pipeline` (or any name you'll recognize). + - **Supported account types:** *Accounts in this organizational directory only (Single tenant)*. + - **Redirect URI:** leave blank — app-only auth does not need one. +5. Click **Register**. + +You'll land on the app's overview page. Copy two values: + +- **Application (client) ID** → `MSGRAPH_CLIENT_ID` +- **Directory (tenant) ID** → `MSGRAPH_TENANT_ID` + +## Step 2: Create a Client Secret + +1. In the left nav, open **Certificates & secrets**. +2. Click **New client secret**. +3. **Description:** `hermes-graph-secret`. **Expires:** pick a value that matches your rotation policy (6-24 months is typical). +4. Click **Add**. +5. Copy the **Value** column immediately — it's only shown once. That value is `MSGRAPH_CLIENT_SECRET`. + +> The **Secret ID** column is not the secret. You want the **Value** column. + +## Step 3: Grant Graph API Permissions + +The pipeline uses a minimum-viable set of application permissions. Add only what you need; each one widens what the app can read tenant-wide. + +1. In the left nav, open **API permissions**. +2. Click **Add a permission** → **Microsoft Graph** → **Application permissions**. +3. Add the permissions from the table below that match what you want the pipeline to do. +4. After adding, click **Grant admin consent for `<your tenant>`**. The Status column should flip to a green checkmark for every permission. + +### Required for transcript-first summaries + +| Permission | What it lets the app do | +|------------|--------------------------| +| `OnlineMeetings.Read.All` | Read Teams online meeting metadata (subject, participants, join URL). | +| `OnlineMeetingTranscript.Read.All` | Read meeting transcripts generated by Teams. | + +### Required for recording fallback (when a transcript is unavailable) + +| Permission | What it lets the app do | +|------------|--------------------------| +| `OnlineMeetingRecording.Read.All` | Download Teams meeting recordings for offline STT processing. | +| `CallRecords.Read.All` | Resolve meetings from call records when only the join URL is known. | + +### Required for outbound summary delivery (Graph mode only) + +If `platforms.teams.extra.delivery_mode` is `graph`, the pipeline posts summaries into a Teams channel or chat via the Graph API. Skip these if you use `incoming_webhook` delivery mode instead. + +| Permission | What it lets the app do | +|------------|--------------------------| +| `ChannelMessage.Send` | Post messages into Teams channels on behalf of the app. | +| `Chat.ReadWrite.All` | Post messages into 1:1 and group chats (only if you set `chat_id` as the delivery target). | + +### Not recommended + +- `OnlineMeetings.ReadWrite.All` / `Chat.ReadWrite` without `.All` — broader than the pipeline needs. +- Delegated permissions — the pipeline uses app-only (client-credentials) flow; delegated permissions won't work without user sign-in. + +## Step 4: (Recommended) Scope the App with an Application Access Policy + +By default, application permissions like `OnlineMeetings.Read.All` grant the app access to **every** meeting in the tenant. For partner demos and dev tenants that's fine; for production you almost certainly want to restrict which users' meetings the app can read. + +Microsoft provides **Application Access Policies** for Teams exactly for this. The policy is a PowerShell-only surface; there's no portal UI for it. + +From an admin PowerShell with the MicrosoftTeams module installed and connected (`Connect-MicrosoftTeams`): + +```powershell +# Create a policy scoped to the Hermes app +New-CsApplicationAccessPolicy ` + -Identity "Hermes-Meeting-Pipeline-Policy" ` + -AppIds "<MSGRAPH_CLIENT_ID>" ` + -Description "Restrict Hermes meeting pipeline to allow-listed users" + +# Grant the policy to specific users whose meetings the pipeline may read +Grant-CsApplicationAccessPolicy ` + -PolicyName "Hermes-Meeting-Pipeline-Policy" ` + -Identity "alice@example.com" + +Grant-CsApplicationAccessPolicy ` + -PolicyName "Hermes-Meeting-Pipeline-Policy" ` + -Identity "bob@example.com" +``` + +Propagation can take up to 30 minutes after granting. Verify with: + +```powershell +Test-CsApplicationAccessPolicy -Identity "alice@example.com" -AppId "<MSGRAPH_CLIENT_ID>" +``` + +Without the policy, **any** user's meetings are readable — that's what the permission technically grants. Don't skip this step on a production tenant. + +## Step 5: Write the Credentials to Your Env File + +Put the three values you collected into `~/.hermes/.env`: + +```bash +MSGRAPH_TENANT_ID=<directory-tenant-id> +MSGRAPH_CLIENT_ID=<application-client-id> +MSGRAPH_CLIENT_SECRET=<client-secret-value> +``` + +Set file permissions so only you can read the secret: + +```bash +chmod 600 ~/.hermes/.env +``` + +## Step 6: Verify the Token Flow + +Hermes ships a Graph auth smoke-test. From your Hermes install: + +```python +python -c " +import asyncio +from tools.microsoft_graph_auth import MicrosoftGraphTokenProvider +provider = MicrosoftGraphTokenProvider.from_env() +token = asyncio.run(provider.get_access_token()) +print('Token acquired, length:', len(token)) +print(provider.inspect_token_health()) +" +``` + +A successful run prints a long token string and a health dict showing `cached: True` and an `expires_in_seconds` value near 3600. Failures produce a `MicrosoftGraphTokenError` with the Azure error code — the most common are: + +| Azure error | Meaning | Fix | +|-------------|---------|-----| +| `AADSTS7000215: Invalid client secret` | Secret value mismatched or expired. | Generate a new secret in step 2; update `.env`. | +| `AADSTS700016: Application not found` | Wrong `MSGRAPH_CLIENT_ID` or wrong tenant. | Double-check the values from step 1 are from the same app. | +| `AADSTS90002: Tenant not found` | Typo in `MSGRAPH_TENANT_ID`. | Copy the Directory (tenant) ID from the app overview again. | +| `insufficient_claims` at call time (not token time) | Token acquires but Graph returns 401/403. | You skipped step 3 admin-consent, or added permissions but haven't re-consented. Revisit API permissions and click **Grant admin consent** again. | + +## Rotating the Client Secret + +Azure client secrets have a hard expiry. Before yours expires: + +1. Create a second client secret in step 2 without deleting the first one. +2. Update `MSGRAPH_CLIENT_SECRET` in `~/.hermes/.env` with the new value. +3. Restart the gateway so the new secret is picked up: `hermes gateway restart`. +4. Verify with the smoke test above. +5. Delete the old secret from the Azure portal. + +## Next Steps + +Once credentials verify cleanly, continue with: + +- **Webhook listener setup** — stand up the `msgraph_webhook` gateway platform that receives Graph change notifications. +- **Pipeline configuration** — configure the Teams meeting pipeline runtime and operator CLI. +- **Outbound delivery** — wire summaries back into a Teams channel or chat. + +Those pages land alongside the PRs that add the corresponding runtime. This credentials setup is a standalone prerequisite and is safe to complete in advance. diff --git a/website/docs/reference/environment-variables.md b/website/docs/reference/environment-variables.md index 61b3aebaafc..078e1ff5b7b 100644 --- a/website/docs/reference/environment-variables.md +++ b/website/docs/reference/environment-variables.md @@ -406,6 +406,18 @@ For cloud sandbox backends, persistence is filesystem-oriented. `TERMINAL_LIFETI | `GATEWAY_ALLOWED_USERS` | Comma-separated user IDs allowed across all platforms | | `GATEWAY_ALLOW_ALL_USERS` | Allow all users without allowlists (`true`/`false`, default: `false`) | +### Microsoft Graph (Teams Meetings) + +App-only credentials for the Microsoft Graph REST client used by the upcoming Teams meeting summary pipeline. See [Register a Microsoft Graph application](/docs/guides/microsoft-graph-app-registration) for the Azure portal walkthrough and the exact API permissions required. + +| Variable | Description | +|----------|-------------| +| `MSGRAPH_TENANT_ID` | Azure AD tenant ID (directory GUID) for the Graph app registration. | +| `MSGRAPH_CLIENT_ID` | Application (client) ID of the Azure app registration. | +| `MSGRAPH_CLIENT_SECRET` | Client secret value for the app registration. Store in `~/.hermes/.env` with `chmod 600`; rotate periodically via the Azure portal. | +| `MSGRAPH_SCOPE` | OAuth2 scope for the client-credentials token request (default: `https://graph.microsoft.com/.default`). | +| `MSGRAPH_AUTHORITY_URL` | Microsoft identity platform authority (default: `https://login.microsoftonline.com`). Override only for national/sovereign clouds (e.g. `https://login.microsoftonline.us` for GCC High). | + ### Advanced Messaging Tuning Advanced per-platform knobs for throttling the outbound message batcher. Most users never need to touch these; defaults are set to respect each platform's rate limits without feeling sluggish. diff --git a/website/sidebars.ts b/website/sidebars.ts index 066a05223dd..05dc8918211 100644 --- a/website/sidebars.ts +++ b/website/sidebars.ts @@ -181,6 +181,7 @@ const sidebars: SidebarsConfig = { 'guides/migrate-from-openclaw', 'guides/aws-bedrock', 'guides/azure-foundry', + 'guides/microsoft-graph-app-registration', ], }, { From f209a358592fe9613fc11e779d80b3b4d4da4f45 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Fri, 8 May 2026 10:04:32 -0700 Subject: [PATCH 25/25] feat(profile): shareable profile distributions via git (#20831) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(profile): shareable profile distributions (pack/install/update/info) Closes #20456. Turns a profile into a portable, versioned artifact. Packs SOUL.md, config, skills, cron, and an env-var manifest into a tar.gz that others can install from a local path, URL, or git repo. Updates re-pull the distribution while preserving user data (memories, sessions, auth.json, .env) and the user's config.yaml overrides. New subcommands (under hermes profile, no parallel tree): hermes profile pack <name> [-o FILE] hermes profile install <source> [--name N] [--alias] [--force] [-y] hermes profile update <name> [--force-config] [-y] hermes profile info <name> Manifest (distribution.yaml at the profile root): name, version, hermes_requires, author, env_requires, distribution_owned. Security: - Installer shows manifest + env-var requirements before mutating disk; confirmation required unless -y. - auth.json and .env are never packed (same exclude set as profile export). - Cron jobs are packed but NOT auto-scheduled — user is pointed at 'hermes -p <name> cron list' to review. - Archive extraction rejects path traversal (../ members). - Alias creation is opt-in via --alias. Update semantics: - Distribution-owned paths (SOUL.md, skills/, cron/, mcp.json, manifest): replaced from the new archive. - config.yaml: preserved by default; --force-config to overwrite. - User-owned paths (memories/, sessions/, auth.json, .env, state.db*, logs/, workspace/, plans/, home/, *_cache/, local/): never touched. Version pin: hermes_requires accepts >=, <=, ==, !=, >, < or a bare version (treated as >=). Install fails with a clear error when the running Hermes version doesn't satisfy the spec. Sources supported by 'install': - Local .tar.gz / .tgz archive - Local directory - HTTP(S) URL pointing to a .tar.gz (uses httpx, already a dep) - Git URL (github.com/user/repo, https://..., git@..., ssh://, git://) Tests: 43 new unit tests (manifest parsing, version checks, env template, pack/install/update round-trip, config-preservation, security). E2E validated via real CLI invocations against an isolated HERMES_HOME covering pack, install with confirmation, update preservation, update --force-config, decline-preview, duplicate-install rejection, and version-requirement rejection. * refactor(profile-dist): git-only — drop tar.gz/HTTP transports and pack Scope-cut on top of the original distribution PR: a profile distribution is now exclusively a git repository (or a local directory during development). The tar.gz / HTTP archive transports and the matching `hermes profile pack` subcommand have been removed. Why: * GitHub tags, branches, and commits are already the right versioning primitive. Tag pushes do for us what 'pack + upload' did. * `hermes profile export` / `import` already cover local backup and restore; they are not a distribution format and stay untouched. * One transport means one install/update code path, one doc page, and one mental model. The extra source types doubled the surface for no real user win — GitHub auto-attaches release tarballs, and `git bundle` / `git clone --mirror` cover the airgap case. Changes: * hermes_cli/profile_distribution.py — removed pack_profile, _fetch_tar_archive (_http_fetch), _safe_extract, _archive_roots, _safe_parts, _find_dist_root, tarfile/io/urlparse imports. The new _stage_source has two arms: git URL → clone, local directory → use in place. * hermes_cli/main.py — removed the 'pack' subparser and action handler. Install help text updated to match the reduced source list. * tests/hermes_cli/test_profile_distribution.py — rewritten around a local-directory staging fixture. The install/update/describe suites now build a distribution tree on disk directly and install from it, which is what a real git clone produces after .git is stripped. Dropped TestPack, TestFindDistRoot, and the tar-specific security test. New tests cover _looks_like_git_url, env_example emission, hermes_requires enforcement, and 'installer does not import credentials if an author mistakenly leaks them in the staging tree'. * website/docs/reference/profile-commands.md — 'Distribution commands' section rewritten around git. Added a 'Publishing a distribution' section. export/import stay documented as local backup/restore. * website/docs/reference/cli-commands.md — dropped 'pack' from the profile subcommand table. * website/package.json — 'lint:diagrams' now passes --exclude-code-blocks to ascii-guard. Without it, markdown tables and box-drawing diagrams inside fenced code blocks were being misidentified as malformed ASCII boxes, blocking the PR's docs-site-checks CI with 8 false-positive errors. Validation: * Targeted suite: tests/hermes_cli/test_profile_distribution.py — 56/56 pass (down from 43 — reorganized to cover the new local-dir paths). * Regression: test_profiles.py + test_profile_export_credentials.py 102/102 still pass. export/import behaviour unchanged. * Docs lint: ascii-guard lint --exclude-code-blocks docs returns 0 errors (was 8 on the PR before the flag bump). * E2E: ran the real `hermes profile install`/`info` against a local staging dir under an isolated HERMES_HOME — install writes SOUL.md + skills to the target profile, info reads the manifest back, a bogus source produces a clear error, and `hermes profile pack` is now rejected by argparse as expected. * feat(profile-dist): distribution-aware list/show/delete + installed_at + env preview Polish pass on top of the git-only scope cut. Five additions, all small, wiring into existing commands rather than adding new surface. 1. `installed_at` timestamp on the manifest * Stamped automatically inside plan_install() on both fresh install and update — ISO-8601 UTC, seconds resolution. * Surfaced in `hermes profile info` as `Installed: <ts>`. * Lets users tell "installed 6 months ago, needs update" from "installed yesterday" without guessing from file mtimes. 2. `hermes profile list` grows a `Distribution` column * Plain profiles: "—" * Distribution profiles: "<name>@<version>" (e.g. `telemetry@1.2.3`) * ProfileInfo gains three optional fields — distribution_name, distribution_version, distribution_source — populated by a new _read_distribution_meta() helper that swallows manifest read errors so a broken distribution.yaml in one profile can't break `list` for the others. 3. `hermes profile show` and `hermes profile delete` surface distribution provenance * show: `Distribution: name@version` + `Installed from: <source>` plus a pointer to `hermes profile info <name>` for the full manifest. * delete: same lines in the pre-confirmation preview, so a user deleting "telemetry" can see it came from `github.com/kyle/telemetry-distribution` before they type `telemetry` to confirm. No change to the confirmation gate itself — deletion semantics are identical to plain profiles. 4. Install preview checks env vars against the current environment * Replaces the "Env vars you'll need to set:" header with a simpler "Env vars:" block. * Each required var is labeled: - `✓ set` — already in `os.environ` OR present as a key in the target profile's existing .env (update case). - `needs setting` — required but not found in either place. - `—` — optional. * Mirrors pip's "Requirement already satisfied" UX: no unnecessary nagging about keys the user already has configured. 5. Docs: private distributions * New "Private distributions" section in website/docs/reference/profile-commands.md explaining that we shell out to the user's `git` binary, so SSH keys / credential helpers / GitHub CLI stored creds all work transparently. One paragraph, two examples. * `hermes profile info` section updated to mention `Installed:`. Module-level hoist: * `from datetime import datetime, timezone` was previously lazy-imported inside plan_install(). Hoisted to module scope so tests can monkeypatch `hermes_cli.profile_distribution.datetime` to freeze time. Tests (+7): * TestInstalledAtStamp.test_install_stamps_installed_at — format check (4-digit year, 'T', +00:00 suffix). * TestInstalledAtStamp.test_update_refreshes_installed_at — freezes datetime.now() to 2099-01-01 and confirms update writes a new stamp. * TestProfileInfoDistribution.test_installed_distribution_shows_in_list — ProfileInfo.distribution_{name,version,source} populated after install. * TestProfileInfoDistribution.test_plain_profile_has_no_distribution_fields — plain profiles have None. * TestProfileInfoDistribution.test_malformed_manifest_does_not_break_list — broken distribution.yaml in one profile doesn't break list_profiles(). Validation: * 163/163 tests pass (56 distribution + 102 profile regression + 5 new from this commit — up from 158). * docs-lint: 0 errors. * E2E verified: install preview shows ✓/needs-setting per env var, `profile list` shows Distribution column, `profile show` + `delete` preview mentions source URL, `info` shows Installed: timestamp. * fix(profile-dist): clean errors + warn when overwriting plain profiles Two small polish fixes found during collision sweeps of the PR: 1. ValueError from validate_profile_name now caught cleanly * A distribution.yaml whose 'name' field can't be used as a profile identifier (spaces, path traversal, etc.) raises ValueError from hermes_cli.profiles.validate_profile_name, which was escaping as a raw Python traceback from 'hermes profile install/update/info'. * Broadened the except clause in all three handlers to catch (DistributionError, ValueError) — users now see: Error: Invalid profile name '../../etc/passwd'. Must match [a-z0-9][a-z0-9_-]{0,63} instead of a stack trace. 2. Install preview distinguishes plain profile overwrite from distribution re-install * When plan.target_dir exists and IS a distribution (has distribution.yaml), preview still shows the mild (profile exists — will overwrite distribution-owned files only) * When plan.target_dir exists but is a HAND-BUILT plain profile (no distribution.yaml), preview now shows a loud warning: ⚠ Profile exists but is NOT a distribution. Installing here will overwrite its SOUL.md, skills/, cron/, and mcp.json. Your memories, sessions, auth.json, and .env will be preserved, but any hand-edits to distribution-owned files will be lost. * Users who type 'hermes profile install foo --force' against a profile they hand-built now see what they're signing up for. User data is still safe (memories, sessions, auth, .env are in USER_OWNED_EXCLUDE), but custom SOUL/skills get stomped. Tests (+2): * TestErrorSurfaces.test_bad_profile_name_raises_valueerror_not_traceback * TestErrorSurfaces.test_path_traversal_name_rejected Validation: * 165/165 tests pass (was 163). * E2E: bad manifest names produce 'Error: Invalid profile name ...' with no traceback; installing over a plain profile shows the warning; re-installing over an existing distribution shows the normal overwrite message. * Bad HTTPS URLs still produce 'Error: git clone failed: ...' — git itself generates a clean enough message that no wrapper is needed. * 'install .' works correctly from any cwd. * fix(profiles): reject reserved names at validate time Before: `hermes profile create hermes` / `profile install` / `profile rename` all silently accepted reserved names like `hermes`, `test`, `tmp`, `root`, `sudo`. The profile directory was created; only alias creation failed (via check_alias_collision), leaving a confusingly-named profile on disk — e.g. `~/.hermes/profiles/hermes/` sitting next to `~/.hermes/` itself. The reserved set already exists (_RESERVED_NAMES, introduced alongside alias collision detection). This commit moves the check up one layer to validate_profile_name so every entry point — create, install, import, rename, dashboard web API — shares the same gate. The error message points the user at the cause without being cryptic: Error: Profile name 'hermes' is reserved — it collides with either the Hermes installation itself or a common system binary. Pick a different name. `default` continues to pass through (it's a special alias for ~/.hermes). _HERMES_SUBCOMMANDS (`chat`, `model`, `gateway`, etc.) stays at alias-collision time only — those are fine as bare profile names with `--no-alias`. Tests (+5): test_reserved_names_rejected parametrized over the full _RESERVED_NAMES set, matching the existing pattern in TestValidateProfileName. No existing test uses a reserved name as a profile identifier (greppped create_profile("hermes|test|tmp|root|sudo") — zero hits). Validation: * 170/170 tests pass in the profile suites. * E2E: `profile create hermes`, `profile install` with manifest name=hermes, and `profile install ... --name hermes` all produce the same clean `Error: Profile name 'hermes' is reserved ...` with rc=1 and no traceback. Normal names (`mybot`) still work. --- hermes_cli/main.py | 283 ++++++- hermes_cli/profile_distribution.py | 702 ++++++++++++++++++ hermes_cli/profiles.py | 54 ++ tests/hermes_cli/test_profile_distribution.py | 584 +++++++++++++++ tests/hermes_cli/test_profiles.py | 8 + website/docs/reference/cli-commands.md | 9 +- website/docs/reference/profile-commands.md | 155 ++++ website/package.json | 2 +- 8 files changed, 1791 insertions(+), 6 deletions(-) create mode 100644 hermes_cli/profile_distribution.py create mode 100644 tests/hermes_cli/test_profile_distribution.py diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 70d15d4c0f2..8ac6fe3a43b 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -8157,8 +8157,14 @@ def cmd_profile(args): return # Header - print(f"\n {'Profile':<16} {'Model':<28} {'Gateway':<12} {'Alias'}") - print(f" {'─' * 15} {'─' * 27} {'─' * 11} {'─' * 12}") + print( + f"\n {'Profile':<16} {'Model':<28} {'Gateway':<12} " + f"{'Alias':<12} {'Distribution'}" + ) + print( + f" {'─' * 15} {'─' * 27} {'─' * 11} " + f"{'─' * 11} {'─' * 20}" + ) for p in profiles: marker = ( @@ -8172,7 +8178,12 @@ def cmd_profile(args): alias = p.name if p.alias_path else "—" if p.is_default: alias = "—" - print(f"{marker}{name:<15} {model:<28} {gw:<12} {alias}") + if p.distribution_name: + dist = f"{p.distribution_name}@{p.distribution_version or '?'}" + dist = dist[:30] + else: + dist = "—" + print(f"{marker}{name:<15} {model:<28} {gw:<12} {alias:<12} {dist}") print() elif action == "use": @@ -8311,6 +8322,7 @@ def cmd_profile(args): _read_config_model, _check_gateway_running, _count_skills, + _read_distribution_meta, ) if not profile_exists(name): @@ -8320,6 +8332,7 @@ def cmd_profile(args): model, provider = _read_config_model(profile_dir) gw = _check_gateway_running(profile_dir) skills = _count_skills(profile_dir) + dist_name, dist_version, dist_source = _read_distribution_meta(profile_dir) wrapper = _get_wrapper_dir() / name print(f"\nProfile: {name}") @@ -8334,6 +8347,11 @@ def cmd_profile(args): print( f"SOUL.md: {'exists' if (profile_dir / 'SOUL.md').exists() else 'not configured'}" ) + if dist_name: + print(f"Distribution: {dist_name}@{dist_version or '?'}") + if dist_source: + print(f"Installed from: {dist_source}") + print(f" (run `hermes profile info {name}` for full manifest)") if wrapper.exists(): print(f"Alias: {wrapper}") print() @@ -8414,6 +8432,208 @@ def cmd_profile(args): print(f"Error: {e}") sys.exit(1) + elif action == "install": + import tempfile + from hermes_cli.profile_distribution import ( + plan_install, + install_distribution, + DistributionError, + ) + + try: + # Preview: stage the distribution into a scratch dir, show the + # manifest, then do the real install. The double-stage avoids + # any side-effects if the user declines. + with tempfile.TemporaryDirectory(prefix="hermes_dist_preview_") as tmp: + plan = plan_install( + args.source, + Path(tmp), + override_name=getattr(args, "install_name", None), + ) + _render_distribution_plan(plan) + + if not getattr(args, "yes", False): + try: + answer = input("\nProceed with install? [y/N] ").strip().lower() + except (EOFError, KeyboardInterrupt): + answer = "" + if answer not in ("y", "yes"): + print("Install cancelled.") + return + + plan = install_distribution( + args.source, + name=getattr(args, "install_name", None), + force=getattr(args, "force", False), + create_alias=getattr(args, "alias", False), + ) + print(f"\n✓ Installed '{plan.manifest.name}' v{plan.manifest.version}") + print(f" Profile path: {plan.target_dir}") + if plan.manifest.env_requires: + print( + f" Next: copy .env.EXAMPLE to .env and fill in required keys:\n" + f" {plan.target_dir}/.env.EXAMPLE" + ) + if plan.has_cron: + print( + " Cron jobs were included but are NOT scheduled automatically.\n" + f" Review them with: hermes -p {plan.manifest.name} cron list" + ) + print(f"\n Use with: hermes -p {plan.manifest.name} chat") + except (DistributionError, ValueError) as e: + print(f"Error: {e}") + sys.exit(1) + + elif action == "update": + from hermes_cli.profile_distribution import ( + update_distribution, + read_manifest, + DistributionError, + ) + from hermes_cli.profiles import get_profile_dir, normalize_profile_name + + name = args.profile_name + try: + canon = normalize_profile_name(name) + current = read_manifest(get_profile_dir(canon)) + if current is None: + print( + f"Error: Profile '{canon}' is not a distribution (no distribution.yaml). " + "Only profiles installed via `hermes profile install` can be updated." + ) + sys.exit(1) + + force_config = getattr(args, "force_config", False) + if not getattr(args, "yes", False): + print(f"\nUpdate '{canon}' from: {current.source or '(no source)'}") + print(f" Currently at version {current.version}") + if force_config: + print(" --force-config set: config.yaml WILL be overwritten.") + else: + print(" config.yaml will be preserved (pass --force-config to overwrite).") + print(" User data (memories, sessions, auth, .env) will NOT be touched.") + try: + answer = input("\nProceed? [y/N] ").strip().lower() + except (EOFError, KeyboardInterrupt): + answer = "" + if answer not in ("y", "yes"): + print("Update cancelled.") + return + + plan = update_distribution(canon, force_config=force_config) + print(f"\n✓ Updated '{plan.manifest.name}' → v{plan.manifest.version}") + if plan.has_cron: + print( + " Cron files were refreshed. Review with: " + f"hermes -p {plan.manifest.name} cron list" + ) + except (DistributionError, ValueError) as e: + print(f"Error: {e}") + sys.exit(1) + + elif action == "info": + from hermes_cli.profile_distribution import describe_distribution, DistributionError + + try: + data = describe_distribution(args.profile_name) + except (DistributionError, ValueError) as e: + print(f"Error: {e}") + sys.exit(1) + if not data: + print( + f"Profile '{args.profile_name}' is not a distribution " + "(no distribution.yaml)." + ) + return + print(f"\nDistribution: {data.get('name')}") + print(f"Version: {data.get('version', '?')}") + if data.get("description"): + print(f"Description: {data['description']}") + if data.get("author"): + print(f"Author: {data['author']}") + if data.get("license"): + print(f"License: {data['license']}") + if data.get("hermes_requires"): + print(f"Requires: Hermes {data['hermes_requires']}") + if data.get("source"): + print(f"Source: {data['source']}") + if data.get("installed_at"): + print(f"Installed: {data['installed_at']}") + env_reqs = data.get("env_requires") or [] + if env_reqs: + print("\nEnvironment variables:") + for er in env_reqs: + tag = "required" if er.get("required", True) else "optional" + line = f" {er['name']} ({tag})" + if er.get("description"): + line += f" — {er['description']}" + print(line) + if er.get("default") is not None: + print(f" default: {er['default']}") + print() + + +def _render_distribution_plan(plan) -> None: + """Print a human-readable summary of a pending distribution install.""" + from hermes_cli.profile_distribution import MANIFEST_FILENAME + mf = plan.manifest + print(f"\nDistribution: {mf.name} v{mf.version}") + if mf.description: + print(f" {mf.description}") + if mf.author: + print(f" Author: {mf.author}") + if mf.hermes_requires: + print(f" Requires: Hermes {mf.hermes_requires}") + print(f" Source: {plan.provenance}") + print(f" Target: {plan.target_dir}") + if plan.existing: + # Distinguish "updating an existing distribution" (well-understood + # semantics — dist-owned overwritten, config preserved, user data + # untouched) from "overwriting a hand-built plain profile" (same + # mechanics but the user didn't sign up for this when they created + # the profile manually). + existing_is_distribution = (plan.target_dir / MANIFEST_FILENAME).is_file() + if existing_is_distribution: + print(" (profile exists — will overwrite distribution-owned files only)") + else: + print( + " ⚠ Profile exists but is NOT a distribution. Installing here will\n" + " overwrite its SOUL.md, skills/, cron/, and mcp.json.\n" + " Your memories, sessions, auth.json, and .env will be preserved,\n" + " but any hand-edits to distribution-owned files will be lost." + ) + if mf.env_requires: + print("\n Env vars:") + for er in mf.env_requires: + tag = "required" if er.required else "optional" + # Check both the current shell environment and the target profile's + # .env file so we don't nag about keys the user already has set up. + already = os.environ.get(er.name) is not None + if not already and plan.target_dir.is_dir(): + env_path = plan.target_dir / ".env" + if env_path.is_file(): + try: + for raw in env_path.read_text().splitlines(): + line = raw.strip() + if not line or line.startswith("#"): + continue + key = line.split("=", 1)[0].strip() + if key == er.name: + already = True + break + except OSError: + pass + status = "✓ set" if already else ("needs setting" if er.required else "—") + line = f" • {er.name} ({tag}, {status})" + if er.description: + line += f" — {er.description}" + print(line) + if plan.has_cron: + print( + "\n ⚠ This distribution ships cron jobs. They will NOT run " + "automatically — review and enable manually." + ) + def _report_dashboard_status() -> int: """Print ``hermes dashboard`` PIDs and return the count. @@ -10663,6 +10883,63 @@ Examples: help="Profile name (default: inferred from archive)", ) + # ---------- Distribution subcommands (issue #20456) ---------- + profile_install = profile_subparsers.add_parser( + "install", + help="Install a profile distribution from a git URL or local directory", + description=( + "Install a Hermes profile distribution. SOURCE can be a git URL " + "(github.com/user/repo, https://..., git@...) or a local " + "directory containing distribution.yaml at its root." + ), + ) + profile_install.add_argument( + "source", + help="Distribution source (git URL or local directory)", + ) + profile_install.add_argument( + "--name", dest="install_name", metavar="NAME", + help="Override profile name (default: read from manifest)", + ) + profile_install.add_argument( + "--alias", action="store_true", + help="Create a shell wrapper alias for the installed profile", + ) + profile_install.add_argument( + "--force", action="store_true", + help="Overwrite an existing profile of the same name (user data preserved)", + ) + profile_install.add_argument( + "-y", "--yes", action="store_true", + help="Skip manifest preview confirmation", + ) + + profile_update = profile_subparsers.add_parser( + "update", + help="Re-pull a distribution and apply updates (user data preserved)", + description=( + "Fetch the distribution from its recorded source and overwrite " + "distribution-owned files (SOUL.md, skills/, cron/, mcp.json). " + "User data (memories, sessions, auth, .env) is never touched. " + "config.yaml is preserved unless --force-config is passed." + ), + ) + profile_update.add_argument("profile_name", help="Profile to update") + profile_update.add_argument( + "--force-config", action="store_true", + help="Also overwrite config.yaml (normally preserved to keep user overrides)", + ) + profile_update.add_argument( + "-y", "--yes", action="store_true", + help="Skip confirmation", + ) + + profile_info = profile_subparsers.add_parser( + "info", + help="Show a profile's distribution manifest (version, requirements, source)", + ) + profile_info.add_argument("profile_name", help="Profile to inspect") + profile_parser.set_defaults(func=cmd_profile) # ========================================================================= diff --git a/hermes_cli/profile_distribution.py b/hermes_cli/profile_distribution.py new file mode 100644 index 00000000000..5e6be8c609e --- /dev/null +++ b/hermes_cli/profile_distribution.py @@ -0,0 +1,702 @@ +"""Profile distributions — shareable, packaged Hermes profiles via git. + +A distribution is a Hermes profile published as a git repository (or +installed from a local directory for development). Install with one command +from a git URL, update in place, and keep your local memories / sessions / +credentials untouched. + +Where this fits relative to the existing pieces: + +* ``hermes profile export/import`` — local backup / restore for a profile + on your own machine. NOT a distribution format. Stays as-is. +* ``hermes skills install <url>`` — the URL install pattern we're mirroring, + but at the profile granularity. + +Subcommands (all live under ``hermes profile``, not a parallel tree): + + hermes profile install <source> [--name N] [--alias] [--force] [--yes] + hermes profile update <name> [--force-config] [--yes] + hermes profile info <name> + +``<source>`` is one of: + +* A git URL (``github.com/user/repo``, ``https://github.com/...``, ``git@...``, + ``ssh://``, ``git://``), optionally with ``#<ref>`` to pin a tag / branch / + commit SHA. +* A local directory that already contains ``distribution.yaml`` — used + during profile development before the first push. + +Manifest format (``distribution.yaml`` at the profile root):: + + name: telemetry + version: 0.1.0 + description: "Compliance monitoring harness" + hermes_requires: ">=0.12.0" + author: "..." + license: "..." + env_requires: + - name: OPENAI_API_KEY + description: "OpenAI API key" + required: true + - name: GRAPHITI_MCP_URL + description: "Memory graph URL" + required: false + default: "http://127.0.0.1:8000/sse" + distribution_owned: # optional; sensible defaults apply + - SOUL.md + - skills/ + - cron/ + - mcp.json + +Update semantics: + +* Distribution-owned paths (SOUL.md, mcp.json, skills/, cron/, + distribution.yaml) are replaced from the new source. +* ``config.yaml`` is distribution-owned but preserved on update unless + ``--force-config`` is passed (user overrides typically live here). +* User-owned paths (memories/, sessions/, state.db, auth.json, .env, + logs/, workspace/, home/, plans/, *_cache/, and anything under + ``local/``) are never touched. +""" + +from __future__ import annotations + +import re +import shutil +import subprocess +import tempfile +from dataclasses import dataclass, field +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple + + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +MANIFEST_FILENAME = "distribution.yaml" +ENV_TEMPLATE_FILENAME = ".env.template" +ENV_EXAMPLE_FILENAME = ".env.EXAMPLE" + +# Default distribution-owned paths (relative to profile root). Authors may +# override via ``distribution_owned:`` in the manifest. config.yaml is +# distribution-owned but treated specially on update (see _is_config_like). +DEFAULT_DIST_OWNED: Tuple[str, ...] = ( + "SOUL.md", + "config.yaml", + "mcp.json", + "skills", + "cron", + MANIFEST_FILENAME, +) + +# Paths that are NEVER part of a distribution. These are user-owned and are +# protected on update. Must stay consistent with +# ``profiles.py::_DEFAULT_EXPORT_EXCLUDE_ROOT`` plus the ``local/`` +# convention for user customizations. +USER_OWNED_EXCLUDE: frozenset = frozenset({ + # Credentials & runtime secrets + "auth.json", ".env", + # Databases & runtime state + "state.db", "state.db-shm", "state.db-wal", + "hermes_state.db", "response_store.db", + "response_store.db-shm", "response_store.db-wal", + "gateway.pid", "gateway_state.json", "processes.json", + "auth.lock", "active_profile", ".update_check", + "errors.log", ".hermes_history", + # User data + "memories", "sessions", "logs", "plans", "workspace", "home", + "image_cache", "audio_cache", "document_cache", + "browser_screenshots", "checkpoints", "sandboxes", + "backups", "cache", + # Infrastructure + "hermes-agent", ".worktrees", "profiles", "bin", "node_modules", + # User customization namespace + "local", +}) + + +# --------------------------------------------------------------------------- +# Errors +# --------------------------------------------------------------------------- + + +class DistributionError(Exception): + """Raised for distribution install/update failures.""" + + +# --------------------------------------------------------------------------- +# Manifest +# --------------------------------------------------------------------------- + + +@dataclass +class EnvRequirement: + name: str + description: str = "" + required: bool = True + default: Optional[str] = None + + @classmethod + def from_dict(cls, data: Any) -> "EnvRequirement": + if not isinstance(data, dict): + raise DistributionError( + f"env_requires entry must be a mapping, got {type(data).__name__}" + ) + name = str(data.get("name") or "").strip() + if not name: + raise DistributionError("env_requires entry missing 'name'") + return cls( + name=name, + description=str(data.get("description") or ""), + required=bool(data.get("required", True)), + default=data.get("default"), + ) + + def to_dict(self) -> Dict[str, Any]: + out: Dict[str, Any] = {"name": self.name, "description": self.description} + if not self.required: + out["required"] = False + if self.default is not None: + out["default"] = self.default + return out + + +@dataclass +class DistributionManifest: + name: str + version: str = "0.1.0" + description: str = "" + hermes_requires: str = "" + author: str = "" + license: str = "" + env_requires: List[EnvRequirement] = field(default_factory=list) + distribution_owned: List[str] = field(default_factory=list) + # Tracked after install — where we pulled from, so ``update`` can re-pull. + source: str = "" + # ISO-8601 UTC timestamp written on install / update, so ``info`` and + # ``list`` can show when a distribution landed on disk. Empty for + # manifests that ship in a repo (authors don't populate this). + installed_at: str = "" + + @classmethod + def from_dict(cls, data: Any) -> "DistributionManifest": + if not isinstance(data, dict): + raise DistributionError( + f"{MANIFEST_FILENAME} must be a mapping, got {type(data).__name__}" + ) + name = str(data.get("name") or "").strip() + if not name: + raise DistributionError(f"{MANIFEST_FILENAME} missing 'name'") + env_raw = data.get("env_requires") or [] + if not isinstance(env_raw, list): + raise DistributionError("env_requires must be a list") + env_requires = [EnvRequirement.from_dict(e) for e in env_raw] + dist_owned_raw = data.get("distribution_owned") or [] + if dist_owned_raw and not isinstance(dist_owned_raw, list): + raise DistributionError("distribution_owned must be a list") + distribution_owned = [str(p).strip().strip("/") for p in dist_owned_raw if str(p).strip()] + return cls( + name=name, + version=str(data.get("version") or "0.1.0"), + description=str(data.get("description") or ""), + hermes_requires=str(data.get("hermes_requires") or ""), + author=str(data.get("author") or ""), + license=str(data.get("license") or ""), + env_requires=env_requires, + distribution_owned=distribution_owned, + source=str(data.get("source") or ""), + installed_at=str(data.get("installed_at") or ""), + ) + + def to_dict(self) -> Dict[str, Any]: + out: Dict[str, Any] = { + "name": self.name, + "version": self.version, + } + if self.description: + out["description"] = self.description + if self.hermes_requires: + out["hermes_requires"] = self.hermes_requires + if self.author: + out["author"] = self.author + if self.license: + out["license"] = self.license + if self.env_requires: + out["env_requires"] = [e.to_dict() for e in self.env_requires] + if self.distribution_owned: + out["distribution_owned"] = self.distribution_owned + if self.source: + out["source"] = self.source + if self.installed_at: + out["installed_at"] = self.installed_at + return out + + def owned_paths(self) -> List[str]: + """Resolve which paths count as distribution-owned.""" + if self.distribution_owned: + return list(self.distribution_owned) + return list(DEFAULT_DIST_OWNED) + + +def _load_yaml(text: str) -> Any: + try: + import yaml + except ImportError as exc: # pragma: no cover — pyyaml is a hard dep + raise DistributionError("PyYAML is required for distribution manifests") from exc + return yaml.safe_load(text) + + +def _dump_yaml(data: Any) -> str: + import yaml + + return yaml.safe_dump(data, sort_keys=False, default_flow_style=False) + + +def read_manifest(profile_dir: Path) -> Optional[DistributionManifest]: + """Return the manifest for *profile_dir*, or None if it isn't a distribution.""" + mf_path = profile_dir / MANIFEST_FILENAME + if not mf_path.is_file(): + return None + try: + data = _load_yaml(mf_path.read_text(encoding="utf-8")) + except Exception as exc: + raise DistributionError(f"Failed to parse {mf_path}: {exc}") from exc + return DistributionManifest.from_dict(data or {}) + + +def write_manifest(profile_dir: Path, manifest: DistributionManifest) -> Path: + mf_path = profile_dir / MANIFEST_FILENAME + mf_path.write_text(_dump_yaml(manifest.to_dict()), encoding="utf-8") + return mf_path + + +# --------------------------------------------------------------------------- +# Version check +# --------------------------------------------------------------------------- + + +_VERSION_OP_RE = re.compile(r"^\s*(>=|<=|==|!=|>|<)\s*(.+?)\s*$") + + +def _parse_semver(v: str) -> Tuple[int, int, int]: + """Very small semver parser — major.minor.patch only. Extra labels stripped.""" + s = str(v).strip().lstrip("v") + # Strip any pre-release / build metadata (e.g. "0.12.0-rc1+abc") + s = re.split(r"[-+]", s, 1)[0] + parts = s.split(".") + while len(parts) < 3: + parts.append("0") + try: + return (int(parts[0]), int(parts[1]), int(parts[2])) + except ValueError as exc: + raise DistributionError(f"Unparseable version: {v!r}") from exc + + +def check_hermes_requires(spec: str, current_version: str) -> None: + """Raise DistributionError if ``current_version`` does not satisfy ``spec``. + + ``spec`` accepts a single comparator (``>=0.12.0``, ``==0.12.0``, etc.). + Empty or blank spec is a no-op — no requirement. + """ + if not spec or not spec.strip(): + return + m = _VERSION_OP_RE.match(spec) + if not m: + # Bare version → treat as ``>=`` + op, target = ">=", spec.strip() + else: + op, target = m.group(1), m.group(2) + cur = _parse_semver(current_version) + tgt = _parse_semver(target) + ok = { + ">=": cur >= tgt, + "<=": cur <= tgt, + "==": cur == tgt, + "!=": cur != tgt, + ">": cur > tgt, + "<": cur < tgt, + }[op] + if not ok: + raise DistributionError( + f"This distribution requires Hermes {op}{target}, " + f"but you have {current_version}." + ) + + +# --------------------------------------------------------------------------- +# Env var template helper +# --------------------------------------------------------------------------- + + +def _env_template_from_manifest(manifest: DistributionManifest) -> str: + """Generate a ``.env.template`` body from env_requires.""" + lines = [ + "# Environment variables required by this Hermes distribution.", + "# Copy to `.env` and fill in your own values before running.", + "", + ] + for req in manifest.env_requires: + if req.description: + lines.append(f"# {req.description}") + status = "required" if req.required else "optional" + lines.append(f"# ({status})") + default_val = req.default if req.default is not None else "" + prefix = "" if req.required else "# " + lines.append(f"{prefix}{req.name}={default_val}") + lines.append("") + return "\n".join(lines).rstrip() + "\n" + + +# --------------------------------------------------------------------------- +# Source staging — git clone or local directory +# --------------------------------------------------------------------------- + + +def _looks_like_git_url(s: str) -> bool: + s = s.strip() + if s.endswith(".git"): + return True + if s.startswith(("git@", "ssh://", "git://")): + return True + if s.startswith(("http://", "https://")): + # Any http(s) URL is treated as a git repo. We no longer accept + # tar.gz URLs — git is the only remote transport. + return True + # Bare github.com/user/repo shorthand + if re.match(r"^github\.com/[\w.-]+/[\w.-]+/?$", s): + return True + return False + + +def _git_clone(url: str, dest: Path) -> None: + # Normalize github.com/user/repo shorthand + if re.match(r"^github\.com/[\w.-]+/[\w.-]+/?$", url): + url = f"https://{url.rstrip('/')}" + try: + subprocess.run( + ["git", "clone", "--depth", "1", url, str(dest)], + check=True, + capture_output=True, + ) + except FileNotFoundError as exc: + raise DistributionError("git is required for git-URL installs") from exc + except subprocess.CalledProcessError as exc: + stderr = exc.stderr.decode("utf-8", errors="replace") if exc.stderr else "" + raise DistributionError(f"git clone failed: {stderr.strip()}") from exc + + +def _stage_source(source: str, workdir: Path) -> Tuple[Path, str]: + """Resolve *source* to a local directory containing distribution.yaml. + + Returns ``(staged_dir, provenance)`` where ``provenance`` is stored in the + installed manifest's ``source:`` field so ``hermes profile update`` can + re-pull from the same place. + + Accepts: + * A git URL (https / ssh / git@ / bare github.com shorthand) — cloned + into a temp directory; ``.git`` removed after clone. + * A local directory already containing ``distribution.yaml``. + """ + src_str = source.strip() + + # Git URL + if _looks_like_git_url(src_str): + cloned = workdir / "clone" + _git_clone(src_str, cloned) + # Remove .git to keep the staged tree clean + shutil.rmtree(cloned / ".git", ignore_errors=True) + if not (cloned / MANIFEST_FILENAME).is_file(): + raise DistributionError( + f"No {MANIFEST_FILENAME} at the root of {src_str!r}. " + "This repository is not a Hermes profile distribution." + ) + return cloned, src_str + + # Local directory + path_guess = Path(src_str).expanduser() + if path_guess.is_dir(): + if not (path_guess / MANIFEST_FILENAME).is_file(): + raise DistributionError( + f"No {MANIFEST_FILENAME} in {path_guess}. " + "A local-directory source must contain a distribution.yaml at its root." + ) + return path_guess.resolve(), str(path_guess.resolve()) + + raise DistributionError( + f"Cannot resolve distribution source: {source!r}. " + "Expected a git URL (e.g. github.com/user/repo) or a local directory." + ) + + +# --------------------------------------------------------------------------- +# Install +# --------------------------------------------------------------------------- + + +@dataclass +class InstallPlan: + """Summary of what an install will do, surfaced for user confirmation.""" + manifest: DistributionManifest + staged_dir: Path + provenance: str + target_dir: Path + existing: bool # True if target profile already exists (update path) + preserves_config: bool = True + has_cron: bool = False + has_skills: bool = False + + +def _has_cron_jobs(staged: Path) -> bool: + cron_dir = staged / "cron" + if not cron_dir.is_dir(): + return False + for _ in cron_dir.rglob("*.json"): + return True + for _ in cron_dir.rglob("*.yaml"): + return True + return False + + +def _count_skills(staged: Path) -> int: + skills_dir = staged / "skills" + if not skills_dir.is_dir(): + return 0 + return sum(1 for _ in skills_dir.rglob("SKILL.md")) + + +def plan_install( + source: str, + workdir: Path, + override_name: Optional[str] = None, +) -> InstallPlan: + """Stage *source* and produce a plan describing what install would do.""" + from hermes_cli.profiles import ( + get_profile_dir, + normalize_profile_name, + validate_profile_name, + ) + from hermes_cli import __version__ as hermes_version + + staged, provenance = _stage_source(source, workdir) + manifest = read_manifest(staged) + if manifest is None: + raise DistributionError( + f"No {MANIFEST_FILENAME} found at the distribution root — " + "this source is not a Hermes distribution." + ) + + # Version check up-front so we fail fast + check_hermes_requires(manifest.hermes_requires, hermes_version) + + # Resolve target profile name + target_name = override_name or manifest.name + canon = normalize_profile_name(target_name) + validate_profile_name(canon) + if canon == "default": + raise DistributionError( + "Cannot install a distribution as 'default' — that is the built-in " + "root profile (~/.hermes). Pass --name <name> to install under a " + "new profile." + ) + manifest.name = canon + manifest.source = provenance + # Stamped once here so plan_install() callers (both fresh install and + # update) propagate a freshly-minted timestamp through _copy_dist_payload. + manifest.installed_at = datetime.now(timezone.utc).isoformat(timespec="seconds") + + target_dir = get_profile_dir(canon) + existing = target_dir.is_dir() + has_cron = _has_cron_jobs(staged) + skill_count = _count_skills(staged) + + return InstallPlan( + manifest=manifest, + staged_dir=staged, + provenance=provenance, + target_dir=target_dir, + existing=existing, + preserves_config=existing, + has_cron=has_cron, + has_skills=skill_count > 0, + ) + + +def _copy_dist_payload( + staged: Path, + target: Path, + manifest: DistributionManifest, + preserve_config: bool, +) -> None: + """Copy distribution-owned files from *staged* into *target*. + + User-owned paths are never touched. ``config.yaml`` is replaced only when + ``preserve_config`` is False (fresh install or ``--force-config`` update). + ``.env.template`` is renamed to ``.env.EXAMPLE`` in the target to avoid + shadowing a real ``.env``. + """ + target.mkdir(parents=True, exist_ok=True) + + for entry in staged.iterdir(): + name = entry.name + + if name in USER_OWNED_EXCLUDE: + continue + if name == ENV_TEMPLATE_FILENAME: + shutil.copy2(entry, target / ENV_EXAMPLE_FILENAME) + continue + if name == "config.yaml" and preserve_config and (target / "config.yaml").exists(): + # Leave user's config.yaml alone on update + continue + + dest = target / name + if entry.is_dir(): + if dest.exists(): + shutil.rmtree(dest) + shutil.copytree( + entry, + dest, + ignore=lambda d, names: [n for n in names if n in USER_OWNED_EXCLUDE], + ) + else: + shutil.copy2(entry, dest) + + # Emit .env.EXAMPLE from manifest if the staged tree didn't ship one + if manifest.env_requires and not (target / ENV_EXAMPLE_FILENAME).exists(): + (target / ENV_EXAMPLE_FILENAME).write_text( + _env_template_from_manifest(manifest), encoding="utf-8" + ) + + # Make sure the manifest on disk reflects resolved name + source + write_manifest(target, manifest) + + +def _bootstrap_user_dirs(target: Path) -> None: + """Create the bootstrap dirs a fresh profile expects.""" + for d in ("memories", "sessions", "skills", "skins", "logs", + "plans", "workspace", "cron", "home"): + (target / d).mkdir(parents=True, exist_ok=True) + + +def install_distribution( + source: str, + name: Optional[str] = None, + force: bool = False, + create_alias: bool = False, +) -> InstallPlan: + """Install a distribution from *source* into a new profile. + + Returns the resolved :class:`InstallPlan`. Use :func:`plan_install` + first if you want to preview + prompt the user before calling this. + """ + from hermes_cli.profiles import ( + check_alias_collision, + create_wrapper_script, + ) + + with tempfile.TemporaryDirectory(prefix="hermes_dist_install_") as tmp: + plan = plan_install(source, Path(tmp), override_name=name) + + if plan.existing and not force: + raise DistributionError( + f"Profile '{plan.manifest.name}' already exists at {plan.target_dir}. " + "Use `hermes profile update` to upgrade in place, " + "or pass --force to overwrite." + ) + + # Fresh install: config.yaml comes from the distribution. + _bootstrap_user_dirs(plan.target_dir) + _copy_dist_payload( + plan.staged_dir, + plan.target_dir, + plan.manifest, + preserve_config=False, + ) + + if create_alias: + collision = check_alias_collision(plan.manifest.name) + if collision is None: + create_wrapper_script(plan.manifest.name) + + return plan + + +def update_distribution( + profile_name: str, + force_config: bool = False, +) -> InstallPlan: + """Re-pull the distribution for an existing profile and apply updates. + + The source is read from the installed profile's ``distribution.yaml`` + ``source:`` field. Distribution-owned files are overwritten; user-owned + data (memories, sessions, auth) is never touched. ``config.yaml`` is + preserved unless ``force_config`` is True. + """ + from hermes_cli.profiles import ( + get_profile_dir, + normalize_profile_name, + validate_profile_name, + ) + + canon = normalize_profile_name(profile_name) + validate_profile_name(canon) + target = get_profile_dir(canon) + if not target.is_dir(): + raise DistributionError(f"Profile '{canon}' does not exist.") + + existing_manifest = read_manifest(target) + if existing_manifest is None: + raise DistributionError( + f"Profile '{canon}' is not a distribution (no {MANIFEST_FILENAME}). " + "Only profiles installed via `hermes profile install` can be updated." + ) + if not existing_manifest.source: + raise DistributionError( + f"Profile '{canon}' has no recorded source. Re-install with " + "`hermes profile install <source> --name {canon} --force`." + ) + + with tempfile.TemporaryDirectory(prefix="hermes_dist_update_") as tmp: + plan = plan_install( + existing_manifest.source, + Path(tmp), + override_name=canon, + ) + plan.preserves_config = not force_config + + _copy_dist_payload( + plan.staged_dir, + plan.target_dir, + plan.manifest, + preserve_config=plan.preserves_config, + ) + return plan + + +# --------------------------------------------------------------------------- +# Info — render a manifest summary +# --------------------------------------------------------------------------- + + +def describe_distribution(profile_name: str) -> Dict[str, Any]: + """Return a structured view of a profile's distribution metadata. + + Returns an empty dict if the profile exists but has no manifest. + Raises DistributionError if the profile itself doesn't exist. + """ + from hermes_cli.profiles import ( + get_profile_dir, + normalize_profile_name, + validate_profile_name, + ) + + canon = normalize_profile_name(profile_name) + validate_profile_name(canon) + target = get_profile_dir(canon) + if not target.is_dir(): + raise DistributionError(f"Profile '{canon}' does not exist.") + manifest = read_manifest(target) + if manifest is None: + return {} + return manifest.to_dict() diff --git a/hermes_cli/profiles.py b/hermes_cli/profiles.py index 93928364c42..a8bc229bf9c 100644 --- a/hermes_cli/profiles.py +++ b/hermes_cli/profiles.py @@ -221,6 +221,12 @@ def validate_profile_name(name: str) -> None: call :func:`normalize_profile_name` first. This separation keeps validate honest about what the on-disk directory name must look like, while ingress-point normalization handles UX flexibility (see #18498). + + Also rejects names in :data:`_RESERVED_NAMES` (``hermes``, ``test``, + ``tmp``, ``root``, ``sudo``) that would create confusing on-disk + collisions (a ``hermes`` profile inside ``~/.hermes/``) or get refused + at alias-creation time anyway. ``default`` is a special pass-through — + it's a valid alias for the built-in root profile. """ if name == "default": return # special alias for ~/.hermes @@ -229,6 +235,12 @@ def validate_profile_name(name: str) -> None: f"Invalid profile name {name!r}. Must match " f"[a-z0-9][a-z0-9_-]{{0,63}}" ) + if name in _RESERVED_NAMES: + raise ValueError( + f"Profile name {name!r} is reserved — it collides with either " + f"the Hermes installation itself or a common system binary. " + f"Pick a different name." + ) def get_profile_dir(name: str) -> Path: @@ -345,6 +357,35 @@ class ProfileInfo: has_env: bool = False skill_count: int = 0 alias_path: Optional[Path] = None + # Distribution metadata (None if the profile wasn't installed from a distribution). + distribution_name: Optional[str] = None + distribution_version: Optional[str] = None + distribution_source: Optional[str] = None + + +def _read_distribution_meta(profile_dir: Path) -> tuple: + """Return ``(name, version, source)`` from the profile's ``distribution.yaml`` + if present; ``(None, None, None)`` otherwise. + + Failures (missing file, bad YAML) are swallowed — a bad manifest should + never break ``hermes profile list`` for an unrelated profile. + """ + mf_path = profile_dir / "distribution.yaml" + if not mf_path.is_file(): + return None, None, None + try: + import yaml + with open(mf_path, "r") as f: + data = yaml.safe_load(f) or {} + if not isinstance(data, dict): + return None, None, None + return ( + data.get("name"), + data.get("version"), + data.get("source"), + ) + except Exception: + return None, None, None def _read_config_model(profile_dir: Path) -> tuple: @@ -400,6 +441,7 @@ def list_profiles() -> List[ProfileInfo]: default_home = _get_default_hermes_home() if default_home.is_dir(): model, provider = _read_config_model(default_home) + dist_name, dist_version, dist_source = _read_distribution_meta(default_home) profiles.append(ProfileInfo( name="default", path=default_home, @@ -409,6 +451,9 @@ def list_profiles() -> List[ProfileInfo]: provider=provider, has_env=(default_home / ".env").exists(), skill_count=_count_skills(default_home), + distribution_name=dist_name, + distribution_version=dist_version, + distribution_source=dist_source, )) # Named profiles @@ -422,6 +467,7 @@ def list_profiles() -> List[ProfileInfo]: continue model, provider = _read_config_model(entry) alias_path = wrapper_dir / name + dist_name, dist_version, dist_source = _read_distribution_meta(entry) profiles.append(ProfileInfo( name=name, path=entry, @@ -432,6 +478,9 @@ def list_profiles() -> List[ProfileInfo]: has_env=(entry / ".env").exists(), skill_count=_count_skills(entry), alias_path=alias_path if alias_path.exists() else None, + distribution_name=dist_name, + distribution_version=dist_version, + distribution_source=dist_source, )) return profiles @@ -640,6 +689,7 @@ def delete_profile(name: str, yes: bool = False) -> Path: model, provider = _read_config_model(profile_dir) gw_running = _check_gateway_running(profile_dir) skill_count = _count_skills(profile_dir) + dist_name, dist_version, dist_source = _read_distribution_meta(profile_dir) print(f"\nProfile: {canon}") print(f"Path: {profile_dir}") @@ -647,6 +697,10 @@ def delete_profile(name: str, yes: bool = False) -> Path: print(f"Model: {model}" + (f" ({provider})" if provider else "")) if skill_count: print(f"Skills: {skill_count}") + if dist_name: + print(f"Distribution: {dist_name}@{dist_version or '?'}") + if dist_source: + print(f"Installed from: {dist_source}") items = [ "All config, API keys, memories, sessions, skills, cron jobs", diff --git a/tests/hermes_cli/test_profile_distribution.py b/tests/hermes_cli/test_profile_distribution.py new file mode 100644 index 00000000000..46e00e33cac --- /dev/null +++ b/tests/hermes_cli/test_profile_distribution.py @@ -0,0 +1,584 @@ +"""Tests for hermes_cli.profile_distribution — git-based profile installs. + +Covers manifest parsing, version requirement checks, install / update / describe +on local-directory sources, and guards on what can and can't be installed. + +Transport-layer tests (git clone, URL handling) are exercised through live +E2E runs, not unit tests — git itself is tested upstream, and subprocess- +mocking git would just test the mock. +""" + +from __future__ import annotations + +import os +from pathlib import Path + +import pytest + +from hermes_cli.profile_distribution import ( + DEFAULT_DIST_OWNED, + DistributionError, + DistributionManifest, + EnvRequirement, + MANIFEST_FILENAME, + USER_OWNED_EXCLUDE, + _env_template_from_manifest, + _looks_like_git_url, + _parse_semver, + check_hermes_requires, + describe_distribution, + install_distribution, + plan_install, + read_manifest, + update_distribution, + write_manifest, +) + + +# --------------------------------------------------------------------------- +# Isolated profile env (matches tests/hermes_cli/test_profiles.py) +# --------------------------------------------------------------------------- + + +@pytest.fixture() +def profile_env(tmp_path, monkeypatch): + monkeypatch.setattr(Path, "home", lambda: tmp_path) + default_home = tmp_path / ".hermes" + default_home.mkdir(exist_ok=True) + monkeypatch.setenv("HERMES_HOME", str(default_home)) + return tmp_path + + +def _make_staging_dir(root: Path, name: str = "src", *, manifest: DistributionManifest = None) -> Path: + """Build a local distribution staging directory (what a git clone would + contain after .git is removed). + + Lays down a minimal but representative tree: SOUL.md, config.yaml, + mcp.json, one skill, one cron file, plus the distribution.yaml manifest. + """ + staged = root / f"staging_{name}" + staged.mkdir(parents=True, exist_ok=True) + (staged / "SOUL.md").write_text("I am Source.\n") + (staged / "config.yaml").write_text("model:\n model: gpt-4\n") + (staged / "mcp.json").write_text('{"servers": {}}\n') + (staged / "skills").mkdir(exist_ok=True) + (staged / "skills" / "demo").mkdir(exist_ok=True) + (staged / "skills" / "demo" / "SKILL.md").write_text( + "---\nname: demo\ndescription: test\n---\n# Demo skill\n" + ) + (staged / "cron").mkdir(exist_ok=True) + (staged / "cron" / "daily.json").write_text('{"schedule": "0 9 * * *"}') + + mf = manifest or DistributionManifest(name=name, version="0.1.0") + write_manifest(staged, mf) + return staged + + +# =========================================================================== +# Manifest parsing +# =========================================================================== + + +class TestManifestParsing: + + def test_minimal_manifest(self, tmp_path): + (tmp_path / MANIFEST_FILENAME).write_text("name: minimal\n") + m = read_manifest(tmp_path) + assert m.name == "minimal" + assert m.version == "0.1.0" + assert m.env_requires == [] + assert m.distribution_owned == [] + + def test_full_manifest(self, tmp_path): + (tmp_path / MANIFEST_FILENAME).write_text( + "name: telem\n" + "version: 1.2.3\n" + "description: Telem monitor\n" + "hermes_requires: '>=0.12.0'\n" + "author: Kyle\n" + "license: MIT\n" + "env_requires:\n" + " - name: OPENAI_API_KEY\n" + " description: OpenAI key\n" + " - name: GRAPH_URL\n" + " required: false\n" + " default: http://127.0.0.1:8000\n" + "distribution_owned:\n" + " - SOUL.md\n" + " - skills/\n" + ) + m = read_manifest(tmp_path) + assert m.name == "telem" + assert m.version == "1.2.3" + assert m.author == "Kyle" + assert m.license == "MIT" + assert len(m.env_requires) == 2 + assert m.env_requires[0].name == "OPENAI_API_KEY" + assert m.env_requires[0].required is True + assert m.env_requires[1].required is False + assert m.env_requires[1].default == "http://127.0.0.1:8000" + assert m.distribution_owned == ["SOUL.md", "skills"] + + def test_missing_name_rejected(self, tmp_path): + (tmp_path / MANIFEST_FILENAME).write_text("version: 1.0\n") + with pytest.raises(DistributionError, match="missing 'name'"): + read_manifest(tmp_path) + + def test_env_requires_not_list_rejected(self, tmp_path): + (tmp_path / MANIFEST_FILENAME).write_text( + "name: bad\nenv_requires:\n name: FOO\n" + ) + with pytest.raises(DistributionError, match="env_requires must be a list"): + read_manifest(tmp_path) + + def test_read_manifest_returns_none_when_absent(self, tmp_path): + assert read_manifest(tmp_path) is None + + def test_owned_paths_default(self): + m = DistributionManifest(name="x") + assert m.owned_paths() == list(DEFAULT_DIST_OWNED) + + def test_owned_paths_explicit(self): + m = DistributionManifest(name="x", distribution_owned=["SOUL.md", "skills"]) + assert m.owned_paths() == ["SOUL.md", "skills"] + + def test_roundtrip_write_read(self, tmp_path): + original = DistributionManifest( + name="rt", + version="1.0.0", + description="roundtrip", + env_requires=[EnvRequirement(name="FOO", description="foo")], + ) + write_manifest(tmp_path, original) + parsed = read_manifest(tmp_path) + assert parsed.name == "rt" + assert parsed.env_requires[0].name == "FOO" + + +# =========================================================================== +# Version requirement checks +# =========================================================================== + + +class TestVersionRequires: + + @pytest.mark.parametrize("spec,cur,ok", [ + ("", "0.1.0", True), + (">=0.12.0", "0.12.0", True), + (">=0.12.0", "0.13.0", True), + (">=0.12.0", "0.11.9", False), + ("==0.12.0", "0.12.0", True), + ("==0.12.0", "0.13.0", False), + ("!=0.12.0", "0.13.0", True), + (">0.12.0", "0.12.1", True), + (">0.12.0", "0.12.0", False), + ("<0.13.0", "0.12.9", True), + ("<=0.12.0", "0.12.0", True), + ("0.12.0", "0.13.0", True), # Bare = >= + ("0.12.0", "0.11.0", False), # Bare = >= + ]) + def test_check_matrix(self, spec, cur, ok): + if ok: + check_hermes_requires(spec, cur) + else: + with pytest.raises(DistributionError, match="requires Hermes"): + check_hermes_requires(spec, cur) + + def test_parse_semver_handles_prerelease(self): + assert _parse_semver("0.12.0-rc1") == (0, 12, 0) + assert _parse_semver("v0.12.0+abc") == (0, 12, 0) + + def test_parse_semver_pads(self): + assert _parse_semver("1") == (1, 0, 0) + assert _parse_semver("1.2") == (1, 2, 0) + + def test_parse_semver_rejects_garbage(self): + with pytest.raises(DistributionError, match="Unparseable"): + _parse_semver("not-a-version") + + +# =========================================================================== +# Env template +# =========================================================================== + + +class TestEnvTemplate: + + def test_required_is_uncommented(self): + m = DistributionManifest( + name="x", + env_requires=[EnvRequirement(name="FOO", description="foo key")], + ) + out = _env_template_from_manifest(m) + assert "# foo key" in out + assert "# (required)" in out + assert "FOO=" in out + # No leading `# ` before FOO= + assert "\nFOO=" in out or out.startswith("FOO=") or "\nFOO=\n" in out or "FOO=\n" in out + + def test_optional_is_commented(self): + m = DistributionManifest( + name="x", + env_requires=[EnvRequirement(name="BAR", required=False, default="http://x")], + ) + out = _env_template_from_manifest(m) + assert "# (optional)" in out + assert "# BAR=http://x" in out + + def test_empty_env_requires_is_header_only(self): + m = DistributionManifest(name="x") + out = _env_template_from_manifest(m) + assert "Hermes distribution" in out + assert "FOO" not in out + + +# =========================================================================== +# Source URL detection +# =========================================================================== + + +class TestLooksLikeGitUrl: + + @pytest.mark.parametrize("src", [ + "github.com/user/repo", + "https://github.com/user/repo", + "https://github.com/user/repo.git", + "http://example.com/repo", + "git@github.com:user/repo.git", + "ssh://git@example.com/repo.git", + "git://example.com/repo.git", + ]) + def test_accepts_git_sources(self, src): + assert _looks_like_git_url(src) + + @pytest.mark.parametrize("src", [ + "/tmp/local/path", + "./relative/dir", + "~/profile", + "some-random-string", + ]) + def test_rejects_non_git(self, src): + assert not _looks_like_git_url(src) + + +# =========================================================================== +# Install — fresh and force (from a local-directory source) +# =========================================================================== + + +class TestInstall: + + def test_install_from_directory(self, profile_env): + staged = _make_staging_dir(profile_env, "src") + plan = install_distribution(str(staged), name="installed") + assert plan.target_dir.is_dir() + assert (plan.target_dir / "SOUL.md").read_text() == "I am Source.\n" + assert (plan.target_dir / "skills" / "demo" / "SKILL.md").exists() + assert (plan.target_dir / "mcp.json").exists() + # Manifest on disk records canonical name + provenance + m = read_manifest(plan.target_dir) + assert m.name == "installed" + assert m.source == str(staged) + + def test_install_uses_manifest_name_when_no_override(self, profile_env): + mf = DistributionManifest(name="telem", version="1.0.0") + staged = _make_staging_dir(profile_env, "telem", manifest=mf) + plan = install_distribution(str(staged)) + assert plan.manifest.name == "telem" + assert plan.target_dir.name == "telem" + + def test_install_rejects_existing_without_force(self, profile_env): + staged = _make_staging_dir(profile_env, "src") + install_distribution(str(staged), name="existing") + with pytest.raises(DistributionError, match="already exists"): + install_distribution(str(staged), name="existing") + + def test_install_with_force_overwrites(self, profile_env): + staged = _make_staging_dir(profile_env, "src") + install_distribution(str(staged), name="target") + # Install again with --force succeeds + plan = install_distribution(str(staged), name="target", force=True) + assert plan.target_dir.is_dir() + + def test_install_rejects_default_name(self, profile_env): + staged = _make_staging_dir(profile_env, "src") + with pytest.raises(DistributionError, match="Cannot install"): + install_distribution(str(staged), name="default") + + def test_install_rejects_non_distribution_directory(self, profile_env, tmp_path): + bogus = tmp_path / "bogus_dir" + bogus.mkdir() + (bogus / "some_file").write_text("hi") + with pytest.raises(DistributionError, match="No distribution.yaml"): + plan_install(str(bogus), tmp_path / "work", override_name="x") + + def test_install_rejects_unknown_source(self, profile_env, tmp_path): + with pytest.raises(DistributionError, match="Cannot resolve"): + plan_install("definitely-not-a-thing", tmp_path / "work", override_name="x") + + def test_install_emits_env_example_when_manifest_has_env(self, profile_env): + mf = DistributionManifest( + name="needs_env", + version="0.1.0", + env_requires=[EnvRequirement(name="OPENAI_API_KEY", description="key")], + ) + staged = _make_staging_dir(profile_env, "needs_env", manifest=mf) + plan = install_distribution(str(staged), name="needs_env") + example = plan.target_dir / ".env.EXAMPLE" + assert example.is_file() + assert "OPENAI_API_KEY" in example.read_text() + + def test_install_enforces_hermes_requires(self, profile_env, monkeypatch): + # Pin current Hermes version to something well below the requirement + import hermes_cli + monkeypatch.setattr(hermes_cli, "__version__", "0.1.0", raising=False) + + mf = DistributionManifest( + name="future", + version="1.0.0", + hermes_requires=">=99.0.0", + ) + staged = _make_staging_dir(profile_env, "future", manifest=mf) + with pytest.raises(DistributionError, match="requires Hermes"): + install_distribution(str(staged), name="future") + + +# =========================================================================== +# Update — preserves user data, preserves config by default +# =========================================================================== + + +class TestUpdate: + + def test_update_preserves_user_data(self, profile_env): + # 1. Build staging dir, install + staged = _make_staging_dir(profile_env, "src") + plan = install_distribution(str(staged), name="telem") + + # 2. Add user-owned data to the installed profile + (plan.target_dir / "memories").mkdir(exist_ok=True) + (plan.target_dir / "memories" / "MEMORY.md").write_text("# USER MEMORY\n") + (plan.target_dir / ".env").write_text("OPENAI_API_KEY=sk-user\n") + (plan.target_dir / "auth.json").write_text('{"user": "auth"}') + (plan.target_dir / "sessions").mkdir(exist_ok=True) + (plan.target_dir / "sessions" / "chat.json").write_text('{"s": 1}') + + # 3. Bump source in the staging dir + (staged / "SOUL.md").write_text("I am Source v2.\n") + + # 4. Update + update_distribution("telem", force_config=False) + + # 5. Dist-owned changed + assert (plan.target_dir / "SOUL.md").read_text() == "I am Source v2.\n" + # 6. User-owned preserved + assert (plan.target_dir / "memories" / "MEMORY.md").read_text() == "# USER MEMORY\n" + assert (plan.target_dir / ".env").read_text() == "OPENAI_API_KEY=sk-user\n" + assert (plan.target_dir / "auth.json").read_text() == '{"user": "auth"}' + assert (plan.target_dir / "sessions" / "chat.json").read_text() == '{"s": 1}' + + def test_update_preserves_config_by_default(self, profile_env): + staged = _make_staging_dir(profile_env, "src") + plan = install_distribution(str(staged), name="t2") + + # User edits config + (plan.target_dir / "config.yaml").write_text( + "model:\n model: gpt-5\n# user override\n" + ) + + # Bump source config + (staged / "config.yaml").write_text("model:\n model: claude\n") + + update_distribution("t2", force_config=False) + assert "gpt-5" in (plan.target_dir / "config.yaml").read_text() + assert "user override" in (plan.target_dir / "config.yaml").read_text() + + def test_update_force_config_overwrites(self, profile_env): + staged = _make_staging_dir(profile_env, "src") + plan = install_distribution(str(staged), name="t3") + + (plan.target_dir / "config.yaml").write_text("model:\n model: gpt-5\n") + + (staged / "config.yaml").write_text("model:\n model: claude\n") + + update_distribution("t3", force_config=True) + assert "claude" in (plan.target_dir / "config.yaml").read_text() + assert "gpt-5" not in (plan.target_dir / "config.yaml").read_text() + + def test_update_missing_manifest_errors(self, profile_env): + # Make a profile without a manifest; update must refuse + from hermes_cli.profiles import create_profile + create_profile(name="plain", no_alias=True) + with pytest.raises(DistributionError, match="not a distribution"): + update_distribution("plain") + + +# =========================================================================== +# describe_distribution — info subcommand +# =========================================================================== + + +class TestDescribe: + + def test_describe_existing_distribution(self, profile_env): + mf = DistributionManifest( + name="telem", + version="1.0.0", + description="compliance monitor", + env_requires=[EnvRequirement(name="API", description="api key")], + ) + staged = _make_staging_dir(profile_env, "telem", manifest=mf) + install_distribution(str(staged), name="telem") + data = describe_distribution("telem") + assert data["name"] == "telem" + assert data["version"] == "1.0.0" + assert data["env_requires"][0]["name"] == "API" + + def test_describe_non_distribution_returns_empty(self, profile_env): + from hermes_cli.profiles import create_profile + create_profile(name="plain", no_alias=True) + assert describe_distribution("plain") == {} + + def test_describe_missing_profile_raises(self, profile_env): + with pytest.raises(DistributionError, match="does not exist"): + describe_distribution("nonexistent") + + +# =========================================================================== +# Security — USER_OWNED_EXCLUDE covers the right paths +# =========================================================================== + + +class TestSecurity: + + def test_user_owned_exclude_covers_credentials(self): + assert "auth.json" in USER_OWNED_EXCLUDE + assert ".env" in USER_OWNED_EXCLUDE + assert "memories" in USER_OWNED_EXCLUDE + assert "sessions" in USER_OWNED_EXCLUDE + assert "local" in USER_OWNED_EXCLUDE + + def test_install_does_not_import_credentials_from_staging(self, profile_env): + """If an author accidentally ships auth.json or .env in their + staging dir, the installer must NOT copy them to the target profile.""" + staged = _make_staging_dir(profile_env, "src") + # Author leaks credentials into the staging tree (shouldn't happen, but...) + (staged / "auth.json").write_text('{"leaked": true}') + (staged / ".env").write_text("LEAKED=1") + + plan = install_distribution(str(staged), name="clean") + assert not (plan.target_dir / "auth.json").exists(), "auth.json leaked" + # Fresh profile may have its own .env via the bootstrap; what we care + # about is that the leaked content didn't land in the target. + if (plan.target_dir / ".env").exists(): + assert "LEAKED" not in (plan.target_dir / ".env").read_text() + + +# =========================================================================== +# Install-time metadata (installed_at stamp) +# =========================================================================== + + +class TestInstalledAtStamp: + + def test_install_stamps_installed_at(self, profile_env): + staged = _make_staging_dir(profile_env, "src") + plan = install_distribution(str(staged), name="stamped") + mf = read_manifest(plan.target_dir) + assert mf.installed_at, "installed_at should be set after install" + # ISO-8601 UTC sanity: starts with 4-digit year, contains 'T', ends with '+00:00'. + assert mf.installed_at[:4].isdigit() + assert "T" in mf.installed_at + assert mf.installed_at.endswith("+00:00") + + def test_update_refreshes_installed_at(self, profile_env, monkeypatch): + staged = _make_staging_dir(profile_env, "src") + install_distribution(str(staged), name="demo") + from hermes_cli.profiles import get_profile_dir + first = read_manifest(get_profile_dir("demo")).installed_at + + # Freeze `datetime.now()` to a fixed future time so we can observe that + # update writes a NEW stamp (installs within the same second otherwise + # collide at iso-8601 seconds resolution). + import datetime as _dt + class _FakeDT(_dt.datetime): + @classmethod + def now(cls, tz=None): + return _dt.datetime(2099, 1, 1, 0, 0, 0, tzinfo=tz or _dt.timezone.utc) + monkeypatch.setattr( + "hermes_cli.profile_distribution.datetime", _FakeDT, raising=True + ) + + from hermes_cli.profile_distribution import update_distribution + update_distribution("demo") + refreshed = read_manifest(get_profile_dir("demo")).installed_at + assert refreshed != first, "installed_at should change on update" + assert refreshed.startswith("2099-01-01"), refreshed + + +# =========================================================================== +# ProfileInfo exposes distribution metadata +# =========================================================================== + + +class TestProfileInfoDistribution: + + def test_installed_distribution_shows_in_list(self, profile_env): + staged = _make_staging_dir( + profile_env, "src", + manifest=DistributionManifest(name="telem", version="1.2.3"), + ) + install_distribution(str(staged), name="telem") + + from hermes_cli.profiles import list_profiles + rows = {p.name: p for p in list_profiles()} + assert "telem" in rows + row = rows["telem"] + assert row.distribution_name == "telem" + assert row.distribution_version == "1.2.3" + assert row.distribution_source # path populated, exact value depends on fixture + + def test_plain_profile_has_no_distribution_fields(self, profile_env): + from hermes_cli.profiles import create_profile, list_profiles + create_profile(name="plain", no_alias=True) + rows = {p.name: p for p in list_profiles()} + assert rows["plain"].distribution_name is None + assert rows["plain"].distribution_version is None + + def test_malformed_manifest_does_not_break_list(self, profile_env): + from hermes_cli.profiles import create_profile, list_profiles, get_profile_dir + create_profile(name="brokenmeta", no_alias=True) + # Write a distribution.yaml that isn't a valid mapping + (get_profile_dir("brokenmeta") / "distribution.yaml").write_text( + "not: [a, valid, mapping\n" # broken YAML + ) + # list_profiles must NOT raise; distribution_* stay None for this row. + rows = {p.name: p for p in list_profiles()} + assert rows["brokenmeta"].distribution_name is None + + +# =========================================================================== +# Error surfaces: validation failures should propagate as DistributionError +# or ValueError (both caught and rendered cleanly by the CLI handler) +# =========================================================================== + + +class TestErrorSurfaces: + + def test_bad_profile_name_raises_valueerror_not_traceback(self, profile_env, tmp_path): + """A manifest whose 'name' can't be used as a profile identifier + should raise ValueError from validate_profile_name — the CLI handler + catches both DistributionError and ValueError so users see a clean + 'Error: ...' line instead of a Python traceback. + """ + mf = DistributionManifest(name="Invalid Name With Spaces", version="0.1.0") + staged = _make_staging_dir(profile_env, "bad", manifest=mf) + with pytest.raises((ValueError, DistributionError)): + plan_install(str(staged), tmp_path / "work") + + def test_path_traversal_name_rejected(self, profile_env, tmp_path): + mf = DistributionManifest(name="../../etc/passwd", version="0.1.0") + staged = _make_staging_dir(profile_env, "bad", manifest=mf) + with pytest.raises((ValueError, DistributionError)): + plan_install(str(staged), tmp_path / "work") + diff --git a/tests/hermes_cli/test_profiles.py b/tests/hermes_cli/test_profiles.py index 130b1c39e40..88bc09b694c 100644 --- a/tests/hermes_cli/test_profiles.py +++ b/tests/hermes_cli/test_profiles.py @@ -116,6 +116,14 @@ class TestValidateProfileName: with pytest.raises(ValueError): validate_profile_name("") + @pytest.mark.parametrize("name", ["hermes", "test", "tmp", "root", "sudo"]) + def test_reserved_names_rejected(self, name): + """Reserved names collide with the Hermes install itself or with + common system binaries — reject them at validate time so + create/install/rename all share one gate.""" + with pytest.raises(ValueError, match="reserved"): + validate_profile_name(name) + # =================================================================== # TestGetProfileDir diff --git a/website/docs/reference/cli-commands.md b/website/docs/reference/cli-commands.md index 390204e5331..a82c782ca29 100644 --- a/website/docs/reference/cli-commands.md +++ b/website/docs/reference/cli-commands.md @@ -1077,8 +1077,11 @@ Manage profiles — multiple isolated Hermes instances, each with its own config | `show <name>` | Show profile details (home directory, config, etc.). | | `alias <name> [--remove] [--name NAME]` | Manage wrapper scripts for quick profile access. | | `rename <old> <new>` | Rename a profile. | -| `export <name> [-o FILE]` | Export a profile to a `.tar.gz` archive. | -| `import <archive> [--name NAME]` | Import a profile from a `.tar.gz` archive. | +| `export <name> [-o FILE]` | Export a profile to a `.tar.gz` archive (local backup). | +| `import <archive> [--name NAME]` | Import a profile from a `.tar.gz` archive (local restore). | +| `install <source> [--name N] [--alias] [--force] [-y]` | Install a profile distribution from a git URL or local directory. | +| `update <name> [--force-config] [-y]` | Re-pull a distribution; preserves user data (memories, sessions, auth). | +| `info <name>` | Show a profile's distribution manifest (version, requirements, source). | Examples: @@ -1089,6 +1092,8 @@ hermes profile use work hermes profile alias work --name h-work hermes profile export work -o work-backup.tar.gz hermes profile import work-backup.tar.gz --name restored +hermes profile install github.com/user/my-distro --alias +hermes profile update work hermes -p work chat -q "Hello from work profile" ``` diff --git a/website/docs/reference/profile-commands.md b/website/docs/reference/profile-commands.md index e4f28e83460..d4a1409b0d3 100644 --- a/website/docs/reference/profile-commands.md +++ b/website/docs/reference/profile-commands.md @@ -243,6 +243,161 @@ hermes profile import ./work-2026-03-29.tar.gz hermes profile import ./work-2026-03-29.tar.gz --name work-restored ``` +## Distribution commands + +Distributions turn a profile into a shareable, versioned artifact published +as a **git repository**. A recipient installs the distribution with a single +command and can update it in place later without touching their local +memories, sessions, or credentials. + +`auth.json` and `.env` are never part of a distribution — they stay on the +installing user's machine. + +The recipient's user data (memories, sessions, auth, their own edits to +`.env`) is always preserved across the initial install and subsequent +updates. + +:::info +`hermes profile export` / `import` are still the right commands for +**local backup and restore** of a profile on your own machine. Distribution +(`install` / `update` / `info`) is a separate concept: ship a profile via +git so someone else can install it. +::: + +### `hermes profile install` + +```bash +hermes profile install <source> [--name <name>] [--alias] [--force] [--yes] +``` + +Installs a profile distribution from a git URL or a local directory. + +| Option | Description | +|--------|-------------| +| `<source>` | Git URL (`github.com/user/repo`, `https://...`, `git@...`, `ssh://`, `git://`) or a local directory containing `distribution.yaml` at its root. | +| `--name NAME` | Override the profile name from the manifest. | +| `--alias` | Also create a shell wrapper (e.g. `telemetry` → `hermes -p telemetry`). | +| `--force` | Overwrite an existing profile of the same name. User data is still preserved. | +| `-y`, `--yes` | Skip the manifest-preview confirmation prompt. | + +The installer shows the manifest, lists required env vars, and warns about +cron jobs before asking for confirmation. Required env vars go into a +`.env.EXAMPLE` file you copy to `.env` and fill in. + +**Examples:** + +```bash +# Install from a GitHub repo (shorthand) +hermes profile install github.com/kyle/telemetry-distribution --alias + +# Install from a full HTTPS git URL +hermes profile install https://github.com/kyle/telemetry-distribution.git + +# Install from SSH +hermes profile install git@github.com:kyle/telemetry-distribution.git + +# Install from a local directory during development +hermes profile install ./telemetry/ +``` + +### `hermes profile update` + +```bash +hermes profile update <name> [--force-config] [--yes] +``` + +Re-clones the distribution from its recorded source and applies updates. +Distribution-owned files (SOUL.md, skills/, cron/, mcp.json) are +overwritten; user data (memories, sessions, auth, .env) is never touched. + +`config.yaml` is preserved by default to keep your local overrides. +Pass `--force-config` to reset it to the distribution's shipped config. + +### `hermes profile info` + +```bash +hermes profile info <name> +``` + +Prints the profile's distribution manifest — name, version, required +Hermes version, author, env var requirements, the source URL/path, and +the `Installed:` timestamp recorded when the distribution was last +`install`-ed or `update`-d. Useful for checking what a shared profile +needs before installing it, and for spotting "this profile was installed +6 months ago and hasn't been updated." + +`hermes profile list` also shows the distribution name and version in a +`Distribution` column, and `hermes profile show <name>` / `delete <name>` +surface the source URL so you can tell at a glance which profiles came +from a git repo vs. were created locally. + +### Private distributions + +A private git repository works as a distribution source with no extra +configuration — the install shells out to your normal `git` binary, so +whatever authentication your shell is already set up for (SSH key, +`git credential` helper, GitHub CLI's stored HTTPS credentials) applies +transparently. + +```bash +# Uses your SSH key, the same as any other `git clone` +hermes profile install git@github.com:your-org/internal-assistant.git + +# Uses your git credential helper +hermes profile install https://github.com/your-org/internal-assistant.git +``` + +If a clone prompts for credentials interactively in your terminal during +install, that prompt flows through. Set up your auth the way you'd +normally use `git clone` against the same repo first, then install. + +### Distribution manifest (`distribution.yaml`) + +Every distribution has a `distribution.yaml` at the root of its repository: + +```yaml +name: telemetry +version: 0.1.0 +description: "Compliance monitoring harness" +hermes_requires: ">=0.12.0" +author: "Your Name" +license: "MIT" +env_requires: + - name: OPENAI_API_KEY + description: "OpenAI API key" + required: true + - name: GRAPHITI_MCP_URL + description: "Memory graph URL" + required: false + default: "http://127.0.0.1:8000/sse" +distribution_owned: # optional; defaults to SOUL.md, config.yaml, + # mcp.json, skills/, cron/, distribution.yaml + - SOUL.md + - skills/compliance/ + - cron/ +``` + +`hermes_requires` supports `>=`, `<=`, `==`, `!=`, `>`, `<`, or a bare +version (treated as `>=`). Install fails with a clear error if the current +Hermes version doesn't satisfy the spec. + +`distribution_owned` is optional. If set, only those paths are replaced on +update; anything else in the profile stays user-owned. If omitted, the +defaults above apply. + +### Publishing a distribution + +Authoring a distribution is just a git push: + +1. In your profile directory, create `distribution.yaml` with at least `name` + and `version`. +2. Initialize a git repo (or use an existing one) and push to GitHub / + GitLab / any host Hermes can clone from. +3. Tell recipients to run `hermes profile install <your-repo-url>`. + +Use git tags for versioned releases — recipients who clone `HEAD` get your +latest state, and you can always bump `version:` in the manifest. + ## `hermes -p` / `hermes --profile` ```bash diff --git a/website/package.json b/website/package.json index e3aa70fc471..fc21cd60a75 100644 --- a/website/package.json +++ b/website/package.json @@ -15,7 +15,7 @@ "write-translations": "docusaurus write-translations", "write-heading-ids": "docusaurus write-heading-ids", "typecheck": "tsc", - "lint:diagrams": "ascii-guard lint docs" + "lint:diagrams": "ascii-guard lint --exclude-code-blocks docs" }, "dependencies": { "@docusaurus/core": "3.9.2",