mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-09 08:21:50 +00:00
Merge branch 'main' of github.com:NousResearch/hermes-agent into bb/gui
This commit is contained in:
commit
0961854b88
52 changed files with 5252 additions and 65 deletions
|
|
@ -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
72
cli.py
|
|
@ -2414,6 +2414,11 @@ class HermesCLI:
|
|||
self._agent_running = False
|
||||
self._pending_input = queue.Queue()
|
||||
self._interrupt_queue = queue.Queue()
|
||||
# Tracks whether the turn that just finished was interrupted via
|
||||
# Ctrl+C. Consumed by _maybe_continue_goal_after_turn so /goal loops
|
||||
# don't auto-queue another continuation on top of a user-cancelled
|
||||
# turn (which would make Ctrl+C feel like it did nothing).
|
||||
self._last_turn_interrupted = False
|
||||
self._should_exit = False
|
||||
self._last_ctrl_c_time = 0
|
||||
self._clarify_state = None
|
||||
|
|
@ -5804,12 +5809,15 @@ class HermesCLI:
|
|||
self.model = result.new_model
|
||||
self.provider = result.target_provider
|
||||
self.requested_provider = result.target_provider
|
||||
# Always overwrite explicit overrides so stale credentials from the
|
||||
# previous provider (e.g. Ollama api_key/base_url) don't leak into
|
||||
# the new provider's credential resolution on the next turn.
|
||||
self._explicit_api_key = result.api_key
|
||||
self._explicit_base_url = result.base_url
|
||||
if result.api_key:
|
||||
self.api_key = result.api_key
|
||||
self._explicit_api_key = result.api_key
|
||||
if result.base_url:
|
||||
self.base_url = result.base_url
|
||||
self._explicit_base_url = result.base_url
|
||||
if result.api_mode:
|
||||
self.api_mode = result.api_mode
|
||||
|
||||
|
|
@ -6027,12 +6035,15 @@ class HermesCLI:
|
|||
self.model = result.new_model
|
||||
self.provider = result.target_provider
|
||||
self.requested_provider = result.target_provider
|
||||
# Always overwrite explicit overrides so stale credentials from the
|
||||
# previous provider (e.g. Ollama api_key/base_url) don't leak into
|
||||
# the new provider's credential resolution on the next turn.
|
||||
self._explicit_api_key = result.api_key
|
||||
self._explicit_base_url = result.base_url
|
||||
if result.api_key:
|
||||
self.api_key = result.api_key
|
||||
self._explicit_api_key = result.api_key
|
||||
if result.base_url:
|
||||
self.base_url = result.base_url
|
||||
self._explicit_base_url = result.base_url
|
||||
if result.api_mode:
|
||||
self.api_mode = result.api_mode
|
||||
|
||||
|
|
@ -7517,6 +7528,15 @@ class HermesCLI:
|
|||
priority and we'll re-judge after that turn). If judge says done,
|
||||
mark it done and tell the user. If judge says continue and we're
|
||||
under budget, push the continuation prompt onto the queue.
|
||||
|
||||
Interrupt handling: if the turn was user-cancelled (Ctrl+C), we
|
||||
AUTO-PAUSE the goal instead of judging + re-queuing. Otherwise
|
||||
Ctrl+C feels like it did nothing — the judge runs on whatever
|
||||
partial output landed, almost always says "continue", and the
|
||||
loop keeps going. Auto-pause keeps the goal recoverable via
|
||||
``/goal resume`` once the user has sorted out what they want.
|
||||
The empty-response skip mirrors the gateway guard at
|
||||
``_handle_message`` in ``gateway/run.py``.
|
||||
"""
|
||||
mgr = self._get_goal_manager()
|
||||
if mgr is None or not mgr.is_active():
|
||||
|
|
@ -7531,6 +7551,22 @@ class HermesCLI:
|
|||
except Exception:
|
||||
pass
|
||||
|
||||
# If the turn was user-interrupted (Ctrl+C), auto-pause the goal
|
||||
# and bail. The judge call would almost always return "continue"
|
||||
# on the partial output and immediately re-queue another turn,
|
||||
# which is exactly what the user cancelled. Pausing (rather than
|
||||
# silently skipping) is the observable, recoverable behavior.
|
||||
if getattr(self, "_last_turn_interrupted", False):
|
||||
try:
|
||||
mgr.pause(reason="user-interrupted (Ctrl+C)")
|
||||
except Exception as exc:
|
||||
logging.debug("goal pause-on-interrupt failed: %s", exc)
|
||||
_cprint(
|
||||
f" {_DIM}⏸ Goal paused — turn was interrupted. "
|
||||
f"Use /goal resume to continue, or /goal clear to stop.{_RST}"
|
||||
)
|
||||
return
|
||||
|
||||
# Extract the agent's final response for this turn.
|
||||
last_response = ""
|
||||
try:
|
||||
|
|
@ -7552,6 +7588,13 @@ class HermesCLI:
|
|||
except Exception:
|
||||
last_response = ""
|
||||
|
||||
# Skip judging on empty/whitespace-only responses. These are almost
|
||||
# always transient failures (API error, empty stream) where the
|
||||
# judge would say "continue" and trip the consecutive-parse-failures
|
||||
# backstop unnecessarily. Mirrors the gateway guard.
|
||||
if not last_response.strip():
|
||||
return
|
||||
|
||||
decision = mgr.evaluate_after_turn(last_response, user_initiated=True)
|
||||
msg = decision.get("message") or ""
|
||||
if msg:
|
||||
|
|
@ -9426,6 +9469,12 @@ class HermesCLI:
|
|||
# register secure secret capture here as well.
|
||||
set_secret_capture_callback(self._secret_capture_callback)
|
||||
|
||||
# Reset the per-turn interrupt flag. Any subsequent path that
|
||||
# discovers an interrupt (below, after run_conversation) will flip
|
||||
# this to True. Early returns (credential refresh failure, etc.)
|
||||
# leave it False, which is correct — those aren't user interrupts.
|
||||
self._last_turn_interrupted = False
|
||||
|
||||
# Refresh provider credentials if needed (handles key rotation transparently)
|
||||
if not self._ensure_runtime_credentials():
|
||||
return None
|
||||
|
|
@ -9849,7 +9898,11 @@ class HermesCLI:
|
|||
|
||||
# Handle interrupt - check if we were interrupted
|
||||
pending_message = None
|
||||
if result and result.get("interrupted"):
|
||||
_interrupted_this_turn = bool(result and result.get("interrupted"))
|
||||
# Expose the flag for post-turn hooks (e.g. goal continuation)
|
||||
# so they can skip themselves when the turn was user-cancelled.
|
||||
self._last_turn_interrupted = _interrupted_this_turn
|
||||
if _interrupted_this_turn:
|
||||
pending_message = result.get("interrupt_message") or interrupt_msg
|
||||
# Add indicator that we were interrupted
|
||||
if response and pending_message:
|
||||
|
|
@ -10329,6 +10382,9 @@ class HermesCLI:
|
|||
self._agent_running = False
|
||||
self._pending_input = queue.Queue() # For normal input (commands + new queries)
|
||||
self._interrupt_queue = queue.Queue() # For messages typed while agent is running
|
||||
# See constructor note. Mirrored here for the run() path that skips
|
||||
# the earlier __init__ branch.
|
||||
self._last_turn_interrupted = False
|
||||
self._should_exit = False
|
||||
self._last_ctrl_c_time = 0 # Track double Ctrl+C for force exit
|
||||
|
||||
|
|
@ -10443,7 +10499,11 @@ class HermesCLI:
|
|||
|
||||
# --- /model picker modal ---
|
||||
if self._model_picker_state:
|
||||
self._handle_model_picker_selection()
|
||||
try:
|
||||
self._handle_model_picker_selection()
|
||||
except Exception as _exc:
|
||||
_cprint(f" ✗ Model selection failed: {_exc}")
|
||||
self._close_model_picker()
|
||||
event.app.current_buffer.reset()
|
||||
event.app.invalidate()
|
||||
return
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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.")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
# =========================================================================
|
||||
|
|
|
|||
702
hermes_cli/profile_distribution.py
Normal file
702
hermes_cli/profile_distribution.py
Normal 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()
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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.)",
|
||||
|
|
|
|||
112
optional-skills/devops/watchers/SKILL.md
Normal file
112
optional-skills/devops/watchers/SKILL.md
Normal 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.
|
||||
|
||||
148
optional-skills/devops/watchers/scripts/_watermark.py
Executable file
148
optional-skills/devops/watchers/scripts/_watermark.py
Executable 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"
|
||||
168
optional-skills/devops/watchers/scripts/watch_github.py
Executable file
168
optional-skills/devops/watchers/scripts/watch_github.py
Executable 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())
|
||||
131
optional-skills/devops/watchers/scripts/watch_http_json.py
Executable file
131
optional-skills/devops/watchers/scripts/watch_http_json.py
Executable 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())
|
||||
121
optional-skills/devops/watchers/scripts/watch_rss.py
Executable file
121
optional-skills/devops/watchers/scripts/watch_rss.py
Executable 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())
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
221
tests/cli/test_cli_goal_interrupt.py
Normal file
221
tests/cli/test_cli_goal_interrupt.py
Normal 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."
|
||||
)
|
||||
|
|
@ -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."""
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
584
tests/hermes_cli/test_profile_distribution.py
Normal file
584
tests/hermes_cli/test_profile_distribution.py
Normal 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")
|
||||
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
179
tests/tools/test_microsoft_graph_auth.py
Normal file
179
tests/tools/test_microsoft_graph_auth.py
Normal 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)
|
||||
257
tests/tools/test_microsoft_graph_client.py
Normal file
257
tests/tools/test_microsoft_graph_client.py
Normal 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")
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
245
tools/microsoft_graph_auth.py
Normal file
245
tools/microsoft_graph_auth.py
Normal 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)
|
||||
408
tools/microsoft_graph_client.py
Normal file
408
tools/microsoft_graph_client.py
Normal 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,
|
||||
)
|
||||
|
|
@ -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]))
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
180
website/docs/guides/microsoft-graph-app-registration.md
Normal file
180
website/docs/guides/microsoft-graph-app-registration.md
Normal 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.
|
||||
|
|
@ -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"
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -181,6 +181,7 @@ const sidebars: SidebarsConfig = {
|
|||
'guides/migrate-from-openclaw',
|
||||
'guides/aws-bedrock',
|
||||
'guides/azure-foundry',
|
||||
'guides/microsoft-graph-app-registration',
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue