Files
hermes-agent/tui_gateway/methods_connectors.py
alt-glitch 31e59b9441 feat: setup agent can search the catalog and install plugins and skills through the approval card
The setup profile's `setup` toolset was empty. It now carries one tool, manage_catalog:

- search: catalog plugins (the Plugins tab's live catalog resolver) and hub skills, with
  whether each is already installed in the default profile. Read-only.
- install: opens the same connection operation manage_connections opens, with rows of kind
  plugin / skill. Nothing installs until the user approves a row. An approved row installs
  into `default` (or the profile the Advanced modal named) through dashboard_install_plugin /
  the hub's headless install, so the catalog pin, kill list, security scan and live
  activation (#119644) are the host's. The row settles with the live MCP tool names and the
  plugin's skill.

The model sends catalog ids and an action only; every other key is refused before anything
runs. An unknown id or a plugin this OS cannot run is drawn failed with the installer's own
text. Anywhere a catalog card cannot be drawn (TUI, CLI, messaging, registry dispatch) the
result is the `hermes plugins install` / `hermes skills install` pointer.

- contract: plugin/skill targets follow the MCP transitions.
- run.apply_answer / reissue route a card answer to the module that owns the operation.
- tool_search: `setup` joins the direct-surface toolsets, so the guide's one tool is never
  deferred behind tool_search.
- docs: tools reference, toolsets reference, plugin catalog page.

Linear NS-964.
2026-09-23 08:04:33 +05:30

442 lines
18 KiB
Python

import contextlib
import contextvars
from .method_ctx import HandlerRegistry, bind_module
_registry = HandlerRegistry()
method = _registry.method
_CONNECTOR_RPC_METHODS = frozenset({"connectors.list", "connectors.connect", "connection.respond"})
_connector_rpc_origin: contextvars.ContextVar[tuple | None] = contextvars.ContextVar("connector_rpc_origin", default=None)
def _capture_connector_rpc_owner(params):
owner = params.get("owner") if isinstance(params, dict) else None
sid = owner.get("session_id") if isinstance(owner, dict) and owner.get("type") == "session" else ""
_, session = _current_session_steer_authority(sid if isinstance(sid, str) else "")
_connector_rpc_origin.set((session, session.get("profile_home") if session is not None else None))
def _connector_rpc_error(rid, code, reason, message):
return _err(rid, code, message, data={"reason": reason})
def _connector_auth_error(rid, exc):
from tui_gateway.contracts.connectors import ConnectorErrorReason
if exc.code in {"no_access", "ORG_ACCESS_DENIED"}:
return _connector_rpc_error(
rid, 4030, ConnectorErrorReason.org_access_denied,
"This account cannot manage connectors for this organization.",
)
if exc.status == 401 or exc.code in {"invalid_token", "INVALID_TOKEN", "NO_TOKEN"}:
return _connector_rpc_error(rid, 4032, ConnectorErrorReason.needs_nous_auth, "Sign in to use connectors.")
return _connector_rpc_error(
rid, 4030, ConnectorErrorReason.forbidden_scope, "Connector access is not permitted for this account."
)
def _connector_guard(fn):
def handler(rid, params):
from tools.connectors.gateway.errors import GatewayAuthError
from tui_gateway.contracts.connectors import ConnectorErrorReason
try:
return fn(rid, params)
except ProfileUnavailableError:
raise
except GatewayAuthError as exc:
return _connector_auth_error(rid, exc)
except Exception:
return _connector_rpc_error(
rid, 5034, ConnectorErrorReason.connector_request_failed, "Connector request failed. Try again explicitly."
)
return handler
def _connector_owner_matches(sid, owner, profile_home):
_, current = _current_session_steer_authority(sid)
return current is owner and not owner.get("_finalized") and owner.get("profile_home") == profile_home
def _session_owner(rid, owner):
from tui_gateway.contracts.connectors import ConnectorErrorReason
sid = owner.session_id
_, session = _current_session_steer_authority(sid)
origin = _connector_rpc_origin.get()
if (session is None or session.get("_finalized")
or origin is not None and (origin[0] is not session or origin[1] != session.get("profile_home"))):
return None, _connector_rpc_error(
rid, 4001, ConnectorErrorReason.not_owner, "session not found or not owned by this transport"
)
if _session_uses_compute_host(session):
return None, _connector_rpc_error(
rid, 5033, ConnectorErrorReason.unsupported_runtime, "Connectors must be managed on the session's compute host."
)
return session, None
def _account_gate_closed(rid):
from tools.connectors import connectors_available
from tui_gateway.contracts.connectors import ConnectorErrorReason
if connectors_available():
return None
return _connector_rpc_error(rid, 4031, ConnectorErrorReason.connectors_unavailable, "Connectors are not available.")
def _parse_params(rid, params, model):
from pydantic import ValidationError
from tui_gateway.contracts.connectors import ConnectorErrorReason
try:
return model.model_validate(params), None
except ValidationError:
return None, _connector_rpc_error(rid, 4000, ConnectorErrorReason.invalid_params, "Connector parameters are invalid.")
def _connector_params(rid, params, model):
request, error = _parse_params(rid, params, model)
if error:
return None, None, error
if request.owner.type == "account":
return request, None, None
session, error = _session_owner(rid, request.owner)
return request, session, error
def _session_connector_gate(rid, session, action):
import model_tools
from tools.connectors import connectors_available
from tui_gateway.contracts.connectors import ConnectorErrorReason
agent = session.get("agent")
enabled = agent.enabled_toolsets if agent is not None else _load_enabled_toolsets(_resolve_agent_platform(_session_source(session)))
disabled = agent.disabled_toolsets if agent is not None else None
if "manage_connections" not in model_tools._select_tool_names(enabled, disabled, quiet_mode=True) or not connectors_available():
if action == "status":
return enabled, disabled, _ok(rid, {"available": False, "connectors": []})
return enabled, disabled, _connector_rpc_error(
rid, 4031, ConnectorErrorReason.connectors_unavailable, "Connectors are not available in this session."
)
return enabled, disabled, None
def _session_connector_rpc(rid, request, session, action):
import json
import model_tools
import uuid
from tools.connectors import live
from tui_gateway.connector_payload import connector_ui_payload
from tui_gateway.contracts.connectors import ConnectorErrorReason
sid = request.owner.session_id
enabled, disabled, error = _session_connector_gate(rid, session, action)
if error:
return error
if not _connector_owner_matches(sid, session, session.get("profile_home")):
return _connector_rpc_error(rid, 4001, ConnectorErrorReason.not_owner, "session ownership changed")
args = {"action": "reconnect" if action == "connect" and request.reconnect else action}
if action == "connect":
args["connectors"] = request.connectors
if action == "connect":
operation = live.current(session["session_key"], profile_home=session.get("profile_home"))
if operation is None:
return _connector_rpc_error(
rid, 4004, ConnectorErrorReason.unknown_operation, "No open connection operation for this session."
)
return _reissue(rid, operation, args)
raw = model_tools.handle_function_call(
"manage_connections",
args,
task_id=session["session_key"],
session_id=getattr(session.get("agent"), "session_id", None) or session["session_key"],
tool_call_id=f"connector-ui-{uuid.uuid4().hex}",
enabled_toolsets=enabled,
disabled_toolsets=disabled,
)
data = json.loads(raw) if isinstance(raw, str) else raw
if not isinstance(data, dict) or "error" in data:
return _connector_rpc_error(rid, 5034, ConnectorErrorReason.connector_request_failed, "Connector request failed. Try again explicitly.")
if action == "status":
if not isinstance(data.get("connectors"), list) or any(not isinstance(row, dict) for row in data["connectors"]):
return _connector_rpc_error(rid, 5034, ConnectorErrorReason.invalid_connector_response, "Connector service returned an invalid response.")
return _ok(rid, {"available": True, "connectors": _connector_rows(data["connectors"])})
if not isinstance(data.get("targets"), list):
return _connector_rpc_error(rid, 5034, ConnectorErrorReason.invalid_connector_response, "Connector service returned no authorization results.")
return _ok(rid, connector_ui_payload(data))
def _connector_rows(rows):
from tools.connectors.gateway.wire import ConnectorListItem
from tui_gateway.connector_payload import connector_ui_payload
return connector_ui_payload([
ConnectorListItem.model_validate(row).model_dump(mode="json", by_alias=False)
for row in rows
])
def _account_connector_list(rid):
from tools.connectors.managed import managed_client
return _ok(rid, {"available": True, "connectors": _connector_rows(managed_client().list_connectors())})
def _account_connector_connect(rid, request):
from tools.connectors import account
from tui_gateway.connector_payload import connector_ui_payload
from tui_gateway.contracts.connectors import ConnectorErrorReason
action = "reconnect" if request.reconnect else "connect"
try:
start = account.find_or_start_operation(request.connectors, action=action, profile_home=_account_home(request))
if not start.started:
from tools.connectors.contract import TargetState
targets = [start.operation.target(name) for name in request.connectors]
if any(target.state in (TargetState.failed, TargetState.expired) for target in targets):
return _reissue(rid, start.operation, {"connectors": request.connectors})
return _ok(rid, connector_ui_payload(_operation_view(start.operation)))
if not account.wait_for_prepare(start):
return _ok(rid, connector_ui_payload(_operation_view(start.operation)))
if start.failed:
return _connector_rpc_error(rid, 5034, ConnectorErrorReason.connector_request_failed, "Connector request failed. Try again explicitly.")
return _ok(rid, connector_ui_payload(_operation_view(start.operation)))
except ValueError:
return _connector_rpc_error(rid, 4000, ConnectorErrorReason.invalid_params, "Connector parameters are invalid.")
def _connector_rpc(rid, params, action):
from tui_gateway.contracts.connectors import ConnectorErrorReason, ConnectorsConnectParams, ConnectorsListParams
model = ConnectorsListParams if action == "status" else ConnectorsConnectParams
request, session, error = _connector_params(rid, params, model)
if error:
return error
if request.owner.type == "account":
with _account_scope(request):
if closed := _account_gate_closed(rid):
return _ok(rid, {"available": False, "connectors": []}) if action == "status" else closed
return _account_connector_list(rid) if action == "status" else _account_connector_connect(rid, request)
runtime_token = _current_runtime_session_record.set(session)
try:
profile_home = session.get("profile_home")
with _session_profile_runtime_scope({"profile_home": profile_home or str(_hermes_home)}):
tokens = _set_session_context(session["session_key"], cwd=_session_cwd(session), ui_session_id=request.owner.session_id)
try:
result = _session_connector_rpc(rid, request, session, action)
finally:
_clear_session_context(tokens)
if not _connector_owner_matches(request.owner.session_id, session, profile_home):
return _connector_rpc_error(rid, 4001, ConnectorErrorReason.not_owner, "session ownership changed")
return result
finally:
_current_runtime_session_record.reset(runtime_token)
def _reissue(rid, operation, args):
from tools.connectors.run import (
LINK_STILL_VALID,
MIXED_KINDS,
NOT_ALLOWED,
REFUSED,
SETTLED,
UNKNOWN_TARGET,
reissue,
)
from tui_gateway.connector_payload import connector_ui_payload
from tui_gateway.contracts.connectors import ConnectorErrorReason
reason = reissue(operation, args["connectors"])
if reason == UNKNOWN_TARGET:
return _connector_rpc_error(rid, 4004, ConnectorErrorReason.unknown_target, "No such target on the open operation.")
if reason == MIXED_KINDS:
return _connector_rpc_error(rid, 4000, ConnectorErrorReason.invalid_params, "One target kind per request.")
if reason == LINK_STILL_VALID:
return _connector_rpc_error(rid, 4002, ConnectorErrorReason.link_still_valid, "Reopen the stored link.")
if reason == SETTLED:
return _connector_rpc_error(rid, 4002, ConnectorErrorReason.reissue_refused, "The operation has settled.")
if reason == NOT_ALLOWED:
return _connector_rpc_error(rid, 4002, ConnectorErrorReason.reissue_refused, "This target cannot be run again.")
if reason == REFUSED:
return _connector_rpc_error(rid, 4002, ConnectorErrorReason.reissue_refused, "The target cannot be run again.")
return _ok(rid, connector_ui_payload(_operation_view(operation)))
def _operation_params(rid, params):
from tui_gateway.contracts.connectors_operation import ConnectionOperationParams
return _connector_params(rid, params, ConnectionOperationParams)
@method("connectors.list")
@_connector_guard
def _(rid, params):
return _connector_rpc(rid, params, "status")
@method("connectors.connect")
@_connector_guard
def _(rid, params):
return _connector_rpc(rid, params, "connect")
def _account_home(request):
home = _profile_home(request.profile)
return str(home) if home else None
@contextlib.contextmanager
def _account_scope(request):
with _session_profile_runtime_scope({"profile_home": _account_home(request)}):
tokens = _set_session_context("")
try:
yield
finally:
_clear_session_context(tokens)
def _operation_for_request(rid, request, session):
from tools.connectors import live
from tui_gateway.contracts.connectors import ConnectorErrorReason
if request.owner.type == "session":
operation = live.get(session["session_key"], request.op_id, profile_home=session.get("profile_home"))
else:
with _account_scope(request):
if closed := _account_gate_closed(rid):
return None, closed
operation = live.get_by_op_id(request.op_id, profile_home=_account_home(request))
if operation is None:
return None, _connector_rpc_error(rid, 4004, ConnectorErrorReason.unknown_operation, "No open operation with that op_id.")
return operation, None
@method("connectors.operation.status")
@_connector_guard
def _(rid, params):
from tui_gateway.connector_payload import connector_ui_payload
request, session, error = _operation_params(rid, params)
if error:
return error
operation, error = _operation_for_request(rid, request, session)
return error or _ok(rid, connector_ui_payload(_operation_view(operation)))
@method("connectors.operation.wake")
@_connector_guard
def _(rid, params):
request, session, error = _operation_params(rid, params)
if error:
return error
operation, error = _operation_for_request(rid, request, session)
if error:
return error
operation.wake.set()
return _ok(rid, {"status": "ok"})
@method("connection.respond")
@_connector_guard
def _(rid, params):
from pydantic import ValidationError
from tui_gateway.contracts.connectors import ConnectorErrorReason
from tui_gateway.contracts.connectors_operation import ConnectionAnswer
envelope = {key: value for key, value in params.items() if key != "result"}
request, session, error = _operation_params(rid, envelope)
if error:
return error
try:
answer = ConnectionAnswer.model_validate(params.get("result"))
except ValidationError:
return _connector_rpc_error(rid, 4002, ConnectorErrorReason.invalid_answer, "Connection answer is invalid.")
operation, error = _operation_for_request(rid, request, session)
if error:
return error
if request.owner.type == "account":
with _account_scope(request):
return _apply_connection_answer(rid, answer, operation)
with _session_profile_runtime_scope({"profile_home": session.get("profile_home") or str(_hermes_home)}):
return _apply_connection_answer(rid, answer, operation)
def _apply_connection_answer(rid, answer, operation):
from tools.connectors import live
from tools.connectors.contract import SettleReason
from tools.connectors.operation import IllegalTransition
from tools.connectors.run import apply_answer
from tui_gateway.contracts.connectors import ConnectorErrorReason
try:
apply_answer(operation, answer.model_dump_json(exclude_none=True))
except IllegalTransition:
return _connector_rpc_error(rid, 4002, ConnectorErrorReason.invalid_answer, "Connection answer is invalid.")
if not operation.settled and operation.all_resolved:
operation.settle(SettleReason.all_resolved)
if operation.settled:
live.close(operation)
return _ok(rid, {"status": "ok", "settled": operation.settled})
def _snapshot_view(snapshot):
return {**snapshot, "settled": snapshot.get("settled_at") is not None}
def _operation_view(operation):
return _snapshot_view(operation.snapshot())
def _connection_update(operation, change, snapshot):
from hermes_constants import get_process_hermes_home, hermes_home_key
from tui_gateway import server
from tui_gateway.connector_payload import connector_ui_payload
payload = _snapshot_view(snapshot)
if change:
payload.update(change)
if operation.session_key.startswith("account:"):
payload["targets"] = [
{key: value for key, value in target.items() if key not in ("connect_url", "connection_id")}
for target in payload["targets"]
]
payload["owner"] = {"type": "account"}
server._broadcast_global_event("connection.update", connector_ui_payload(payload))
return
with server._sessions_lock:
sid = next(
(
sid
for sid, session in server._sessions.items()
if session.get("session_key") == operation.session_key
and hermes_home_key(session.get("profile_home") or get_process_hermes_home()) == operation.profile_key
),
None,
)
if sid is not None:
payload["owner"] = {"type": "session", "session_id": sid}
server._emit("connection.update", sid, connector_ui_payload(payload))
def _install_update_hook():
from tools.connectors import operation as op_module
if getattr(op_module.ConnectionOperation, "_update_hook_installed", False):
return
op_module.ConnectionOperation._update_hook_installed = True
op_module.ConnectionOperation.on_change = staticmethod(_connection_update)
def register(server):
bind_module(globals(), server, skip=("_",))
server._LONG_HANDLERS = server._LONG_HANDLERS | _CONNECTOR_RPC_METHODS
_install_update_hook()