mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-27 11:22:03 +00:00
Merge remote-tracking branch 'origin/main' into bb/pets-merge
# Conflicts: # hermes_cli/commands.py # tui_gateway/server.py
This commit is contained in:
commit
e495b33bf1
251 changed files with 23395 additions and 2720 deletions
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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")),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
83
hermes_cli/memory_oauth.py
Normal file
83
hermes_cli/memory_oauth.py
Normal 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}")
|
||||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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"])
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue