Compare commits

...
Author SHA1 Message Date
teknium1 b1bde77122 docs(code-execution): document HERMES_* env narrowing + passthrough workaround
The execute_code sandbox-child env scrub (108397726, #27303) deliberately
dropped the broad HERMES_ prefix passthrough, keeping only an operational
4-var allowlist (HERMES_HOME/PROFILE/CONFIG/ENV). A script that relied on a
non-secret HERMES_* var (HERMES_BASE_URL, HERMES_KANBAN_DB, HERMES_*_WEBHOOK,
or a plugin-defined one) now sees it unset in the child.

Document the behavior change and the two recovery routes (terminal.env_passthrough
in config.yaml, or required_environment_variables in skill frontmatter), plus
the debug log line that surfaces the drop for diagnosis.
2026-05-29 04:49:38 -07:00
teknium1 1c53d39eaa test: deflake process-registry kill + PTY resize tests
Two CI flakes surfaced on PR #34572 (both in files this PR doesn't touch;
pre-existing host-dependent flakes):

1. test_process_registry::TestPopenLeakOnSetupFailure — the failure-cleanup
   tests use a fake proc.pid (8888/9999) and assert proc.kill() runs. But
   spawn_local's primary cleanup is os.killpg(os.getpgid(pid), SIGKILL),
   falling back to proc.kill() only on ProcessLookupError/PermissionError/
   OSError. When the fake PID happens to exist on a busy host, os.getpgid
   succeeds, os.killpg fires against an UNRELATED real process group, and
   proc.kill() is never reached -> flaky AssertionError (and a real risk of
   SIGKILLing an innocent process group from a unit test). Patch os.getpgid
   to raise ProcessLookupError so the fallback path runs deterministically
   and no real killpg is ever issued.

2. test_web_server::test_resize_escape_is_forwarded — the receive loop calls
   the blocking conn.receive_bytes() with no exception guard. Once the child
   prints its winsize and exits, the PTY closes; on a missed-marker run the
   next recv blocks until the 30s pytest-timeout instead of failing fast.
   Add a try/except break (matching the working sibling tests) and bump the
   child's pre-read sleep 0.15s -> 0.5s so the resize reliably lands first.

Verified: 4/4 pass across 3 consecutive runs; root cause for #1 reproduced
(os.getpgid(1) succeeds -> old code skips proc.kill).
2026-05-29 04:22:41 -07:00
teknium1 6a2e3c2d26 fix(gateway): guard adapter-trust check against bare GatewayRunner in tests
_adapter_enforces_own_access_policy accessed self.adapters directly, but
several auth tests build a bare GatewayRunner via object.__new__ without
setting .adapters (pitfalls.md #17). Read it defensively with getattr so a
missing/empty adapter map means "no adapter owns the policy" instead of
raising AttributeError.

Fixes 4 tests: test_feishu_bot_auth_bypass, test_discord_bot_auth_bypass (x2),
test_signal::test_signal_in_allowlist_maps.
2026-05-29 04:22:41 -07:00
teknium1andFrowtek fd09b2c55e fix(gateway): trust adapter-owned access policy over env default-deny (#34515)
Config-driven platform policies (dm_policy / group_policy / allow_from /
group_allow_from) for WeCom, Weixin, Yuanbao, and QQBot now work without
also setting a PLATFORM_ALLOWED_USERS env var.

These adapters enforce their access policy at intake — a message is dropped
inside the adapter and never dispatched unless it already passed the policy.
The gateway's env-based check (_is_user_authorized) ran afterward and, with
no env allowlist set, fell through to an env-only default-deny — silently
rejecting `dm_policy: open` and config-only allowlists the adapter had
already authorized.

Rather than re-implement each adapter's policy a second time in run.py
(which would drift), adapters that own their gate now declare it via a new
BasePlatformAdapter.enforces_own_access_policy property (default False). The
gateway trusts that flag and skips the env-only default-deny for those
platforms. Env allowlists still take precedence when set.

Also resolves unauthorized DM behavior from config dm_policy so allowlist /
disabled policies drop unauthorized DMs silently instead of leaking pairing
codes, while an explicit pairing policy opts back in.

Co-authored-by: Frowtek <frowte3k@gmail.com>
2026-05-29 04:22:41 -07:00
teknium1 ddaf2f6712 style: restore PEP8 blank-line separation after dead-code removal
The deletions in the salvaged commit left some top-level defs/classes
separated by a single blank line. Restore the 2-blank-line separation.
2026-05-29 04:22:27 -07:00
kshitijk4poor dc235e93cb chore: remove dead code — 28 unused functions/classes across 16 files
Vulture + per-symbol verification (whole-repo grep incl. tests, string
literals, getattr, decorator/registry/argparse dispatch) confirmed each of
these has zero callers anywhere — not reachable via any dynamic-dispatch path,
not referenced by tests, not re-exported.

Removed:
- acp_adapter/tools.py: _build_patch_mode_content
- agent/anthropic_adapter.py: read_claude_managed_key (diagnostics-only, never called)
- agent/bedrock_adapter.py: get_bedrock_model_ids
- agent/browser_registry.py: get_active_browser_provider
- agent/chat_completion_helpers.py: _take_request_client (x2 nested closures, never invoked)
- gateway/platforms/weixin.py: _rewrite_headers_for_weixin, _rewrite_table_block_for_weixin
- hermes_cli/banner.py: _skin_branding
- hermes_cli/debug.py: _delete_hint
- hermes_cli/gateway.py: _setup_email, _setup_sms, _setup_yuanbao
  (platform keys absent from the _builtin_setup_fn dispatch dict; handled by
  the _setup_standard_platform fallback)
- hermes_cli/kanban_db.py: set_max_runtime, active_run
- hermes_cli/kanban_diagnostics.py: severity_of_highest, _latest_clean_event_ts
- hermes_cli/main.py: _build_provider_choices, cmd_portal
  (portal subcommand is wired via portal_cli.add_parser, not this wrapper)
- hermes_cli/model_switch.py: CustomAutoResult (orphaned by the switch_model() extraction)
- hermes_cli/models.py: format_model_pricing_table, fetch_nous_account_tier
- hermes_cli/portal_cli.py: _nous_portal_base_url
- hermes_cli/proxy/server.py: handle_models_fallback (defined but never registered on the router)
- tools/computer_use/cua_backend.py: _parse_element, _is_arm_mac
- tools/file_operations.py: _get_safe_write_root (prod uses the imported
  agent.file_safety.get_safe_write_root directly)
- tools/skills_tool.py: _load_category_description

Also dropped two imports left unused by the removals:
- tools/file_operations.py: get_safe_write_root alias
- tools/computer_use/cua_backend.py: import platform

Pure deletion: -551 LOC. No behavior change. Test files covering the edited
modules pass (640/640); the broader suite's pre-existing/env-dependent
failures reproduce unchanged on origin/main.
2026-05-29 04:22:27 -07:00
teknium1 0aa9f6acfa docs(nav): wire multi-profile-gateways guide into sidebar
Follow-up for #30240 — the new page was not referenced in sidebars.ts,
leaving it orphaned (unreachable via nav and flagged as a broken relative
link to ./profiles.md). Added under Using Hermes after profile-distributions.
2026-05-29 04:11:10 -07:00
William ChenandClaude Opus 4.7 0c0a905011 docs(gateway): add multi-profile gateways operations guide
Covers running multiple Hermes profiles as managed services on one host:

- A shell-loop wrapper pattern for start/stop/restart/status across every
  profile (the per-profile CLI commands stay unchanged).
- Per-platform service file locations (LaunchAgent on macOS, systemd user
  unit on Linux), plus the rules around clashes.
- Log paths per profile and how to tail every gateway at once.
- Config file layout per profile and the restart-after-edit workflow.
- Keeping the host awake: caffeinate flags on macOS,
  systemd-inhibit + loginctl enable-linger on Linux.
- Token-conflict auditing across .env files.
- Troubleshooting for the common "Could not find service in domain for
  user gui: 501" message and stale PIDs after a crash.

Tested locally with five profiles on macOS launchd.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 04:11:10 -07:00
Tekniumandzapabob e4b9532c18 feat: embedder environment-hint hook for the system prompt (#34574)
* fix(security): block AWS SDK creds from subprocess env

* fix(security): narrow Bedrock subprocess strip to inference bearer token only

Scopes the AWS_SDK subprocess strip down from the full AWS credential chain
to just AWS_BEARER_TOKEN_BEDROCK — the only Hermes-managed *inference* secret
(analogous to OPENAI_API_KEY). The general AWS credential chain
(AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY / AWS_SESSION_TOKEN / AWS_PROFILE
/ config + role pointers) is intentionally left inheritable.

Why: per SECURITY.md §3.2 the local terminal is the user's trusted operator
shell. Hard-blocklisting the general chain would (a) regress *every* user who
runs aws/terraform/cdk/boto3 in the agent terminal — not just Bedrock users,
since PROVIDER_REGISTRY is iterated unconditionally at import — and (b) be
unrecoverable, because env_passthrough.py refuses to re-allow anything in
_HERMES_PROVIDER_ENV_BLOCKLIST (GHSA-rhgp-j443-p4rf). The narrow strip closes
the reported leak (opencode enumerating the Bedrock catalog off the leaked
bearer token) with no capability loss.

Keeps zapabob's self-healing auth_type=="aws_sdk" mechanism so any future
SDK-cred provider is covered automatically.

Tests: bearer token stripped + general chain preserved (no-regression guard),
on both the runtime strip path and the blocklist-membership path.

Co-authored-by: zapabob <1920071390@campus.ouj.ac.jp>

* feat: embedder environment-hint hook for the system prompt

Adds HERMES_ENVIRONMENT_HINT env var (and config.yaml agent.environment_hint)
so a host wrapping Hermes (sandbox runner, managed platform) can describe the
runtime environment — proxy, credential handling, mount layout — in the system
prompt's environment-hints block, without editing the identity slot (SOUL.md).

Read once at prompt-build time, so it lands in the stable, cache-safe portion
of the system prompt. Env var overrides the config key (build-time/container
mechanism). Empty by default — no behavior change for existing installs.

---------

Co-authored-by: zapabob <1920071390@campus.ouj.ac.jp>
2026-05-29 04:10:05 -07:00
Hariharan Ayappane c0b17b3c0c docs(weixin): clarify allowed users setup 2026-05-29 04:01:06 -07:00
Dave Tist 2520c9ad68 docs(skills): clarify Reminders alarm timing 2026-05-29 04:01:01 -07:00
LeonSGP43 62e81b2d9b docs(windows): add WSL desktop shortcut guide 2026-05-29 04:00:57 -07:00
SHL0MS fe7e0a8c1d docs(feishu): add permission scopes, event subscription, and publish steps
The setup guide was missing the specific Feishu permission scopes to
configure and the event subscription (im.message.receive_v1) needed
for the bot to receive messages. Users had to reference external
OpenClaw documentation to complete the setup.

Adds:
- Required permissions table (im:message, im:message:send_as_bot,
  im:resource, im:chat, im:chat:readonly)
- Recommended permissions (reactions, app info, contact)
- Event subscription step (im.message.receive_v1)
- App version publish reminder (permissions require published version)
2026-05-29 04:00:52 -07:00
briandevans 6e179c44b1 fix(web): ensure plugin discovery before web_*_tool registry lookups
Web search/extract dispatch read agent.web_search_registry before plugin
discovery had run, so in any process that hadn't imported model_tools.py
(subprocess agent runs, delegate children, standalone scripts) the registry
was empty: get_provider('firecrawl') returned None and the dispatcher emitted
the misleading 'No web extract provider configured' error even with
web.extract_backend set and FIRECRAWL_API_KEY exported.

Adds an idempotent _ensure_web_plugins_loaded() helper (mirrors
tools.browser_tool._ensure_browser_plugins_loaded) and calls it at the top of
both the web_search_tool and web_extract_tool dispatch sites before the
registry lookup.

Fixes #27580.

Co-authored-by: briandevans <252620095+briandevans@users.noreply.github.com>
2026-05-29 04:00:00 -07:00
teknium1 58e1b04665 chore(release): map tillfalko to GitHub login for PR #29987 salvage 2026-05-29 03:58:56 -07:00
teknium1 c77a697fa4 refactor(vision): consolidate native fast-path gate into one shared helper
The fast-path decision (native routing + provider allowlist OR
supports_vision override) lived inline in vision_analyze and was copied
into browser_vision. Extract it to _should_use_native_vision_fast_path()
so both tools share one source of truth.

- vision_tools: gate logic now one helper; vision_analyze calls it in 3 lines
- browser_tool: thin envelope decoration over the shared helper, not a copy
- browser_vision typed Union[str, Dict] to match its real return shape
- tests slimmed to target the override path + text-mode-wins invariant
2026-05-29 03:58:56 -07:00
tillfalko c3f28c651d docs(browser): update browser_vision tool description for native vision routing 2026-05-29 03:58:56 -07:00
tillfalko 2402ec5e7b test: extend test coverage to native image routing 2026-05-29 03:58:56 -07:00
tillfalko f8b8dffccf fix(browser): add native image support to browser_vision and respect supports_vision 2026-05-29 03:58:56 -07:00
tillfalko f05353397d fix(vision): respect supports_vision in vision_analyze 2026-05-29 03:58:56 -07:00
EloquentBrush0x 784d8dd2c2 fix(matrix): fail-closed approval reaction auth when MATRIX_ALLOWED_USERS is empty
The _on_reaction approval handler used:

    if self._allowed_user_ids and sender not in self._allowed_user_ids:

When MATRIX_ALLOWED_USERS is not configured, _allowed_user_ids is an
empty set. The short-circuit on the empty set caused the deny block to
never execute, allowing any Matrix room member to approve or deny tool
calls via ✅/❎ reactions — even users that run.py's _is_user_authorized
would reject for regular messages.

Fix mirrors the Telegram _is_callback_user_authorized fix (commit
89d32052e, PR #28494): deny by default when no allowlist is configured,
unless GATEWAY_ALLOW_ALL_USERS=true is explicitly set.
2026-05-29 03:58:45 -07:00
47 changed files with 1518 additions and 557 deletions
-66
View File
@@ -907,72 +907,6 @@ def _build_polished_completion_content(
return [_text(text)]
def _build_patch_mode_content(patch_text: str) -> List[Any]:
"""Parse V4A patch mode input into ACP diff blocks when possible."""
if not patch_text:
return [acp.tool_content(acp.text_block(""))]
try:
from tools.patch_parser import OperationType, parse_v4a_patch
operations, error = parse_v4a_patch(patch_text)
if error or not operations:
return [acp.tool_content(acp.text_block(patch_text))]
content: List[Any] = []
for op in operations:
if op.operation == OperationType.UPDATE:
old_chunks: list[str] = []
new_chunks: list[str] = []
for hunk in op.hunks:
old_lines = [line.content for line in hunk.lines if line.prefix in {" ", "-"}]
new_lines = [line.content for line in hunk.lines if line.prefix in {" ", "+"}]
if old_lines or new_lines:
old_chunks.append("\n".join(old_lines))
new_chunks.append("\n".join(new_lines))
old_text = "\n...\n".join(chunk for chunk in old_chunks if chunk)
new_text = "\n...\n".join(chunk for chunk in new_chunks if chunk)
if old_text or new_text:
content.append(
acp.tool_diff_content(
path=op.file_path,
old_text=old_text or None,
new_text=new_text or "",
)
)
continue
if op.operation == OperationType.ADD:
added_lines = [line.content for hunk in op.hunks for line in hunk.lines if line.prefix == "+"]
content.append(
acp.tool_diff_content(
path=op.file_path,
new_text="\n".join(added_lines),
)
)
continue
if op.operation == OperationType.DELETE:
content.append(
acp.tool_diff_content(
path=op.file_path,
old_text=f"Delete file: {op.file_path}",
new_text="",
)
)
continue
if op.operation == OperationType.MOVE:
content.append(
acp.tool_content(acp.text_block(f"Move file: {op.file_path} -> {op.new_path}"))
)
return content or [acp.tool_content(acp.text_block(patch_text))]
except Exception:
return [acp.tool_content(acp.text_block(patch_text))]
def _strip_diff_prefix(path: str) -> str:
raw = str(path or "").strip()
if raw.startswith(("a/", "b/")):
-14
View File
@@ -894,20 +894,6 @@ def read_claude_code_credentials() -> Optional[Dict[str, Any]]:
return None
def read_claude_managed_key() -> Optional[str]:
"""Read Claude's native managed key from ~/.claude.json for diagnostics only."""
claude_json = Path.home() / ".claude.json"
if claude_json.exists():
try:
data = json.loads(claude_json.read_text(encoding="utf-8"))
primary_key = data.get("primaryApiKey", "")
if isinstance(primary_key, str) and primary_key.strip():
return primary_key.strip()
except (json.JSONDecodeError, OSError, IOError) as e:
logger.debug("Failed to read ~/.claude.json: %s", e)
return None
def is_claude_code_token_valid(creds: Dict[str, Any]) -> bool:
"""Check if Claude Code credentials have a non-expired access token."""
import time
-12
View File
@@ -1167,18 +1167,6 @@ def _extract_provider_from_arn(arn: str) -> str:
"""
match = re.search(r"foundation-model/([^.]+)", arn)
return match.group(1) if match else ""
def get_bedrock_model_ids(region: str) -> List[str]:
"""Return a flat list of available Bedrock model IDs for the given region.
Convenience wrapper around ``discover_bedrock_models()`` for use in
the model selection UI.
"""
models = discover_bedrock_models(region)
return [m["id"] for m in models]
# ---------------------------------------------------------------------------
# Error classification — Bedrock-specific exceptions
# ---------------------------------------------------------------------------
-31
View File
@@ -186,37 +186,6 @@ def _resolve(configured: Optional[str]) -> Optional[BrowserProvider]:
return None
def get_active_browser_provider() -> Optional[BrowserProvider]:
"""Resolve the currently-active cloud browser provider.
Reads ``browser.cloud_provider`` from config.yaml; falls back per the
module docstring. Returns None for local mode or when no provider is
available.
"""
try:
from hermes_cli.config import read_raw_config
cfg = read_raw_config()
browser_cfg = cfg.get("browser", {})
except Exception as exc:
logger.debug("Could not read browser config: %s", exc)
browser_cfg = {}
configured: Optional[str] = None
if isinstance(browser_cfg, dict) and "cloud_provider" in browser_cfg:
try:
from tools.tool_backend_helpers import normalize_browser_cloud_provider
configured = normalize_browser_cloud_provider(
browser_cfg.get("cloud_provider")
)
except Exception as exc:
logger.debug("normalize_browser_cloud_provider failed: %s", exc)
configured = None
return _resolve(configured)
def _reset_for_tests() -> None:
"""Clear the registry. **Test-only.**"""
with _lock:
-14
View File
@@ -149,13 +149,6 @@ def interruptible_api_call(agent, api_kwargs: dict):
request_client_holder["owner_tid"] = threading.get_ident()
return client
def _take_request_client():
with request_client_lock:
client = request_client_holder.get("client")
request_client_holder["client"] = None
request_client_holder["owner_tid"] = None
return client
def _close_request_client_once(reason: str) -> None:
# #29507: dispatch on the calling thread.
#
@@ -1628,13 +1621,6 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
request_client_holder["owner_tid"] = threading.get_ident()
return client
def _take_request_client():
with request_client_lock:
client = request_client_holder.get("client")
request_client_holder["client"] = None
request_client_holder["owner_tid"] = None
return client
def _close_request_client_once(reason: str) -> None:
# See #29507 explanation in the non-streaming variant above. A
# stranger thread (the interrupt-check / stale-stream detector loop)
+21
View File
@@ -848,6 +848,27 @@ def build_environment_hints() -> str:
if is_wsl():
hints.append(WSL_ENVIRONMENT_HINT)
# Embedder-supplied environment description. Lets a host that wraps Hermes
# (e.g. a sandbox runner / managed platform) explain the environment the
# agent is running in — proxy, credential handling, mount layout — without
# forking the identity slot (SOUL.md). Read once at prompt-build time, so
# it's part of the stable, cache-safe system prompt. The env var is the
# build-time/embedder mechanism (set in a container ENV); config.yaml
# ``agent.environment_hint`` is the user-facing surface. Env var wins.
extra = (os.getenv("HERMES_ENVIRONMENT_HINT") or "").strip()
if not extra:
try:
from hermes_cli.config import load_config
extra = str(
(load_config().get("agent", {}) or {}).get("environment_hint", "")
).strip()
except Exception as e:
logger.debug("Could not read agent.environment_hint from config: %s", e)
if extra:
hints.append(extra)
return "\n\n".join(hints)
+23
View File
@@ -1655,6 +1655,29 @@ class BasePlatformAdapter(ABC):
"""
return len
@property
def enforces_own_access_policy(self) -> bool:
"""Whether this adapter gates inbound access before dispatch.
Some adapters (WeCom, Weixin, Yuanbao, QQBot) implement a documented
config-driven access surface — ``dm_policy`` / ``group_policy`` /
``allow_from`` / ``group_allow_from`` in ``PlatformConfig.extra`` — and
enforce it at intake: a message is dropped inside the adapter and never
reaches the gateway unless it already passed that policy.
The gateway's env-based allowlist check runs *after* the adapter, so for
these platforms a message arriving at ``_is_user_authorized`` has, by
definition, already been authorized by the adapter. Without this flag the
gateway would then deny it again (no env allowlist → default deny),
silently breaking ``dm_policy: open`` and config-only allowlists.
Adapters that own their access policy override this to return ``True``.
The gateway treats that as "already authorized at intake" and skips the
env-allowlist default-deny. Adapters that delegate access control to the
gateway leave it ``False`` (the default).
"""
return False
def supports_draft_streaming(
self,
chat_type: Optional[str] = None,
+2 -1
View File
@@ -2236,7 +2236,8 @@ class MatrixAdapter(BasePlatformAdapter):
if prompt and not prompt.resolved:
if room_id != prompt.chat_id:
return
if self._allowed_user_ids and sender not in self._allowed_user_ids:
_allow_all = os.getenv("GATEWAY_ALLOW_ALL_USERS", "").lower() in {"true", "1", "yes"}
if not _allow_all and not (self._allowed_user_ids and sender in self._allowed_user_ids):
logger.info(
"Matrix: ignoring approval reaction from unauthorized user %s on %s",
sender, reacts_to,
+5
View File
@@ -269,6 +269,11 @@ class QQAdapter(BasePlatformAdapter):
def name(self) -> str:
return "QQBot"
@property
def enforces_own_access_policy(self) -> bool:
"""QQBot gates DM/group access at intake via dm_policy/group_policy."""
return True
# ------------------------------------------------------------------
# Connection lifecycle
# ------------------------------------------------------------------
+5
View File
@@ -847,6 +847,11 @@ class WeComAdapter(BasePlatformAdapter):
# Policy helpers
# ------------------------------------------------------------------
@property
def enforces_own_access_policy(self) -> bool:
"""WeCom gates DM/group access at intake via dm_policy/group_policy."""
return True
def _is_dm_allowed(self, sender_id: str) -> bool:
if self._dm_policy == "disabled":
return False
+5 -46
View File
@@ -658,52 +658,6 @@ def _split_table_row(line: str) -> List[str]:
return [cell.strip() for cell in row.split("|")]
def _rewrite_headers_for_weixin(line: str) -> str:
match = _HEADER_RE.match(line)
if not match:
return line.rstrip()
level = len(match.group(1))
title = match.group(2).strip()
if level == 1:
return f"【{title}】"
return f"**{title}**"
def _rewrite_table_block_for_weixin(lines: List[str]) -> str:
if len(lines) < 2:
return "\n".join(lines)
headers = _split_table_row(lines[0])
body_rows = [_split_table_row(line) for line in lines[2:] if line.strip()]
if not headers or not body_rows:
return "\n".join(lines)
formatted_rows: List[str] = []
for row in body_rows:
pairs = []
for idx, header in enumerate(headers):
if idx >= len(row):
break
label = header or f"Column {idx + 1}"
value = row[idx].strip()
if value:
pairs.append((label, value))
if not pairs:
continue
if len(pairs) == 1:
label, value = pairs[0]
formatted_rows.append(f"- {label}: {value}")
continue
if len(pairs) == 2:
label, value = pairs[0]
other_label, other_value = pairs[1]
formatted_rows.append(f"- {label}: {value}")
formatted_rows.append(f" {other_label}: {other_value}")
continue
summary = " | ".join(f"{label}: {value}" for label, value in pairs)
formatted_rows.append(f"- {summary}")
return "\n".join(formatted_rows) if formatted_rows else "\n".join(lines)
def _normalize_markdown_blocks(content: str) -> str:
lines = content.splitlines()
result: List[str] = []
@@ -1443,6 +1397,11 @@ class WeixinAdapter(BasePlatformAdapter):
logger.info("[%s] inbound from=%s type=%s media=%d", self.name, _safe_id(sender_id), source.chat_type, len(media_paths))
await self.handle_message(event)
@property
def enforces_own_access_policy(self) -> bool:
"""Weixin gates DM/group access at intake via dm_policy/group_policy."""
return True
def _is_dm_allowed(self, sender_id: str) -> bool:
if self._dm_policy == "disabled":
return False
+5
View File
@@ -4691,6 +4691,11 @@ class YuanbaoAdapter(BasePlatformAdapter):
# Abstract method implementations
# ------------------------------------------------------------------
@property
def enforces_own_access_policy(self) -> bool:
"""Yuanbao gates DM/group access at intake via dm_policy/group_policy."""
return True
async def connect(self) -> bool:
"""Connect to Yuanbao WS gateway and authenticate.
+48
View File
@@ -6542,6 +6542,31 @@ class GatewayRunner:
return YuanbaoAdapter(config)
return None
def _adapter_enforces_own_access_policy(self, platform: Optional[Platform]) -> bool:
"""Whether the adapter for *platform* gates access at intake itself.
Mirrors ``BasePlatformAdapter.enforces_own_access_policy``. Adapters
such as WeCom, Weixin, Yuanbao, and QQBot evaluate their documented
``dm_policy`` / ``group_policy`` / ``allow_from`` config before a
message is dispatched to the gateway, so a message that reaches
``_is_user_authorized`` has already been authorized by the adapter.
Defaults to ``False`` when the adapter is unknown or doesn't expose
the flag.
"""
if not platform:
return False
# Some test helpers build a bare GatewayRunner via object.__new__ and
# never set ``adapters``; treat a missing/empty map as "no adapter"
# rather than raising (see pitfalls.md #17).
adapters = getattr(self, "adapters", None)
if not adapters:
return False
adapter = adapters.get(platform)
if adapter is None:
return False
return bool(getattr(adapter, "enforces_own_access_policy", False))
def _is_user_authorized(self, source: SessionSource) -> bool:
"""
Check if a user is authorized to use the bot.
@@ -6681,6 +6706,15 @@ class GatewayRunner:
global_allowlist = os.getenv("GATEWAY_ALLOWED_USERS", "").strip()
if not platform_allowlist and not group_user_allowlist and not group_chat_allowlist and not global_allowlist:
# No env allowlists configured. Adapters that own their own
# config-driven access policy (dm_policy / group_policy /
# allow_from / group_allow_from) already gated this message at
# intake — it would not have reached the gateway otherwise — so
# honor that decision instead of falling through to the
# env-only default-deny below, which would silently break
# `dm_policy: open` and config-only allowlists. (#34515)
if self._adapter_enforces_own_access_policy(source.platform):
return True
# No allowlists configured -- check global allow-all flag
return os.getenv("GATEWAY_ALLOW_ALL_USERS", "").lower() in {"true", "1", "yes"}
@@ -6788,6 +6822,20 @@ class GatewayRunner:
if config.unauthorized_dm_behavior != "pair": # non-default → explicit override
return config.unauthorized_dm_behavior
# Config-driven dm_policy (WeCom / Weixin / Yuanbao / QQBot). An
# allowlist or disabled DM policy means the operator restricted access,
# so unauthorized DMs should be dropped silently rather than answered
# with a pairing code. An explicit pairing policy opts back into codes.
if platform and config and hasattr(config, "platforms"):
platform_cfg = config.platforms.get(platform)
extra = getattr(platform_cfg, "extra", None) if platform_cfg else None
if isinstance(extra, dict):
dm_policy = str(extra.get("dm_policy") or "").strip().lower()
if dm_policy == "pairing":
return "pair"
if dm_policy in {"allowlist", "disabled"}:
return "ignore"
# No explicit override. Fall back to allowlist-aware default:
# if any allowlist is configured for this platform, silently drop
# unauthorized messages instead of sending pairing codes.
-11
View File
@@ -50,17 +50,6 @@ def _skin_color(key: str, fallback: str) -> str:
return get_active_skin().get_color(key, fallback)
except Exception:
return fallback
def _skin_branding(key: str, fallback: str) -> str:
"""Get a branding string from the active skin, or return fallback."""
try:
from hermes_cli.skin_engine import get_active_skin
return get_active_skin().get_branding(key, fallback)
except Exception:
return fallback
# =========================================================================
# ASCII Art & Branding
# =========================================================================
+7
View File
@@ -683,6 +683,13 @@ DEFAULT_CONFIG = {
# (docker/modal/ssh — they have their own probe). Set False to
# disable entirely.
"environment_probe": True,
# Embedder-supplied environment description appended to the system
# prompt's environment-hints block. Lets a host that wraps Hermes
# (sandbox runner, managed platform) explain the runtime environment
# — proxy, credential handling, mount layout — without editing the
# identity slot (SOUL.md). Empty by default. The HERMES_ENVIRONMENT_HINT
# env var overrides this (build-time/container mechanism).
"environment_hint": "",
# Staged inactivity warning: send a warning to the user at this
# threshold before escalating to a full timeout. The warning fires
# once per run and does not interrupt the agent. 0 = disable warning.
-9
View File
@@ -258,15 +258,6 @@ def _schedule_auto_delete(urls: list[str], delay_seconds: int = _AUTO_DELETE_SEC
_record_pending(urls, delay_seconds=delay_seconds)
def _delete_hint(url: str) -> str:
"""Return a one-liner delete command for the given paste URL."""
paste_id = _extract_paste_id(url)
if paste_id:
return f"hermes debug delete {url}"
# dpaste.com — no API delete, expires on its own.
return "(auto-expires per dpaste.com policy)"
def _upload_paste_rs(content: str) -> str:
"""Upload to paste.rs. Returns the paste URL.
-18
View File
@@ -3960,18 +3960,6 @@ def _setup_whatsapp():
cmd_whatsapp(argparse.Namespace())
def _setup_email():
"""Configure Email via the standard platform setup."""
email_platform = next(p for p in _PLATFORMS if p["key"] == "email")
_setup_standard_platform(email_platform)
def _setup_sms():
"""Configure SMS (Twilio) via the standard platform setup."""
sms_platform = next(p for p in _PLATFORMS if p["key"] == "sms")
_setup_standard_platform(sms_platform)
def _setup_dingtalk():
"""Configure DingTalk — QR scan (recommended) or manual credential entry."""
from hermes_cli.setup import (
@@ -4144,12 +4132,6 @@ def _setup_wecom():
print_success("💬 WeCom configured!")
def _setup_yuanbao():
"""Configure Yuanbao via the standard platform setup."""
yuanbao_platform = next(p for p in _PLATFORMS if p["key"] == "yuanbao")
_setup_standard_platform(yuanbao_platform)
def _is_service_installed() -> bool:
"""Check if the gateway is installed as a system service."""
if supports_systemd_services():
-25
View File
@@ -4865,21 +4865,6 @@ def detect_stale_running(
return reclaimed
def set_max_runtime(
conn: sqlite3.Connection,
task_id: str,
seconds: Optional[int],
) -> bool:
"""Set or clear the per-task max_runtime_seconds. Returns True on
success."""
with write_txn(conn):
cur = conn.execute(
"UPDATE tasks SET max_runtime_seconds = ? WHERE id = ?",
(int(seconds) if seconds is not None else None, task_id),
)
return cur.rowcount == 1
def _error_fingerprint(error_text: str) -> str:
"""Normalize an error message for grouping identical failures.
@@ -6967,16 +6952,6 @@ def get_run(conn: sqlite3.Connection, run_id: int) -> Optional[Run]:
return Run.from_row(row) if row else None
def active_run(conn: sqlite3.Connection, task_id: str) -> Optional[Run]:
"""Return the currently-open run for ``task_id`` (``ended_at IS NULL``)."""
row = conn.execute(
"SELECT * FROM task_runs WHERE task_id = ? AND ended_at IS NULL "
"ORDER BY started_at DESC LIMIT 1",
(task_id,),
).fetchone()
return Run.from_row(row) if row else None
def latest_run(conn: sqlite3.Connection, task_id: str) -> Optional[Run]:
"""Return the most recent run regardless of outcome (active or closed)."""
row = conn.execute(
-30
View File
@@ -191,23 +191,6 @@ def _active_hallucination_events(
elif k == kind:
active.append(ev)
return active
def _latest_clean_event_ts(events: Iterable[Any]) -> int:
"""Timestamp of the most recent clean completion / edit event.
Kept for general "has this task ever been successfully completed"
lookups; hallucination rules use ``_active_hallucination_events``
instead because they need strict ordering.
"""
latest = 0
for ev in events:
if _event_kind(ev) in {"completed", "edited"}:
t = _event_ts(ev)
latest = max(latest, t)
return latest
# Standard always-available actions. Every diagnostic can offer these as
# fallbacks regardless of kind — they're the two baseline recovery
# primitives the kernel supports.
@@ -1122,16 +1105,3 @@ def compute_task_diagnostics(
)
)
return out
def severity_of_highest(diagnostics: Iterable[Diagnostic]) -> Optional[str]:
"""Highest severity present in the list, or None if empty. Useful
for card badges that need a single color."""
highest_idx = -1
highest = None
for d in diagnostics:
idx = SEVERITY_ORDER.index(d.severity) if d.severity in SEVERITY_ORDER else -1
if idx > highest_idx:
highest_idx = idx
highest = d.severity
return highest
-25
View File
@@ -6160,13 +6160,6 @@ def cmd_webhook(args):
webhook_command(args)
def cmd_portal(args):
"""Nous Portal status and Tool Gateway routing surface."""
from hermes_cli.portal_cli import portal_command
return portal_command(args)
def cmd_slack(args):
"""Slack integration helpers.
@@ -10975,24 +10968,6 @@ def cmd_logs(args):
since=getattr(args, "since", None),
component=getattr(args, "component", None),
)
def _build_provider_choices() -> list[str]:
"""Build the --provider choices list from CANONICAL_PROVIDERS + 'auto'."""
try:
from hermes_cli.models import CANONICAL_PROVIDERS as _cp
return ["auto"] + [p.slug for p in _cp]
except Exception:
# Fallback: static list guarantees the CLI always works
return [
"auto", "openrouter", "nous", "openai-codex", "xai-oauth", "copilot-acp", "copilot",
"anthropic", "gemini", "google-gemini-cli", "xai", "bedrock", "azure-foundry",
"ollama-cloud", "huggingface", "zai", "kimi-coding", "kimi-coding-cn",
"stepfun", "minimax", "minimax-cn", "kilocode", "novita", "xiaomi", "arcee",
"nvidia", "deepseek", "alibaba", "qwen-oauth", "opencode-zen", "opencode-go",
]
# Top-level subcommands that argparse knows about WITHOUT running plugin
# discovery. Used to short-circuit eager plugin imports (which can take
# 500ms+ pulling in google.cloud.pubsub_v1, aiohttp, grpc, etc.) when the
-13
View File
@@ -277,19 +277,6 @@ class ModelSwitchResult:
capabilities: Optional[ModelCapabilities] = None
model_info: Optional[ModelInfo] = None
is_global: bool = False
@dataclass
class CustomAutoResult:
"""Result of switching to bare 'custom' provider with auto-detect."""
success: bool
model: str = ""
base_url: str = ""
api_key: str = ""
error_message: str = ""
# ---------------------------------------------------------------------------
# Flag parsing
# ---------------------------------------------------------------------------
-97
View File
@@ -484,41 +484,6 @@ def _is_model_free(model_id: str, pricing: dict[str, dict[str, str]]) -> bool:
# ---------------------------------------------------------------------------
# Nous Portal account tier detection
# ---------------------------------------------------------------------------
def fetch_nous_account_tier(access_token: str, portal_base_url: str = "") -> dict[str, Any]:
"""Fetch the user's Nous Portal account/subscription info.
Calls ``<portal>/api/oauth/account`` with the OAuth access token.
Returns the parsed JSON dict on success, e.g.::
{
"subscription": {
"plan": "Plus",
"tier": 2,
"monthly_charge": 20,
"credits_remaining": 1686.60,
...
},
...
}
Returns an empty dict on any failure (network, auth, parse).
"""
base = (portal_base_url or "https://portal.nousresearch.com").rstrip("/")
url = f"{base}/api/oauth/account"
headers = {
"Authorization": f"Bearer {access_token}",
"Accept": "application/json",
}
try:
req = urllib.request.Request(url, headers=headers)
with urllib.request.urlopen(req, timeout=8) as resp:
return json.loads(resp.read().decode())
except Exception:
return {}
def is_nous_free_tier(account_info: dict[str, Any]) -> bool:
"""Return True if the account info indicates a free (unpaid) tier.
@@ -1223,68 +1188,6 @@ def _format_price_per_mtok(per_token_str: str) -> str:
return f"${per_m:.2f}"
def format_model_pricing_table(
models: list[tuple[str, str]],
pricing_map: dict[str, dict[str, str]],
current_model: str = "",
indent: str = " ",
) -> list[str]:
"""Build a column-aligned model+pricing table for terminal display.
Returns a list of pre-formatted lines ready to print.
*models* is ``[(model_id, description), ...]``.
"""
if not models:
return []
# Build rows: (model_id, input_price, output_price, cache_price, is_current)
rows: list[tuple[str, str, str, str, bool]] = []
has_cache = False
for mid, _desc in models:
is_cur = mid == current_model
p = pricing_map.get(mid)
if p:
inp = _format_price_per_mtok(p.get("prompt", ""))
out = _format_price_per_mtok(p.get("completion", ""))
cache_read = p.get("input_cache_read", "")
cache = _format_price_per_mtok(cache_read) if cache_read else ""
if cache:
has_cache = True
else:
inp, out, cache = "", "", ""
rows.append((mid, inp, out, cache, is_cur))
name_col = max(len(r[0]) for r in rows) + 2
# Compute price column widths from the actual data so decimals align
price_col = max(
max((len(r[1]) for r in rows if r[1]), default=4),
max((len(r[2]) for r in rows if r[2]), default=4),
3, # minimum: "In" / "Out" header
)
cache_col = max(
max((len(r[3]) for r in rows if r[3]), default=4),
5, # minimum: "Cache" header
) if has_cache else 0
lines: list[str] = []
# Header
if has_cache:
lines.append(f"{indent}{'Model':<{name_col}} {'In':>{price_col}} {'Out':>{price_col}} {'Cache':>{cache_col}} /Mtok")
lines.append(f"{indent}{'-' * name_col} {'-' * price_col} {'-' * price_col} {'-' * cache_col}")
else:
lines.append(f"{indent}{'Model':<{name_col}} {'In':>{price_col}} {'Out':>{price_col}} /Mtok")
lines.append(f"{indent}{'-' * name_col} {'-' * price_col} {'-' * price_col}")
for mid, inp, out, cache, is_cur in rows:
marker = " ← current" if is_cur else ""
if has_cache:
lines.append(f"{indent}{mid:<{name_col}} {inp:>{price_col}} {out:>{price_col}} {cache:>{cache_col}}{marker}")
else:
lines.append(f"{indent}{mid:<{name_col}} {inp:>{price_col}} {out:>{price_col}}{marker}")
return lines
def fetch_models_with_pricing(
api_key: str | None = None,
base_url: str = "https://openrouter.ai/api",
-13
View File
@@ -22,19 +22,6 @@ SUBSCRIPTION_URL = "https://portal.nousresearch.com/manage-subscription"
DOCS_URL = "https://hermes-agent.nousresearch.com/docs/user-guide/features/tool-gateway"
def _nous_portal_base_url() -> str:
"""Resolve the Portal base URL from auth state or default."""
try:
from hermes_cli.auth import get_nous_auth_status
status = get_nous_auth_status() or {}
url = status.get("portal_base_url")
if isinstance(url, str) and url.strip():
return url.rstrip("/")
except Exception:
pass
return DEFAULT_PORTAL_URL
def _cmd_status(args) -> int:
"""Show Portal auth + Tool Gateway routing summary."""
from hermes_cli.auth import get_nous_auth_status
-11
View File
@@ -104,17 +104,6 @@ def create_app(adapter: UpstreamAdapter) -> "web.Application":
}
)
async def handle_models_fallback(request: "web.Request") -> "web.Response":
# Most clients hit /v1/models on startup. If the upstream doesn't
# serve /models, synthesize a minimal response so clients don't
# crash. The actual forwarding path handles /models when allowed.
return web.json_response(
{
"object": "list",
"data": [],
}
)
async def handle_proxy(request: "web.Request") -> "web.StreamResponse":
# Extract the path *after* /v1
rel_path = request.match_info.get("tail", "")
+2
View File
@@ -59,12 +59,14 @@ AUTHOR_MAP = {
"wangpuv@hotmail.com": "wangpuv",
"202622897+ticketclosed-wontfix@users.noreply.github.com": "ticketclosed-wontfix",
"wuxuebin1993@gmail.com": "victorGPT",
"frowte3k@gmail.com": "Frowtek",
"211828103+julio-cloudvisor@users.noreply.github.com": "julio-cloudvisor",
"17778+kweiner@users.noreply.github.com": "kweiner",
"223516181+faisfamilytravel@users.noreply.github.com": "faisfamilytravel",
"45189813+baofuen@users.noreply.github.com": "baofuen",
"interstellar.consulting@gmail.com": "Interstellar-code",
"33978413+Interstellar-code@users.noreply.github.com": "Interstellar-code",
"tillfalko@gmail.com": "tillfalko",
# teknium (multiple emails)
"teknium1@gmail.com": "teknium1",
"kenyon1977@gmail.com": "kenyonxu",
+32
View File
@@ -68,6 +68,38 @@ remindctl add --title "Call mom" --list Personal --due tomorrow
remindctl add --title "Meeting prep" --due "2026-02-15 09:00"
```
### Due Time vs Alarm / Early Nudge
`--due` and `--alarm` are different fields:
- `--due` sets the reminder's due date/time.
- `--alarm` sets the EventKit alarm/notification trigger. Timed due reminders may default to an alarm at the due time, but pass `--alarm` explicitly when the user asks for an earlier nudge.
For a reminder due at 2:00 PM with a notification 30 minutes earlier:
```bash
remindctl add --title "Hairdresser" --due "2026-05-15 14:00" --alarm "2026-05-15 13:30"
```
To edit an existing reminder:
```bash
remindctl edit 87354 --due "2026-05-15 14:00" --alarm "2026-05-15 13:30"
```
The Reminders UI may show or group the item by the alarm time because that is when the notification fires. Verify with JSON instead of assuming the due time moved:
```bash
remindctl today --json
```
Expected shape:
- `dueDate`: actual due time
- `alarmDate`: notification / early nudge time
Apple's public `EKReminder` docs list only reminder-specific properties. Alarm support comes from inherited `EKCalendarItem` behavior exposed by remindctl's `--alarm` flag.
### Complete / Delete
```bash
+52
View File
@@ -947,6 +947,58 @@ class TestEnvironmentHints:
f"info is suppressed in the system prompt"
)
def test_environment_hint_from_env_var_is_appended(self, monkeypatch):
"""HERMES_ENVIRONMENT_HINT lets an embedder describe the runtime env."""
import agent.prompt_builder as _pb
monkeypatch.setattr(_pb, "is_wsl", lambda: False)
monkeypatch.delenv("TERMINAL_ENV", raising=False)
monkeypatch.setenv("HERMES_ENVIRONMENT_HINT", "Running inside an OpenShell sandbox.")
_pb._clear_backend_probe_cache()
result = _pb.build_environment_hints()
assert "Running inside an OpenShell sandbox." in result
# The factual host block must still come first.
assert result.index("Host:") < result.index("OpenShell")
def test_environment_hint_env_var_overrides_config(self, monkeypatch):
"""Env var wins over config.yaml agent.environment_hint."""
import agent.prompt_builder as _pb
monkeypatch.setattr(_pb, "is_wsl", lambda: False)
monkeypatch.delenv("TERMINAL_ENV", raising=False)
monkeypatch.setenv("HERMES_ENVIRONMENT_HINT", "ENV-WINS")
monkeypatch.setattr(
"hermes_cli.config.load_config",
lambda: {"agent": {"environment_hint": "CONFIG-VALUE"}},
)
_pb._clear_backend_probe_cache()
result = _pb.build_environment_hints()
assert "ENV-WINS" in result
assert "CONFIG-VALUE" not in result
def test_environment_hint_falls_back_to_config(self, monkeypatch):
"""With no env var, the config.yaml value is used."""
import agent.prompt_builder as _pb
monkeypatch.setattr(_pb, "is_wsl", lambda: False)
monkeypatch.delenv("TERMINAL_ENV", raising=False)
monkeypatch.delenv("HERMES_ENVIRONMENT_HINT", raising=False)
monkeypatch.setattr(
"hermes_cli.config.load_config",
lambda: {"agent": {"environment_hint": "CONFIG-VALUE"}},
)
_pb._clear_backend_probe_cache()
result = _pb.build_environment_hints()
assert "CONFIG-VALUE" in result
def test_environment_hint_empty_by_default(self, monkeypatch):
"""No hint configured anywhere → no embedder text, host block intact."""
import agent.prompt_builder as _pb
monkeypatch.setattr(_pb, "is_wsl", lambda: False)
monkeypatch.delenv("TERMINAL_ENV", raising=False)
monkeypatch.delenv("HERMES_ENVIRONMENT_HINT", raising=False)
monkeypatch.setattr("hermes_cli.config.load_config", lambda: {"agent": {}})
_pb._clear_backend_probe_cache()
result = _pb.build_environment_hints()
assert "Host:" in result
# =========================================================================
# Conditional skill activation
@@ -0,0 +1,234 @@
"""Tests for config-driven platform access policies at the gateway layer.
Background (#34515): WeCom, Weixin, Yuanbao, and QQBot expose a documented
config-driven access surface (``dm_policy`` / ``group_policy`` / ``allow_from``
/ ``group_allow_from`` in ``PlatformConfig.extra``) and enforce it at intake —
a message is dropped inside the adapter and never reaches the gateway unless it
already passed that policy.
The gateway's env-based allowlist check (``_is_user_authorized``) runs *after*
the adapter. Before the fix it fell through to an env-only default-deny when no
``PLATFORM_ALLOWED_USERS`` env var was set, silently rejecting ``dm_policy:
open`` and config-only allowlists even though the adapter had already
authorized the sender.
The fix is a single drift-proof contract: adapters that own their access policy
declare ``enforces_own_access_policy`` (a ``BasePlatformAdapter`` property,
default ``False``). The gateway trusts that flag and skips the env-only
default-deny for those platforms, rather than re-implementing each adapter's
policy logic a second time.
"""
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock
import pytest
from gateway.config import GatewayConfig, Platform, PlatformConfig
from gateway.session import SessionSource
# Platforms whose adapters own their access policy at intake.
_OWN_POLICY_PLATFORMS = [
Platform.WECOM,
Platform.WEIXIN,
Platform.YUANBAO,
Platform.QQBOT,
]
def _clear_auth_env(monkeypatch) -> None:
for key in (
"WECOM_ALLOWED_USERS",
"WEIXIN_ALLOWED_USERS",
"YUANBAO_ALLOWED_USERS",
"QQ_ALLOWED_USERS",
"QQ_GROUP_ALLOWED_USERS",
"TELEGRAM_ALLOWED_USERS",
"GATEWAY_ALLOWED_USERS",
"GATEWAY_ALLOW_ALL_USERS",
"WECOM_ALLOW_ALL_USERS",
"WEIXIN_ALLOW_ALL_USERS",
"YUANBAO_ALLOW_ALL_USERS",
"QQ_ALLOW_ALL_USERS",
):
monkeypatch.delenv(key, raising=False)
def _make_runner(platform: Platform, config: GatewayConfig, *, enforces: bool):
"""Build a bare GatewayRunner with one adapter for *platform*.
``enforces`` controls whether the adapter declares
``enforces_own_access_policy`` — i.e. whether it owns its access gate.
"""
from gateway.run import GatewayRunner
runner = object.__new__(GatewayRunner)
runner.config = config
adapter = SimpleNamespace(send=AsyncMock(), enforces_own_access_policy=enforces)
runner.adapters = {platform: adapter}
runner.pairing_store = MagicMock()
runner.pairing_store.is_approved.return_value = False
runner.pairing_store._is_rate_limited.return_value = False
return runner, adapter
def _source(platform: Platform, *, chat_type: str = "dm") -> SessionSource:
return SessionSource(
platform=platform,
user_id="some-user",
chat_id="some-chat",
user_name="tester",
chat_type=chat_type,
)
# ---------------------------------------------------------------------------
# Layer 1: the base-class contract and per-adapter overrides
# ---------------------------------------------------------------------------
def test_base_adapter_defaults_to_not_owning_access_policy():
"""Adapters that don't override the property delegate to the gateway."""
from gateway.platforms.base import BasePlatformAdapter
# The default lives on the base property descriptor.
assert BasePlatformAdapter.enforces_own_access_policy.fget(object()) is False
@pytest.mark.parametrize(
"module_path, class_name",
[
("gateway.platforms.wecom", "WeComAdapter"),
("gateway.platforms.weixin", "WeixinAdapter"),
("gateway.platforms.yuanbao", "YuanbaoAdapter"),
("gateway.platforms.qqbot.adapter", "QQAdapter"),
],
)
def test_own_policy_adapters_declare_the_flag(module_path, class_name):
"""The four config-policy adapters override the flag to True."""
import importlib
module = importlib.import_module(module_path)
adapter_cls = getattr(module, class_name)
# Property is overridden on the subclass and returns True regardless of
# instance state (it reflects a static capability, not runtime config).
value = adapter_cls.enforces_own_access_policy.fget(object.__new__(adapter_cls))
assert value is True
# ---------------------------------------------------------------------------
# Layer 2: gateway trusts the adapter-enforced flag
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("platform", _OWN_POLICY_PLATFORMS)
def test_own_policy_platform_authorized_without_env_allowlist(monkeypatch, platform):
"""A message reaching the gateway from an own-policy adapter is trusted.
With no env allowlist set, the gateway must NOT default-deny — the adapter
already authorized the sender at intake (e.g. ``dm_policy: open``).
"""
_clear_auth_env(monkeypatch)
config = GatewayConfig(
platforms={platform: PlatformConfig(enabled=True, extra={"dm_policy": "open"})}
)
runner, _adapter = _make_runner(platform, config, enforces=True)
assert runner._is_user_authorized(_source(platform)) is True
@pytest.mark.parametrize("platform", _OWN_POLICY_PLATFORMS)
def test_own_policy_platform_authorized_for_group_chat(monkeypatch, platform):
"""Group traffic from an own-policy adapter is trusted the same way."""
_clear_auth_env(monkeypatch)
config = GatewayConfig(
platforms={platform: PlatformConfig(enabled=True, extra={"group_policy": "open"})}
)
runner, _adapter = _make_runner(platform, config, enforces=True)
assert runner._is_user_authorized(_source(platform, chat_type="group")) is True
def test_non_owning_platform_still_default_denies(monkeypatch):
"""Adapters that don't own their policy keep the env-only default-deny."""
_clear_auth_env(monkeypatch)
config = GatewayConfig(
platforms={Platform.TELEGRAM: PlatformConfig(enabled=True, token="t")}
)
runner, _adapter = _make_runner(Platform.TELEGRAM, config, enforces=False)
assert runner._is_user_authorized(_source(Platform.TELEGRAM)) is False
def test_env_allowlist_still_takes_precedence_for_own_policy_platform(monkeypatch):
"""When an env allowlist IS set, it governs — adapter trust is a fallback.
The adapter-trust branch only fires when no env allowlist exists, so an
operator who sets ``WECOM_ALLOWED_USERS`` still gets env-based gating and
a non-listed user is denied.
"""
_clear_auth_env(monkeypatch)
monkeypatch.setenv("WECOM_ALLOWED_USERS", "allowed-user")
config = GatewayConfig(
platforms={Platform.WECOM: PlatformConfig(enabled=True, extra={"dm_policy": "open"})}
)
runner, _adapter = _make_runner(Platform.WECOM, config, enforces=True)
listed = SessionSource(
platform=Platform.WECOM, user_id="allowed-user", chat_id="c",
user_name="t", chat_type="dm",
)
stranger = SessionSource(
platform=Platform.WECOM, user_id="stranger", chat_id="c",
user_name="t", chat_type="dm",
)
assert runner._is_user_authorized(listed) is True
assert runner._is_user_authorized(stranger) is False
def test_unknown_adapter_does_not_crash_trust_check(monkeypatch):
"""No adapter registered for the platform → safe default-deny."""
_clear_auth_env(monkeypatch)
config = GatewayConfig(platforms={Platform.WECOM: PlatformConfig(enabled=True)})
runner, _adapter = _make_runner(Platform.WECOM, config, enforces=True)
runner.adapters = {} # nothing registered
assert runner._adapter_enforces_own_access_policy(Platform.WECOM) is False
assert runner._is_user_authorized(_source(Platform.WECOM)) is False
# ---------------------------------------------------------------------------
# Layer 3: unauthorized-DM behavior reads config dm_policy
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"dm_policy, expected",
[
("allowlist", "ignore"),
("disabled", "ignore"),
("pairing", "pair"),
],
)
def test_unauthorized_dm_behavior_follows_config_dm_policy(monkeypatch, dm_policy, expected):
"""A restrictive dm_policy drops unauthorized DMs; pairing opts back in."""
_clear_auth_env(monkeypatch)
config = GatewayConfig(
platforms={Platform.WECOM: PlatformConfig(enabled=True, extra={"dm_policy": dm_policy})}
)
runner, _adapter = _make_runner(Platform.WECOM, config, enforces=True)
assert runner._get_unauthorized_dm_behavior(Platform.WECOM) == expected
def test_unauthorized_dm_behavior_open_policy_keeps_default(monkeypatch):
"""``dm_policy: open`` is not restrictive → falls through to the default."""
_clear_auth_env(monkeypatch)
config = GatewayConfig(
platforms={Platform.WECOM: PlatformConfig(enabled=True, extra={"dm_policy": "open"})}
)
runner, _adapter = _make_runner(Platform.WECOM, config, enforces=True)
# No allowlist + no restrictive policy → open-gateway pairing default.
assert runner._get_unauthorized_dm_behavior(Platform.WECOM) == "pair"
@@ -0,0 +1,130 @@
"""Tests for Matrix adapter fail-closed approval reaction auth.
When MATRIX_ALLOWED_USERS is not configured, _on_reaction must deny
approval reactions by default unless GATEWAY_ALLOW_ALL_USERS=true.
Mirrors the Telegram _is_callback_user_authorized fix (commit 89d32052e,
PR #28494).
"""
import asyncio
import sys
import types
from collections import deque
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
import pytest
# ---------------------------------------------------------------------------
# Stub mautrix so gateway.platforms.matrix can be imported without the SDK.
# ---------------------------------------------------------------------------
def _stub_mautrix():
stub = types.ModuleType("mautrix")
for sub in ("mautrix.types", "mautrix.client", "mautrix.client.api",
"mautrix.errors", "mautrix.crypto", "mautrix.util",
"mautrix.util.config"):
sys.modules.setdefault(sub, types.ModuleType(sub))
sys.modules.setdefault("mautrix", stub)
m = sys.modules["mautrix.types"]
for attr in (
"ContentURI", "EventID", "EventType", "PaginationDirection",
"PresenceState", "RoomCreatePreset", "RoomID", "SyncToken",
"TrustState", "UserID",
):
if not hasattr(m, attr):
setattr(m, attr, str)
_stub_mautrix()
from gateway.platforms.matrix import MatrixAdapter, _MatrixApprovalPrompt # noqa: E402
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_adapter(allowed_user_ids=None):
"""Construct a MatrixAdapter with only the state needed by _on_reaction."""
adapter = object.__new__(MatrixAdapter)
adapter._user_id = "@bot:matrix.org"
adapter._allowed_user_ids = set(allowed_user_ids) if allowed_user_ids else set()
adapter._approval_reaction_map = {"✅": "once", "❎": "deny"}
adapter._approval_prompts_by_event = {}
adapter._approval_prompt_by_session = {}
adapter._processed_events = deque(maxlen=512)
adapter._processed_events_set = set()
return adapter
def _make_event(sender, reacts_to, key="✅"):
"""Minimal Matrix reaction event."""
return SimpleNamespace(
sender=sender,
event_id=f"$reaction-{sender.split(':')[0]}",
room_id="!testroom:matrix.org",
content={"m.relates_to": {"event_id": reacts_to, "key": key}},
)
def _make_prompt(chat_id="!testroom:matrix.org"):
return _MatrixApprovalPrompt(
session_key="session-abc",
chat_id=chat_id,
message_id="$prompt-event-1",
)
def _run(adapter, event):
"""Run _on_reaction and return whether the prompt was resolved."""
prompt_event_id = "$prompt-event-1"
prompt = _make_prompt()
adapter._approval_prompts_by_event[prompt_event_id] = prompt
adapter._redact_bot_approval_reactions = AsyncMock()
fake_approval = types.ModuleType("tools.approval")
fake_approval.resolve_gateway_approval = lambda session_key, choice: 1
with patch.dict(sys.modules, {"tools.approval": fake_approval}):
asyncio.run(adapter._on_reaction(event))
return prompt.resolved
# ---------------------------------------------------------------------------
# Test class
# ---------------------------------------------------------------------------
class TestApprovalReactionFailClosed:
"""_on_reaction approval auth must be fail-closed (parity with Telegram)."""
def test_no_allowlist_no_allow_all_denies(self, monkeypatch):
"""No MATRIX_ALLOWED_USERS + no GATEWAY_ALLOW_ALL_USERS → deny."""
monkeypatch.delenv("MATRIX_ALLOWED_USERS", raising=False)
monkeypatch.delenv("GATEWAY_ALLOW_ALL_USERS", raising=False)
adapter = _make_adapter(allowed_user_ids=None)
event = _make_event("@stranger:matrix.org", "$prompt-event-1")
assert _run(adapter, event) is False
def test_no_allowlist_allow_all_permits(self, monkeypatch):
"""No MATRIX_ALLOWED_USERS + GATEWAY_ALLOW_ALL_USERS=true → allow."""
monkeypatch.delenv("MATRIX_ALLOWED_USERS", raising=False)
monkeypatch.setenv("GATEWAY_ALLOW_ALL_USERS", "true")
adapter = _make_adapter(allowed_user_ids=None)
event = _make_event("@anyone:matrix.org", "$prompt-event-1")
assert _run(adapter, event) is True
def test_listed_sender_permits(self, monkeypatch):
"""Sender in MATRIX_ALLOWED_USERS → allow."""
monkeypatch.delenv("GATEWAY_ALLOW_ALL_USERS", raising=False)
adapter = _make_adapter(allowed_user_ids=["@alice:matrix.org"])
event = _make_event("@alice:matrix.org", "$prompt-event-1")
assert _run(adapter, event) is True
def test_unlisted_sender_denies(self, monkeypatch):
"""Sender not in MATRIX_ALLOWED_USERS → deny."""
monkeypatch.delenv("GATEWAY_ALLOW_ALL_USERS", raising=False)
adapter = _make_adapter(allowed_user_ids=["@alice:matrix.org"])
event = _make_event("@mallory:matrix.org", "$prompt-event-1")
assert _run(adapter, event) is False
+9 -2
View File
@@ -2195,7 +2195,7 @@ class TestPtyWebSocket:
winsize_script = (
"import fcntl, struct, termios, time; "
"time.sleep(0.15); "
"time.sleep(0.5); "
"rows, cols, *_ = struct.unpack('HHHH', "
"fcntl.ioctl(0, termios.TIOCGWINSZ, b'\\0' * 8)); "
"print(cols); print(rows)"
@@ -2217,7 +2217,14 @@ class TestPtyWebSocket:
deadline = time.monotonic() + 5.0
while time.monotonic() < deadline:
frame = conn.receive_bytes()
# receive_bytes() blocks; once the child prints its winsize and
# exits, the PTY closes and further reads raise. Without this
# guard a missed-marker run blocks until the 30s pytest-timeout
# (flaky failure) instead of failing fast on the assert below.
try:
frame = conn.receive_bytes()
except Exception:
break
if frame:
buf += frame
if b"99" in buf and b"41" in buf:
+77
View File
@@ -250,6 +250,83 @@ class TestBrowserVisionConfig:
assert mock_llm.call_args.kwargs["temperature"] == 0.1
assert mock_llm.call_args.kwargs["timeout"] == 120.0
def test_browser_vision_native_fast_path_returns_multimodal(self, tmp_path):
"""supports_vision override → screenshot attached natively, no aux call."""
from agent.auxiliary_client import clear_runtime_main, set_runtime_main
from tools.browser_tool import browser_vision
shots_dir, screenshot = self._setup_screenshot(tmp_path)
annotations = [{"id": 1, "label": "Search box"}]
set_runtime_main("brand-new-provider", "llava-v1.6")
try:
with (
patch("hermes_constants.get_hermes_dir", return_value=shots_dir),
patch("tools.browser_tool._cleanup_old_screenshots"),
patch(
"tools.browser_tool._run_browser_command",
return_value={
"success": True,
"data": {"path": str(screenshot), "annotations": annotations},
},
),
patch(
"hermes_cli.config.load_config",
return_value={"model": {"supports_vision": True}},
),
patch("tools.browser_tool._get_vision_model") as mock_get_vision_model,
patch("tools.browser_tool.call_llm") as mock_llm,
):
result = browser_vision("what is on the page?", annotate=True, task_id="test")
finally:
clear_runtime_main()
assert isinstance(result, dict)
assert result["_multimodal"] is True
assert result["meta"]["screenshot_path"] == str(screenshot)
assert result["meta"]["annotations"] == annotations
assert any(p.get("type") == "image_url" for p in result["content"])
assert f"Screenshot path: {screenshot}" in result["text_summary"]
mock_get_vision_model.assert_not_called()
mock_llm.assert_not_called()
def test_browser_vision_text_mode_blocks_native_fast_path(self, tmp_path):
"""Explicit text routing → aux LLM used even with supports_vision."""
from agent.auxiliary_client import clear_runtime_main, set_runtime_main
from tools.browser_tool import browser_vision
shots_dir, screenshot = self._setup_screenshot(tmp_path)
mock_response = MagicMock()
mock_choice = MagicMock()
mock_choice.message.content = "Text-mode screenshot analysis"
mock_response.choices = [mock_choice]
set_runtime_main("brand-new-provider", "llava-v1.6")
try:
with (
patch("hermes_constants.get_hermes_dir", return_value=shots_dir),
patch("tools.browser_tool._cleanup_old_screenshots"),
patch(
"tools.browser_tool._run_browser_command",
return_value={"success": True, "data": {"path": str(screenshot)}},
),
patch(
"hermes_cli.config.load_config",
return_value={
"agent": {"image_input_mode": "text"},
"model": {"supports_vision": True},
},
),
patch("tools.browser_tool._get_vision_model", return_value="test-model"),
patch("tools.browser_tool.call_llm", return_value=mock_response) as mock_llm,
):
result = json.loads(browser_vision("what is on the page?", task_id="test"))
finally:
clear_runtime_main()
assert result["success"] is True
assert result["analysis"] == "Text-mode screenshot analysis"
mock_llm.assert_called_once()
# ── auto-recording config ────────────────────────────────────────────
+14
View File
@@ -561,9 +561,18 @@ class TestPopenLeakOnSetupFailure:
def boom(*args, **kwargs):
raise RuntimeError("Thread creation failed")
# proc.pid is a MagicMock-backed fake; os.getpgid(fake_pid) would query
# the real OS for an arbitrary PID. On a busy host that PID may exist,
# in which case spawn_local's primary cleanup path
# (os.killpg(os.getpgid(pid), SIGKILL)) succeeds against an UNRELATED
# real process group and proc.kill() is never reached — flaky failure,
# and a real risk of SIGKILLing an innocent process group. Force the
# ProcessLookupError fallback so the test deterministically exercises
# proc.kill() and never issues a real killpg.
with patch("tools.process_registry._find_shell", return_value="/bin/bash"), \
patch("subprocess.Popen", return_value=proc), \
patch("threading.Thread", side_effect=boom), \
patch("os.getpgid", side_effect=ProcessLookupError), \
patch.object(registry, "_write_checkpoint"):
with pytest.raises(RuntimeError, match="Thread creation failed"):
registry.spawn_local("echo hello", cwd="/tmp")
@@ -588,9 +597,14 @@ class TestPopenLeakOnSetupFailure:
fake_thread = MagicMock()
# See note in test_popen_killed_when_thread_creation_fails: force the
# ProcessLookupError fallback so cleanup deterministically calls
# proc.kill() instead of issuing a real os.killpg against whatever
# process group happens to own the fake PID on the host.
with patch("tools.process_registry._find_shell", return_value="/bin/bash"), \
patch("subprocess.Popen", return_value=proc), \
patch("threading.Thread", return_value=fake_thread), \
patch("os.getpgid", side_effect=ProcessLookupError), \
patch.object(registry, "_write_checkpoint", side_effect=OSError("disk full")):
with pytest.raises(OSError, match="disk full"):
registry.spawn_local("echo hello", cwd="/tmp")
@@ -209,3 +209,57 @@ class TestHandleVisionAnalyzeFastPath:
assert not (isinstance(result, dict) and result.get("_multimodal") is True), \
"Fast path fired for unknown provider; should have fallen through"
def test_supports_vision_override_bypasses_provider_allowlist(self, tmp_path):
"""supports_vision=true enables the fast path on an unlisted provider."""
img = tmp_path / "x.png"
img.write_bytes(_TINY_PNG)
async def _aux_sentinel(*args, **kwargs):
return '{"sentinel": "aux-path"}'
from agent.auxiliary_client import set_runtime_main, clear_runtime_main
set_runtime_main("brand-new-provider", "llava-v1.6")
try:
with patch(
"hermes_cli.config.load_config",
return_value={"model": {"supports_vision": True}},
), patch(
"tools.vision_tools.vision_analyze_tool", side_effect=_aux_sentinel,
) as mock_aux:
coro = _handle_vision_analyze({"image_url": str(img), "question": "?"})
result = asyncio.get_event_loop().run_until_complete(coro)
finally:
clear_runtime_main()
assert isinstance(result, dict) and result.get("_multimodal") is True
mock_aux.assert_not_called()
def test_text_mode_wins_over_supports_vision_override(self, tmp_path):
"""Explicit text routing blocks the fast path even with supports_vision."""
img = tmp_path / "x.png"
img.write_bytes(_TINY_PNG)
async def _aux_sentinel(*args, **kwargs):
return '{"sentinel": "aux-path"}'
from agent.auxiliary_client import set_runtime_main, clear_runtime_main
set_runtime_main("brand-new-provider", "llava-v1.6")
try:
with patch(
"hermes_cli.config.load_config",
return_value={
"agent": {"image_input_mode": "text"},
"model": {"supports_vision": True},
},
), patch(
"tools.vision_tools.vision_analyze_tool", side_effect=_aux_sentinel,
) as mock_aux:
coro = _handle_vision_analyze({"image_url": str(img), "question": "?"})
result = asyncio.get_event_loop().run_until_complete(coro)
finally:
clear_runtime_main()
assert isinstance(result, str)
assert json.loads(result) == {"sentinel": "aux-path"}
mock_aux.assert_called_once()
+181
View File
@@ -311,3 +311,184 @@ class TestUnconfiguredErrorEnvelopeParity:
# No per-result burying
assert "results" not in result
class TestDispatchersTriggerPluginDiscovery:
"""Regression tests for #27580: each web_*_tool dispatcher must
idempotently call ``_ensure_web_plugins_loaded()`` before consulting
``agent.web_search_registry``.
Without this, a tool call from a context that hasn't already loaded
plugins (subprocess agent runs, delegate children, standalone scripts,
test paths that import the registry directly) sees an empty registry
and returns the misleading "No web extract provider configured" error
even when the user has both the config key set AND the API key
exported.
Mirrors :func:`tools.browser_tool._ensure_browser_plugins_loaded` —
every other plugin-backed dispatcher (image_gen, video_gen, browser,
skills) already does this.
"""
def _clear_registry(self):
"""Reset the web_search registry to empty and return a callback
that restores the original contents. Used in a try/finally so the
snapshot is restored even when the dispatcher under test raises."""
from agent import web_search_registry
with web_search_registry._lock:
original = dict(web_search_registry._providers)
web_search_registry._providers.clear()
def _restore():
with web_search_registry._lock:
web_search_registry._providers.clear()
web_search_registry._providers.update(original)
return _restore
def test_web_extract_tool_runs_discovery_before_registry_lookup(self, monkeypatch):
"""``web_extract_tool`` must invoke ``_ensure_web_plugins_loaded()``
before looking up the configured backend so the registry is
populated even from cold-start subprocess contexts.
Without the fix, ``get_provider('firecrawl')`` returns ``None``
on a fresh process and the dispatcher emits "No web extract
provider configured" despite the user having both
``web.extract_backend: firecrawl`` and ``FIRECRAWL_API_KEY`` set
(issue #27580).
"""
import asyncio
import json
from unittest.mock import MagicMock
from agent.web_search_provider import WebSearchProvider
from agent import web_search_registry
from tools import web_tools
restore = self._clear_registry()
try:
class FakeFirecrawl(WebSearchProvider):
@property
def name(self) -> str:
return "firecrawl"
@property
def display_name(self) -> str:
return "Fake Firecrawl"
def is_available(self) -> bool:
return True
def supports_extract(self) -> bool:
return True
async def extract(self, urls, format=None):
return [
{"url": u, "title": "", "content": "ok",
"raw_content": "ok", "metadata": {}}
for u in urls
]
# Simulate "plugin discovery loads the firecrawl plugin": the
# wrapped helper registers the provider, mirroring what
# ``plugins/web/firecrawl/__init__.py:register`` does at
# real-process startup. Wrapping with ``MagicMock`` lets us
# also assert the dispatcher actually invoked the hook — if
# a future refactor accidentally drops the call the regression
# would otherwise hide behind a still-populated registry.
def _register_fake() -> None:
if web_search_registry.get_provider("firecrawl") is None:
web_search_registry.register_provider(FakeFirecrawl())
mock_hook = MagicMock(wraps=_register_fake)
# Patch the helper on ``tools.web_tools`` directly rather than the
# underlying ``hermes_cli.plugins._ensure_plugins_discovered`` so
# the test stays valid even if the import inside the helper is
# later moved to module scope or renamed.
monkeypatch.setattr(
web_tools, "_ensure_web_plugins_loaded", mock_hook
)
monkeypatch.setattr(
web_tools, "_load_web_config",
lambda: {"extract_backend": "firecrawl"},
)
# Sanity: registry IS empty before the tool call.
assert web_search_registry.get_provider("firecrawl") is None
result = json.loads(asyncio.run(
web_tools.web_extract_tool(
["https://example.com"],
use_llm_processing=False,
)
))
# The hook must have been called BEFORE the registry lookup —
# that is the invariant under regression test. Without the
# explicit ``.called`` assertion the test could pass if the
# registry were populated by some unrelated side effect.
assert mock_hook.called, (
"web_extract_tool must call _ensure_web_plugins_loaded() "
"before resolving the registry"
)
assert "No web extract provider configured" not in json.dumps(result)
assert web_search_registry.get_provider("firecrawl") is not None
finally:
restore()
def test_web_search_tool_runs_discovery_before_registry_lookup(self, monkeypatch):
"""``web_search_tool`` must invoke ``_ensure_web_plugins_loaded()``
before the registry lookup for the same reason as the extract
path (issue #27580 root cause applies to all dispatchers).
"""
import json
from unittest.mock import MagicMock
from agent.web_search_provider import WebSearchProvider
from agent import web_search_registry
from tools import web_tools
restore = self._clear_registry()
try:
class FakeBrave(WebSearchProvider):
@property
def name(self) -> str:
return "brave-free"
@property
def display_name(self) -> str:
return "Fake Brave"
def is_available(self) -> bool:
return True
def supports_search(self) -> bool:
return True
def search(self, query, limit=5):
return {"success": True, "data": {"web": [
{"title": "ok", "url": "https://x", "description": "",
"position": 0}
]}}
def _register_fake() -> None:
if web_search_registry.get_provider("brave-free") is None:
web_search_registry.register_provider(FakeBrave())
mock_hook = MagicMock(wraps=_register_fake)
monkeypatch.setattr(
web_tools, "_ensure_web_plugins_loaded", mock_hook
)
monkeypatch.setattr(
web_tools, "_load_web_config",
lambda: {"search_backend": "brave-free"},
)
assert web_search_registry.get_provider("brave-free") is None
result = json.loads(web_tools.web_search_tool("hello", limit=1))
assert mock_hook.called, (
"web_search_tool must call _ensure_web_plugins_loaded() "
"before resolving the registry"
)
assert "No web search provider configured" not in json.dumps(result)
assert web_search_registry.get_provider("brave-free") is not None
finally:
restore()
+42 -11
View File
@@ -62,7 +62,7 @@ import tempfile
import threading
import time
import requests
from typing import Dict, Any, Optional, List, Tuple
from typing import Dict, Any, Optional, List, Tuple, Union
from pathlib import Path
from agent.auxiliary_client import call_llm
from hermes_constants import get_hermes_home
@@ -1578,7 +1578,7 @@ BROWSER_TOOL_SCHEMAS = [
},
{
"name": "browser_vision",
"description": "Take a screenshot of the current page and analyze it with vision AI. Use this when you need to visually understand what's on the page - especially useful for CAPTCHAs, visual verification challenges, complex layouts, or when the text snapshot doesn't capture important visual information. Returns both the AI analysis and a screenshot_path that you can share with the user by including MEDIA:<screenshot_path> in your response. Requires browser_navigate to be called first.",
"description": "Take a screenshot of the current page so you can inspect it visually. Use this when you need to understand what the page looks like - especially for CAPTCHAs, visual verification challenges, complex layouts, or cases where the text snapshot misses important visual information. When your active model has native vision, the screenshot is attached to your context directly and you inspect it on the next turn; otherwise Hermes falls back to an auxiliary vision model and returns a text analysis. Includes a screenshot_path that you can share with the user by including MEDIA:<screenshot_path> in your response. Requires browser_navigate to be called first.",
"parameters": {
"type": "object",
"properties": {
@@ -3044,17 +3044,19 @@ def browser_get_images(task_id: Optional[str] = None) -> str:
return json.dumps(_copy_fallback_warning(response, result), ensure_ascii=False)
def browser_vision(question: str, annotate: bool = False, task_id: Optional[str] = None) -> str:
def browser_vision(question: str, annotate: bool = False, task_id: Optional[str] = None) -> Union[str, Dict[str, Any]]:
"""
Take a screenshot of the current page and analyze it with vision AI.
Take a screenshot of the current page for visual inspection.
This tool captures what's visually displayed in the browser and sends it
to Gemini for analysis. Useful for understanding visual content that the
text-based snapshot may not capture (CAPTCHAs, verification challenges,
images, complex layouts, etc.).
Captures what's visually displayed in the browser. When the active model
supports native vision, the screenshot is attached directly to the
conversation so the model can inspect it on the next turn; otherwise Hermes
falls back to the auxiliary vision model and returns a text analysis. Useful
for visual content the text-based snapshot may not capture (CAPTCHAs,
verification challenges, images, complex layouts, etc.).
The screenshot is saved persistently and its file path is returned alongside
the analysis, so it can be shared with users via MEDIA:<path> in the response.
The screenshot is saved persistently and its file path is returned so it
can be shared with users via MEDIA:<path> in the response.
Args:
question: What you want to know about the page visually
@@ -3062,7 +3064,8 @@ def browser_vision(question: str, annotate: bool = False, task_id: Optional[str]
task_id: Task identifier for session isolation
Returns:
JSON string with vision analysis results and screenshot_path
A JSON string with vision analysis results and screenshot_path, or a
multimodal tool-result envelope carrying the screenshot and metadata.
"""
if _is_camofox_mode():
from tools.browser_camofox import camofox_vision
@@ -3187,6 +3190,34 @@ def browser_vision(question: str, annotate: bool = False, task_id: Optional[str]
_screenshot_b64 = base64.b64encode(_screenshot_bytes).decode("ascii")
data_url = f"data:image/png;base64,{_screenshot_b64}"
# Fast path: when native image routing is in effect for the active main
# model, attach the screenshot directly instead of describing it through
# an auxiliary vision LLM. The model inspects the pixels on its next
# turn — no aux call, no information loss. Consistent with vision_analyze.
from tools.vision_tools import (
_build_native_vision_tool_result,
_should_use_native_vision_fast_path,
)
if _should_use_native_vision_fast_path():
native_result = _build_native_vision_tool_result(
image_url=str(screenshot_path),
question=question,
image_data_url=data_url,
image_size_bytes=len(_screenshot_bytes),
)
meta = native_result.setdefault("meta", {})
meta["screenshot_path"] = str(screenshot_path)
if _lp_fallback_warning:
meta["fallback_warning"] = _lp_fallback_warning
if annotate and result.get("data", {}).get("annotations"):
meta["annotations"] = result["data"]["annotations"]
native_result["text_summary"] = (
f"{native_result.get('text_summary', '')} "
f"Screenshot path: {screenshot_path}"
).strip()
return native_result
vision_prompt = (
f"You are analyzing a screenshot of a web browser.\n\n"
f"User's question: {question}\n\n"
-31
View File
@@ -22,7 +22,6 @@ import base64
import json
import logging
import os
import platform
import re
import shutil
import sys
@@ -79,10 +78,6 @@ def _is_macos() -> bool:
return sys.platform == "darwin"
def _is_arm_mac() -> bool:
return _is_macos() and platform.machine() == "arm64"
def cua_driver_binary_available() -> bool:
"""True if `cua-driver` is on $PATH or HERMES_CUA_DRIVER_CMD resolves."""
return bool(shutil.which(_CUA_DRIVER_CMD))
@@ -705,29 +700,3 @@ class CuaDriverBackend(ComputerUseBackend):
message = data
return ActionResult(ok=ok, action=name, message=message,
meta=data if isinstance(data, dict) else {})
def _parse_element(d: Dict[str, Any]) -> UIElement:
bounds = d.get("bounds") or (0, 0, 0, 0)
if isinstance(bounds, dict):
bounds = (
int(bounds.get("x", 0)),
int(bounds.get("y", 0)),
int(bounds.get("w", bounds.get("width", 0))),
int(bounds.get("h", bounds.get("height", 0))),
)
elif isinstance(bounds, (list, tuple)) and len(bounds) == 4:
bounds = tuple(int(v) for v in bounds)
else:
bounds = (0, 0, 0, 0)
return UIElement(
index=int(d.get("index", 0)),
role=str(d.get("role", "") or ""),
label=str(d.get("label", "") or ""),
bounds=bounds, # type: ignore[arg-type]
app=str(d.get("app", "") or ""),
pid=int(d.get("pid", 0) or 0),
window_id=int(d.get("windowId", 0) or 0),
attributes={k: v for k, v in d.items()
if k not in {"index", "role", "label", "bounds", "app", "pid", "windowId"}},
)
-12
View File
@@ -37,7 +37,6 @@ from tools.binary_extensions import BINARY_EXTENSIONS
from agent.file_safety import (
build_write_denied_paths,
build_write_denied_prefixes,
get_safe_write_root as _shared_get_safe_write_root,
is_write_denied as _shared_is_write_denied,
)
@@ -114,17 +113,6 @@ def _normalize_line_endings(text: str, target: str) -> str:
return text
def _get_safe_write_root() -> Optional[str]:
"""Return the resolved HERMES_WRITE_SAFE_ROOT path, or None if unset.
When set, all write_file/patch operations are constrained to this
directory tree. Writes outside it are denied even if the target is
not on the static deny list. Opt-in hardening for gateway/messaging
deployments that should only touch a workspace checkout.
"""
return _shared_get_safe_write_root()
def _is_write_denied(path: str) -> bool:
"""Return True if path is on the write deny list."""
return _shared_is_write_denied(path)
-43
View File
@@ -629,49 +629,6 @@ def _sort_skills(skills: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
return sorted(skills, key=lambda s: (s.get("category") or "", s["name"]))
def _load_category_description(category_dir: Path) -> Optional[str]:
"""
Load category description from DESCRIPTION.md if it exists.
Args:
category_dir: Path to the category directory
Returns:
Description string or None if not found
"""
desc_file = category_dir / "DESCRIPTION.md"
if not desc_file.exists():
return None
try:
content = desc_file.read_text(encoding="utf-8")
# Parse frontmatter if present
frontmatter, body = _parse_frontmatter(content)
# Prefer frontmatter description, fall back to first non-header line
description = frontmatter.get("description", "")
if not description:
for line in body.strip().split("\n"):
line = line.strip()
if line and not line.startswith("#"):
description = line
break
# Truncate to reasonable length
if len(description) > MAX_DESCRIPTION_LENGTH:
description = description[: MAX_DESCRIPTION_LENGTH - 3] + "..."
return description if description else None
except (UnicodeDecodeError, PermissionError) as e:
logger.debug("Failed to read category description %s: %s", desc_file, e)
return None
except Exception as e:
logger.warning(
"Error parsing category description %s: %s", desc_file, e, exc_info=True
)
return None
def skills_list(category: str = None, task_id: str = None) -> str:
"""
List all available skills (progressive disclosure tier 1 - minimal metadata).
+39 -22
View File
@@ -476,6 +476,36 @@ def _supports_media_in_tool_results(provider: str, model: str) -> bool:
return False
def _should_use_native_vision_fast_path() -> bool:
"""Whether vision tools should attach the image to the main model directly
instead of routing through the auxiliary vision LLM.
True when image routing resolves to ``native`` AND either the provider is
known to accept images inside tool results, or the user explicitly declared
the model vision-capable via the ``model.supports_vision`` config override.
The override is the escape hatch for custom/local providers that aren't in
the static allowlist. Best-effort: any resolution failure returns False so
the caller falls back to the legacy aux-LLM path.
"""
try:
from agent.auxiliary_client import _read_main_provider, _read_main_model
from agent.image_routing import decide_image_input_mode, _lookup_supports_vision
from hermes_cli.config import load_config
provider = _read_main_provider()
model = _read_main_model()
cfg = load_config()
if decide_image_input_mode(provider, model, cfg) != "native":
return False
return (
_supports_media_in_tool_results(provider, model)
or _lookup_supports_vision(provider, model, cfg) is True
)
except Exception as exc:
logger.debug("Native vision fast-path check failed: %s", exc)
return False
def _build_native_vision_tool_result(
image_url: str,
question: str,
@@ -1030,28 +1060,15 @@ def _handle_vision_analyze(args: Dict[str, Any], **kw: Any) -> Awaitable[str]:
image_url = args.get("image_url", "")
question = args.get("question", "")
# Fast path: when the active main model supports native vision AND the
# provider supports image content inside tool results, short-circuit
# the auxiliary LLM and return the image bytes as a multimodal
# tool-result envelope. The main model sees the pixels directly on its
# next turn — no aux call, no information loss, no extra latency.
try:
from agent.auxiliary_client import _read_main_provider, _read_main_model
from agent.image_routing import decide_image_input_mode
from hermes_cli.config import load_config
_provider = _read_main_provider()
_model = _read_main_model()
_cfg = load_config()
_mode = decide_image_input_mode(_provider, _model, _cfg)
if _mode == "native" and _supports_media_in_tool_results(_provider, _model):
logger.info(
"vision_analyze: native fast path (provider=%s, model=%s)",
_provider, _model,
)
return _vision_analyze_native(image_url, question)
except Exception as exc:
logger.debug("Native vision fast-path check failed; using aux LLM: %s", exc)
# Fast path: when native image routing is in effect for the active main
# model (provider accepts images in tool results, or the user set the
# model.supports_vision override), short-circuit the auxiliary LLM and
# return the image bytes as a multimodal tool-result envelope. The main
# model sees the pixels directly on its next turn — no aux call, no
# information loss, no extra latency.
if _should_use_native_vision_fast_path():
logger.info("vision_analyze: native fast path")
return _vision_analyze_native(image_url, question)
# Legacy path: aux LLM describes the image and we return its text.
full_prompt = (
+31
View File
@@ -732,6 +732,35 @@ def clean_base64_images(text: str) -> str:
# dispatchers in this file resolve them via get_active_*_provider().
def _ensure_web_plugins_loaded() -> None:
"""Idempotently trigger plugin discovery so the web registry is populated.
Every bundled web provider (brave-free, ddgs, searxng, exa, parallel,
tavily, firecrawl) registers itself via ``plugins/web/<vendor>/__init__.py``
during plugin discovery. Tool dispatch can be reached from contexts that
haven't already triggered discovery — subprocess agent runs, delegate
children, standalone scripts, certain test paths — and without it the
registry is empty and ``get_provider('firecrawl')`` returns ``None`` even
when the user has ``web.extract_backend: firecrawl`` configured and
``FIRECRAWL_API_KEY`` set. The symptom is a misleading "No web extract
provider configured" error (issue #27580).
Mirrors :func:`tools.browser_tool._ensure_browser_plugins_loaded` exactly:
the underlying discovery call is idempotent and cheap on subsequent
invocations.
"""
try:
from hermes_cli.plugins import _ensure_plugins_discovered
_ensure_plugins_discovered()
except Exception as exc: # noqa: BLE001
# Warning, not debug: if a plugin import is genuinely broken the
# user otherwise hits the misleading "No web extract provider
# configured" error this helper is meant to eliminate, with no
# clue in normal logs about the real cause.
logger.warning("Web plugin discovery failed (non-fatal): %s", exc)
def web_search_tool(query: str, limit: int = 5) -> str:
"""
Search the web for information using available search API backend.
@@ -792,6 +821,7 @@ def web_search_tool(query: str, limit: int = 5) -> str:
# (brave-free, ddgs, searxng, exa, parallel, tavily, firecrawl)
# now live as plugins; the dispatcher is just a registry lookup +
# delegation. Sync only — every provider's search() is sync.
_ensure_web_plugins_loaded()
from agent.web_search_registry import (
get_active_search_provider,
get_provider as _wsp_get_provider,
@@ -924,6 +954,7 @@ async def web_extract_tool(
# detect coroutine functions and await; sync functions run
# inline (the policy gate, SSRF re-check, etc. live inside the
# provider itself for the firecrawl per-URL loop).
_ensure_web_plugins_loaded()
from agent.web_search_registry import (
get_active_extract_provider,
get_provider as _wsp_get_provider,
@@ -219,6 +219,62 @@ terminal:
See the [Security guide](/user-guide/security#environment-variable-passthrough) for full details.
### `HERMES_*` variables in the child
The child process receives only a small, fixed set of operational `HERMES_*`
variables by exact name:
- `HERMES_HOME`
- `HERMES_PROFILE`
- `HERMES_CONFIG`
- `HERMES_ENV`
(plus `HERMES_RPC_DIR` / `HERMES_RPC_SOCKET` / `TZ` / `HOME`, which Hermes
injects explicitly so the RPC channel works).
:::note Behavior change
Earlier versions passed **any** variable whose name began with `HERMES_`
through to the child. That broad prefix was removed for security hardening: it
could leak `HERMES_*`-named configuration that doesn't match a secret substring
(for example `HERMES_BASE_URL`, `HERMES_KANBAN_DB`, or a `HERMES_*_WEBHOOK`
endpoint) into arbitrary sandboxed code.
If an `execute_code` script — or a repo/plugin module it imports at import time
— relied on a `HERMES_*` variable outside the four operational names above, it
will now find that variable **unset** in the child. The drop is intentional,
not a bug.
:::
**Workaround — opt the variable back in explicitly.** Both routes pass the
variable through `execute_code` *and* `terminal` children, and neither weakens
the secret-stripping guarantee (Hermes-managed provider credentials can never
be re-allowed this way):
1. **Per-machine, in `config.yaml`** — add the exact variable name to the
passthrough allowlist:
```yaml
terminal:
env_passthrough:
- HERMES_KANBAN_DB
- HERMES_BASE_URL
```
2. **Per-skill, in the skill's frontmatter** — declare it so it is registered
automatically whenever that skill is loaded:
```yaml
required_environment_variables:
- HERMES_KANBAN_DB
```
**Diagnosing it.** When the child drops one or more non-allowlisted `HERMES_*`
variables, Hermes emits a one-line `debug` log naming them and pointing at the
`env_passthrough` escape hatch. Run with debug logging (`hermes logs --level
DEBUG`, or check `~/.hermes/logs/agent.log`) and look for
`execute_code: dropped N non-allowlisted HERMES_* var(s)` if a script behaves
as though a `HERMES_*` variable is missing.
Hermes always writes the script and the auto-generated `hermes_tools.py` RPC stub into a temp staging directory that is cleaned up after execution. In `strict` mode the script also *runs* there; in `project` mode it runs in the session's working directory (the staging directory stays on `PYTHONPATH` so imports still resolve). The child process runs in its own process group so it can be cleanly killed on timeout or interruption.
## execute_code vs terminal
@@ -55,6 +55,40 @@ If scan-to-create is not available, the wizard falls back to manual input:
Keep the App Secret private. Anyone with it can impersonate your app.
:::
### Configure Permissions
In the Feishu developer console, go to **Permission Management** and add the following scopes. You can bulk-import them in the permissions page.
**Required permissions:**
| Scope | Purpose |
|-------|---------|
| `im:message` | Receive and read messages |
| `im:message:send_as_bot` | Send messages as the bot |
| `im:resource` | Access images, files, and audio sent by users |
| `im:chat` | Access chat/group metadata |
| `im:chat:readonly` | Read chat list and membership |
**Recommended permissions (for full functionality):**
| Scope | Purpose |
|-------|---------|
| `im:message.reactions:readonly` | Receive emoji reaction events |
| `admin:app.info:readonly` | Auto-detect bot identity for @mention gating |
| `contact:user.id:readonly` | Resolve user IDs for allowlist matching |
### Configure Events
In **Events and Callbacks**:
1. Set the connection mode to **Long Connection (WebSocket)** (recommended) or configure a webhook URL
2. In the **Event Configuration** section, subscribe to:
- `im.message.receive_v1` — required for receiving messages
### Publish the App
After configuring permissions and events, go to **Version Management** and publish a new version of the app. The permissions won't take effect until a version is published and approved (for enterprise apps, this may require admin approval).
## Step 2: Choose a Connection Mode
### Recommended: WebSocket mode
@@ -142,6 +142,25 @@ WEIXIN_DM_POLICY=allowlist
WEIXIN_ALLOWED_USERS=user_id_1,user_id_2
```
`WEIXIN_ALLOWED_USERS` is an **inbound filter**, not an invitation system. QR
login connects one iLink bot identity to Hermes. Other people do not scan the
Hermes QR code with their own accounts; they must message the connected iLink
bot/contact through WeChat, and Hermes will process the DM only if the sender's
Weixin user ID is present in `WEIXIN_ALLOWED_USERS`.
A practical setup flow is:
1. Pair Hermes once with `hermes gateway setup` and note the connected iLink bot
account.
2. Have each allowed user send a direct message to that bot/contact.
3. Read the sender/user ID from the gateway logs or the inbound event payload.
4. Add those IDs to `WEIXIN_ALLOWED_USERS`, then restart the gateway.
If only the account that scanned the QR code can talk to Hermes, verify that the
other users are messaging the iLink bot identity itself, not the personal WeChat
account that performed the QR login. The iLink bot is a separate identity, and
ordinary WeChat contact/group routing can be limited by Tencent's iLink behavior.
### Group Policy
Controls which groups the bot responds in **when iLink delivers group events for the connected identity**. For QR-login iLink bot identities (e.g. `...@im.bot`), group events are typically not delivered at all, so this policy may have no effect — see the iLink bot limitation warning at the top of the page.
@@ -0,0 +1,332 @@
---
sidebar_position: 4
---
# Running Many Gateways at Once
Operate multiple [profiles](./profiles.md) — each with its own bot tokens,
sessions, and memory — as managed services on a single machine. This page
covers the operational concerns: starting them all together, viewing logs
across profiles, preventing the host from sleeping, and recovering from common
launchd/systemd quirks.
If you only run one Hermes agent, you don't need this page — see
[Profiles](./profiles.md) for the basics.
## When to use this
You want this setup when you have two or more Hermes agents that should all
be online at the same time. Common reasons:
- A personal assistant on one Telegram bot and a coding agent on another
- One agent per family member or one per Slack workspace
- Sandbox + production instances of the same configuration
- A research agent + a writing agent + a cron-driven bot — each with isolated
memory and skills
Every profile already gets its own per-platform LaunchAgent
(`ai.hermes.gateway-<name>.plist`) or systemd user service
(`hermes-gateway-<name>.service`). This guide adds the patterns for managing
them collectively.
## Quick start
```bash
# Create profiles (once)
hermes profile create coder
hermes profile create personal-bot
hermes profile create research
# Configure each
coder setup
personal-bot setup
research setup
# Install each gateway as a managed service
coder gateway install
personal-bot gateway install
research gateway install
# Start them all
coder gateway start
personal-bot gateway start
research gateway start
```
That's it — three independent agents, each on its own process, restarting
automatically on crash and on user login.
## Start, stop, or restart all gateways at once
The CLI ships with single-profile lifecycle commands. To act across every
profile, wrap them in a shell loop. Put the snippet below in
`~/.local/bin/hermes-gateways` and `chmod +x` it:
```sh
#!/bin/sh
set -eu
# Add or remove profile names here as you create / delete profiles.
profiles="default coder personal-bot research"
usage() {
echo "Usage: hermes-gateways {start|stop|restart|status|list}"
}
run_for_profile() {
profile="$1"
action="$2"
if [ "$profile" = "default" ]; then
hermes gateway "$action"
else
hermes -p "$profile" gateway "$action"
fi
}
action="${1:-}"
case "$action" in
start|stop|restart|status)
for profile in $profiles; do
echo "==> $action $profile"
run_for_profile "$profile" "$action"
done
;;
list)
hermes gateway list
;;
*)
usage
exit 2
;;
esac
```
Then:
```bash
hermes-gateways start # start every configured profile
hermes-gateways stop # stop every configured profile
hermes-gateways restart # restart all
hermes-gateways status # status across all
hermes-gateways list # delegates to `hermes gateway list`
```
:::tip
The `default` profile is targeted with `hermes gateway <action>` (no `-p`),
not `hermes -p default gateway <action>`. The wrapper above handles both forms.
:::
## Manage one profile
The shortcut commands every profile installs:
```bash
coder gateway run # foreground (Ctrl-C to stop)
coder gateway start # start the managed service
coder gateway stop # stop the managed service
coder gateway restart # restart
coder gateway status # status
coder gateway install # create the LaunchAgent / systemd unit
coder gateway uninstall # remove the service file
```
These are equivalent to `hermes -p coder gateway <action>` — useful if a
profile alias is not on `PATH` or if you target profiles dynamically from a
script.
## Service files
Each profile installs its own service with a unique name, so installations
never clash:
| Platform | Path |
| -------- | ----------------------------------------------------------------- |
| macOS | `~/Library/LaunchAgents/ai.hermes.gateway-<profile>.plist` |
| Linux | `~/.config/systemd/user/hermes-gateway-<profile>.service` |
The default profile keeps the historical names: `ai.hermes.gateway.plist` /
`hermes-gateway.service`.
## Viewing logs
Each profile writes to its own log files:
```bash
# Default profile
tail -f ~/.hermes/logs/gateway.log
tail -f ~/.hermes/logs/gateway.error.log
# Named profile
tail -f ~/.hermes/profiles/<name>/logs/gateway.log
tail -f ~/.hermes/profiles/<name>/logs/gateway.error.log
```
Stream every profile's log simultaneously:
```bash
tail -f ~/.hermes/logs/gateway.log ~/.hermes/profiles/*/logs/gateway.log
```
The CLI also has a structured log viewer:
```bash
hermes logs --tail # follow default profile
hermes -p coder logs --tail # follow one profile
hermes logs --help # filters, levels, JSON output
```
## Identify what's actually running
```bash
hermes profile list # profiles + model + gateway state
hermes-gateways status # full status across every profile
launchctl list | grep hermes # macOS — PIDs and labels
systemctl --user list-units 'hermes-gateway-*' # Linux — units
```
## Editing configuration
Every profile keeps its config inside its own directory:
```
~/.hermes/profiles/<name>/
├── .env # API keys, bot tokens (chmod 600)
├── config.yaml # model, provider, toolsets, gateway settings
└── SOUL.md # personality / system prompt
```
The default profile uses `~/.hermes/` directly with the same three files.
Edit them with any editor or via the CLI:
```bash
hermes config set model.model anthropic/claude-sonnet-4 # default profile
coder config set model.model openai/gpt-5 # named profile
```
After editing `.env` or `config.yaml`, restart the affected gateway:
```bash
coder gateway restart
# or, for everything:
hermes-gateways restart
```
## Keeping the host awake
The gateway process can run all day, but the operating system will still try
to sleep when idle. Two patterns:
### macOS — `caffeinate`
`caffeinate` is built into macOS and prevents sleep while it runs. No install.
```bash
caffeinate -dis # block display, idle, and system sleep
caffeinate -dis -t 28800 # same, auto-exit after 8 hours
caffeinate -i -w $(cat ~/.hermes/gateway.pid) & # awake while default gateway runs
# Persistent: run in background and forget
nohup caffeinate -dis >/dev/null 2>&1 &
disown
# Inspect / stop
pmset -g assertions | grep -iE 'caffeinate|prevent|user is active'
pkill caffeinate
```
| Flag | Effect |
| ------ | ------------------------------------------------- |
| `-d` | block display sleep |
| `-i` | block idle system sleep (default) |
| `-m` | block disk sleep |
| `-s` | block system sleep (AC-powered Macs only) |
| `-u` | simulate user activity (prevents screen lock) |
| `-t N` | auto-exit after `N` seconds |
| `-w P` | exit when PID `P` exits |
:::warning Lid-close still sleeps the Mac
`caffeinate` cannot override the hardware-driven lid-close sleep on MacBooks.
For lid-closed operation, change your Energy Saver / Battery preferences or
use a third-party tool.
:::
### Linux — `systemd-inhibit` or `loginctl`
```bash
# Inhibit suspend while a command runs
systemd-inhibit --what=idle:sleep --who=hermes --why="gateways running" \
sleep infinity &
# Allow user services to keep running after logout (recommended)
sudo loginctl enable-linger "$USER"
```
After enabling lingering, your systemd user units (including
`hermes-gateway-<profile>.service`) continue running across SSH disconnects
and reboots.
## Token-conflict safety
Each profile must use unique bot tokens for each platform. If two profiles
share a Telegram, Discord, Slack, WhatsApp, or Signal token, the second
gateway refuses to start with an error naming the conflicting profile.
To audit:
```bash
grep -H 'TELEGRAM_BOT_TOKEN\|DISCORD_BOT_TOKEN' \
~/.hermes/.env ~/.hermes/profiles/*/.env
```
## Updating the code
`hermes update` pulls the latest code once and syncs new bundled skills into
every profile:
```bash
hermes update
hermes-gateways restart
```
User-modified skills are never overwritten.
## Troubleshooting
### "Could not find service in domain for user gui: 501"
You ran `hermes gateway start` after a previous `hermes gateway stop`. The
CLI's `stop` does a full `launchctl unload`, which removes the service from
launchd's registry. The CLI catches this specific error on `start` and
automatically re-loads the plist (`↻ launchd job was unloaded; reloading
service definition`). The service starts normally. Nothing to fix.
### Stale PID after a crash
If a profile's gateway shows `not running` but a process is still alive:
```bash
ps -ef | grep "hermes_cli.*-p <profile>"
cat ~/.hermes/profiles/<profile>/gateway.pid
kill -TERM <pid> # graceful
kill -KILL <pid> # if that fails after a few seconds
<profile> gateway start
```
### Forcing a hard reset of one service
```bash
# macOS
launchctl unload ~/Library/LaunchAgents/ai.hermes.gateway-<profile>.plist
launchctl load ~/Library/LaunchAgents/ai.hermes.gateway-<profile>.plist
# Linux
systemctl --user restart hermes-gateway-<profile>.service
```
### Health check
```bash
hermes doctor # default profile
hermes -p <profile> doctor # one profile
```
@@ -84,6 +84,38 @@ remindctl add --title "Call mom" --list Personal --due tomorrow
remindctl add --title "Meeting prep" --due "2026-02-15 09:00"
```
### Due Time vs Alarm / Early Nudge
`--due` and `--alarm` are different fields:
- `--due` sets the reminder's due date/time.
- `--alarm` sets the EventKit alarm/notification trigger. Timed due reminders may default to an alarm at the due time, but pass `--alarm` explicitly when the user asks for an earlier nudge.
For a reminder due at 2:00 PM with a notification 30 minutes earlier:
```bash
remindctl add --title "Hairdresser" --due "2026-05-15 14:00" --alarm "2026-05-15 13:30"
```
To edit an existing reminder:
```bash
remindctl edit 87354 --due "2026-05-15 14:00" --alarm "2026-05-15 13:30"
```
The Reminders UI may show or group the item by the alarm time because that is when the notification fires. Verify with JSON instead of assuming the due time moved:
```bash
remindctl today --json
```
Expected shape:
- `dueDate`: actual due time
- `alarmDate`: notification / early nudge time
Apple's public `EKReminder` docs list only reminder-specific properties. Alarm support comes from inherited `EKCalendarItem` behavior exposed by remindctl's `--alarm` flag.
### Complete / Delete
```bash
@@ -260,6 +260,32 @@ For webhooks from cloud messaging providers (Telegram `setWebhook`, Slack events
The Hermes [Tool Gateway](/user-guide/features/tool-gateway) and the API server are long-lived processes. In WSL2 you have a few options for keeping them up.
### Desktop shortcut for opening Hermes quickly
If you just want a double-click launcher for an interactive Hermes shell, create
it on the Windows side and have it jump into WSL for you:
1. Right-click the Windows desktop and choose **New -> Shortcut**.
2. For the target, use your distro name (replace `Ubuntu` if needed):
```text
wt.exe -w 0 -p "Ubuntu" wsl.exe -d Ubuntu --cd ~ -- bash -ic "hermes"
```
3. Name it something obvious like `Hermes`.
That opens Windows Terminal, starts your WSL distro, drops you in your Linux
home directory, and launches Hermes. If `hermes` is not on PATH yet, open WSL
once manually and run `source ~/.bashrc`, or replace the command with
`uv run hermes` inside your project checkout.
Optional polish:
- **Custom icon:** open **Properties -> Change Icon** and point it at an `.ico`
file, such as the Hermes favicon from the repo.
- **Pinned launcher:** once the shortcut works, pin it to Start or Taskbar so
you do not have to browse for it again.
### Inside WSL with systemd (recommended)
If you enabled systemd per the setup section above, `hermes gateway` and the API server work the way they do on any Linux machine. Use the gateway setup wizard:
+1
View File
@@ -39,6 +39,7 @@ const sidebars: SidebarsConfig = {
'user-guide/sessions',
'user-guide/profiles',
'user-guide/profile-distributions',
'user-guide/multi-profile-gateways',
'user-guide/git-worktrees',
'user-guide/docker',
'user-guide/security',