Merge branch 'main' of github.com:NousResearch/hermes-agent into bb/gui

This commit is contained in:
Brooklyn Nicholson 2026-05-08 13:06:23 -04:00
commit 0961854b88
52 changed files with 5252 additions and 65 deletions

View file

@ -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 {}))

72
cli.py
View file

@ -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

View file

@ -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

View file

@ -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:

View file

@ -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"

View file

@ -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())

View file

@ -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,
},
)

View file

@ -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.")

View file

@ -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

View file

@ -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)
# =========================================================================

View file

@ -0,0 +1,702 @@
"""Profile distributions — shareable, packaged Hermes profiles via git.
A distribution is a Hermes profile published as a git repository (or
installed from a local directory for development). Install with one command
from a git URL, update in place, and keep your local memories / sessions /
credentials untouched.
Where this fits relative to the existing pieces:
* ``hermes profile export/import`` local backup / restore for a profile
on your own machine. NOT a distribution format. Stays as-is.
* ``hermes skills install <url>`` the URL install pattern we're mirroring,
but at the profile granularity.
Subcommands (all live under ``hermes profile``, not a parallel tree):
hermes profile install <source> [--name N] [--alias] [--force] [--yes]
hermes profile update <name> [--force-config] [--yes]
hermes profile info <name>
``<source>`` is one of:
* A git URL (``github.com/user/repo``, ``https://github.com/...``, ``git@...``,
``ssh://``, ``git://``), optionally with ``#<ref>`` to pin a tag / branch /
commit SHA.
* A local directory that already contains ``distribution.yaml`` used
during profile development before the first push.
Manifest format (``distribution.yaml`` at the profile root)::
name: telemetry
version: 0.1.0
description: "Compliance monitoring harness"
hermes_requires: ">=0.12.0"
author: "..."
license: "..."
env_requires:
- name: OPENAI_API_KEY
description: "OpenAI API key"
required: true
- name: GRAPHITI_MCP_URL
description: "Memory graph URL"
required: false
default: "http://127.0.0.1:8000/sse"
distribution_owned: # optional; sensible defaults apply
- SOUL.md
- skills/
- cron/
- mcp.json
Update semantics:
* Distribution-owned paths (SOUL.md, mcp.json, skills/, cron/,
distribution.yaml) are replaced from the new source.
* ``config.yaml`` is distribution-owned but preserved on update unless
``--force-config`` is passed (user overrides typically live here).
* User-owned paths (memories/, sessions/, state.db, auth.json, .env,
logs/, workspace/, home/, plans/, *_cache/, and anything under
``local/``) are never touched.
"""
from __future__ import annotations
import re
import shutil
import subprocess
import tempfile
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
MANIFEST_FILENAME = "distribution.yaml"
ENV_TEMPLATE_FILENAME = ".env.template"
ENV_EXAMPLE_FILENAME = ".env.EXAMPLE"
# Default distribution-owned paths (relative to profile root). Authors may
# override via ``distribution_owned:`` in the manifest. config.yaml is
# distribution-owned but treated specially on update (see _is_config_like).
DEFAULT_DIST_OWNED: Tuple[str, ...] = (
"SOUL.md",
"config.yaml",
"mcp.json",
"skills",
"cron",
MANIFEST_FILENAME,
)
# Paths that are NEVER part of a distribution. These are user-owned and are
# protected on update. Must stay consistent with
# ``profiles.py::_DEFAULT_EXPORT_EXCLUDE_ROOT`` plus the ``local/``
# convention for user customizations.
USER_OWNED_EXCLUDE: frozenset = frozenset({
# Credentials & runtime secrets
"auth.json", ".env",
# Databases & runtime state
"state.db", "state.db-shm", "state.db-wal",
"hermes_state.db", "response_store.db",
"response_store.db-shm", "response_store.db-wal",
"gateway.pid", "gateway_state.json", "processes.json",
"auth.lock", "active_profile", ".update_check",
"errors.log", ".hermes_history",
# User data
"memories", "sessions", "logs", "plans", "workspace", "home",
"image_cache", "audio_cache", "document_cache",
"browser_screenshots", "checkpoints", "sandboxes",
"backups", "cache",
# Infrastructure
"hermes-agent", ".worktrees", "profiles", "bin", "node_modules",
# User customization namespace
"local",
})
# ---------------------------------------------------------------------------
# Errors
# ---------------------------------------------------------------------------
class DistributionError(Exception):
"""Raised for distribution install/update failures."""
# ---------------------------------------------------------------------------
# Manifest
# ---------------------------------------------------------------------------
@dataclass
class EnvRequirement:
name: str
description: str = ""
required: bool = True
default: Optional[str] = None
@classmethod
def from_dict(cls, data: Any) -> "EnvRequirement":
if not isinstance(data, dict):
raise DistributionError(
f"env_requires entry must be a mapping, got {type(data).__name__}"
)
name = str(data.get("name") or "").strip()
if not name:
raise DistributionError("env_requires entry missing 'name'")
return cls(
name=name,
description=str(data.get("description") or ""),
required=bool(data.get("required", True)),
default=data.get("default"),
)
def to_dict(self) -> Dict[str, Any]:
out: Dict[str, Any] = {"name": self.name, "description": self.description}
if not self.required:
out["required"] = False
if self.default is not None:
out["default"] = self.default
return out
@dataclass
class DistributionManifest:
name: str
version: str = "0.1.0"
description: str = ""
hermes_requires: str = ""
author: str = ""
license: str = ""
env_requires: List[EnvRequirement] = field(default_factory=list)
distribution_owned: List[str] = field(default_factory=list)
# Tracked after install — where we pulled from, so ``update`` can re-pull.
source: str = ""
# ISO-8601 UTC timestamp written on install / update, so ``info`` and
# ``list`` can show when a distribution landed on disk. Empty for
# manifests that ship in a repo (authors don't populate this).
installed_at: str = ""
@classmethod
def from_dict(cls, data: Any) -> "DistributionManifest":
if not isinstance(data, dict):
raise DistributionError(
f"{MANIFEST_FILENAME} must be a mapping, got {type(data).__name__}"
)
name = str(data.get("name") or "").strip()
if not name:
raise DistributionError(f"{MANIFEST_FILENAME} missing 'name'")
env_raw = data.get("env_requires") or []
if not isinstance(env_raw, list):
raise DistributionError("env_requires must be a list")
env_requires = [EnvRequirement.from_dict(e) for e in env_raw]
dist_owned_raw = data.get("distribution_owned") or []
if dist_owned_raw and not isinstance(dist_owned_raw, list):
raise DistributionError("distribution_owned must be a list")
distribution_owned = [str(p).strip().strip("/") for p in dist_owned_raw if str(p).strip()]
return cls(
name=name,
version=str(data.get("version") or "0.1.0"),
description=str(data.get("description") or ""),
hermes_requires=str(data.get("hermes_requires") or ""),
author=str(data.get("author") or ""),
license=str(data.get("license") or ""),
env_requires=env_requires,
distribution_owned=distribution_owned,
source=str(data.get("source") or ""),
installed_at=str(data.get("installed_at") or ""),
)
def to_dict(self) -> Dict[str, Any]:
out: Dict[str, Any] = {
"name": self.name,
"version": self.version,
}
if self.description:
out["description"] = self.description
if self.hermes_requires:
out["hermes_requires"] = self.hermes_requires
if self.author:
out["author"] = self.author
if self.license:
out["license"] = self.license
if self.env_requires:
out["env_requires"] = [e.to_dict() for e in self.env_requires]
if self.distribution_owned:
out["distribution_owned"] = self.distribution_owned
if self.source:
out["source"] = self.source
if self.installed_at:
out["installed_at"] = self.installed_at
return out
def owned_paths(self) -> List[str]:
"""Resolve which paths count as distribution-owned."""
if self.distribution_owned:
return list(self.distribution_owned)
return list(DEFAULT_DIST_OWNED)
def _load_yaml(text: str) -> Any:
try:
import yaml
except ImportError as exc: # pragma: no cover — pyyaml is a hard dep
raise DistributionError("PyYAML is required for distribution manifests") from exc
return yaml.safe_load(text)
def _dump_yaml(data: Any) -> str:
import yaml
return yaml.safe_dump(data, sort_keys=False, default_flow_style=False)
def read_manifest(profile_dir: Path) -> Optional[DistributionManifest]:
"""Return the manifest for *profile_dir*, or None if it isn't a distribution."""
mf_path = profile_dir / MANIFEST_FILENAME
if not mf_path.is_file():
return None
try:
data = _load_yaml(mf_path.read_text(encoding="utf-8"))
except Exception as exc:
raise DistributionError(f"Failed to parse {mf_path}: {exc}") from exc
return DistributionManifest.from_dict(data or {})
def write_manifest(profile_dir: Path, manifest: DistributionManifest) -> Path:
mf_path = profile_dir / MANIFEST_FILENAME
mf_path.write_text(_dump_yaml(manifest.to_dict()), encoding="utf-8")
return mf_path
# ---------------------------------------------------------------------------
# Version check
# ---------------------------------------------------------------------------
_VERSION_OP_RE = re.compile(r"^\s*(>=|<=|==|!=|>|<)\s*(.+?)\s*$")
def _parse_semver(v: str) -> Tuple[int, int, int]:
"""Very small semver parser — major.minor.patch only. Extra labels stripped."""
s = str(v).strip().lstrip("v")
# Strip any pre-release / build metadata (e.g. "0.12.0-rc1+abc")
s = re.split(r"[-+]", s, 1)[0]
parts = s.split(".")
while len(parts) < 3:
parts.append("0")
try:
return (int(parts[0]), int(parts[1]), int(parts[2]))
except ValueError as exc:
raise DistributionError(f"Unparseable version: {v!r}") from exc
def check_hermes_requires(spec: str, current_version: str) -> None:
"""Raise DistributionError if ``current_version`` does not satisfy ``spec``.
``spec`` accepts a single comparator (``>=0.12.0``, ``==0.12.0``, etc.).
Empty or blank spec is a no-op no requirement.
"""
if not spec or not spec.strip():
return
m = _VERSION_OP_RE.match(spec)
if not m:
# Bare version → treat as ``>=``
op, target = ">=", spec.strip()
else:
op, target = m.group(1), m.group(2)
cur = _parse_semver(current_version)
tgt = _parse_semver(target)
ok = {
">=": cur >= tgt,
"<=": cur <= tgt,
"==": cur == tgt,
"!=": cur != tgt,
">": cur > tgt,
"<": cur < tgt,
}[op]
if not ok:
raise DistributionError(
f"This distribution requires Hermes {op}{target}, "
f"but you have {current_version}."
)
# ---------------------------------------------------------------------------
# Env var template helper
# ---------------------------------------------------------------------------
def _env_template_from_manifest(manifest: DistributionManifest) -> str:
"""Generate a ``.env.template`` body from env_requires."""
lines = [
"# Environment variables required by this Hermes distribution.",
"# Copy to `.env` and fill in your own values before running.",
"",
]
for req in manifest.env_requires:
if req.description:
lines.append(f"# {req.description}")
status = "required" if req.required else "optional"
lines.append(f"# ({status})")
default_val = req.default if req.default is not None else ""
prefix = "" if req.required else "# "
lines.append(f"{prefix}{req.name}={default_val}")
lines.append("")
return "\n".join(lines).rstrip() + "\n"
# ---------------------------------------------------------------------------
# Source staging — git clone or local directory
# ---------------------------------------------------------------------------
def _looks_like_git_url(s: str) -> bool:
s = s.strip()
if s.endswith(".git"):
return True
if s.startswith(("git@", "ssh://", "git://")):
return True
if s.startswith(("http://", "https://")):
# Any http(s) URL is treated as a git repo. We no longer accept
# tar.gz URLs — git is the only remote transport.
return True
# Bare github.com/user/repo shorthand
if re.match(r"^github\.com/[\w.-]+/[\w.-]+/?$", s):
return True
return False
def _git_clone(url: str, dest: Path) -> None:
# Normalize github.com/user/repo shorthand
if re.match(r"^github\.com/[\w.-]+/[\w.-]+/?$", url):
url = f"https://{url.rstrip('/')}"
try:
subprocess.run(
["git", "clone", "--depth", "1", url, str(dest)],
check=True,
capture_output=True,
)
except FileNotFoundError as exc:
raise DistributionError("git is required for git-URL installs") from exc
except subprocess.CalledProcessError as exc:
stderr = exc.stderr.decode("utf-8", errors="replace") if exc.stderr else ""
raise DistributionError(f"git clone failed: {stderr.strip()}") from exc
def _stage_source(source: str, workdir: Path) -> Tuple[Path, str]:
"""Resolve *source* to a local directory containing distribution.yaml.
Returns ``(staged_dir, provenance)`` where ``provenance`` is stored in the
installed manifest's ``source:`` field so ``hermes profile update`` can
re-pull from the same place.
Accepts:
* A git URL (https / ssh / git@ / bare github.com shorthand) cloned
into a temp directory; ``.git`` removed after clone.
* A local directory already containing ``distribution.yaml``.
"""
src_str = source.strip()
# Git URL
if _looks_like_git_url(src_str):
cloned = workdir / "clone"
_git_clone(src_str, cloned)
# Remove .git to keep the staged tree clean
shutil.rmtree(cloned / ".git", ignore_errors=True)
if not (cloned / MANIFEST_FILENAME).is_file():
raise DistributionError(
f"No {MANIFEST_FILENAME} at the root of {src_str!r}. "
"This repository is not a Hermes profile distribution."
)
return cloned, src_str
# Local directory
path_guess = Path(src_str).expanduser()
if path_guess.is_dir():
if not (path_guess / MANIFEST_FILENAME).is_file():
raise DistributionError(
f"No {MANIFEST_FILENAME} in {path_guess}. "
"A local-directory source must contain a distribution.yaml at its root."
)
return path_guess.resolve(), str(path_guess.resolve())
raise DistributionError(
f"Cannot resolve distribution source: {source!r}. "
"Expected a git URL (e.g. github.com/user/repo) or a local directory."
)
# ---------------------------------------------------------------------------
# Install
# ---------------------------------------------------------------------------
@dataclass
class InstallPlan:
"""Summary of what an install will do, surfaced for user confirmation."""
manifest: DistributionManifest
staged_dir: Path
provenance: str
target_dir: Path
existing: bool # True if target profile already exists (update path)
preserves_config: bool = True
has_cron: bool = False
has_skills: bool = False
def _has_cron_jobs(staged: Path) -> bool:
cron_dir = staged / "cron"
if not cron_dir.is_dir():
return False
for _ in cron_dir.rglob("*.json"):
return True
for _ in cron_dir.rglob("*.yaml"):
return True
return False
def _count_skills(staged: Path) -> int:
skills_dir = staged / "skills"
if not skills_dir.is_dir():
return 0
return sum(1 for _ in skills_dir.rglob("SKILL.md"))
def plan_install(
source: str,
workdir: Path,
override_name: Optional[str] = None,
) -> InstallPlan:
"""Stage *source* and produce a plan describing what install would do."""
from hermes_cli.profiles import (
get_profile_dir,
normalize_profile_name,
validate_profile_name,
)
from hermes_cli import __version__ as hermes_version
staged, provenance = _stage_source(source, workdir)
manifest = read_manifest(staged)
if manifest is None:
raise DistributionError(
f"No {MANIFEST_FILENAME} found at the distribution root — "
"this source is not a Hermes distribution."
)
# Version check up-front so we fail fast
check_hermes_requires(manifest.hermes_requires, hermes_version)
# Resolve target profile name
target_name = override_name or manifest.name
canon = normalize_profile_name(target_name)
validate_profile_name(canon)
if canon == "default":
raise DistributionError(
"Cannot install a distribution as 'default' — that is the built-in "
"root profile (~/.hermes). Pass --name <name> to install under a "
"new profile."
)
manifest.name = canon
manifest.source = provenance
# Stamped once here so plan_install() callers (both fresh install and
# update) propagate a freshly-minted timestamp through _copy_dist_payload.
manifest.installed_at = datetime.now(timezone.utc).isoformat(timespec="seconds")
target_dir = get_profile_dir(canon)
existing = target_dir.is_dir()
has_cron = _has_cron_jobs(staged)
skill_count = _count_skills(staged)
return InstallPlan(
manifest=manifest,
staged_dir=staged,
provenance=provenance,
target_dir=target_dir,
existing=existing,
preserves_config=existing,
has_cron=has_cron,
has_skills=skill_count > 0,
)
def _copy_dist_payload(
staged: Path,
target: Path,
manifest: DistributionManifest,
preserve_config: bool,
) -> None:
"""Copy distribution-owned files from *staged* into *target*.
User-owned paths are never touched. ``config.yaml`` is replaced only when
``preserve_config`` is False (fresh install or ``--force-config`` update).
``.env.template`` is renamed to ``.env.EXAMPLE`` in the target to avoid
shadowing a real ``.env``.
"""
target.mkdir(parents=True, exist_ok=True)
for entry in staged.iterdir():
name = entry.name
if name in USER_OWNED_EXCLUDE:
continue
if name == ENV_TEMPLATE_FILENAME:
shutil.copy2(entry, target / ENV_EXAMPLE_FILENAME)
continue
if name == "config.yaml" and preserve_config and (target / "config.yaml").exists():
# Leave user's config.yaml alone on update
continue
dest = target / name
if entry.is_dir():
if dest.exists():
shutil.rmtree(dest)
shutil.copytree(
entry,
dest,
ignore=lambda d, names: [n for n in names if n in USER_OWNED_EXCLUDE],
)
else:
shutil.copy2(entry, dest)
# Emit .env.EXAMPLE from manifest if the staged tree didn't ship one
if manifest.env_requires and not (target / ENV_EXAMPLE_FILENAME).exists():
(target / ENV_EXAMPLE_FILENAME).write_text(
_env_template_from_manifest(manifest), encoding="utf-8"
)
# Make sure the manifest on disk reflects resolved name + source
write_manifest(target, manifest)
def _bootstrap_user_dirs(target: Path) -> None:
"""Create the bootstrap dirs a fresh profile expects."""
for d in ("memories", "sessions", "skills", "skins", "logs",
"plans", "workspace", "cron", "home"):
(target / d).mkdir(parents=True, exist_ok=True)
def install_distribution(
source: str,
name: Optional[str] = None,
force: bool = False,
create_alias: bool = False,
) -> InstallPlan:
"""Install a distribution from *source* into a new profile.
Returns the resolved :class:`InstallPlan`. Use :func:`plan_install`
first if you want to preview + prompt the user before calling this.
"""
from hermes_cli.profiles import (
check_alias_collision,
create_wrapper_script,
)
with tempfile.TemporaryDirectory(prefix="hermes_dist_install_") as tmp:
plan = plan_install(source, Path(tmp), override_name=name)
if plan.existing and not force:
raise DistributionError(
f"Profile '{plan.manifest.name}' already exists at {plan.target_dir}. "
"Use `hermes profile update` to upgrade in place, "
"or pass --force to overwrite."
)
# Fresh install: config.yaml comes from the distribution.
_bootstrap_user_dirs(plan.target_dir)
_copy_dist_payload(
plan.staged_dir,
plan.target_dir,
plan.manifest,
preserve_config=False,
)
if create_alias:
collision = check_alias_collision(plan.manifest.name)
if collision is None:
create_wrapper_script(plan.manifest.name)
return plan
def update_distribution(
profile_name: str,
force_config: bool = False,
) -> InstallPlan:
"""Re-pull the distribution for an existing profile and apply updates.
The source is read from the installed profile's ``distribution.yaml``
``source:`` field. Distribution-owned files are overwritten; user-owned
data (memories, sessions, auth) is never touched. ``config.yaml`` is
preserved unless ``force_config`` is True.
"""
from hermes_cli.profiles import (
get_profile_dir,
normalize_profile_name,
validate_profile_name,
)
canon = normalize_profile_name(profile_name)
validate_profile_name(canon)
target = get_profile_dir(canon)
if not target.is_dir():
raise DistributionError(f"Profile '{canon}' does not exist.")
existing_manifest = read_manifest(target)
if existing_manifest is None:
raise DistributionError(
f"Profile '{canon}' is not a distribution (no {MANIFEST_FILENAME}). "
"Only profiles installed via `hermes profile install` can be updated."
)
if not existing_manifest.source:
raise DistributionError(
f"Profile '{canon}' has no recorded source. Re-install with "
"`hermes profile install <source> --name {canon} --force`."
)
with tempfile.TemporaryDirectory(prefix="hermes_dist_update_") as tmp:
plan = plan_install(
existing_manifest.source,
Path(tmp),
override_name=canon,
)
plan.preserves_config = not force_config
_copy_dist_payload(
plan.staged_dir,
plan.target_dir,
plan.manifest,
preserve_config=plan.preserves_config,
)
return plan
# ---------------------------------------------------------------------------
# Info — render a manifest summary
# ---------------------------------------------------------------------------
def describe_distribution(profile_name: str) -> Dict[str, Any]:
"""Return a structured view of a profile's distribution metadata.
Returns an empty dict if the profile exists but has no manifest.
Raises DistributionError if the profile itself doesn't exist.
"""
from hermes_cli.profiles import (
get_profile_dir,
normalize_profile_name,
validate_profile_name,
)
canon = normalize_profile_name(profile_name)
validate_profile_name(canon)
target = get_profile_dir(canon)
if not target.is_dir():
raise DistributionError(f"Profile '{canon}' does not exist.")
manifest = read_manifest(target)
if manifest is None:
return {}
return manifest.to_dict()

View file

@ -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",

View file

@ -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 <section>``.
Applies sensible defaults for TTS (Edge), agent settings, and tools
the user can customize later via ``hermes setup <section>``.
"""
# 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.)",

View file

@ -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 | `<guid>` / `<id>` |
| `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: `## <title>\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.

View file

@ -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"

View file

@ -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())

View file

@ -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())

View file

@ -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())

View file

@ -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",

View file

@ -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"):

View file

@ -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
}

View file

@ -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 |

View file

@ -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)

View file

@ -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:

View file

@ -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."
)

View file

@ -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."""

View file

@ -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)

View file

@ -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.

View file

@ -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")

View file

@ -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")

View file

@ -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

View file

@ -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()

View file

@ -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)

View file

@ -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)

View file

@ -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")

View file

@ -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

View file

@ -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",

View file

@ -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)

View file

@ -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,
)

View file

@ -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]))
})
})

View file

@ -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)
})
})

View file

@ -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(

View file

@ -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}>

View file

@ -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} />

View file

@ -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)
}

View file

@ -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.

View file

@ -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"
```

View file

@ -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.

View file

@ -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

View file

@ -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:

View file

@ -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",

View file

@ -181,6 +181,7 @@ const sidebars: SidebarsConfig = {
'guides/migrate-from-openclaw',
'guides/aws-bedrock',
'guides/azure-foundry',
'guides/microsoft-graph-app-registration',
],
},
{