"""Toolset / terminal-backend dashboard routes. The toolset/terminal catalogs and helpers stay in web_server (some are defined *after* this router's mount point) — reached via the late-binding seam so monkeypatching on web_server stays authoritative. """ import asyncio import shutil import subprocess import sys from typing import Any, Dict, List, Optional from fastapi import APIRouter, HTTPException from hermes_cli.web_deps import late from hermes_cli.web_models import ( TerminalBackendSelect, ToolsetEnvUpdate, ToolsetModelSelect, ToolsetPostSetup, ToolsetProviderSelect, ToolsetToggle, ) from hermes_cli.web_routers._common import ( _CONFIG_MUTATION_LOCK, _profile_cli_args, _profile_scope, _spawn_hermes_action, config_write_scope, log as _log, scoped_to_thread, spawn_profile_action, ) router = APIRouter() load_config = late("load_config") save_config = late("save_config") run_in_threadpool = late("run_in_threadpool") _plugin_terminal_backend_rows = late("_plugin_terminal_backend_rows") def _terminal_cfg_value(terminal_cfg: dict, key: str, env_var: str) -> str: """Read a terminal.* setting from config.yaml, falling back to its env var.""" value = terminal_cfg.get(key) if value is not None and str(value).strip(): return str(value).strip() try: from hermes_cli.config import get_env_value return (get_env_value(env_var) or "").strip() except Exception: return "" def _terminal_backend_rows() -> List[Dict[str, str]]: """Built-in picker rows plus plugin-registered backends (request time). Computed per request (mirrors ``_schema_with_dynamic_provider_options``) so a plugin installed after server start still shows up. """ from hermes_cli.web_server import _TERMINAL_BACKENDS return [*_TERMINAL_BACKENDS, *_plugin_terminal_backend_rows()] def _probe_docker_backend() -> tuple: if not shutil.which("docker"): return ( "needs_setup", "Docker CLI not found — install Docker Desktop or docker-ce.", ) try: proc = subprocess.run( ["docker", "info", "--format", "{{.ServerVersion}}"], capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=2, ) if proc.returncode == 0: return ("ready", "") return ( "needs_setup", "Docker daemon not reachable — start Docker and retry.", ) except subprocess.TimeoutExpired: return ("needs_setup", "Docker daemon not responding (timed out).") except Exception as exc: return ("unavailable", f"Docker probe failed: {exc}") def _probe_singularity_backend() -> tuple: if shutil.which("singularity") or shutil.which("apptainer"): return ("ready", "") return ( "needs_setup", "Neither singularity nor apptainer found on PATH.", ) def _probe_ssh_backend(terminal_cfg: dict) -> tuple: host = _terminal_cfg_value(terminal_cfg, "ssh_host", "TERMINAL_SSH_HOST") user = _terminal_cfg_value(terminal_cfg, "ssh_user", "TERMINAL_SSH_USER") missing = [] if not host: missing.append("terminal.ssh_host") if not user: missing.append("terminal.ssh_user") if missing: return ( "needs_setup", f"Set {' and '.join(missing)} in config.yaml (or the matching TERMINAL_SSH_* env vars).", ) return ("ready", f"{user}@{host}") def _probe_modal_backend() -> tuple: try: from tools.tool_backend_helpers import has_direct_modal_credentials if has_direct_modal_credentials(): return ("ready", "") except Exception: pass try: from hermes_cli.config import get_env_value if get_env_value("MODAL_TOKEN_ID") and get_env_value("MODAL_TOKEN_SECRET"): return ("ready", "") except Exception: pass return ( "needs_setup", "Modal credentials not found — set MODAL_TOKEN_ID and MODAL_TOKEN_SECRET (or run `modal setup`).", ) def _probe_daytona_backend() -> tuple: try: from hermes_cli.config import get_env_value if get_env_value("DAYTONA_API_KEY"): return ("ready", "") except Exception: pass return ("needs_setup", "Set DAYTONA_API_KEY to use the Daytona backend.") # Built-ins + plugin-registered backends, computed per request so a plugin # installed after server start still shows up. # Toolsets whose backends carry a selectable model catalog, mapped to the # config.yaml section their `model` key lives in. Mirrors the CLI's # post-selection model pickers (`_configure_imagegen_model_for_plugin` / # `_configure_videogen_model_for_plugin` in tools_config.py). _MODEL_CATALOG_TOOLSETS = { "image_gen": "image_gen", "video_gen": "video_gen", } def _resolve_toolset_model_plugin(ts_key: str, provider_row: dict) -> Optional[str]: """Map a provider picker row to its model-catalog plugin name. Plugin-backed rows carry ``image_gen_plugin_name`` / ``video_gen_plugin_name``; the managed "Nous Subscription" image row instead carries the legacy ``imagegen_backend: "fal"`` marker (same underlying FAL catalog). """ if ts_key == "image_gen": return provider_row.get("image_gen_plugin_name") or ( "fal" if provider_row.get("imagegen_backend") else None ) if ts_key == "video_gen": return provider_row.get("video_gen_plugin_name") return None def _toolset_model_catalog(ts_key: str, plugin_name: str): """Return ``(catalog_dict, default_model)`` for a toolset's plugin backend.""" from hermes_cli.tools_config import ( _plugin_image_gen_catalog, _plugin_video_gen_catalog, ) if ts_key == "image_gen": return _plugin_image_gen_catalog(plugin_name) return _plugin_video_gen_catalog(plugin_name) def _find_toolset_provider_row(ts_key: str, config: dict, provider: Optional[str]) -> Optional[dict]: """Resolve a provider picker row by name, or the active row when omitted.""" from hermes_cli.tools_config import ( TOOL_CATEGORIES, _is_provider_active, _visible_providers, ) cat = TOOL_CATEGORIES.get(ts_key) if cat is None: return None rows = _visible_providers(cat, config, force_fresh=True) if provider: return next((p for p in rows if p.get("name") == provider), None) return next( (p for p in rows if _is_provider_active(p, config, force_fresh=True)), None ) def _terminal_backend_names() -> set: """Valid ``terminal.backend`` values, including plugin backends.""" return {row["name"] for row in _terminal_backend_rows()} def _probe_terminal_backend(name: str, terminal_cfg: dict) -> tuple: """Return ``(status, detail)`` for one backend. Never raises.""" try: if name == "local": return ("ready", "") if name == "docker": return _probe_docker_backend() if name == "singularity": return _probe_singularity_backend() if name == "ssh": return _probe_ssh_backend(terminal_cfg) if name == "modal": return _probe_modal_backend() if name == "daytona": return _probe_daytona_backend() try: from agent.terminal_env_registry import get_provider provider = get_provider(name) if provider is not None: return provider.probe() except Exception: pass return ("unavailable", f"Unknown backend: {name}") except Exception as exc: # pragma: no cover — belt-and-braces guard return ("unavailable", f"Probe failed: {exc}") def _require_known_toolset(name: str) -> None: """400 for toolset keys outside the effective configurable set.""" from hermes_cli.tools_config import _get_effective_configurable_toolsets if name not in {ts_key for ts_key, _, _ in _get_effective_configurable_toolsets()}: raise HTTPException(status_code=400, detail=f"Unknown toolset: {name}") def _dict_section(config: dict, key: str) -> dict: """``config[key]`` as a dict, replacing a non-dict value in place.""" section = config.setdefault(key, {}) if not isinstance(section, dict): section = {} config[key] = section return section def _no_models(name: str) -> dict: return {"name": name, "has_models": False, "models": [], "current": None, "default": None} @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, get_nous_subscription_features, gui_toolset_label, ) from hermes_cli.platforms import platform_label from toolsets import resolve_toolset def _read(): 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 } features = get_nous_subscription_features(config) return config, toolset_rows, enabled_by_platform, features config, toolset_rows, enabled_by_platform, features = await run_in_threadpool(_read) 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, features=features), "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 (``platform_toolsets.cli`` for most; platform-restricted toolsets target their own platform) via the same ``_save_platform_tools`` the CLI uses.""" from hermes_cli.tools_config import ( _CONFIG_ONLY_TOOLSETS, _get_platform_tools, _save_platform_tools, _toolset_configuration_platform, ) _require_known_toolset(name) target_platform = _toolset_configuration_platform(name) def _run(): with config_write_scope(body.profile or profile): config = load_config() if name in _CONFIG_ONLY_TOOLSETS: # Config-only capabilities (stt) toggle their own section's # ``enabled`` flag — there is no platform_toolsets entry. _dict_section(config, name)["enabled"] = bool(body.enabled) save_config(config) return 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) await asyncio.to_thread(_run) # Install-on-enable: a provider with a post_setup hook whose install-state # predicate is UNSATISFIED (cua-driver binary missing, etc.) gets the same # background install `hermes tools` runs interactively — otherwise the # toggle "saves" but the tool never appears because its check_fn can't # find the binary. Best-effort: a spawn failure never fails the toggle. post_setup_started: Optional[str] = None if body.enabled and name not in _CONFIG_ONLY_TOOLSETS: def _pending_install_key() -> Optional[str]: from hermes_cli.tools_config import ( TOOL_CATEGORIES, _post_setup_already_installed, _visible_providers, ) cat = TOOL_CATEGORIES.get(name) if not cat: return None with _profile_scope(body.profile or profile): config = load_config() for prov in _visible_providers(cat, config): key = prov.get("post_setup") if key and not _post_setup_already_installed(key): return key return None try: pending_key = await asyncio.to_thread(_pending_install_key) if pending_key: _spawn_hermes_action( _profile_cli_args(body.profile or profile) + ["tools", "post-setup", pending_key], "tools-post-setup", ) post_setup_started = pending_key except Exception: _log.exception("install-on-enable post-setup spawn failed for %s", name) return { "ok": True, "name": name, "platform": target_platform, "enabled": body.enabled, "post_setup_started": post_setup_started, } @router.get("/api/tools/toolsets/{name}/config") async def get_toolset_config(name: str, profile: Optional[str] = None): """Provider matrix + key status for a toolset's config panel: the same rows the CLI ``hermes tools`` picker shows, each env var annotated with ``is_set``. No ``TOOL_CATEGORIES`` entry -> empty list, ``has_category: false``.""" from hermes_cli.tools_config import ( TOOL_CATEGORIES, _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 _require_known_toolset(name) def _read(): 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: # Entitlement state fetched once for the whole matrix. 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", []) ] # Same active-provider determination as the CLI picker, so the # GUI highlights the provider actually written to config. 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, # Server-side readiness: zero-env-var rows are NOT # automatically ready (logged-out Nous rows, never-run # post_setup installs). "status": provider_readiness_status( prov, config, features=features, is_active=is_active ), } if name == "web" and prov.get("web_backend"): # web is two capabilities (search/extract); surface each # row's backend key + capabilities for 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"): # Key written to tts.provider; doubles as the config section # (tts..*) holding the provider's voice/model settings. row["tts_provider"] = prov["tts_provider"] providers.append(row) if name == "web": # Resolve active backends exactly as the web_search/web_extract # dispatchers do, so badges reflect what a call would hit 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 return cat, providers, active_provider, active_search_backend, active_extract_backend cat, providers, active_provider, active_search_backend, active_extract_backend = await asyncio.to_thread(_read) 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 ): """Model catalog for a toolset backend (image/video gen) — the GUI counterpart of the CLI model picker. ``provider`` names a picker row (default: the active provider); no catalog -> ``has_models: false``.""" section = _MODEL_CATALOG_TOOLSETS.get(name) if section is None: return _no_models(name) def _read(): 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 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 return row, plugin, catalog, default_model, current resolved = await asyncio.to_thread(_read) if resolved is None: return _no_models(name) row, plugin, catalog, default_model, current = resolved 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``), validated against the resolved backend's catalog.""" 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") def _run(): with config_write_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}", ) _dict_section(config, section)["model"] = model_id save_config(config) return plugin plugin = await asyncio.to_thread(_run) 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 via ``apply_provider_selection`` (the non-interactive core shared with ``hermes tools``, so both write identical config keys). Keys and post-setup are separate endpoints. ``web`` only: ``capability`` ('search' | 'extract') scopes the write to ``web._backend`` (the per-capability override the runtime dispatchers resolve first); the provider must support that capability. Omitted -> legacy whole-provider write of ``web.backend``. Managed Nous rows report Portal entitlement: the GUI has no inline login, so an unentitled selection would write config and never activate. The response adds ``needs_nous_auth: true`` + ``feature`` so the client can drive the Nous Portal OAuth flow and refetch. """ from hermes_cli.tools_config import ( TOOL_CATEGORIES, apply_provider_selection, web_provider_capabilities, _visible_providers, ) from hermes_cli.nous_subscription import ( MANAGED_FEATURE_COVERAGE_CATEGORY, get_nous_subscription_features, ) _require_known_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')", ) def _provider_row(config): cat = TOOL_CATEGORIES.get(name) providers = _visible_providers(cat, config, force_fresh=True) if cat else [] return next((p for p in providers if p.get("name") == body.provider), None) def _run(): with _profile_scope(body.profile or profile): with _CONFIG_MUTATION_LOCK: config = load_config() if body.capability is not None: # Per-capability path writes web._backend only — # web.backend is untouched so the other capability keeps # resolving through the shared fallback chain. prov = _provider_row(config) 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}", ) _dict_section(config, "web")[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 CLI's # ensure_nous_portal_access gate). Hits the Portal, so it runs AFTER # releasing the mutation lock — still in the worker thread + scope. row = _provider_row(config) 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 return await asyncio.to_thread(_run) @router.put("/api/tools/toolsets/{name}/env") async def save_toolset_env(name: str, body: ToolsetEnvUpdate, profile: Optional[str] = None): """Persist API keys to ``.env`` via ``save_env_value``. Keys are validated against the union of the category's visible-provider ``env_vars`` so this can't write arbitrary env vars; a blank value means "leave unchanged".""" from hermes_cli.tools_config import TOOL_CATEGORIES, _visible_providers from hermes_cli.config import get_env_value, save_env_value _require_known_toolset(name) def _run(): 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 saved, skipped, status saved, skipped, status = await asyncio.to_thread(_run) 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 ``hermes tools post-setup `` (long-running installs: npm, pip, cua-driver fetch) as a background action the frontend tails via ``GET /api/actions/tools-post-setup/status``. The key is validated against the declared allowlist; ``profile`` is threaded so hooks that touch config see the same HERMES_HOME as the drawer's other writes.""" from hermes_cli.tools_config import valid_post_setup_keys _require_known_toolset(name) if body.key not in valid_post_setup_keys(): raise HTTPException( status_code=400, detail=f"Unknown post-setup key: {body.key}" ) result = spawn_profile_action( body.profile or profile, ["tools", "post-setup", body.key], "tools-post-setup", log_msg="Failed to spawn tools post-setup", prefix="Failed to run post-setup", ) result["key"] = body.key return result @router.get("/api/tools/terminal/backends") async def get_terminal_backends(profile: Optional[str] = None): """Terminal backend rows with health probes: ``status`` is ``ready`` / ``needs_setup`` / ``unavailable``; a probe failure is a status, never an error response.""" def _read(): with _profile_scope(profile): config = load_config() terminal_cfg = config.get("terminal") if not isinstance(terminal_cfg, dict): terminal_cfg = {} rows = _terminal_backend_rows() active = str(terminal_cfg.get("backend") or "local").strip().lower() if active not in {row["name"] for row in rows}: active = "local" backends = [] for row in rows: 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} return await asyncio.to_thread(_read) @router.put("/api/tools/terminal/backend") async def select_terminal_backend( body: TerminalBackendSelect, profile: Optional[str] = None ): """Persist ``terminal.backend``. A backend that still needs setup is allowed — the picker shows guidance instead of blocking, like the CLI.""" backend = (body.backend or "").strip().lower() valid_names = _terminal_backend_names() if backend not in valid_names: raise HTTPException( status_code=400, detail=f"Unknown terminal backend: {body.backend!r}. " f"Use one of: {', '.join(sorted(valid_names))}", ) def _run(): with config_write_scope(body.profile or profile): config = load_config() _dict_section(config, "terminal")["backend"] = backend save_config(config) await asyncio.to_thread(_run) return {"ok": True, "backend": backend} @router.get("/api/tools/computer-use/status") async def get_computer_use_status(profile: Optional[str] = None): """Computer Use readiness for the desktop card (payload shape: see ``tools.computer_use.permissions.computer_use_status``).""" from tools.computer_use.permissions import computer_use_status return await scoped_to_thread(profile, 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`` (macOS-only: launches CuaDriver via LaunchServices so the TCC dialog is attributed correctly). The frontend polls ``GET /api/actions/computer-use-grant/status``.""" if sys.platform != "darwin": raise HTTPException( status_code=400, detail="Computer Use permission grants are a macOS concept.", ) return spawn_profile_action( profile, ["computer-use", "permissions", "grant"], "computer-use-grant", log_msg="Failed to spawn computer-use permissions grant", prefix="Failed to request permissions", )