Merge remote-tracking branch 'origin/main' into bb/pets-merge

# Conflicts:
#	hermes_cli/commands.py
#	tui_gateway/server.py
This commit is contained in:
Brooklyn Nicholson 2026-06-23 19:05:22 -05:00
commit e495b33bf1
251 changed files with 23395 additions and 2720 deletions

View file

@ -78,7 +78,7 @@ def active_session_limit_message(active_count: int, max_sessions: int) -> str:
def _state_dir() -> Path:
return get_hermes_home() / "runtime"
return Path(get_hermes_home()) / "runtime"
def _state_path() -> Path:
@ -311,6 +311,43 @@ def release_active_session(lease: ActiveSessionLease) -> None:
lease.released = True
def transfer_active_session(
lease: ActiveSessionLease,
*,
session_id: str,
metadata: Optional[dict[str, Any]] = None,
) -> bool:
"""Move an existing lease to a new session id without dropping the slot."""
new_session_id = str(session_id or "")
if not new_session_id:
return False
if lease.released:
return False
if not lease.enabled:
lease.session_id = new_session_id
return True
state_path = _state_path()
with _FileLock(_lock_path()):
entries = _prune_dead(_read_entries(state_path))
updated = False
for entry in entries:
if str(entry.get("lease_id") or "") != lease.lease_id:
continue
entry["session_id"] = new_session_id
entry["updated_at"] = time.time()
if metadata:
entry["metadata"] = {
str(k): v for k, v in metadata.items() if isinstance(k, str)
}
updated = True
break
if updated:
_write_entries(state_path, entries)
lease.session_id = new_session_id
return updated
def active_session_registry_snapshot() -> list[dict[str, Any]]:
"""Return the pruned active-session registry for diagnostics/tests."""
state_path = _state_path()

View file

@ -199,15 +199,43 @@ def _check_via_local_git(repo_dir: Path) -> Optional[int]:
head_rev = _git_stdout(["rev-parse", "HEAD"], cwd=repo_dir)
return _check_via_rev(head_rev) if head_rev else None
# Installer checkouts are shallow (`git clone --depth 1`). On a shallow
# clone the history stops at a single commit, so a plain `git fetch` would
# unshallow the repo (dragging in the whole history) and
# `rev-list --count HEAD..origin/main` would report a huge bogus "behind"
# number (e.g. "12492 commits behind"). Detect shallow up front: fetch with
# --depth 1 to preserve the boundary and compare tip SHAs instead of
# counting. Full clones (developers, Docker dev images) keep the exact
# count path unchanged. Mirrors the desktop fix in apps/desktop/electron/main.cjs.
shallow = _git_stdout(["rev-parse", "--is-shallow-repository"], cwd=repo_dir)
is_shallow = shallow == "true"
try:
fetch_args = ["git", "fetch", "origin"]
if is_shallow:
fetch_args += ["--depth", "1"]
fetch_args.append("--quiet")
subprocess.run(
["git", "fetch", "origin", "--quiet"],
fetch_args,
capture_output=True, timeout=10,
cwd=str(repo_dir),
)
except Exception:
pass # Offline or timeout — use stale refs, that's fine
if is_shallow:
# No history to count across the shallow boundary. `origin/main` may not
# be a tracking ref in a `clone --depth 1`, so prefer FETCH_HEAD (just
# updated by the fetch above) and fall back to origin/main.
head_rev = _git_stdout(["rev-parse", "HEAD"], cwd=repo_dir)
target_rev = (
_git_stdout(["rev-parse", "FETCH_HEAD"], cwd=repo_dir)
or _git_stdout(["rev-parse", "origin/main"], cwd=repo_dir)
)
if not head_rev or not target_rev:
return None
return 0 if head_rev == target_rev else UPDATE_AVAILABLE_NO_COUNT
try:
result = subprocess.run(
["git", "rev-list", "--count", "HEAD..origin/main"],

View file

@ -1412,6 +1412,32 @@ class CLICommandsMixin:
from hermes_cli.skills_hub import handle_skills_slash
handle_skills_slash(cmd, ChatConsole())
def _handle_learn_command(self, cmd: str):
"""Handle /learn — distill a reusable skill from anything the user describes.
Open-ended: the argument is free text describing the source(s) a
directory, a URL, "what we just did", pasted notes. We build a
standards-guided prompt and inject it onto the agent's input queue; the
live agent gathers the material with the tools it already has and
authors the skill via ``skill_manage``. No engine, no model-tool
footprint, works on any terminal backend.
"""
from agent.learn_prompt import build_learn_prompt
# Everything after the command word is the open-ended request.
parts = cmd.strip().split(None, 1)
user_request = parts[1].strip() if len(parts) > 1 else ""
msg = build_learn_prompt(user_request)
if user_request:
print("\n⚡ Learning a skill from what you described...")
else:
print("\n⚡ Learning a skill from this conversation...")
if hasattr(self, "_pending_input"):
self._pending_input.put(msg)
else: # pragma: no cover - defensive (no live input loop)
print(" /learn needs an active chat session to run.")
def _handle_memory_command(self, cmd: str):
"""Handle /memory slash command — pending review + approval-gate toggle."""
from hermes_cli.write_approval_commands import handle_pending_subcommand
@ -1419,6 +1445,17 @@ class CLICommandsMixin:
parts = cmd.strip().split()
args = parts[1:] if len(parts) > 1 else []
store = getattr(self.agent, "_memory_store", None) if getattr(self, "agent", None) else None
if store is None:
# No live agent store (e.g. /memory approve invoked from the Desktop
# GUI, or any context without an active agent). Apply against a freshly
# loaded on-disk store, mirroring the gateway path
# (gateway/slash_commands.py): it persists to the same MEMORY/USER.md
# and creates MEMORY.md on the first approved write. Without this the
# shared handler returns "memory store unavailable". See #46783.
# load_on_disk_store() honors the user's configured char limits, so
# an approval here enforces the same caps as the live agent would.
from tools.memory_tool import load_on_disk_store
store = load_on_disk_store()
out = handle_pending_subcommand(
wa.MEMORY, args,
memory_store=store,
@ -1833,7 +1870,7 @@ class CLICommandsMixin:
print()
def _handle_goal_command(self, cmd: str) -> None:
"""Dispatch /goal subcommands: set / status / pause / resume / clear."""
"""Dispatch /goal subcommands: set / draft / show / status / pause / resume / clear."""
from cli import _DIM, _RST, _cprint
parts = (cmd or "").strip().split(None, 1)
arg = parts[1].strip() if len(parts) > 1 else ""
@ -1850,6 +1887,25 @@ class CLICommandsMixin:
_cprint(f" {mgr.status_line()}")
return
# /goal show → print the active goal's completion contract
if lower == "show":
_cprint(f" {mgr.status_line()}")
_cprint(f" {mgr.render_contract()}")
return
# /goal draft <objective> → expand plain text into a structured
# completion contract (outcome / verification / constraints /
# boundaries / stop_when) and set it as the active goal. Adapted
# from Codex's "let the agent draft the goal" guidance: the contract
# makes "done" evidence-based instead of a loose vibe check.
if lower.startswith("draft"):
objective = arg[len("draft"):].strip()
if not objective:
_cprint(" Usage: /goal draft <objective in plain language>")
return
self._handle_goal_draft(objective)
return
if lower == "pause":
state = mgr.pause(reason="user-paused")
if state is None:
@ -1879,18 +1935,62 @@ class CLICommandsMixin:
_cprint(f" {_DIM}No active goal.{_RST}")
return
# Otherwise treat the arg as the goal text.
# /goal wait <pid> [reason] — park the loop on a background process so
# it stops re-poking the agent every turn while it waits on CI / a
# build / a long job. The barrier auto-clears when the PID exits.
if lower == "wait" or lower.startswith("wait "):
wait_arg = arg[len("wait"):].strip()
if not wait_arg:
_cprint(" Usage: /goal wait <pid> [reason]")
return
wtokens = wait_arg.split(None, 1)
try:
pid = int(wtokens[0])
except ValueError:
_cprint(" /goal wait: <pid> must be an integer process id.")
return
reason = wtokens[1].strip() if len(wtokens) > 1 else ""
try:
mgr.wait_on(pid, reason=reason)
except (RuntimeError, ValueError) as exc:
_cprint(f" /goal wait: {exc}")
return
rtxt = f" ({reason})" if reason else ""
_cprint(f" ⏳ Goal parked on pid {pid}{rtxt}. Loop pauses until it exits.")
return
# /goal unwait — drop the wait barrier and resume normal looping.
if lower == "unwait":
if mgr.stop_waiting():
_cprint(" ▶ Wait barrier cleared — goal loop resumes.")
else:
_cprint(f" {_DIM}No wait barrier set.{_RST}")
return
# Otherwise treat the arg as the goal text. Inline `field: value`
# lines (verify:, constraints:, boundaries:, stop when:) are parsed
# into a completion contract; the remaining prose is the headline.
# A plain free-form goal with no such lines behaves exactly as before.
from hermes_cli.goals import parse_contract
headline, contract = parse_contract(arg)
goal_text = headline or arg
try:
state = mgr.set(arg)
state = mgr.set(goal_text, contract=contract if not contract.is_empty() else None)
except ValueError as exc:
_cprint(f" Invalid goal: {exc}")
return
_cprint(f" ⊙ Goal set ({state.max_turns}-turn budget): {state.goal}")
if state.has_contract():
_cprint(f" {_DIM}Completion contract:{_RST}")
for line in state.contract.render_block().splitlines():
_cprint(f" {line}")
_cprint(
f" {_DIM}After each turn, a judge model will check if the goal is done. "
f" {_DIM}After each turn, a judge model checks if the goal is done"
f"{' against the contract above' if state.has_contract() else ''}. "
f"Hermes keeps working until it is, you pause/clear it, or the budget is "
f"exhausted. Use /goal status, /goal pause, /goal resume, /goal clear.{_RST}"
f"exhausted. Use /goal status, /goal show, /goal pause, /goal resume, /goal clear.{_RST}"
)
# Kick the loop off immediately so the user doesn't have to send a
# separate message after setting the goal.
@ -1899,6 +1999,52 @@ class CLICommandsMixin:
except Exception:
pass
def _handle_goal_draft(self, objective: str) -> None:
"""Draft a structured completion contract from a plain objective and
set it as the active goal. Falls back to a bare goal if the aux model
can't produce a contract."""
from cli import _DIM, _RST, _cprint
from hermes_cli.goals import draft_contract
mgr = self._get_goal_manager()
if mgr is None:
_cprint(f" {_DIM}Goals unavailable (no active session).{_RST}")
return
_cprint(f" {_DIM}Drafting completion contract…{_RST}")
try:
contract = draft_contract(objective)
except Exception as exc:
import logging as _logging
_logging.getLogger(__name__).debug("goal draft failed: %s", exc)
contract = None
try:
state = mgr.set(objective, contract=contract)
except ValueError as exc:
_cprint(f" Invalid goal: {exc}")
return
_cprint(f" ⊙ Goal set ({state.max_turns}-turn budget): {state.goal}")
if state.has_contract():
_cprint(f" {_DIM}Drafted completion contract:{_RST}")
for line in state.contract.render_block().splitlines():
_cprint(f" {line}")
_cprint(
f" {_DIM}Tighten any field by re-setting the goal with inline "
f"lines (e.g. verify: <command>), then /goal resume. "
f"Use /goal show to review.{_RST}"
)
else:
_cprint(
f" {_DIM}Couldn't draft a contract (aux model unavailable) — "
f"running as a free-form goal. The per-turn judge still applies.{_RST}"
)
try:
self._pending_input.put(state.goal)
except Exception:
pass
def _handle_subgoal_command(self, cmd: str) -> None:
"""Dispatch /subgoal subcommands.

View file

@ -108,7 +108,7 @@ COMMAND_REGISTRY: list[CommandDef] = [
CommandDef("steer", "Inject a message after the next tool call without interrupting", "Session",
args_hint="<prompt>"),
CommandDef("goal", "Set a standing goal Hermes works on across turns until achieved", "Session",
args_hint="[text | pause | resume | clear | status]"),
args_hint="[text | draft <text> | show | pause | resume | clear | status | wait <pid> | unwait]"),
CommandDef("subgoal", "Add or manage extra criteria on the active goal", "Session",
args_hint="[text | remove N | clear]"),
CommandDef("status", "Show session, model, token, and context info", "Session"),
@ -181,6 +181,8 @@ COMMAND_REGISTRY: list[CommandDef] = [
"Tools & Skills"),
CommandDef("pet", "Toggle or adopt a petdex mascot (/pet, /pet list, /pet <slug>)", "Tools & Skills",
cli_only=True, args_hint="[toggle|list|scale <n>|<slug>]", subcommands=("toggle", "list", "scale", "off")),
CommandDef("learn", "Learn a reusable skill from anything you describe (dirs, URLs, this chat, notes)",
"Tools & Skills", args_hint="<what to learn from>"),
CommandDef("cron", "Manage scheduled tasks", "Tools & Skills",
cli_only=True, args_hint="[subcommand]",
subcommands=("list", "add", "create", "edit", "pause", "resume", "run", "remove")),

View file

@ -1535,6 +1535,25 @@ DEFAULT_CONFIG = {
"timeout": 60,
"extra_body": {},
},
# Background review — the post-turn self-improvement fork that decides
# whether to save a memory / patch a skill. "auto" (default) = run on
# the main chat model, replaying the full conversation, which is already
# warm in the prompt cache (cheap cache reads) — unchanged, optimal.
# Set provider/model to a cheaper model (e.g. openrouter
# google/gemini-3-flash-preview) to run the review there for ~3-5x lower
# cost. A different model can't reuse the main prompt cache anyway, so
# the fork automatically replays a compact digest instead of the full
# transcript when routed (minimises the cold-write). Same model = full
# replay; different model = digest. Quality holds (memory capture
# identical, skill near-identical in benchmarks).
"background_review": {
"provider": "auto",
"model": "",
"base_url": "",
"api_key": "",
"timeout": 120,
"extra_body": {},
},
},
"display": {
@ -1648,6 +1667,12 @@ DEFAULT_CONFIG = {
# applies where tool_progress is already enabled. Per-platform override
# via display.platforms.<platform>.tool_progress_grouping.
"tool_progress_grouping": "accumulate",
# How a reasoning/thinking summary renders when show_reasoning is on.
# "code" (default) = 💭 fenced code block; "blockquote" = "> " lines;
# "subtext" = "-# " lines (Discord small grey metadata text). Discord
# defaults to "subtext"; override per-platform via
# display.platforms.<platform>.reasoning_style.
"reasoning_style": "code",
# Auto-delete system-notice replies (e.g. "✨ New session started!",
# "♻ Restarting gateway…", "⚡ Stopped…") after N seconds on platforms
# that support message deletion (currently Telegram; other platforms
@ -2819,6 +2844,17 @@ DEFAULT_CONFIG = {
"paste_collapse_threshold_fallback": 5,
"paste_collapse_char_threshold": 2000,
# Computer Use (cua-driver) toolset settings.
"computer_use": {
# cua-driver ships with anonymous usage telemetry (PostHog) ENABLED
# by default upstream. Hermes disables it for our users unless they
# explicitly opt in here. When false (default), Hermes sets
# CUA_DRIVER_RS_TELEMETRY_ENABLED=0 in the cua-driver child env for
# every invocation (MCP backend, status, doctor, install). Set true
# to let cua-driver use its own default (telemetry on).
"cua_telemetry": False,
},
# Config schema version - bump this when adding new required fields
"_config_version": 30,

View file

@ -38,6 +38,7 @@ import subprocess
import sys
import time
from pathlib import Path
from xml.sax.saxutils import escape
# Short timeouts: schtasks occasionally wedges and we don't want to hang forever.
_SCHTASKS_TIMEOUT_S = 15
@ -51,6 +52,9 @@ _ACCESS_DENIED_PATTERN = re.compile(r"(access is denied|acceso denegado)", re.IG
_TASK_NAME_DEFAULT = "Hermes_Gateway"
_TASK_DESCRIPTION = "Hermes Agent Gateway - Messaging Platform Integration"
_TASK_LOGON_DELAY = "PT30S"
_TASK_RESTART_INTERVAL = "PT1M"
_TASK_RESTART_COUNT = 999
def _schtasks_encoding() -> str:
@ -358,12 +362,13 @@ def _build_gateway_cmd_script(
lines.append(f'set "HERMES_HOME={hermes_home}"')
lines.append('set "PYTHONIOENCODING=utf-8"')
lines.append('set "HERMES_GATEWAY_DETACHED=1"')
pythonw_path, venv_dir, extra_pythonpath = _resolve_detached_python(python_path)
# VIRTUAL_ENV lets the gateway's own python detection find the venv
# if someone imports hermes_constants-based logic during startup.
venv_dir = str(Path(python_path).resolve().parent.parent)
lines.append(f'set "VIRTUAL_ENV={venv_dir}"')
pythonpath_entries = [str(Path(__file__).resolve().parent.parent), *extra_pythonpath]
lines.append(f'set "PYTHONPATH={";".join([*pythonpath_entries, "%PYTHONPATH%"])}"')
pythonw_path = _derive_venv_pythonw(python_path)
prog_args = [pythonw_path, "-m", "hermes_cli.main"]
if profile_arg:
prog_args.extend(profile_arg.split())
@ -379,6 +384,78 @@ def _build_gateway_cmd_script(
return "\r\n".join(lines) + "\r\n"
def _quote_vbs_string(value: str) -> str:
"""Quote a value as a VBScript double-quoted string literal.
VBScript escapes an embedded double-quote by doubling it. A newline cannot
appear inside a literal, so refuse it (same guard as ``_quote_cmd_script_arg``).
"""
if "\r" in value or "\n" in value:
raise ValueError(f"refusing to quote VBScript value containing newline: {value!r}")
return '"' + value.replace('"', '""') + '"'
def _build_gateway_vbs_script(
python_path: str,
working_dir: str,
hermes_home: str,
profile_arg: str,
) -> str:
"""Build a console-less ``gateway.vbs`` launcher (CRLF-terminated).
The Scheduled Task runs this through ``wscript.exe`` instead of ``cmd.exe``.
Why: issue #45599 root cause #1. Driving the gateway through ``cmd.exe``
allocates a console, and during logon Windows broadcasts ``CTRL_CLOSE_EVENT``
to console process groups reaping cmd.exe and the half-initialized gateway
with ``STATUS_CONTROL_C_EXIT`` (``0xC000013A``). Task Scheduler treats that
code as a user cancel, so the ``RestartOnFailure`` policy never fires and the
gateway silently disappears on every reboot.
``wscript.exe`` and ``pythonw.exe`` are both GUI-subsystem executables with
no console, so this launcher receives no console control events. It mirrors
``_build_gateway_cmd_script`` (same env + argv via ``_resolve_detached_python``)
but sets the environment on the WScript.Shell process and ``Run``s pythonw
directly no cmd.exe anywhere in the chain.
"""
pythonw_path, venv_dir, extra_pythonpath = _resolve_detached_python(python_path)
prog_args = [pythonw_path, "-m", "hermes_cli.main"]
if profile_arg:
prog_args.extend(profile_arg.split())
prog_args.extend(["gateway", "run"])
# list2cmdline gives CreateProcess-correct quoting for WScript.Shell.Run.
command_line = subprocess.list2cmdline(prog_args)
repo_root = str(Path(__file__).resolve().parent.parent)
static_pythonpath = os.pathsep.join([repo_root, *extra_pythonpath])
lines = [
f"' {_TASK_DESCRIPTION}",
"Option Explicit",
"Dim sh, env, existing_pp",
'Set sh = CreateObject("WScript.Shell")',
'Set env = sh.Environment("PROCESS")',
f"env.Item({_quote_vbs_string('HERMES_HOME')}) = {_quote_vbs_string(hermes_home)}",
f"env.Item({_quote_vbs_string('PYTHONIOENCODING')}) = {_quote_vbs_string('utf-8')}",
f"env.Item({_quote_vbs_string('HERMES_GATEWAY_DETACHED')}) = {_quote_vbs_string('1')}",
f"env.Item({_quote_vbs_string('VIRTUAL_ENV')}) = {_quote_vbs_string(str(venv_dir))}",
# Mirror the cmd wrapper's ``PYTHONPATH=<static>;%PYTHONPATH%``: chain onto
# whatever PYTHONPATH the task environment already carries, at runtime.
f"existing_pp = env.Item({_quote_vbs_string('PYTHONPATH')})",
"If Len(existing_pp) > 0 Then",
f" env.Item({_quote_vbs_string('PYTHONPATH')}) = {_quote_vbs_string(static_pythonpath + os.pathsep)} & existing_pp",
"Else",
f" env.Item({_quote_vbs_string('PYTHONPATH')}) = {_quote_vbs_string(static_pythonpath)}",
"End If",
f"sh.CurrentDirectory = {_quote_vbs_string(working_dir)}",
# Window style 0 = hidden; bWaitOnReturn False = detached/async. pythonw is
# GUI-subsystem so no console is ever created for the gateway either.
f"sh.Run {_quote_vbs_string(command_line)}, 0, False",
]
return "\r\n".join(lines) + "\r\n"
def _build_startup_launcher(script_path: Path) -> str:
"""The tiny .cmd that goes in the Startup folder. Just minimizes and chains.
@ -425,6 +502,15 @@ def _write_task_script() -> Path:
tmp = script_path.with_suffix(".tmp")
tmp.write_text(content, encoding="utf-8", newline="")
tmp.replace(script_path)
# Also render the console-less .vbs launcher the Scheduled Task runs via
# wscript.exe (issue #45599 fix A). The .cmd above stays for the
# Startup-folder fallback and direct /Run paths.
vbs_content = _build_gateway_vbs_script(python_path, working_dir, hermes_home, profile_arg)
vbs_path = script_path.with_suffix(".vbs")
vbs_tmp = vbs_path.with_name(vbs_path.name + ".tmp")
vbs_tmp.write_text(vbs_content, encoding="utf-8", newline="")
vbs_tmp.replace(vbs_path)
return script_path
@ -443,6 +529,74 @@ def _resolve_task_user() -> str | None:
return f"{domain}\\{username}" if domain else username
def _build_scheduled_task_xml(task_name: str, launcher_path: Path, user: str | None) -> str:
"""Render a Task Scheduler XML definition with safe long-running defaults.
``launcher_path`` is the console-less ``.vbs`` the task runs via
``wscript.exe`` not the ``.cmd`` (see ``_build_gateway_vbs_script`` /
issue #45599 root cause #1).
"""
user_principal = f"\n <UserId>{escape(user)}</UserId>" if user else ""
return f"""<?xml version="1.0" encoding="UTF-16"?>
<Task version="1.4" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
<RegistrationInfo>
<Description>{escape(_TASK_DESCRIPTION)}</Description>
</RegistrationInfo>
<Triggers>
<LogonTrigger>
<Enabled>true</Enabled>
<Delay>{_TASK_LOGON_DELAY}</Delay>
</LogonTrigger>
</Triggers>
<Principals>
<Principal id="Author">{user_principal}
<LogonType>InteractiveToken</LogonType>
<RunLevel>LeastPrivilege</RunLevel>
</Principal>
</Principals>
<Settings>
<MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
<DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
<StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
<AllowHardTerminate>true</AllowHardTerminate>
<StartWhenAvailable>true</StartWhenAvailable>
<RunOnlyIfNetworkAvailable>false</RunOnlyIfNetworkAvailable>
<IdleSettings>
<StopOnIdleEnd>false</StopOnIdleEnd>
<RestartOnIdle>false</RestartOnIdle>
</IdleSettings>
<AllowStartOnDemand>true</AllowStartOnDemand>
<Enabled>true</Enabled>
<Hidden>false</Hidden>
<RunOnlyIfIdle>false</RunOnlyIfIdle>
<WakeToRun>false</WakeToRun>
<ExecutionTimeLimit>PT0S</ExecutionTimeLimit>
<Priority>7</Priority>
<RestartOnFailure>
<Interval>{_TASK_RESTART_INTERVAL}</Interval>
<Count>{_TASK_RESTART_COUNT}</Count>
</RestartOnFailure>
</Settings>
<Actions Context="Author">
<Exec>
<Command>wscript.exe</Command>
<Arguments>//B //Nologo "{escape(str(launcher_path))}"</Arguments>
</Exec>
</Actions>
</Task>
"""
def _write_scheduled_task_xml(task_name: str, launcher_path: Path, user: str | None) -> Path:
xml_path = launcher_path.with_suffix(".task.xml")
xml_path.write_text(
_build_scheduled_task_xml(task_name, launcher_path, user),
encoding="utf-16",
newline="",
)
return xml_path
def _install_scheduled_task(task_name: str, script_path: Path) -> tuple[bool, str]:
"""Create or replace the Scheduled Task. Returns (success, detail).
@ -451,8 +605,6 @@ def _install_scheduled_task(task_name: str, script_path: Path) -> tuple[bool, st
preserves those stale triggers and can make the gateway relaunch every
minute. Delete+create gives us a clean ONLOGON task every install.
"""
quoted_script = _quote_schtasks_arg(str(script_path))
delete_code, delete_out, delete_err = _exec_schtasks(["/Delete", "/F", "/TN", task_name])
delete_detail = (delete_err or delete_out or "").strip()
if delete_code != 0 and delete_detail and "cannot find" not in delete_detail.lower():
@ -460,32 +612,28 @@ def _install_scheduled_task(task_name: str, script_path: Path) -> tuple[bool, st
return (False, f"schtasks /Delete failed (code {delete_code}): {delete_detail}")
# Non-fatal: /Create /F below may still replace it. Keep the detail in
# the final error if creation also fails.
# password" variant; if that fails, retry without /RU /NP /IT.
base = [
"/Create",
"/F",
"/SC",
"ONLOGON",
"/RL",
"LIMITED",
"/TN",
task_name,
"/TR",
quoted_script,
]
user = _resolve_task_user()
variants = []
if user:
variants.append([*base, "/RU", user, "/NP", "/IT"])
# The Scheduled Task launches the console-less .vbs (issue #45599 fix A), not
# the .cmd. The .cmd stays for the Startup-folder fallback and direct /Run.
launcher_path = script_path.with_suffix(".vbs")
xml_path = _write_scheduled_task_xml(task_name, launcher_path, user)
base = ["/Create", "/F", "/TN", task_name, "/XML", str(xml_path)]
variants = [[*base, "/RU", user, "/NP", "/IT"]] if user else []
variants.append(base)
last_code = 1
last_err = ""
for argv in variants:
code, out, err = _exec_schtasks(argv)
if code == 0:
return (True, f"Created Scheduled Task {task_name!r}")
last_code, last_err = code, (err or out or "")
try:
for argv in variants:
code, out, err = _exec_schtasks(argv)
if code == 0:
return (True, f"Created Scheduled Task {task_name!r}")
last_code, last_err = code, (err or out or "")
finally:
try:
xml_path.unlink(missing_ok=True)
except OSError:
pass
if delete_detail and "cannot find" not in delete_detail.lower():
last_err = f"{last_err.strip()} (delete detail: {delete_detail})"
return (False, f"schtasks /Create failed (code {last_code}): {last_err.strip()}")

File diff suppressed because it is too large Load diff

View file

@ -173,11 +173,11 @@ def build_models_payload(
# aggregator rows honest: they only show models the user can't get
# from a more-specific provider. (#45954)
try:
from hermes_cli.providers import is_aggregator as _is_aggregator
from hermes_cli.providers import is_routing_aggregator as _is_routing_aggregator
except Exception:
_is_aggregator = None # type: ignore[assignment]
_is_routing_aggregator = None # type: ignore[assignment]
if _is_aggregator is not None:
if _is_routing_aggregator is not None:
user_models: set[str] = set()
for row in rows:
if row.get("is_user_defined"):
@ -186,14 +186,21 @@ def build_models_payload(
for row in rows:
# A user's own configured provider is never an "aggregator
# duplicate" of itself: user_models is built from these very
# rows, and is_aggregator() reports True for every custom:*
# slug. Without this guard the dedup strips a user-defined
# custom provider's entire model list (all of it lives in
# user_models), emptying its picker row.
# rows, and is_routing_aggregator() reports True for every
# custom:* slug. Without this guard the dedup strips a
# user-defined custom provider's entire model list (all of it
# lives in user_models), emptying its picker row.
if row.get("is_user_defined"):
continue
slug = row.get("slug", "")
if not _is_aggregator(slug):
# Only strip overlaps from TRUE routing aggregators (OpenRouter,
# custom:* proxies). Flat-namespace resellers (opencode-go /
# opencode-zen) serve every listed model as a first-party model,
# so their rows must keep models that a user's proxy happens to
# share a name with — otherwise a subscription provider's own
# catalog (minimax-m3, glm-5, deepseek-v4-flash, ...) is silently
# gutted in the picker. (#47077)
if not _is_routing_aggregator(slug):
continue
original = row.get("models") or []
filtered = [m for m in original if m.lower() not in user_models]

View file

@ -8040,10 +8040,26 @@ def _cmd_update_check(branch: str = "main", *, branch_explicit: bool = False):
# Note: upstream/<branch> may not exist for non-main branches (a fork's
# bb/gui has no upstream counterpart), so when the caller picks a
# non-default branch we skip the upstream probe and use origin directly.
# Installer checkouts are shallow (`git clone --depth 1`). A plain
# `git fetch` would unshallow the repo (dragging in the whole history —
# the exact cost the shallow clone avoided) and the rev-list count below
# would then report a huge bogus "behind" number. Detect shallow up front:
# fetch with --depth 1 to preserve the boundary and report presence-only.
is_shallow = (
subprocess.run(
git_cmd + ["rev-parse", "--is-shallow-repository"],
cwd=PROJECT_ROOT,
capture_output=True,
text=True,
).stdout.strip()
== "true"
)
depth_args = ["--depth", "1"] if is_shallow else []
if branch == "main":
print("→ Fetching from upstream...")
fetch_result = subprocess.run(
git_cmd + ["fetch", "upstream", branch],
git_cmd + ["fetch"] + depth_args + ["upstream", branch],
cwd=PROJECT_ROOT,
capture_output=True,
text=True,
@ -8052,7 +8068,7 @@ def _cmd_update_check(branch: str = "main", *, branch_explicit: bool = False):
# Fallback to origin if upstream doesn't exist
print("→ Fetching from origin...")
fetch_result = subprocess.run(
git_cmd + ["fetch", "origin", branch],
git_cmd + ["fetch"] + depth_args + ["origin", branch],
cwd=PROJECT_ROOT,
capture_output=True,
text=True,
@ -8066,7 +8082,7 @@ def _cmd_update_check(branch: str = "main", *, branch_explicit: bool = False):
# Non-default branch: compare against origin/<branch> directly.
print("→ Fetching from origin...")
fetch_result = subprocess.run(
git_cmd + ["fetch", "origin", branch],
git_cmd + ["fetch"] + depth_args + ["origin", branch],
cwd=PROJECT_ROOT,
capture_output=True,
text=True,
@ -8100,6 +8116,26 @@ def _cmd_update_check(branch: str = "main", *, branch_explicit: bool = False):
print(f"✗ Branch '{branch}' not found on {compare_branch.split('/', 1)[0]}.")
sys.exit(1)
if is_shallow:
# No history to count across the shallow boundary. Compare tip SHAs and
# report presence-only (mirrors the banner's _check_via_local_git).
head_sha = subprocess.run(
git_cmd + ["rev-parse", "HEAD"],
cwd=PROJECT_ROOT, capture_output=True, text=True,
).stdout.strip()
target_sha = subprocess.run(
git_cmd + ["rev-parse", compare_branch],
cwd=PROJECT_ROOT, capture_output=True, text=True,
).stdout.strip()
if head_sha and target_sha and head_sha == target_sha:
print("✓ Already up to date.")
else:
print(f"⚕ Update available (behind {compare_branch}).")
from hermes_cli.config import recommended_update_command
print(f" Run '{recommended_update_command()}' to install.")
return
rev_result = subprocess.run(
git_cmd + ["rev-list", f"HEAD..{compare_branch}", "--count"],
cwd=PROJECT_ROOT,
@ -8395,6 +8431,31 @@ def _pause_windows_gateways_for_update() -> dict | None:
logger.debug("Could not discover Windows gateway PIDs before update: %s", exc)
return None
if not running_pids:
# No gateway is running right now, but the user may have installed an
# autostart entry (Scheduled Task or Startup-folder login item) — that
# is an explicit "I want a gateway" signal. A gateway that died between
# updates (e.g. the spawning terminal/TUI closed, taking its child with
# it) would otherwise never come back: the autostart entry only fires on
# the next login, and the update flow's resume path only relaunched
# gateways that were running when the update began. Cold-start one after
# the update so an installed gateway is actually up post-update. Users
# who run gateway-less (no autostart entry) get nothing forced on them.
try:
from hermes_cli import gateway_windows
if gateway_windows.is_installed():
return {
"resume_needed": True,
"profiles": {},
"unmapped_pids": [],
"unmapped": [],
"cold_start_if_installed": True,
}
except Exception as exc:
logger.debug(
"Could not check Windows gateway autostart state before update: %s",
exc,
)
return None
profile_processes = {}
@ -8472,6 +8533,51 @@ def _pause_windows_gateways_for_update() -> dict | None:
}
def _cold_start_windows_gateway_after_update() -> None:
"""Start a fresh detached gateway after update when one is installed but down.
Invoked from ``_resume_windows_gateways_after_update`` for the
``cold_start_if_installed`` case: no gateway was running when the update
began, but an autostart entry (Scheduled Task / Startup-folder login item)
is installed, signalling the user wants a gateway. Unlike the relaunch
paths which watch an old PID and respawn once it exits this is a direct
fresh spawn via the same windowless ``pythonw`` + breakaway path that
``hermes gateway start`` uses (``gateway_windows._spawn_detached``).
Best-effort and idempotent: re-checks that nothing is running first so a
concurrent start (e.g. the autostart entry firing) can't produce a
duplicate gateway.
"""
if not _is_windows():
return
try:
from hermes_cli import gateway_windows
from hermes_cli.gateway import find_gateway_pids
except Exception as exc:
logger.debug("Could not load Windows gateway cold-start helpers: %s", exc)
return
# Re-check liveness right before spawning — between pause and resume the
# autostart entry may have already brought a gateway up, or a leftover
# process may have re-registered. Don't double-start.
try:
if list(find_gateway_pids(all_profiles=True)):
return
except Exception as exc:
logger.debug("Could not re-check gateway liveness before cold-start: %s", exc)
return
try:
pid = gateway_windows._spawn_detached()
except Exception as exc:
logger.debug("Could not cold-start Windows gateway after update: %s", exc)
return
if pid:
print()
print(f" ✓ Starting Windows gateway after update (PID {pid})")
def _resume_windows_gateways_after_update(token: dict | None) -> None:
"""Restart Windows profile gateways previously paused for update."""
if not token or not token.get("resume_needed"):
@ -8482,7 +8588,10 @@ def _resume_windows_gateways_after_update(token: dict | None) -> None:
profiles = token.get("profiles") or {}
unmapped = token.get("unmapped") or []
cold_start = bool(token.get("cold_start_if_installed"))
if not profiles and not any(u.get("argv") for u in unmapped):
if cold_start:
_cold_start_windows_gateway_after_update()
return
try:
@ -9488,13 +9597,13 @@ def _cmd_update_impl(args, gateway_mode: bool):
logger.debug("FHS PATH guard check failed: %s", e)
# Refresh the cua-driver binary used by the Computer Use toolset.
# The upstream installer is gated on macOS and on the binary already
# being on PATH, so this is a no-op for users who don't have it.
# Tying the refresh to ``hermes update`` gives users a predictable
# cadence (matches when they pull new agent code) without adding
# startup latency or a per-launch GitHub API call.
# The upstream installer is gated on supported platforms and on the
# binary already being on PATH, so this is a no-op for users who
# don't have it. Tying the refresh to ``hermes update`` gives users a
# predictable cadence (matches when they pull new agent code) without
# adding startup latency or a per-launch GitHub API call.
try:
if sys.platform == "darwin" and shutil.which("cua-driver"):
if sys.platform in ("darwin", "win32", "linux") and shutil.which("cua-driver"):
from hermes_cli.tools_config import install_cua_driver
print()
@ -12346,23 +12455,28 @@ def main():
# =========================================================================
computer_use_parser = subparsers.add_parser(
"computer-use",
help="Manage the Computer Use (cua-driver) backend (macOS)",
help="Manage the Computer Use (cua-driver) backend (macOS/Windows/Linux)",
description=(
"Install or check the cua-driver binary used by the\n"
"`computer_use` toolset. macOS-only.\n\n"
"`computer_use` toolset. Supported on macOS, Windows, and\n"
"Linux.\n\n"
"Use `hermes computer-use install` to fetch and run the\n"
"upstream cua-driver installer. This is equivalent to the\n"
"post-setup hook that `hermes tools` runs when you first\n"
"enable the Computer Use toolset, and is a stable target\n"
"for re-running the install if it didn't fire (e.g. when\n"
"toggling the toolset on a returning-user setup)."
"toggling the toolset on a returning-user setup).\n\n"
"Use `hermes computer-use doctor` to run cua-driver's\n"
"`health_report` MCP tool and surface its check matrix\n"
"(TCC, bundle identity, version, platform support, ...)\n"
"in human-readable form."
),
)
computer_use_sub = computer_use_parser.add_subparsers(dest="computer_use_action")
computer_use_install = computer_use_sub.add_parser(
"install",
help="Install or repair the cua-driver binary (macOS)",
help="Install or repair the cua-driver binary (macOS/Windows/Linux)",
)
computer_use_install.add_argument(
"--upgrade",
@ -12377,6 +12491,69 @@ def main():
"status",
help="Print whether cua-driver is installed and on PATH",
)
computer_use_doctor = computer_use_sub.add_parser(
"doctor",
help="Run cua-driver `health_report` and surface the check matrix",
description=(
"Drive cua-driver's stable `health_report` MCP tool and render\n"
"its check matrix (TCC permissions, bundle identity, version,\n"
"platform support, screenshot probe, …) as human-readable\n"
"output. cua-driver owns the health model; this command stays\n"
"thin so new checks added upstream surface here without code\n"
"changes. Exits 0 when overall=ok, 1 when degraded/failed, 2\n"
"when the binary is missing or unreachable."
),
)
computer_use_doctor.add_argument(
"--include",
action="append",
default=[],
metavar="CHECK",
help=(
"Run only the listed checks. Repeat for multiple "
"(e.g. --include tcc_accessibility --include bundle_identity). "
"Unknown names are reported by cua-driver."
),
)
computer_use_doctor.add_argument(
"--skip",
action="append",
default=[],
metavar="CHECK",
help="Skip the listed checks. Repeat for multiple. Wins over --include.",
)
computer_use_doctor.add_argument(
"--json",
action="store_true",
help="Emit the raw structured payload as JSON (same shape as `tools/call`).",
)
computer_use_perms = computer_use_sub.add_parser(
"permissions",
help="Check or grant macOS Accessibility + Screen Recording (macOS)",
description=(
"Computer Use drives the Mac through cua-driver, whose TCC grants\n"
"attach to cua-driver's own identity (com.trycua.driver) — not the\n"
"terminal or the Hermes app. `status` reports the driver's grant\n"
"state; `grant` launches CuaDriver via LaunchServices so the macOS\n"
"permission dialog is attributed to the process that does the work."
),
)
computer_use_perms_sub = computer_use_perms.add_subparsers(
dest="computer_use_perms_action"
)
computer_use_perms_status = computer_use_perms_sub.add_parser(
"status",
help="Report Accessibility + Screen Recording grant state (read-only)",
)
computer_use_perms_status.add_argument(
"--json",
action="store_true",
help="Emit the normalized permission payload as JSON.",
)
computer_use_perms_sub.add_parser(
"grant",
help="Request the grants (opens the dialog attributed to CuaDriver)",
)
def cmd_computer_use(args):
action = getattr(args, "computer_use_action", None)
@ -12387,13 +12564,20 @@ def main():
if action == "status":
import shutil
import subprocess
path = shutil.which("cua-driver")
from hermes_cli.tools_config import _cua_driver_cmd
# Honor HERMES_CUA_DRIVER_CMD for local-build testing — same
# resolver `install_cua_driver` and the runtime backend use,
# so `status` reports what `computer_use` will actually invoke.
driver_cmd = _cua_driver_cmd()
path = shutil.which(driver_cmd)
if path:
version = ""
try:
from hermes_cli.tools_config import _cua_driver_env
version = subprocess.run(
["cua-driver", "--version"],
[path, "--version"],
capture_output=True, text=True, timeout=5,
env=_cua_driver_env(),
).stdout.strip()
except Exception:
pass
@ -12401,11 +12585,67 @@ def main():
print(f"cua-driver: installed at {path} ({version})")
else:
print(f"cua-driver: installed at {path}")
print(" Refresh to latest: hermes computer-use install --upgrade")
try:
from tools.computer_use.cua_backend import cua_driver_update_check
st = cua_driver_update_check()
if st and st.get("update_available"):
latest = st.get("latest_version") or "?"
print(f" ⬆ Update available: cua-driver {latest}.")
print(" Run: hermes computer-use install --upgrade")
elif st:
print(" ✓ Up to date.")
else:
# Older driver (no check-update verb) or offline.
print(" Refresh to latest: hermes computer-use install --upgrade")
except Exception:
print(" Refresh to latest: hermes computer-use install --upgrade")
return
print("cua-driver: not installed")
print(" Run: hermes computer-use install")
return
if action == "doctor":
from tools.computer_use.doctor import run_doctor
code = run_doctor(
include=list(getattr(args, "include", []) or []),
skip=list(getattr(args, "skip", []) or []),
json_output=bool(getattr(args, "json", False)),
)
sys.exit(code)
if action == "permissions":
perms_action = getattr(args, "computer_use_perms_action", None)
if perms_action == "grant":
from tools.computer_use.permissions import request_permissions_grant
sys.exit(request_permissions_grant())
if perms_action == "status":
import json as _json
from tools.computer_use.permissions import computer_use_status
st = computer_use_status()
if bool(getattr(args, "json", False)):
print(_json.dumps(st, indent=2, sort_keys=True))
sys.exit(0 if st["ready"] else 1)
if not st["platform_supported"]:
print(f"Computer Use is not supported on {st['platform']}.")
sys.exit(1)
if not st["installed"]:
print("cua-driver: not installed. Run: hermes computer-use install")
sys.exit(1)
glyph = lambda v: "" if v is True else ("" if v is False else "") # noqa: E731
print(f"cua-driver: {st['version'] or 'installed'} ({st['platform']})")
if st["can_grant"]: # macOS TCC permissions
print(f" {glyph(st['accessibility'])} Accessibility")
print(f" {glyph(st['screen_recording'])} Screen Recording")
if not st["ready"]:
print(" Grant: hermes computer-use permissions grant")
else: # no TCC model — readiness is driver health
print(f" {glyph(st['ready'])} driver health (no permission toggles on {st['platform']})")
for c in st["checks"]:
if c["status"] != "ok":
print(f"{c['label']}: {c['message']}")
if st["error"]:
print(f"{st['error']}")
sys.exit(0 if st["ready"] else 1)
computer_use_perms.print_help()
return
# No subcommand → show help
computer_use_parser.print_help()

View file

@ -0,0 +1,83 @@
"""HTTP routes for memory-provider OAuth connect, mounted by ``web_server``.
Kept out of ``web_server.py`` so the memory feature's surface stays in the
memory layer. Dispatch is by convention: a provider's flow lives at
``plugins.memory.<provider>.oauth_flow`` exposing ``start_loopback_flow_background``
and ``get_flow_status``; a provider without that module simply 404s. No provider
is named here.
"""
from __future__ import annotations
import importlib
from contextlib import contextmanager
from typing import Optional
from fastapi import APIRouter, HTTPException
router = APIRouter(prefix="/api/memory/providers")
def _resolve_flow(provider: str):
"""Return a provider's OAuth flow module by convention, or raise 404."""
if not provider.isidentifier():
raise HTTPException(status_code=404, detail=f"unknown memory provider {provider!r}")
try:
return importlib.import_module(f"plugins.memory.{provider}.oauth_flow")
except ImportError:
raise HTTPException(status_code=404, detail=f"{provider} does not support OAuth connect")
@contextmanager
def _scope_to_profile(profile: Optional[str]):
"""Scope config resolution to ``profile`` so the flow's eager path resolve
targets that profile's honcho.json. None/""/"current" leaves it untouched."""
requested = (profile or "").strip()
if not requested or requested.lower() == "current":
yield
return
from hermes_cli import profiles as profiles_mod
from hermes_constants import reset_hermes_home_override, set_hermes_home_override
try:
profiles_mod.validate_profile_name(requested)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
if not profiles_mod.profile_exists(requested):
raise HTTPException(status_code=404, detail=f"Profile '{requested}' does not exist.")
token = set_hermes_home_override(str(profiles_mod.get_profile_dir(requested)))
try:
yield
finally:
reset_hermes_home_override(token)
@router.post("/{provider}/oauth/start")
async def start_memory_oauth(provider: str, profile: Optional[str] = None):
"""Begin a provider's zero-CLI OAuth flow — opens the browser and captures
the grant via the loopback listener. Returns immediately; poll status."""
flow = _resolve_flow(provider)
try:
# The flow resolves its config path eagerly inside this scope; the worker
# thread it spawns outlives the request and the override.
with _scope_to_profile(profile):
return flow.start_loopback_flow_background()
except HTTPException:
raise
except Exception as exc:
raise HTTPException(status_code=500, detail=f"Failed to start {provider} OAuth: {exc}")
@router.get("/{provider}/oauth/status")
async def memory_oauth_status(provider: str, profile: Optional[str] = None):
"""Poll a provider's OAuth flow: idle | pending | connected | error."""
flow = _resolve_flow(provider)
try:
with _scope_to_profile(profile):
return flow.get_flow_status()
except HTTPException:
raise
except Exception as exc:
raise HTTPException(status_code=500, detail=f"Failed to read {provider} OAuth status: {exc}")

View file

@ -489,6 +489,41 @@ def is_aggregator(provider: str) -> bool:
return pdef.is_aggregator if pdef else False
# Flat-namespace resellers (e.g. opencode-go, opencode-zen) are flagged
# ``is_aggregator=True`` because their live ``/v1/models`` returns bare model
# IDs ("deepseek-v4-flash") rather than ``vendor/model`` routing slugs — the
# model-switch resolver relies on that flag to search their flat catalog
# (see model_switch.py step d). But they are NOT routing aggregators: every
# model they list is a first-party model served under their own subscription,
# not a passthrough route to another provider's endpoint. The picker dedup
# (build_models_payload) must treat them differently from true routers like
# OpenRouter — a reseller's first-party "minimax-m3" must never be stripped
# just because a user's custom proxy also happens to serve a same-named model.
_FLAT_NAMESPACE_RESELLERS: frozenset[str] = frozenset({
# Use normalized provider IDs: normalize_provider("opencode-zen") -> "opencode".
"opencode-go",
"opencode",
})
def is_routing_aggregator(provider: str) -> bool:
"""Return True only for TRUE routing aggregators (e.g. OpenRouter, named
``custom:*`` proxies) those that route bare/vendor-slugged model names
to *other* providers' endpoints.
Distinct from :func:`is_aggregator`, which also reports True for
flat-namespace resellers (opencode-go/zen) whose catalog is entirely
first-party. Use this gate when the question is "would selecting this
model silently re-route the call away from the user's intended provider?"
i.e. the picker dedup. Resellers answer no: their listed models are
their own, so their rows must not be deduped against user proxies.
"""
provider_norm = normalize_provider(provider or "")
if provider_norm in _FLAT_NAMESPACE_RESELLERS:
return False
return is_aggregator(provider_norm)
def determine_api_mode(provider: str, base_url: str = "") -> str:
"""Determine the API mode (wire protocol) for a provider/endpoint.

View file

@ -23,7 +23,11 @@ import sys
from pathlib import Path
def _build_full_manifest(bot_name: str, bot_description: str) -> dict:
def _build_full_manifest(
bot_name: str,
bot_description: str,
include_assistant: bool = True,
) -> dict:
"""Build a full Slack manifest merging display info + our slash list.
The slash-command list is always generated from ``COMMAND_REGISTRY`` so
@ -31,12 +35,71 @@ def _build_full_manifest(bot_name: str, bot_description: str) -> dict:
(display info, OAuth scopes, socket mode) are set to sensible defaults
for a Hermes deployment users can tweak them in the Slack UI after
pasting.
When ``include_assistant`` is True (default) the manifest opts the app
into Slack's AI Assistant container: the ``assistant_view`` feature, the
``assistant:write`` scope, and the ``assistant_thread_*`` events. Slack
then renders DMs as the right-hand Assistant split-pane, where every
exchange is a thread and bare slash commands are not delivered as normal
``command`` events. Pass ``include_assistant=False`` (``--no-assistant``)
to omit those three pieces and get a flat DM surface where ``/help``,
``/new``, etc. work inline.
"""
from hermes_cli.commands import slack_app_manifest
partial = slack_app_manifest()
slashes = partial["features"]["slash_commands"]
features = {
"app_home": {
"home_tab_enabled": False,
"messages_tab_enabled": True,
"messages_tab_read_only_enabled": False,
},
"bot_user": {
"display_name": bot_name[:80],
"always_online": True,
},
"slash_commands": slashes,
}
bot_scopes = [
"app_mentions:read",
"channels:history",
"channels:read",
"chat:write",
"commands",
"files:read",
"files:write",
"groups:history",
"groups:read",
"im:history",
"im:read",
"im:write",
"users:read",
]
bot_events = [
"app_mention",
"message.channels",
"message.groups",
"message.im",
]
if include_assistant:
features["assistant_view"] = {
"assistant_description": "Chat with Hermes in threads and DMs.",
}
bot_scopes.append("assistant:write")
bot_events.extend(
[
"assistant_thread_context_changed",
"assistant_thread_started",
]
)
bot_scopes.sort()
bot_events.sort()
return {
"_metadata": {
"major_version": 1,
@ -47,51 +110,15 @@ def _build_full_manifest(bot_name: str, bot_description: str) -> dict:
"description": (bot_description or "Your Hermes agent on Slack")[:140],
"background_color": "#1a1a2e",
},
"features": {
"app_home": {
"home_tab_enabled": False,
"messages_tab_enabled": True,
"messages_tab_read_only_enabled": False,
},
"bot_user": {
"display_name": bot_name[:80],
"always_online": True,
},
"slash_commands": slashes,
"assistant_view": {
"assistant_description": "Chat with Hermes in threads and DMs.",
},
},
"features": features,
"oauth_config": {
"scopes": {
"bot": [
"app_mentions:read",
"assistant:write",
"channels:history",
"channels:read",
"chat:write",
"commands",
"files:read",
"files:write",
"groups:history",
"groups:read",
"im:history",
"im:read",
"im:write",
"users:read",
],
"bot": bot_scopes,
},
},
"settings": {
"event_subscriptions": {
"bot_events": [
"app_mention",
"assistant_thread_context_changed",
"assistant_thread_started",
"message.channels",
"message.groups",
"message.im",
],
"bot_events": bot_events,
},
"interactivity": {
"is_enabled": True,
@ -113,16 +140,21 @@ def slack_manifest_command(args) -> int:
--description DESC Override the bot description
--slashes-only Emit only the ``features.slash_commands`` array (for
merging into an existing manifest manually)
--no-assistant Omit Slack AI Assistant mode (assistant_view feature,
assistant:write scope, assistant_thread_* events) so
DMs render as a flat chat where bare slash commands
work inline instead of the Assistant thread pane.
"""
name = getattr(args, "name", None) or "Hermes"
description = getattr(args, "description", None) or "Your Hermes agent on Slack"
include_assistant = not getattr(args, "no_assistant", False)
if getattr(args, "slashes_only", False):
from hermes_cli.commands import slack_app_manifest
manifest = slack_app_manifest()["features"]["slash_commands"]
else:
manifest = _build_full_manifest(name, description)
manifest = _build_full_manifest(name, description, include_assistant=include_assistant)
payload = json.dumps(manifest, indent=2, ensure_ascii=False) + "\n"

View file

@ -57,4 +57,12 @@ def build_slack_parser(subparsers, *, cmd_slack: Callable) -> None:
help="Emit only the features.slash_commands array (for merging "
"into an existing manifest manually).",
)
slack_manifest.add_argument(
"--no-assistant",
action="store_true",
help="Omit Slack AI Assistant mode (assistant_view, assistant:write "
"scope, assistant_thread_* events). DMs then render as a flat chat "
"where bare slash commands (/help, /new) work inline instead of "
"Slack's Assistant thread pane.",
)
slack_parser.set_defaults(func=cmd_slack)

View file

@ -78,7 +78,7 @@ CONFIGURABLE_TOOLSETS = [
("discord", "💬 Discord (read/participate)", "fetch messages, search members, create thread"),
("discord_admin", "🛡️ Discord Server Admin", "list channels/roles, pin, assign roles"),
("yuanbao", "🤖 Yuanbao", "group info, member queries, DM"),
("computer_use", "🖱️ Computer Use (macOS)", "background desktop control via cua-driver"),
("computer_use", "🖱️ Computer Use (macOS/Windows/Linux)", "background desktop control via cua-driver"),
]
@ -516,21 +516,24 @@ TOOL_CATEGORIES = {
],
},
"computer_use": {
"name": "Computer Use (macOS)",
"name": "Computer Use (macOS/Windows/Linux)",
"icon": "🖱️",
"platform_gate": "darwin",
# Runtime backends ship for macOS, Windows, and Linux (X11 today,
# Wayland via XWayland). Per-host gaps surface via `computer-use doctor`.
"platform_gate": ["darwin", "win32", "linux"],
"providers": [
{
"name": "cua-driver (background)",
"badge": "★ recommended · free · local",
"tag": (
"macOS background computer-use via SkyLight SPIs — does "
"NOT steal your cursor or focus. Works with any model."
"Background computer-use via cua-driver — does NOT steal "
"your cursor or focus. Works with any model."
),
"env_vars": [
# cua-driver reads HOME/TMPDIR from the process env, no
# extra keys required. HERMES_CUA_DRIVER_VERSION is an
# optional pin for reproducibility across macOS updates.
# extra keys required. Set HERMES_CUA_DRIVER_CMD to use a
# specific binary (e.g. a local build); there is no
# version-pin env var.
],
"post_setup": "cua_driver",
},
@ -579,6 +582,22 @@ def _cua_driver_cmd() -> str:
return os.environ.get("HERMES_CUA_DRIVER_CMD", "").strip() or "cua-driver"
def _cua_driver_env() -> dict:
"""cua-driver child env with the Hermes telemetry policy applied.
Delegates to ``cua_backend.cua_driver_child_env`` (telemetry disabled by
default; user opt-in via ``computer_use.cua_telemetry``). Falls back to the
current environment if the helper can't be imported, so install/status
never break on a telemetry-helper error.
"""
try:
from tools.computer_use.cua_backend import cua_driver_child_env
return cua_driver_child_env()
except Exception:
return dict(os.environ)
def _pip_install(
args: List[str],
*,
@ -648,52 +667,31 @@ def _pip_install(
def _check_cua_driver_asset_for_arch() -> bool:
"""Check whether the latest CUA release ships an asset for this architecture.
Returns True if the asset likely exists (or if we cannot determine it).
Returns False and prints a warning when the asset is confirmed missing,
so callers can skip the install attempt and avoid a raw 404.
"""
import platform as _plat
import urllib.request
machine = _plat.machine() # "x86_64" or "arm64"
if machine == "arm64":
# arm64 (Apple Silicon) assets are always published.
return True
# x86_64 / Intel — probe the latest release for an architecture-specific
# asset before falling through to the upstream installer.
api_url = (
"https://api.github.com/repos/trycua/cua/releases/latest"
)
try:
req = urllib.request.Request(api_url, headers={"Accept": "application/vnd.github+json"})
with urllib.request.urlopen(req, timeout=10) as resp:
release = _json.loads(resp.read().decode())
tag = release.get("tag_name", "")
assets = release.get("assets", [])
arch_names = {"x86_64", "amd64"}
has_asset = any(
any(a in a_info.get("name", "").lower() for a in arch_names)
for a_info in assets
)
if not has_asset:
_print_warning(
f" Latest CUA release ({tag}) has no Intel (x86_64) asset."
)
_print_info(
" CUA Driver currently only ships Apple Silicon builds."
)
_print_info(
" See: https://github.com/trycua/cua/issues/1493"
)
return False
except Exception:
# Network / API failure — proceed and let the installer handle it.
pass
return True
# The asset-probe that lived here used to hit `/releases/latest` on
# trycua/cua and inspect the release's asset list before piping the
# installer to bash. It was broken in two places:
#
# 1. cua-driver-rs releases are marked **prerelease** on every cut,
# and GitHub's `/releases/latest` endpoint explicitly skips
# prereleases. On the live trycua/cua repo today, `/releases/latest`
# returns the Python `cua-agent v0.8.3` package (zero binary
# assets) instead of `cua-driver-rs-v0.6.0` (19 binary assets).
# The probe then reported "no asset for this arch" and skipped the
# install on every non-arm64 host — Linux x86_64, Windows, macOS
# Intel, Linux arm64 — even when the upstream installer would have
# succeeded.
# 2. Even with the right endpoint, we'd be duplicating tag-resolution
# logic the upstream installer already does correctly via
# `CUA_DRIVER_RS_BAKED_VERSION` (auto-baked by CD on every release,
# with an API fallback). Drift between our probe and theirs is a
# maintenance hazard.
#
# Resolution: trust the upstream installer. For fresh installs, run
# install.sh directly — it errors clean if the target arch has no
# asset. For the upgrade path, `cua_driver_update_check()` (which calls
# `cua-driver check-update --json`) gives us the canonical update
# answer from the binary itself — same tag-resolution as the installer,
# no Python-side duplication.
def install_cua_driver(upgrade: bool = False) -> bool:
@ -710,32 +708,41 @@ def install_cua_driver(upgrade: bool = False) -> bool:
by ``hermes computer-use install --upgrade``.
Returns True iff cua-driver is installed (or successfully refreshed)
when the function returns. macOS-only silently returns False on
other platforms.
when the function returns. Supported on macOS, Windows, and Linux
(Linux is alpha). Silently returns False on unsupported platforms.
"""
import platform as _plat
import shutil
import subprocess
if _plat.system() != "Darwin":
system = _plat.system()
if system not in ("Darwin", "Windows", "Linux"):
if upgrade:
# Silent on non-macOS — `hermes update` calls this for every
# user; only macOS users with cua-driver care.
# Silent on unsupported platforms — `hermes update` calls this
# for every user; only macOS/Windows/Linux users care.
return False
_print_warning(" Computer Use (cua-driver) is macOS-only; skipping.")
_print_warning(" Computer Use (cua-driver) is unsupported on this platform; skipping.")
return False
is_windows = system == "Windows"
is_linux = system == "Linux"
# The Windows installer (install.ps1) is fetched via PowerShell's `irm`,
# so it needs PowerShell rather than curl. macOS/Linux use curl | bash.
fetch_tool = "powershell" if is_windows else "curl"
driver_cmd = _cua_driver_cmd()
binary = shutil.which(driver_cmd)
# Not installed → fresh install path (only when caller asked for it).
if not binary and not upgrade:
if not shutil.which("curl"):
_print_warning(" curl not found — install manually:")
if not shutil.which(fetch_tool):
_print_warning(f" {fetch_tool} not found — install manually:")
_print_info(" https://github.com/trycua/cua/blob/main/libs/cua-driver/README.md")
return False
if not _check_cua_driver_asset_for_arch():
return False
# Pre-install asset probe deleted — see comment near the top of
# tools_config.py for why. install.sh has CUA_DRIVER_RS_BAKED_VERSION
# baked in by CD and errors cleanly on missing-arch assets.
return _run_cua_driver_installer(label="Installing")
# Already installed and caller didn't ask to upgrade → just confirm.
@ -743,30 +750,55 @@ def install_cua_driver(upgrade: bool = False) -> bool:
try:
version = subprocess.run(
[driver_cmd, "--version"],
capture_output=True, text=True, timeout=5,
capture_output=True, text=True, timeout=5, env=_cua_driver_env(),
).stdout.strip()
_print_success(f" {driver_cmd} already installed: {version or 'unknown version'}")
except Exception:
_print_success(f" {driver_cmd} already installed.")
_print_info(" Grant macOS permissions if not done yet:")
_print_info(" System Settings > Privacy & Security > Accessibility")
_print_info(" System Settings > Privacy & Security > Screen Recording")
if is_windows:
_print_info(" cua-driver may spawn a UIAccess worker (cua-driver-uia.exe);")
_print_info(" Windows/SmartScreen may prompt the first time it runs.")
elif is_linux:
_print_warning(" Linux support is alpha.")
else:
_print_info(" Grant macOS permissions if not done yet:")
_print_info(" System Settings > Privacy & Security > Accessibility")
_print_info(" System Settings > Privacy & Security > Screen Recording")
return True
# upgrade=True path — refresh to the latest upstream release.
if not shutil.which("curl"):
_print_warning(" curl not found — cannot refresh cua-driver.")
if not shutil.which(fetch_tool):
_print_warning(f" {fetch_tool} not found — cannot refresh cua-driver.")
return bool(binary)
if not _check_cua_driver_asset_for_arch():
return bool(binary)
# Pre-install asset probe deleted (see top-of-file comment). The
# `cua_driver_update_check()` call further down asks the installed
# cua-driver binary itself whether an update exists — same
# tag-resolution as the installer, no duplication.
# Skip the (network) re-install when the driver itself reports it's already
# on the latest release. Best-effort: an older driver (no check-update
# verb) or an offline check returns None, in which case we fall through and
# re-run the installer as before.
if binary:
try:
from tools.computer_use.cua_backend import cua_driver_update_check
_state = cua_driver_update_check()
if _state is not None and not _state.get("update_available"):
_print_success(
f" {driver_cmd} is already on the latest release "
f"({_state.get('current_version') or 'unknown'})."
)
return True
except Exception:
pass
if binary:
# Show before/after version when we have a baseline. Best-effort.
try:
before = subprocess.run(
[driver_cmd, "--version"],
capture_output=True, text=True, timeout=5,
capture_output=True, text=True, timeout=5, env=_cua_driver_env(),
).stdout.strip()
except Exception:
before = ""
@ -778,7 +810,7 @@ def install_cua_driver(upgrade: bool = False) -> bool:
try:
after = subprocess.run(
[driver_cmd, "--version"],
capture_output=True, text=True, timeout=5,
capture_output=True, text=True, timeout=5, env=_cua_driver_env(),
).stdout.strip()
if after and after != before:
_print_success(f" {driver_cmd} upgraded: {before}{after}")
@ -790,36 +822,70 @@ def install_cua_driver(upgrade: bool = False) -> bool:
def _run_cua_driver_installer(label: str = "Installing", verbose: bool = True) -> bool:
"""Run the upstream cua-driver install.sh. Returns True on success.
"""Run the upstream cua-driver installer for this platform.
The script is idempotent: it always downloads the latest release, so
re-running it on an already-installed system performs an upgrade.
The scripts are idempotent: they always download the latest release, so
re-running on an already-installed system performs an upgrade.
* macOS / Linux ``curl -fsSL /install.sh | /bin/bash``.
* Windows ``powershell -NoProfile -ExecutionPolicy Bypass -Command
"irm …/install.ps1 | iex"``.
"""
import platform as _plat
import shutil
import subprocess
install_cmd = (
"/bin/bash -c \"$(curl -fsSL "
"https://raw.githubusercontent.com/trycua/cua/main/"
"libs/cua-driver/scripts/install.sh)\""
)
system = _plat.system()
is_windows = system == "Windows"
is_linux = system == "Linux"
if is_windows:
# Mirror the one-liner printed by cua_driver_install_hint().
ps_oneliner = (
"irm https://raw.githubusercontent.com/trycua/cua/main/"
"libs/cua-driver/scripts/install.ps1 | iex"
)
install_cmd = [
"powershell", "-NoProfile", "-ExecutionPolicy", "Bypass",
"-Command", ps_oneliner,
]
use_shell = False
manual_hint = (
'powershell -NoProfile -ExecutionPolicy Bypass -Command '
f'"{ps_oneliner}"'
)
else:
install_cmd = (
"/bin/bash -c \"$(curl -fsSL "
"https://raw.githubusercontent.com/trycua/cua/main/"
"libs/cua-driver/scripts/install.sh)\""
)
use_shell = True
manual_hint = install_cmd
if verbose:
_print_info(f" {label} cua-driver (macOS background computer-use)...")
_print_info(f" {label} cua-driver (background computer-use)...")
else:
_print_info(f" {label} cua-driver...")
driver_cmd = _cua_driver_cmd()
try:
result = subprocess.run(install_cmd, shell=True, timeout=300)
result = subprocess.run(install_cmd, shell=use_shell, timeout=300, env=_cua_driver_env())
if result.returncode == 0 and shutil.which(driver_cmd):
if verbose:
_print_success(f" {driver_cmd} installed.")
_print_info(" IMPORTANT — grant macOS permissions now:")
_print_info(" System Settings > Privacy & Security > Accessibility")
_print_info(" System Settings > Privacy & Security > Screen Recording")
_print_info(" Both must allow the terminal / Hermes process.")
if is_windows:
_print_info(" cua-driver may spawn a UIAccess worker (cua-driver-uia.exe);")
_print_info(" Windows/SmartScreen may prompt the first time it runs.")
elif is_linux:
_print_warning(" Linux support is alpha.")
else:
_print_info(" IMPORTANT — grant macOS permissions now:")
_print_info(" System Settings > Privacy & Security > Accessibility")
_print_info(" System Settings > Privacy & Security > Screen Recording")
_print_info(" Both must allow the terminal / Hermes process.")
return True
_print_warning(f" cua-driver {label.lower()} did not complete. Re-run manually:")
_print_info(f" {install_cmd}")
_print_info(f" {manual_hint}")
return False
except subprocess.TimeoutExpired:
_print_warning(f" cua-driver {label.lower()} timed out. Re-run manually.")
@ -1284,6 +1350,24 @@ def _parse_enabled_flag(value, default: bool = True) -> bool:
return default
def enabled_mcp_server_names(config: dict) -> Set[str]:
"""Names of MCP servers globally enabled in config.yaml.
Shared by the gateway/CLI platform resolver (``_get_platform_tools``) and
the cron per-job toolset resolver (``cron.scheduler``) so every path agrees
on MCP membership. A server is enabled unless its config sets an explicitly
falsey ``enabled`` (per ``_parse_enabled_flag``: false/0/no/off) a missing
flag or an unrecognized value is treated as enabled.
"""
mcp_servers = (config or {}).get("mcp_servers") or {}
return {
str(name)
for name, server_cfg in mcp_servers.items()
if isinstance(server_cfg, dict)
and _parse_enabled_flag(server_cfg.get("enabled", True), default=True)
}
def _get_platform_tools(
config: dict,
platform: str,
@ -1503,13 +1587,7 @@ def _get_platform_tools(
# If the platform explicitly lists one or more MCP server names, treat that
# as an allowlist. Otherwise include every globally enabled MCP server.
# Special sentinel: "no_mcp" in the toolset list disables all MCP servers.
mcp_servers = config.get("mcp_servers") or {}
enabled_mcp_servers = {
str(name)
for name, server_cfg in mcp_servers.items()
if isinstance(server_cfg, dict)
and _parse_enabled_flag(server_cfg.get("enabled", True), default=True)
}
enabled_mcp_servers = enabled_mcp_server_names(config)
# Allow "no_mcp" sentinel to opt out of all MCP servers for this platform
if "no_mcp" in toolset_names:
explicit_mcp_servers = set()

View file

@ -234,6 +234,11 @@ def _get_chat_argv_lock(app: "FastAPI") -> asyncio.Lock:
app = FastAPI(title="Hermes Agent", version=__version__, lifespan=_lifespan)
# Memory-provider OAuth connect routes live in the memory layer, not here.
from hermes_cli.memory_oauth import router as _memory_oauth_router # noqa: E402
app.include_router(_memory_oauth_router)
# ---------------------------------------------------------------------------
# Session token for protecting sensitive endpoints (reveal).
# The desktop shell mints the token and injects it via
@ -623,6 +628,10 @@ _CATEGORY_MERGE: Dict[str, str] = {
# with the other messaging-platform config (discord) so it isn't an
# orphan tab of one field.
"telegram": "discord",
# `computer_use.cua_telemetry` is the only schema-surfaced computer_use
# field — fold it into the agent tab rather than spawning a one-field
# orphan category.
"computer_use": "agent",
}
# Display order for tabs — unlisted categories sort alphabetically after these.
@ -1318,13 +1327,35 @@ def _dashboard_local_update_managed_externally() -> bool:
in-browser local update action. Keep this dashboard capability separate
from install-method detection: manual git/pip installs inside containers can
still behave like their actual install method in the CLI.
However, when the install method is ``git`` (a bind-mounted checkout inside
a container e.g. the hermes-webui image sharing the Hermes source tree),
the dashboard's ``hermes update`` button is the correct update path and
should not be suppressed. Other containerized install methods remain
externally managed unless their apply path is proven safe inside the
running container filesystem.
"""
if _default_hermes_root_is_opt_data():
return True
try:
from hermes_constants import is_container
return is_container()
if not is_container():
return False
except Exception:
return False
# We are inside a container, but the install may still be self-managed.
# If the install method is git, the dashboard update button works against
# the mounted checkout and should be offered. Keep pip blocked inside
# containers: its apply path mutates the running container filesystem and
# is not the bind-mounted checkout case this gate is meant to recover.
try:
method = detect_install_method(PROJECT_ROOT)
if method == "git":
return False
except Exception:
pass
return True
def _managed_files_policy(request: Request, *, create_root: bool = True) -> ManagedFilesPolicy:
@ -8323,6 +8354,7 @@ async def install_mcp_catalog_entry(body: MCPCatalogInstall, profile: Optional[s
# Register the mcp-install action log so /api/actions/mcp-install/status works.
_ACTION_LOG_FILES.setdefault("mcp-install", "action-mcp-install.log")
_ACTION_LOG_FILES.setdefault("computer-use-grant", "action-computer-use-grant.log")
# ---------------------------------------------------------------------------
@ -10645,6 +10677,63 @@ async def run_toolset_post_setup(
return {"ok": True, "pid": proc.pid, "name": "tools-post-setup", "key": body.key}
# ---------------------------------------------------------------------------
# Computer Use (cua-driver) — cross-platform readiness + macOS permission grant
#
# cua-driver runs on macOS, Windows, and Linux. The desktop card reflects
# per-OS readiness: on macOS the Accessibility + Screen Recording TCC grants
# (which attach to cua-driver's OWN identity, com.trycua.driver — not Hermes,
# so no app entitlement is involved); elsewhere, driver health from
# `cua-driver doctor`. The grant flow is macOS-only (no TCC toggles to request
# on Windows/Linux).
# ---------------------------------------------------------------------------
@app.get("/api/tools/computer-use/status")
async def get_computer_use_status(profile: Optional[str] = None):
"""Cross-platform Computer Use readiness for the desktop card.
See ``tools.computer_use.permissions.computer_use_status`` for the payload
shape. Read-only and fast (shells ``cua-driver doctor`` + macOS
``permissions status``).
"""
from tools.computer_use.permissions import computer_use_status
with _profile_scope(profile):
return computer_use_status()
@app.post("/api/tools/computer-use/permissions/grant")
async def grant_computer_use_permissions(profile: Optional[str] = None):
"""Spawn ``hermes computer-use permissions grant`` as a background action.
macOS-only: ``cua-driver permissions grant`` launches CuaDriver via
LaunchServices so the TCC dialog is attributed to com.trycua.driver, then
waits for approval. The frontend polls ``GET /api/actions/computer-use-
grant/status`` and re-reads ``/status`` once it exits. Windows/Linux have
no TCC toggles to grant, so this returns 400 there.
"""
if sys.platform != "darwin":
raise HTTPException(
status_code=400,
detail="Computer Use permission grants are a macOS concept.",
)
try:
proc = _spawn_hermes_action(
_profile_cli_args(profile)
+ ["computer-use", "permissions", "grant"],
"computer-use-grant",
)
except HTTPException:
raise
except Exception as exc:
_log.exception("Failed to spawn computer-use permissions grant")
raise HTTPException(
status_code=500, detail=f"Failed to request permissions: {exc}"
)
return {"ok": True, "pid": proc.pid, "name": "computer-use-grant"}
# ---------------------------------------------------------------------------
# Raw YAML config endpoint
# ---------------------------------------------------------------------------
@ -12178,12 +12267,20 @@ def _safe_plugin_api_relpath(api_field: Any, *, dashboard_dir: Path) -> Optional
return api_field
# Plugin sources whose Python backend (dashboard manifest `api` file) must NEVER
# be auto-imported by the dashboard web server — only bundled plugins may. Shared
# by the discovery-time scrub and the mount-time refuse guards so a typo in one
# site cannot silently disable a security gate (GHSA-5qr3-c538-wm9j / #43719).
_NON_BUNDLED_PLUGIN_SOURCES = frozenset({"user", "project"})
def _discover_dashboard_plugins() -> list:
"""Scan plugins/*/dashboard/manifest.json for dashboard extensions.
Checks three plugin sources (same as hermes_cli.plugins):
1. User plugins: ~/.hermes/plugins/<name>/dashboard/manifest.json
2. Bundled plugins: <repo>/plugins/<name>/dashboard/manifest.json (memory/, etc.)
Checks three plugin sources. Bundled dashboard plugins win name conflicts
so non-bundled plugins cannot shadow trusted backend-capable routes:
1. Bundled plugins: <repo>/plugins/<name>/dashboard/manifest.json (memory/, etc.)
2. User plugins: ~/.hermes/plugins/<name>/dashboard/manifest.json
3. Project plugins: ./.hermes/plugins/ (only if HERMES_ENABLE_PROJECT_PLUGINS)
"""
plugins = []
@ -12192,9 +12289,9 @@ def _discover_dashboard_plugins() -> list:
from hermes_cli.plugins import get_bundled_plugins_dir
bundled_root = get_bundled_plugins_dir()
search_dirs = [
(get_hermes_home() / "plugins", "user"),
(bundled_root / "memory", "bundled"),
(bundled_root, "bundled"),
(get_hermes_home() / "plugins", "user"),
]
# GHSA-5qr3-c538-wm9j (#29156): the previous ``os.environ.get(...)``
# check treated *any* non-empty string as truthy, so ``=0``, ``=false``,
@ -12253,10 +12350,20 @@ def _discover_dashboard_plugins() -> list:
raw_api = data.get("api")
dashboard_dir = child / "dashboard"
safe_api = _safe_plugin_api_relpath(raw_api, dashboard_dir=dashboard_dir)
if source in _NON_BUNDLED_PLUGIN_SOURCES and safe_api:
_log.warning(
"Plugin %s: refusing dashboard backend api=%s "
"(only bundled plugins may auto-import Python "
"backend routes; non-bundled plugins may extend "
"the dashboard with static UI assets only)",
name, safe_api,
)
safe_api = None
raw_api = None
if raw_api and safe_api is None:
_log.warning(
"Plugin %s: refusing unsafe api path %r (must be a "
"relative file inside the plugin's dashboard/ "
"relative file inside a bundled plugin's dashboard/ "
"directory); backend routes from this plugin will "
"not be mounted",
name, raw_api,
@ -12663,23 +12770,36 @@ def _mount_plugin_api_routes():
a ``router`` (FastAPI APIRouter). Routes are mounted under
``/api/plugins/<name>/``.
Backend import is restricted to ``bundled`` and ``user`` sources.
Project plugins (``./.hermes/plugins/``) ship with the CWD and are
therefore attacker-controlled in any threat model where the user
opens a malicious repo; they can extend the dashboard UI via
static JS/CSS but their Python ``api`` file is never auto-imported
by the web server. See GHSA-5qr3-c538-wm9j (#29156).
Backend import is restricted to bundled plugins. User and project
plugins can extend the dashboard UI via static JS/CSS, but their
Python ``api`` files are never auto-imported by the web server.
See GHSA-5qr3-c538-wm9j (#29156) and #43719.
"""
for plugin in _get_dashboard_plugins():
api_file_name = plugin.get("_api_file")
if not api_file_name:
continue
if plugin.get("source") == "project":
source = plugin.get("source")
if source in _NON_BUNDLED_PLUGIN_SOURCES:
# Backend Python auto-import is reserved for bundled plugins; user
# and project plugins extend the dashboard with static UI assets
# only (GHSA-5qr3-c538-wm9j / #43719). Defence-in-depth: discovery
# already nulls _api_file for these sources, but re-refusing here —
# at the actual importlib call site — keeps the import primitive
# contained even if a future caller or a tampered cache entry slips
# a non-bundled plugin through with an _api_file set.
_reason = {
"user": (
"user-installed plugins may not auto-import Python code"
),
"project": (
"project plugins may not auto-import Python code; backend "
"auto-import is reserved for bundled plugins"
),
}.get(source, "only bundled plugins may auto-import Python code")
_log.warning(
"Plugin %s: ignoring backend api=%s (project plugins may "
"not auto-import Python code; move the plugin to "
"~/.hermes/plugins/ if you trust it)",
plugin["name"], api_file_name,
"Plugin %s: ignoring backend api=%s (%s)",
plugin["name"], api_file_name, _reason,
)
continue
dashboard_dir = Path(plugin["_dir"])