hermes-agent/hermes_cli/web_routers/tools.py
teknium1 011ec4513e refactor(web): extract sessions/mcp/skills/tools routes to APIRouter modules (wave 2; route-table equality verified)
- hermes_cli/web_routers/sessions.py: 14 routes across 3 routers
  (list_router, search_router, manage_router) mounted at the three original
  registration points so global route order is preserved exactly.
- hermes_cli/web_routers/mcp.py: 11 routes; OAuth flow registry
  (_mcp_oauth_flows/lock/cap) stays in web_server, reached via new
  web_deps.LateState live proxies so tests mutating web_server._mcp_oauth_flows
  keep working.
- hermes_cli/web_routers/skills.py: 12 routes across hub_router + router
  (two original registration points straddle the profiles router include).
- hermes_cli/web_routers/tools.py: 12 routes; toolset/terminal catalogs stay
  in web_server (some are defined after the mount point), reached via LateState.
- web_deps.py: add LateState — operation-time proxy for web_server-owned
  module state (getattr/item/iter/len/contains/context-manager/comparisons).
- Handler bodies byte-identical; legacy re-exports keep
  web_server.<handler> importable for tests.
- Verified: ordered route table (method, path) identical to pre-refactor app
  (291 routes); import smoke; ruff; windows-footguns clean.
- test_web_server_sessiondb_eventloop.py: structural AST scan now reads both
  web_server.py and web_routers/sessions.py (handlers moved; helpers stayed).
2026-07-29 16:21:54 -07:00

734 lines
30 KiB
Python

"""Toolset / terminal-backend dashboard routes (extracted verbatim from
web_server.py).
Handler bodies are byte-identical. The toolset/terminal catalogs
(``_MODEL_CATALOG_TOOLSETS``, ``_TERMINAL_BACKENDS``,
``_TERMINAL_BACKEND_NAMES`` - some defined *after* this router's mount point
in web_server's body) and all helpers stay in web_server - reached via the
late-binding seam in :mod:`hermes_cli.web_deps` (``late`` for callables,
``LateState`` for the catalog constants) so monkeypatching on web_server
stays authoritative.
"""
import logging
import sys # noqa: F401 — used by handlers
from typing import Any, Dict, List, Optional # noqa: F401
from fastapi import APIRouter, HTTPException # noqa: F401
from hermes_cli.web_deps import late, LateState
from hermes_cli.web_models import (
TerminalBackendSelect,
ToolsetEnvUpdate,
ToolsetModelSelect,
ToolsetPostSetup,
ToolsetProviderSelect,
ToolsetToggle,
)
# Same logger the handlers used before extraction (identical logger object).
_log = logging.getLogger("hermes_cli.web_server")
router = APIRouter()
# Late-bound web_server helpers (resolved at call time; cycle-safe,
# monkeypatch-transparent).
_find_toolset_provider_row = late("_find_toolset_provider_row")
_probe_terminal_backend = late("_probe_terminal_backend")
_profile_cli_args = late("_profile_cli_args")
_profile_scope = late("_profile_scope")
_resolve_toolset_model_plugin = late("_resolve_toolset_model_plugin")
_spawn_hermes_action = late("_spawn_hermes_action")
_toolset_model_catalog = late("_toolset_model_catalog")
load_config = late("load_config")
save_config = late("save_config")
# Live proxies for web_server-owned module state (mutations/monkeypatches
# on web_server remain authoritative; resolved at operation time).
_MODEL_CATALOG_TOOLSETS = LateState("_MODEL_CATALOG_TOOLSETS")
_TERMINAL_BACKENDS = LateState("_TERMINAL_BACKENDS")
_TERMINAL_BACKEND_NAMES = LateState("_TERMINAL_BACKEND_NAMES")
@router.get("/api/tools/toolsets")
async def get_toolsets(profile: Optional[str] = None):
from hermes_cli.tools_config import (
_CONFIG_ONLY_TOOLSETS,
_get_effective_configurable_toolsets,
_get_platform_tools,
_toolset_configuration_platform,
_toolset_has_keys,
gui_toolset_label,
)
from hermes_cli.platforms import platform_label
from toolsets import resolve_toolset
with _profile_scope(profile):
config = load_config()
toolset_rows = _get_effective_configurable_toolsets()
target_platforms = {
_toolset_configuration_platform(name) for name, _, _ in toolset_rows
}
enabled_by_platform = {
platform: _get_platform_tools(
config,
platform,
include_default_mcp_servers=False,
)
for platform in target_platforms
}
result = []
for name, label, desc in toolset_rows:
try:
tools = sorted(set(resolve_toolset(name)))
except Exception:
tools = []
target_platform = _toolset_configuration_platform(name)
if name in _CONFIG_ONLY_TOOLSETS:
# Config-only capabilities (stt) have no per-platform toolset —
# their switch is their own config section (e.g. stt.enabled).
from utils import is_truthy_value
section = config.get(name)
section = section if isinstance(section, dict) else {}
is_enabled = is_truthy_value(section.get("enabled", True), default=True)
else:
is_enabled = name in enabled_by_platform[target_platform]
result.append({
"name": name,
"label": gui_toolset_label(label),
"description": desc,
"platform": target_platform,
"platform_label": gui_toolset_label(
platform_label(target_platform, target_platform)
),
"enabled": is_enabled,
"available": is_enabled,
"configured": _toolset_has_keys(name, config),
"tools": tools,
})
return result
@router.put("/api/tools/toolsets/{name}")
async def toggle_toolset(name: str, body: ToolsetToggle, profile: Optional[str] = None):
"""Enable/disable a configurable toolset for its configuration platform.
Most toolsets persist to ``platform_toolsets.cli``. Platform-restricted
toolsets instead target their supported platform (for example, Discord's
native toolsets persist to ``platform_toolsets.discord``). The shared
``_save_platform_tools`` helper keeps the GUI and CLI in lockstep. Scoped
to ``body.profile`` when provided. Returns 400 for unknown toolset keys.
"""
from hermes_cli.tools_config import (
_CONFIG_ONLY_TOOLSETS,
_get_effective_configurable_toolsets,
_get_platform_tools,
_save_platform_tools,
_toolset_configuration_platform,
)
valid = {ts_key for ts_key, _, _ in _get_effective_configurable_toolsets()}
if name not in valid:
raise HTTPException(status_code=400, detail=f"Unknown toolset: {name}")
target_platform = _toolset_configuration_platform(name)
if name in _CONFIG_ONLY_TOOLSETS:
# Config-only capabilities (stt) toggle their own config section's
# ``enabled`` flag — there is no platform_toolsets entry to write.
with _profile_scope(body.profile or profile):
config = load_config()
section = config.setdefault(name, {})
if not isinstance(section, dict):
section = {}
config[name] = section
section["enabled"] = bool(body.enabled)
save_config(config)
return {
"ok": True,
"name": name,
"platform": target_platform,
"enabled": body.enabled,
}
with _profile_scope(body.profile or profile):
config = load_config()
enabled = set(
_get_platform_tools(
config,
target_platform,
include_default_mcp_servers=False,
)
)
if body.enabled:
enabled.add(name)
else:
enabled.discard(name)
_save_platform_tools(config, target_platform, enabled)
return {
"ok": True,
"name": name,
"platform": target_platform,
"enabled": body.enabled,
}
@router.get("/api/tools/toolsets/{name}/config")
async def get_toolset_config(name: str, profile: Optional[str] = None):
"""Return the provider matrix + key status for a toolset's config panel.
Surfaces the same provider rows the CLI ``hermes tools`` picker shows
(via ``_visible_providers``), each with its ``env_vars`` annotated with
current ``is_set`` state so the GUI can render provider selection + key
entry. Toolsets without a ``TOOL_CATEGORIES`` entry return an empty
provider list and ``has_category: false``. Returns 400 for unknown keys.
"""
from hermes_cli.tools_config import (
TOOL_CATEGORIES,
_get_effective_configurable_toolsets,
_is_provider_active,
_visible_providers,
provider_readiness_status,
web_provider_capabilities,
)
from hermes_cli.config import get_env_value
from hermes_cli.nous_subscription import get_nous_subscription_features
valid = {ts_key for ts_key, _, _ in _get_effective_configurable_toolsets()}
if name not in valid:
raise HTTPException(status_code=400, detail=f"Unknown toolset: {name}")
with _profile_scope(profile):
config = load_config()
cat = TOOL_CATEGORIES.get(name)
providers = []
active_provider = None
active_search_backend = None
active_extract_backend = None
if cat:
# Fetch portal/entitlement state once for the whole matrix — the
# per-provider readiness computation below reuses it instead of
# re-probing per row.
features = get_nous_subscription_features(config, force_fresh=True)
for prov in _visible_providers(cat, config, force_fresh=True):
env_vars = [
{
"key": e["key"],
"prompt": e.get("prompt", e["key"]),
"url": e.get("url"),
"default": e.get("default"),
"is_set": bool(get_env_value(e["key"])),
}
for e in prov.get("env_vars", [])
]
# Surface the same active-provider determination the CLI picker
# uses (``_is_provider_active``) so the GUI highlights the provider
# actually written to config (e.g. web.backend), not just the first
# keyless one in the list.
is_active = _is_provider_active(prov, config, force_fresh=True)
if is_active and active_provider is None:
active_provider = prov["name"]
row = {
"name": prov["name"],
"badge": prov.get("badge", ""),
"tag": prov.get("tag", ""),
"env_vars": env_vars,
"post_setup": prov.get("post_setup"),
"requires_nous_auth": bool(prov.get("requires_nous_auth")),
"is_active": is_active,
# Honest server-side readiness. The GUI's old client-side
# heuristic showed "Ready" for every zero-env-var row —
# including logged-out Nous Subscription rows and never-run
# post_setup installs (see provider_readiness_status).
"status": provider_readiness_status(
prov, config, features=features, is_active=is_active
),
}
if name == "web" and prov.get("web_backend"):
# The runtime split web into two capabilities long ago
# (web.search_backend / web.extract_backend); surface each
# row's backend key and which capabilities it can serve so
# the GUI can offer per-capability selection.
row["web_backend"] = prov["web_backend"]
row["capabilities"] = web_provider_capabilities(prov["web_backend"])
if name == "tts" and prov.get("tts_provider"):
# The provider key written to tts.provider on selection.
# Doubles as the config section holding the provider's
# voice/model settings (tts.<key>.*) so the GUI can render
# those fields inline in the Capabilities panel.
row["tts_provider"] = prov["tts_provider"]
providers.append(row)
if name == "web":
# Resolve the per-capability active backends exactly the way the
# web_search / web_extract dispatchers do (per-capability key →
# shared web.backend → credential auto-detect), so the GUI badges
# reflect what a tool call would actually hit right now.
try:
from tools.web_tools import _get_extract_backend, _get_search_backend
active_search_backend = _get_search_backend()
active_extract_backend = _get_extract_backend()
except Exception:
active_search_backend = None
active_extract_backend = None
payload = {
"name": name,
"has_category": cat is not None,
"providers": providers,
"active_provider": active_provider,
}
if name == "web":
payload["active_search_backend"] = active_search_backend
payload["active_extract_backend"] = active_extract_backend
return payload
@router.get("/api/tools/toolsets/{name}/models")
async def get_toolset_models(
name: str, provider: Optional[str] = None, profile: Optional[str] = None
):
"""Return the model catalog for a toolset backend (image/video gen).
The GUI counterpart of the model picker `hermes tools` runs after a
backend is selected — e.g. FAL's multi-model catalog (speed / strengths /
price per model). ``provider`` names a picker row; omitted, the currently
active provider is used. Toolsets without model catalogs return
``has_models: false``.
"""
section = _MODEL_CATALOG_TOOLSETS.get(name)
if section is None:
return {"name": name, "has_models": False, "models": [], "current": None, "default": None}
with _profile_scope(profile):
config = load_config()
row = _find_toolset_provider_row(name, config, provider)
plugin = _resolve_toolset_model_plugin(name, row) if row else None
if not plugin:
return {
"name": name,
"has_models": False,
"models": [],
"current": None,
"default": None,
}
catalog, default_model = _toolset_model_catalog(name, plugin)
section_cfg = config.get(section)
current = None
if isinstance(section_cfg, dict):
raw = section_cfg.get("model")
if isinstance(raw, str) and raw.strip():
current = raw.strip()
if current not in catalog:
current = default_model if default_model in catalog else None
models = [
{
"id": model_id,
"display": meta.get("display", model_id),
"speed": meta.get("speed", ""),
"strengths": meta.get("strengths", ""),
"price": meta.get("price", ""),
}
for model_id, meta in catalog.items()
]
return {
"name": name,
"has_models": bool(models),
"provider": row.get("name") if row else None,
"plugin": plugin,
"models": models,
"current": current,
"default": default_model,
}
@router.put("/api/tools/toolsets/{name}/model")
async def select_toolset_model(
name: str, body: ToolsetModelSelect, profile: Optional[str] = None
):
"""Persist a backend model selection (``image_gen.model`` / ``video_gen.model``).
Validates the model against the resolved backend's catalog — the same
write the CLI's post-selection model picker performs. Returns 400 for
toolsets without model catalogs or unknown model ids.
"""
section = _MODEL_CATALOG_TOOLSETS.get(name)
if section is None:
raise HTTPException(
status_code=400, detail=f"Toolset has no model catalog: {name}"
)
model_id = (body.model or "").strip()
if not model_id:
raise HTTPException(status_code=400, detail="model is required")
with _profile_scope(body.profile or profile):
config = load_config()
row = _find_toolset_provider_row(name, config, body.provider)
plugin = _resolve_toolset_model_plugin(name, row) if row else None
if not plugin:
raise HTTPException(
status_code=400,
detail=f"No model-capable backend is active for {name}",
)
catalog, _default = _toolset_model_catalog(name, plugin)
if model_id not in catalog:
raise HTTPException(
status_code=400,
detail=f"Unknown model {model_id!r} for backend {plugin!r}",
)
section_cfg = config.setdefault(section, {})
if not isinstance(section_cfg, dict):
section_cfg = {}
config[section] = section_cfg
section_cfg["model"] = model_id
save_config(config)
return {"ok": True, "name": name, "model": model_id, "plugin": plugin}
@router.put("/api/tools/toolsets/{name}/provider")
async def select_toolset_provider(
name: str, body: ToolsetProviderSelect, profile: Optional[str] = None
):
"""Persist a provider selection for a toolset (no key prompting).
Delegates to ``apply_provider_selection`` — the shared, non-interactive
core extracted from the CLI configurator — so the GUI and ``hermes tools``
write identical config keys (``web.backend``, ``tts.provider``, etc.).
API keys and post-setup flows are handled by separate endpoints. Returns
400 for unknown toolset or provider names.
For the ``web`` toolset only, an optional ``capability`` ('search' |
'extract') scopes the selection to ``web.search_backend`` /
``web.extract_backend`` — the same per-capability overrides the runtime
dispatchers (``tools.web_tools._get_search_backend`` /
``_get_extract_backend``) resolve first. The provider must actually
support the requested capability (a search-only backend can't be the
extract backend). Omitting ``capability`` keeps the legacy whole-provider
behavior (writes ``web.backend``).
Managed Nous rows (``managed_nous_feature``) additionally report the
Portal entitlement state: the CLI flow gates these selections on
``ensure_nous_portal_access`` (inline login), but the GUI has no inline
prompt, so selecting one while logged out / unentitled used to write the
config keys and then never activate (``_is_provider_active`` requires
``managed_by_nous``). The response now carries an additive
``needs_nous_auth: true`` + ``feature`` so the client can drive the
existing Nous Portal OAuth flow (``POST /api/providers/oauth/nous/start``)
and refetch.
"""
from hermes_cli.tools_config import (
TOOL_CATEGORIES,
apply_provider_selection,
web_provider_capabilities,
_get_effective_configurable_toolsets,
_visible_providers,
)
from hermes_cli.nous_subscription import (
MANAGED_FEATURE_COVERAGE_CATEGORY,
get_nous_subscription_features,
)
valid = {ts_key for ts_key, _, _ in _get_effective_configurable_toolsets()}
if name not in valid:
raise HTTPException(status_code=400, detail=f"Unknown toolset: {name}")
if body.capability is not None:
if name != "web":
raise HTTPException(
status_code=400,
detail="capability selection is only supported for the web toolset",
)
if body.capability not in ("search", "extract"):
raise HTTPException(
status_code=400,
detail=f"Unknown capability: {body.capability!r} (expected 'search' or 'extract')",
)
with _profile_scope(body.profile or profile):
config = load_config()
if body.capability is not None:
# Per-capability path: resolve the picker row to its backend key
# and write web.<capability>_backend. Does NOT touch web.backend,
# so the other capability keeps resolving through the shared
# fallback chain.
cat = TOOL_CATEGORIES.get(name)
providers = _visible_providers(cat, config, force_fresh=True) if cat else []
prov = next((p for p in providers if p.get("name") == body.provider), None)
if prov is None:
raise HTTPException(
status_code=400,
detail=f"Unknown provider {body.provider!r} for toolset {name!r}",
)
backend = prov.get("web_backend")
if not backend:
raise HTTPException(
status_code=400,
detail=f"Provider {body.provider!r} has no web backend key",
)
if body.capability not in web_provider_capabilities(backend):
raise HTTPException(
status_code=400,
detail=f"{body.provider} does not support {body.capability}",
)
web_cfg = config.setdefault("web", {})
if not isinstance(web_cfg, dict):
web_cfg = {}
config["web"] = web_cfg
web_cfg[f"{body.capability}_backend"] = backend
else:
try:
apply_provider_selection(name, body.provider, config)
except KeyError as exc:
raise HTTPException(status_code=400, detail=str(exc).strip('"'))
save_config(config)
response: Dict[str, Any] = {"ok": True, "name": name, "provider": body.provider}
if body.capability is not None:
response["capability"] = body.capability
# Entitlement check for managed Nous rows — mirrors the gate the CLI
# applies via ensure_nous_portal_access at selection time.
cat = TOOL_CATEGORIES.get(name)
row = None
if cat:
row = next(
(
p
for p in _visible_providers(cat, config, force_fresh=True)
if p.get("name") == body.provider
),
None,
)
managed_feature = (row or {}).get("managed_nous_feature")
if managed_feature:
features = get_nous_subscription_features(config, force_fresh=True)
acct = features.account_info
category = MANAGED_FEATURE_COVERAGE_CATEGORY.get(managed_feature)
entitled = bool(
acct
and acct.logged_in
and (
acct.tool_gateway_entitled_for(category)
if category
else acct.tool_gateway_entitled
)
)
if not entitled:
response["needs_nous_auth"] = True
response["feature"] = managed_feature
return response
@router.put("/api/tools/toolsets/{name}/env")
async def save_toolset_env(name: str, body: ToolsetEnvUpdate, profile: Optional[str] = None):
"""Persist API keys for a toolset's provider env vars.
Writes each ``key: value`` to ``~/.hermes/.env`` via ``save_env_value`` —
the same store ``hermes tools`` writes when it prompts for keys. Keys are
validated against the env-var allowlist for the toolset's category (the
union of every visible provider's ``env_vars``), so the GUI can't write an
arbitrary env var through this endpoint. A blank value is treated as
"leave unchanged" and skipped. Returns the saved/skipped key lists and the
refreshed ``is_set`` status. Returns 400 for unknown toolset or env keys.
"""
from hermes_cli.tools_config import (
TOOL_CATEGORIES,
_get_effective_configurable_toolsets,
_visible_providers,
)
from hermes_cli.config import get_env_value, save_env_value
valid_ts = {ts_key for ts_key, _, _ in _get_effective_configurable_toolsets()}
if name not in valid_ts:
raise HTTPException(status_code=400, detail=f"Unknown toolset: {name}")
with _profile_scope(body.profile or profile):
config = load_config()
cat = TOOL_CATEGORIES.get(name)
allowed: set[str] = set()
if cat:
for prov in _visible_providers(cat, config, force_fresh=True):
for e in prov.get("env_vars", []):
allowed.add(e["key"])
unknown = [k for k in body.env if k not in allowed]
if unknown:
raise HTTPException(
status_code=400,
detail=f"Unknown env var(s) for toolset {name}: {', '.join(sorted(unknown))}",
)
saved: List[str] = []
skipped: List[str] = []
for key, value in body.env.items():
if value and value.strip():
try:
save_env_value(key, value.strip())
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
saved.append(key)
else:
skipped.append(key)
status = {k: bool(get_env_value(k)) for k in allowed}
return {"ok": True, "name": name, "saved": saved, "skipped": skipped, "is_set": status}
@router.post("/api/tools/toolsets/{name}/post-setup")
async def run_toolset_post_setup(
name: str, body: ToolsetPostSetup, profile: Optional[str] = None
):
"""Spawn a provider's post-setup install hook as a background action.
Post-setup hooks (npm install for browser/Camofox, pip install for
KittenTTS/Piper/ddgs, cua-driver fetch, etc.) are long-running and
text-output, so this follows the spawn-action pattern: it launches
``hermes tools post-setup <key>`` and the frontend tails the log via
``GET /api/actions/tools-post-setup/status``. The ``key`` is validated
against the declared post-setup allowlist before spawning. Returns 400
for unknown toolset or post-setup key.
``profile`` spawns the hook as ``hermes -p <profile> tools post-setup``.
Most hooks install machine-level artifacts (repo node_modules, shared
pip packages) where the scope is inert, but hooks that read config or
write per-profile state must see the same HERMES_HOME the rest of the
drawer's writes targeted — so the scope is threaded for consistency.
"""
from hermes_cli.tools_config import (
_get_effective_configurable_toolsets,
valid_post_setup_keys,
)
valid_ts = {ts_key for ts_key, _, _ in _get_effective_configurable_toolsets()}
if name not in valid_ts:
raise HTTPException(status_code=400, detail=f"Unknown toolset: {name}")
if body.key not in valid_post_setup_keys():
raise HTTPException(
status_code=400, detail=f"Unknown post-setup key: {body.key}"
)
try:
proc = _spawn_hermes_action(
_profile_cli_args(body.profile or profile)
+ ["tools", "post-setup", body.key],
"tools-post-setup",
)
except HTTPException:
raise
except Exception as exc:
_log.exception("Failed to spawn tools post-setup")
raise HTTPException(
status_code=500, detail=f"Failed to run post-setup: {exc}"
)
return {"ok": True, "pid": proc.pid, "name": "tools-post-setup", "key": body.key}
@router.get("/api/tools/terminal/backends")
async def get_terminal_backends(profile: Optional[str] = None):
"""Terminal execution backend rows with health probes for the picker panel.
Returns ``{active, backends: [{name, label, description, active, status,
detail}]}`` where ``status`` is ``ready`` / ``needs_setup`` /
``unavailable`` and ``detail`` carries setup guidance for non-ready rows.
Probes are fast (<~2s each) and defensive — a probe failure surfaces as a
status, never an error response.
"""
with _profile_scope(profile):
config = load_config()
terminal_cfg = config.get("terminal")
if not isinstance(terminal_cfg, dict):
terminal_cfg = {}
active = str(terminal_cfg.get("backend") or "local").strip().lower()
if active not in _TERMINAL_BACKEND_NAMES:
active = "local"
backends = []
for row in _TERMINAL_BACKENDS:
status, detail = _probe_terminal_backend(row["name"], terminal_cfg)
backends.append({
"name": row["name"],
"label": row["label"],
"description": row["description"],
"active": row["name"] == active,
"status": status,
"detail": detail,
})
return {"active": active, "backends": backends}
@router.put("/api/tools/terminal/backend")
async def select_terminal_backend(
body: TerminalBackendSelect, profile: Optional[str] = None
):
"""Persist ``terminal.backend`` in config.yaml.
Validates against the known backend set (the same enum the raw-config
settings row exposes). Selecting a backend that still needs setup is
allowed — the picker shows guidance instead of blocking, matching the CLI.
"""
backend = (body.backend or "").strip().lower()
if backend not in _TERMINAL_BACKEND_NAMES:
raise HTTPException(
status_code=400,
detail=f"Unknown terminal backend: {body.backend!r}. "
f"Use one of: {', '.join(sorted(_TERMINAL_BACKEND_NAMES))}",
)
with _profile_scope(body.profile or profile):
config = load_config()
terminal_cfg = config.setdefault("terminal", {})
if not isinstance(terminal_cfg, dict):
terminal_cfg = {}
config["terminal"] = terminal_cfg
terminal_cfg["backend"] = backend
save_config(config)
return {"ok": True, "backend": backend}
@router.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()
@router.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"}