241 lines
11 KiB
Python
241 lines
11 KiB
Python
"""Session-scoped connector RPCs and the connection-operation bridge.
|
|
|
|
Live transport ownership, not renderer-supplied profile or identity, authorizes requests.
|
|
The operation itself lives in ``tools.connectors.live``; this module reads and drives it and
|
|
pushes every transition to the session as ``connection.update``.
|
|
"""
|
|
|
|
import contextvars
|
|
|
|
from .method_ctx import HandlerRegistry, bind_module
|
|
|
|
_registry = HandlerRegistry()
|
|
method = _registry.method
|
|
_CONNECTOR_RPC_METHODS = frozenset({"connectors.list", "connectors.connect"})
|
|
_connector_rpc_origin: contextvars.ContextVar[tuple | None] = contextvars.ContextVar("connector_rpc_origin", default=None)
|
|
|
|
|
|
def _capture_connector_rpc_owner(params):
|
|
sid = params.get("session_id")
|
|
_, owner = _current_session_steer_authority(sid if isinstance(sid, str) else "")
|
|
_connector_rpc_origin.set((owner, owner.get("profile_home") if owner is not None else None))
|
|
|
|
|
|
def _connector_rpc_error(rid, code, reason, message):
|
|
return _err(rid, code, message, data={"reason": reason})
|
|
|
|
|
|
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 _owned_session(rid, params):
|
|
"""(owner, None) for a session this transport owns; (None, error reply) otherwise."""
|
|
sid = params.get("session_id")
|
|
if not isinstance(sid, str) or not sid.strip():
|
|
return None, _connector_rpc_error(rid, 4000, "INVALID_PARAMS", "session_id required")
|
|
_, owner = _current_session_steer_authority(sid)
|
|
origin = _connector_rpc_origin.get()
|
|
if (owner is None or owner.get("_finalized")
|
|
or origin is not None and (origin[0] is not owner or origin[1] != owner.get("profile_home"))):
|
|
return None, _connector_rpc_error(rid, 4001, "NOT_OWNER", "session not found or not owned by this transport")
|
|
if _session_uses_compute_host(owner):
|
|
return None, _connector_rpc_error(rid, 5033, "UNSUPPORTED_RUNTIME", "Connectors must be managed on the session's compute host.")
|
|
return owner, None
|
|
|
|
|
|
def _connector_rpc(rid, params, action):
|
|
owner, error = _owned_session(rid, params)
|
|
if error:
|
|
return error
|
|
sid = params["session_id"]
|
|
allowed = {"session_id"} if action == "status" else {"session_id", "connectors", "reconnect"}
|
|
# ``profile`` is routing metadata, never authorization.
|
|
allowed.add("profile")
|
|
if set(params) - allowed:
|
|
return _connector_rpc_error(rid, 4000, "INVALID_PARAMS", "unsupported connector parameters")
|
|
args = {"action": action}
|
|
if action != "status":
|
|
import re
|
|
slugs = params.get("connectors")
|
|
if (not isinstance(slugs, list) or not slugs
|
|
or any(not isinstance(slug, str) or re.fullmatch(r"[a-z0-9][a-z0-9_-]*", slug) is None for slug in slugs)
|
|
or not isinstance(params.get("reconnect", False), bool)):
|
|
return _connector_rpc_error(rid, 4000, "INVALID_PARAMS", "connectors must be nonempty slugs; reconnect must be boolean")
|
|
args.update(action="reconnect" if params.get("reconnect", False) else "connect", connectors=slugs)
|
|
profile_home = owner.get("profile_home")
|
|
runtime_token = _current_runtime_session_record.set(owner)
|
|
try:
|
|
# Bind the launch profile to prevent ambient sibling-profile leakage.
|
|
scope = {"profile_home": profile_home or str(_hermes_home)}
|
|
with _session_profile_runtime_scope(scope):
|
|
tokens = _set_session_context(owner["session_key"], cwd=_session_cwd(owner), ui_session_id=sid)
|
|
try:
|
|
result = _dispatch_connector_rpc(rid, sid, owner, profile_home, args)
|
|
finally:
|
|
_clear_session_context(tokens)
|
|
if not _connector_owner_matches(sid, owner, profile_home):
|
|
return _connector_rpc_error(rid, 4001, "NOT_OWNER", "session ownership changed")
|
|
return result
|
|
except Exception:
|
|
# Do not expose exception strings: HTTP errors can contain credentials.
|
|
return _connector_rpc_error(rid, 5034, "CONNECTOR_REQUEST_FAILED", "Connector request failed. Try again explicitly.")
|
|
finally:
|
|
_current_runtime_session_record.reset(runtime_token)
|
|
|
|
|
|
def _dispatch_connector_rpc(rid, sid, owner, profile_home, args):
|
|
import model_tools
|
|
from tools.connectors import connectors_available, live
|
|
from tui_gateway.connector_payload import connector_ui_payload
|
|
|
|
agent = owner.get("agent")
|
|
enabled = (agent.enabled_toolsets if agent is not None
|
|
else _load_enabled_toolsets(_resolve_agent_platform(_session_source(owner))))
|
|
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 args["action"] == "status":
|
|
return _ok(rid, {"available": False, "connectors": []})
|
|
return _connector_rpc_error(rid, 4031, "CONNECTORS_UNAVAILABLE", "Connectors are not available in this session.")
|
|
if not _connector_owner_matches(sid, owner, profile_home):
|
|
return _connector_rpc_error(rid, 4001, "NOT_OWNER", "session ownership changed")
|
|
if args["action"] != "status" and (operation := live.current(owner["session_key"])) is not None:
|
|
# The card's Try again / Connect while the model's operation is open: reissue on that op.
|
|
return _reissue(rid, operation, args)
|
|
raw = model_tools.handle_function_call(
|
|
"manage_connections", args, task_id=owner["session_key"],
|
|
session_id=getattr(agent, "session_id", None) or owner["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, "CONNECTOR_REQUEST_FAILED", "Connector request failed or was refused by policy.")
|
|
if args["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, "INVALID_CONNECTOR_RESPONSE", "Connector service returned an invalid response.")
|
|
return _ok(rid, {"available": True, "connectors": connector_ui_payload(data["connectors"])})
|
|
if not isinstance(data.get("targets"), list):
|
|
return _connector_rpc_error(rid, 5034, "INVALID_CONNECTOR_RESPONSE", "Connector service returned no authorization results.")
|
|
return _ok(rid, connector_ui_payload(data))
|
|
|
|
|
|
def _reissue(rid, operation, args):
|
|
"""Re-mint links for the named targets on the open operation (user actor)."""
|
|
from tools.connectors.contract import Actor, TargetState
|
|
from tools.connectors.gateway.client import ConnectorClient
|
|
from tools.connectors.managed import mint
|
|
from tui_gateway.connector_payload import connector_ui_payload
|
|
|
|
# Only a dead link is re-minted. A waiting target already holds its link (minted up front);
|
|
# the card re-opens that one and never calls here for it.
|
|
targets = [operation.target(n) for n in args["connectors"]]
|
|
if any(t is None for t in targets):
|
|
return _connector_rpc_error(rid, 4004, "UNKNOWN_TARGET", "no such target on the open operation")
|
|
stale = [t.name for t in targets if t.state in (TargetState.failed, TargetState.expired)]
|
|
if len(stale) != len(targets):
|
|
return _connector_rpc_error(rid, 4002, "LINK_STILL_VALID",
|
|
"only a failed or expired target can be re-minted; reopen the stored link")
|
|
mint(ConnectorClient(), operation, stale, reinitiate=True, actor=Actor.user)
|
|
return _ok(rid, connector_ui_payload(_operation_view(operation)))
|
|
|
|
|
|
def _live_operation(rid, params, owner):
|
|
from tools.connectors import live
|
|
|
|
op_id = params.get("op_id")
|
|
if not isinstance(op_id, str) or not op_id:
|
|
return None, _connector_rpc_error(rid, 4000, "INVALID_PARAMS", "op_id required")
|
|
operation = live.get(owner["session_key"], op_id)
|
|
if operation is None:
|
|
return None, _connector_rpc_error(rid, 4004, "UNKNOWN_OPERATION", "no open operation with that op_id in this session")
|
|
return operation, None
|
|
|
|
|
|
@method("connectors.list")
|
|
def _(rid, params):
|
|
return _connector_rpc(rid, params, "status")
|
|
|
|
|
|
@method("connectors.connect")
|
|
def _(rid, params):
|
|
return _connector_rpc(rid, params, "connect")
|
|
|
|
|
|
@method("connectors.operation.status")
|
|
def _(rid, params):
|
|
from tui_gateway.connector_payload import connector_ui_payload
|
|
|
|
owner, error = _owned_session(rid, params)
|
|
if error:
|
|
return error
|
|
operation, error = _live_operation(rid, params, owner)
|
|
if error:
|
|
return error
|
|
return _ok(rid, connector_ui_payload(_operation_view(operation)))
|
|
|
|
|
|
@method("connection.respond")
|
|
def _(rid, params):
|
|
"""The card's answer for the operation named by ``op_id``: per-target user / renderer-flow
|
|
transitions and an optional Continue. The contract decides what the card may claim."""
|
|
from tools.connectors import live
|
|
from tools.connectors.contract import SettleReason
|
|
from tools.connectors.mcp import apply_answer
|
|
from tools.connectors.operation import IllegalTransition
|
|
|
|
owner, error = _owned_session(rid, params)
|
|
if error:
|
|
return error
|
|
operation, error = _live_operation(rid, params, owner)
|
|
if error:
|
|
return error
|
|
try:
|
|
apply_answer(operation, json.dumps(params["result"]))
|
|
except IllegalTransition as exc:
|
|
return _connector_rpc_error(rid, 4002, "ILLEGAL_TRANSITION", str(exc))
|
|
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 _operation_view(operation):
|
|
return {**operation.result(), "settled": operation.settled}
|
|
|
|
|
|
def _connection_update(operation, change=None):
|
|
"""Emit ``connection.update`` for one transition, a link refresh, or settlement. Every frame
|
|
carries the full target snapshot so the renderer never reconstructs state from deltas."""
|
|
from tui_gateway import server
|
|
|
|
with server._sessions_lock:
|
|
sid = next((s for s, c in server._sessions.items() if c.get("session_key") == operation.session_key), None)
|
|
if sid is None:
|
|
return
|
|
payload = _operation_view(operation)
|
|
if change:
|
|
payload.update(change)
|
|
server._emit("connection.update", sid, payload)
|
|
|
|
|
|
def _install_update_hook():
|
|
"""Route every operation change through ``_connection_update``. Idempotent: ``register`` can run
|
|
more than once (reload, tests) and must not stack wrappers."""
|
|
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()
|