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/cli.py b/cli.py index 08a9bb94ced..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 @@ -5804,12 +5809,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 +6035,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 @@ -7517,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(): @@ -7531,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: @@ -7552,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: @@ -9426,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 @@ -9849,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: @@ -10329,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 @@ -10443,7 +10499,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/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 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/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" 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/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/hermes_cli/claw.py b/hermes_cli/claw.py index 5f9d728252d..5455b4355d0 100644 --- a/hermes_cli/claw.py +++ b/hermes_cli/claw.py @@ -685,10 +685,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 ca0102d8713..09a0976ac55 100644 --- a/hermes_cli/doctor.py +++ b/hermes_cli/doctor.py @@ -1087,9 +1087,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 diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 1e60f319725..380f2c9bc19 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -8163,8 +8163,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 = ( @@ -8178,7 +8184,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": @@ -8317,6 +8328,7 @@ def cmd_profile(args): _read_config_model, _check_gateway_running, _count_skills, + _read_distribution_meta, ) if not profile_exists(name): @@ -8326,6 +8338,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}") @@ -8340,6 +8353,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() @@ -8420,6 +8438,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. @@ -10669,6 +10889,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 `` — 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 [--name N] [--alias] [--force] [--yes] + hermes profile update [--force-config] [--yes] + hermes profile info + +```` is one of: + +* A git URL (``github.com/user/repo``, ``https://github.com/...``, ``git@...``, + ``ssh://``, ``git://``), optionally with ``#`` 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 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 --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/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.)", 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()) 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/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"): diff --git a/scripts/release.py b/scripts/release.py index ce94fd16629..bb943595ab1 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", @@ -696,6 +697,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", @@ -903,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/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 ./<name> 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 ac48b65c7cf..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"] @@ -130,7 +130,33 @@ def _ensure_deps(): sys.exit(1) -def check_auth(): +def check_auth_live(): + """Check auth with a real API call to detect disabled_client/account issues.""" + # quiet=True suppresses the "AUTHENTICATED" print from check_auth so the + # final status line reflects the live-call outcome (OK or FAILED). + if not check_auth(quiet=True): + return False + 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(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}") @@ -157,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: @@ -174,10 +201,25 @@ 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: - 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 +426,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 +436,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: 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." + ) 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/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/tests/hermes_cli/test_auth_nous_provider.py b/tests/hermes_cli/test_auth_nous_provider.py index 136265c7e48..bd6098d3746 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,46 @@ 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 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"] == { + "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. 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/<ver> 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/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/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() 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/tests/tools/test_microsoft_graph_auth.py b/tests/tools/test_microsoft_graph_auth.py new file mode 100644 index 00000000000..4c45ca2c29e --- /dev/null +++ b/tests/tools/test_microsoft_graph_auth.py @@ -0,0 +1,179 @@ +"""Tests for tools/microsoft_graph_auth.py.""" + +from __future__ import annotations + +import asyncio + +import httpx +import pytest + +from tools.microsoft_graph_auth import ( + CachedAccessToken, + 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_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] = [] + + 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..b0f6ba31e3a --- /dev/null +++ b/tests/tools/test_microsoft_graph_client.py @@ -0,0 +1,257 @@ +"""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_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( + 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/approval.py b/tools/approval.py index a7faaff21f2..068748f6854 100644 --- a/tools/approval.py +++ b/tools/approval.py @@ -83,6 +83,37 @@ 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. + + 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()) + # 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 +860,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 +977,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 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/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..dbdf211f6e4 --- /dev/null +++ b/tools/microsoft_graph_client.py @@ -0,0 +1,408 @@ +"""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]: + """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") + + 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, + 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, + ) 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 ( <Text color={color}> 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 ( <> <ScrollBox @@ -95,6 +104,12 @@ const TranscriptPane = memo(function TranscriptPane({ {transcript.virtualRows.slice(transcript.virtualHistory.start, transcript.virtualHistory.end).map(row => ( <Box flexDirection="column" key={row.key} ref={transcript.virtualHistory.measureRef(row.key)}> + {row.msg.role === 'user' && firstUserIdx >= 0 && row.index > firstUserIdx && ( + <Box marginTop={1}> + <Text color={ui.theme.color.border}>───</Text> + </Box> + )} + {row.msg.kind === 'intro' ? ( <Box flexDirection="column" paddingTop={1}> <Banner t={ui.theme} /> 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) } 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/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/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/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/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: 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", 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', ], }, {