mirror of
https://github.com/andrewyng/openworker.git
synced 2026-09-04 16:42:35 +00:00
Slack installer joins the workspace allow-list on managed connect; MCP interactive oauth only from explicit connects. Run-started toast over a new app-wide /ws/events socket; Automations e2e locators scoped to the account menu.
508 lines
22 KiB
Python
508 lines
22 KiB
Python
"""Connect / disconnect / list connectors — writes tokens to the SecretStore.
|
|
|
|
Pure functions over a SecretStore so they're testable without the server. `validate=False`
|
|
skips the network check (used by tests). Secrets are never returned — only status + the
|
|
public bot identity captured at connect time.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
|
|
from ..secrets import SecretStore
|
|
from .catalog_copy import about_for, access_for
|
|
from .descriptors import get_descriptor, list_descriptors
|
|
from .tool_defs import patch_tool_settings, tool_dicts
|
|
|
|
_EXPERIMENTAL_KEY = "experimental:settings"
|
|
|
|
|
|
def experimental_enabled(secrets: SecretStore) -> bool:
|
|
"""Whether the user has opted in to experimental (use-at-your-own-risk) connectors."""
|
|
return bool((secrets.get(_EXPERIMENTAL_KEY) or {}).get("enabled"))
|
|
|
|
|
|
def set_experimental_enabled(secrets: SecretStore, value: bool) -> dict[str, Any]:
|
|
secrets.put(_EXPERIMENTAL_KEY, {"enabled": bool(value)})
|
|
return {"ok": True, "enabled": bool(value)}
|
|
|
|
|
|
def _profile_connected(descriptor, profile: dict[str, Any]) -> bool:
|
|
if not descriptor.available:
|
|
return False
|
|
if descriptor.auth == "none":
|
|
return True
|
|
# Managed relay (e.g. Slack cloud relay) carries no manual credential in the
|
|
# :default profile — the tokens live per-team (slack:team:*). The relay-mode
|
|
# flag is what marks it connected, so don't require the manual fields.
|
|
if profile.get("mode") == "relay":
|
|
return True
|
|
required = [
|
|
f.key for f in descriptor.fields if f.required and f.key != "allowed_users"
|
|
]
|
|
return bool(profile) and all(bool(profile.get(k)) for k in required)
|
|
|
|
|
|
def _mcp_tokens_present(secrets: SecretStore, name: str) -> bool:
|
|
# Lazy import: the mcp package pulls in the MCP SDK, which connector listing
|
|
# shouldn't pay for unless an MCP-backed profile actually exists.
|
|
from ..mcp.oauth import has_tokens
|
|
|
|
return has_tokens(name, secrets)
|
|
|
|
|
|
def connector_list(secrets: SecretStore) -> list[dict[str, Any]]:
|
|
show_experimental = experimental_enabled(secrets)
|
|
out: list[dict[str, Any]] = []
|
|
for d in list_descriptors():
|
|
# Experimental connectors are invisible (not just disabled) until the user opts in;
|
|
# hiding them here also drops their tools from engine builds via
|
|
# _enabled_connector_tools, so flipping the setting off cuts access immediately.
|
|
if d.experimental and not show_experimental:
|
|
continue
|
|
profile = secrets.get(f"{d.name}:default") or {}
|
|
if d.mcp_url and profile.get("mode") == "mcp":
|
|
# MCP-backed connect: the profile is just a marker — connected-ness
|
|
# lives with the OAuth tokens (mcp-oauth:<name> in the SecretStore).
|
|
connected = _mcp_tokens_present(secrets, d.name)
|
|
else:
|
|
connected = _profile_connected(d, profile)
|
|
entry = {
|
|
"name": d.name,
|
|
"title": d.title,
|
|
"icon": d.icon,
|
|
"blurb": d.blurb,
|
|
# Pre-connect detail page copy (UX-DECISIONS §38): About paragraph
|
|
# (may be empty → GUI omits the group) + honest Access bullets.
|
|
"about": about_for(d.name),
|
|
"access": access_for(d.name),
|
|
"auth": d.auth,
|
|
"two_way": d.two_way,
|
|
"channels": d.channels,
|
|
"available": d.available,
|
|
"brand_color": d.brand_color,
|
|
"logo": d.logo,
|
|
"aliases": list(d.aliases),
|
|
# MCP-backed one-click (vendor-hosted MCP server + local OAuth) —
|
|
# distinct from `managed` (broker OAuth): no cloud sign-in needed.
|
|
"mcp": bool(d.mcp_url),
|
|
"fields": [f.to_dict() for f in d.fields],
|
|
"instructions": d.instructions,
|
|
"connected": connected,
|
|
"account": profile.get("account"),
|
|
"enabled": bool(profile.get("enabled", True)) and connected,
|
|
# The actual allow-list (the GUI manages it inline); was a bare count.
|
|
"allowed_users": list(profile.get("allowed_users") or []),
|
|
"tools": tool_dicts(secrets, d.name),
|
|
"experimental": d.experimental,
|
|
"risk_notice": d.risk_notice,
|
|
"managed": d.managed,
|
|
# Whether THIS profile came from managed OAuth (vs manual paste).
|
|
"managed_profile": bool(profile.get("managed")),
|
|
# "relay" for the managed cloud path; empty for manual/token connect.
|
|
"mode": profile.get("mode") or "",
|
|
}
|
|
if d.name == "slack":
|
|
# Managed relay is multi-workspace: each `slack:team:*` profile is one
|
|
# connected workspace with its OWN allow-list (ids are workspace-scoped).
|
|
entry["workspaces"] = _slack_workspaces(secrets)
|
|
if d.name == "gmail":
|
|
# Multi-account: each `gmail:account:*` profile is one mailbox; the
|
|
# :default profile is just the default pointer + privacy filters.
|
|
from . import gmail_accounts
|
|
|
|
accounts = _gmail_account_list(secrets)
|
|
default_email = gmail_accounts.default_account(secrets)
|
|
entry["accounts"] = accounts
|
|
entry["connected"] = bool(accounts)
|
|
entry["enabled"] = bool(profile.get("enabled", True)) and bool(accounts)
|
|
entry["account"] = default_email or None
|
|
entry["managed_profile"] = any(
|
|
a["email"] == default_email and a["managed"] for a in accounts
|
|
)
|
|
entry["filters"] = gmail_accounts.get_filters(secrets)
|
|
if d.name == "google_calendar":
|
|
# Multi-account, same shape as gmail: each `google_calendar:account:*`
|
|
# profile is one Google account; :default is just the default pointer.
|
|
from . import gcal_accounts
|
|
|
|
accounts = _gcal_account_list(secrets)
|
|
default_email = gcal_accounts.default_account(secrets)
|
|
entry["accounts"] = accounts
|
|
entry["connected"] = bool(accounts)
|
|
entry["enabled"] = bool(profile.get("enabled", True)) and bool(accounts)
|
|
entry["account"] = default_email or None
|
|
entry["managed_profile"] = any(
|
|
a["email"] == default_email and a["managed"] for a in accounts
|
|
)
|
|
if d.name == "github":
|
|
# Managed relay is multi-installation: each `github:install:*`
|
|
# profile is one App installation with its OWN allow-list of
|
|
# sender logins. The manual PAT path stays on the default profile.
|
|
entry["installations"] = _github_installations(secrets)
|
|
if entry["installations"] and profile.get("mode") == "relay":
|
|
first = entry["installations"][0]
|
|
entry["account"] = entry["account"] or first["account_login"]
|
|
if d.account_field:
|
|
# Generic multi-account (batch-2 connectors): each
|
|
# `<name>:account:*` profile is one account; :default is pointer-only.
|
|
from . import accounts as _accounts
|
|
|
|
rows = _accounts.account_rows(secrets, d.name)
|
|
default_id = _accounts.default_account(secrets, d.name)
|
|
entry["accounts"] = rows
|
|
entry["connected"] = bool(rows)
|
|
entry["enabled"] = bool(profile.get("enabled", True)) and bool(rows)
|
|
default_row = next((r for r in rows if r["account_id"] == default_id), None)
|
|
entry["account"] = (default_row or {}).get("name") or None
|
|
entry["managed_profile"] = bool((default_row or {}).get("managed"))
|
|
if d.name == "hubspot":
|
|
# Multi-portal: each `hubspot:portal:*` profile is one portal; the
|
|
# :default profile is the default pointer + hidden-fields policy.
|
|
from . import hubspot_portals
|
|
|
|
portals = _hubspot_portal_list(secrets)
|
|
default_hub = hubspot_portals.default_portal(secrets)
|
|
entry["portals"] = portals
|
|
entry["connected"] = bool(portals)
|
|
entry["enabled"] = bool(profile.get("enabled", True)) and bool(portals)
|
|
default_row = next((p for p in portals if p["hub_id"] == default_hub), None)
|
|
entry["account"] = (default_row or {}).get("name") or None
|
|
entry["managed_profile"] = bool((default_row or {}).get("managed"))
|
|
entry["hidden_fields"] = hubspot_portals.get_hidden_fields(secrets)
|
|
out.append(entry)
|
|
return out
|
|
|
|
|
|
def _slack_workspaces(secrets: SecretStore) -> list[dict[str, Any]]:
|
|
from .config import _slack_team_profiles
|
|
|
|
return [
|
|
{
|
|
"team_id": team_id,
|
|
"account": profile.get("account") or team_id,
|
|
"domain": profile.get("domain") or "",
|
|
"allowed_users": list(profile.get("allowed_users") or []),
|
|
"allow_all": bool(profile.get("allow_all")),
|
|
# Who installed (authed_user) — the GUI marks their chip "you" and
|
|
# keys the post-connect card's "your mentions get through" line.
|
|
"installer_user_id": profile.get("slack_user_id") or "",
|
|
"installer_name": profile.get("sender_name") or "",
|
|
}
|
|
for team_id, profile in sorted(
|
|
_slack_team_profiles(secrets), key=lambda t: t[0]
|
|
)
|
|
]
|
|
|
|
|
|
def _github_installations(secrets: SecretStore) -> list[dict[str, Any]]:
|
|
from .github_installs import list_installs
|
|
|
|
return [
|
|
{
|
|
"installation_id": installation_id,
|
|
"account_login": profile.get("account_login") or installation_id,
|
|
"account_type": profile.get("account_type") or "",
|
|
"repo_selection": profile.get("repo_selection") or "",
|
|
"github_login": profile.get("github_login") or "",
|
|
"allowed_users": list(profile.get("allowed_users") or []),
|
|
"allow_all": bool(profile.get("allow_all")),
|
|
}
|
|
for installation_id, profile in list_installs(secrets)
|
|
]
|
|
|
|
|
|
def _gmail_account_list(secrets: SecretStore) -> list[dict[str, Any]]:
|
|
from time import time
|
|
|
|
from . import gmail_accounts
|
|
|
|
default = gmail_accounts.default_account(secrets)
|
|
out = []
|
|
for email, profile in gmail_accounts.list_accounts(secrets):
|
|
expires = float(profile.get("expires") or 0)
|
|
out.append(
|
|
{
|
|
"email": email,
|
|
"default": email == default,
|
|
"managed": bool(profile.get("managed")),
|
|
"scopes": profile.get("scope") or "",
|
|
# Expired with no way to renew silently → the GUI offers Reauthorize.
|
|
"needs_reauth": bool(
|
|
expires and expires < time() and not profile.get("refresh_token")
|
|
),
|
|
}
|
|
)
|
|
return out
|
|
|
|
|
|
def _gcal_account_list(secrets: SecretStore) -> list[dict[str, Any]]:
|
|
from time import time
|
|
|
|
from . import gcal_accounts
|
|
|
|
default = gcal_accounts.default_account(secrets)
|
|
out = []
|
|
for email, profile in gcal_accounts.list_accounts(secrets):
|
|
expires = float(profile.get("expires") or 0)
|
|
out.append(
|
|
{
|
|
"email": email,
|
|
"default": email == default,
|
|
"managed": bool(profile.get("managed")),
|
|
"scopes": profile.get("scope") or "",
|
|
# Expired with no way to renew silently → the GUI offers Reauthorize.
|
|
"needs_reauth": bool(
|
|
expires and expires < time() and not profile.get("refresh_token")
|
|
),
|
|
}
|
|
)
|
|
return out
|
|
|
|
|
|
def _hubspot_portal_list(secrets: SecretStore) -> list[dict[str, Any]]:
|
|
from . import hubspot_portals
|
|
|
|
default = hubspot_portals.default_portal(secrets)
|
|
out = []
|
|
for hub_id, profile in hubspot_portals.list_portals(secrets):
|
|
scope = str(profile.get("scope") or "")
|
|
out.append(
|
|
{
|
|
"hub_id": hub_id,
|
|
"name": profile.get("account") or f"portal {hub_id}",
|
|
"sandbox": bool(profile.get("sandbox")),
|
|
"default": hub_id == default,
|
|
"managed": bool(profile.get("managed")),
|
|
# Consent tier granted at connect: managed profiles reveal it in
|
|
# their scope grant; a manual private-app token doesn't say.
|
|
"access": (".write" in scope and "write") or (scope and "read") or "",
|
|
}
|
|
)
|
|
return out
|
|
|
|
|
|
def update_connector_tools(
|
|
secrets: SecretStore, name: str, enabled: dict[str, Any]
|
|
) -> dict[str, Any]:
|
|
if get_descriptor(name) is None:
|
|
return {"ok": False, "error": "unknown connector"}
|
|
return patch_tool_settings(secrets, name, enabled)
|
|
|
|
|
|
def connect_connector(
|
|
secrets: SecretStore,
|
|
name: str,
|
|
fields: dict[str, Any],
|
|
*,
|
|
validate: bool = True,
|
|
acknowledged: bool = False,
|
|
) -> dict[str, Any]:
|
|
d = get_descriptor(name)
|
|
if d is None or not d.available:
|
|
return {"ok": False, "error": "unknown or unavailable connector"}
|
|
if d.experimental:
|
|
if not experimental_enabled(secrets):
|
|
return {"ok": False, "error": "experimental connectors are disabled"}
|
|
if not acknowledged:
|
|
return {
|
|
"ok": False,
|
|
"error": "risk acknowledgment required",
|
|
"risk_notice": d.risk_notice,
|
|
}
|
|
|
|
# Reconnect-safe: never let a re-submit clobber a stored secret. The GUI masks a connected
|
|
# connector's secret fields (it shows the placeholder, e.g. `xoxb-…`), so a blank — or
|
|
# mask-equal — submission means "keep what's stored", not "overwrite with the mask". (This is
|
|
# the bug that reset a real token down to its 6-char placeholder.)
|
|
existing = secrets.get(f"{name}:default") or {}
|
|
|
|
def _resolved(f) -> str:
|
|
v = str(fields.get(f.key) or "").strip()
|
|
if f.key == "allowed_users":
|
|
return v # a list in storage / CSV in the form — handled separately below
|
|
if not v or (f.secret and v == (f.placeholder or "").strip()):
|
|
return str(existing.get(f.key) or "").strip()
|
|
return v
|
|
|
|
raw = {f.key: _resolved(f) for f in d.fields}
|
|
missing = [f.label for f in d.fields if f.required and not raw.get(f.key)]
|
|
if missing:
|
|
return {"ok": False, "error": "missing: " + ", ".join(missing)}
|
|
|
|
allowed = sorted(
|
|
{u.strip() for u in raw.get("allowed_users", "").split(",") if u.strip()}
|
|
)
|
|
if not allowed and existing.get("allowed_users"):
|
|
allowed = list(
|
|
existing["allowed_users"]
|
|
) # don't wipe the live allow-list on reconnect
|
|
token_creds = {k: v for k, v in raw.items() if k != "allowed_users" and v}
|
|
|
|
identity = None
|
|
if validate and d.validate is not None:
|
|
result = d.validate(token_creds)
|
|
if not result.ok:
|
|
return {"ok": False, "error": result.error or "validation failed"}
|
|
identity = result.identity
|
|
|
|
profile_type = (
|
|
"oauth" if d.auth == "oauth" else "none" if d.auth == "none" else "token"
|
|
)
|
|
profile: dict[str, Any] = {"type": profile_type, "enabled": True, **token_creds}
|
|
if any(f.key == "allowed_users" for f in d.fields):
|
|
profile["allowed_users"] = allowed
|
|
if identity:
|
|
profile["account"] = identity
|
|
if d.account_field:
|
|
# Account-patterned connector: connecting ADDS an account (a second
|
|
# submit with different creds is a second account, not an overwrite).
|
|
from . import accounts as _accounts
|
|
|
|
account_id = _accounts.derive_account_id(d, profile)
|
|
result = _accounts.add_account(secrets, name, account_id, profile)
|
|
if not result.get("ok"):
|
|
return result
|
|
return {"ok": True, "account": identity or account_id, "account_id": account_id}
|
|
secrets.put(f"{name}:default", profile)
|
|
return {"ok": True, "account": identity}
|
|
|
|
|
|
def managed_connect_connector(
|
|
secrets: SecretStore, name: str, profile: dict[str, Any]
|
|
) -> dict[str, Any]:
|
|
"""Store a profile produced by managed OAuth (cloud.managed_profile_from_callback).
|
|
|
|
Field-compatible with a manual connect for the same connector, so tools and
|
|
session gating can't tell the paths apart; preserves an existing allow-list
|
|
on reconnect just like the manual path does.
|
|
"""
|
|
d = get_descriptor(name)
|
|
if d is None or not d.available:
|
|
return {"ok": False, "error": "unknown or unavailable connector"}
|
|
if not d.managed:
|
|
return {"ok": False, "error": f"{name} does not support managed connect"}
|
|
if d.account_field:
|
|
from . import accounts as _accounts
|
|
|
|
account_id = _accounts.derive_account_id(d, profile)
|
|
result = _accounts.add_account(secrets, name, account_id, profile)
|
|
if not result.get("ok"):
|
|
return result
|
|
return {
|
|
"ok": True,
|
|
"account": profile.get("account") or account_id,
|
|
"account_id": account_id,
|
|
}
|
|
existing = secrets.get(f"{name}:default") or {}
|
|
if existing.get("allowed_users"):
|
|
profile = {**profile, "allowed_users": list(existing["allowed_users"])}
|
|
secrets.put(f"{name}:default", profile)
|
|
return {"ok": True, "account": profile.get("account") or None}
|
|
|
|
|
|
def managed_connect_slack_install(
|
|
secrets: SecretStore, form: dict[str, Any]
|
|
) -> dict[str, Any]:
|
|
"""Store a managed Slack install (relay mode) from the broker's form-POST.
|
|
|
|
Slack managed install is multi-workspace and inbound-via-relay, so unlike a
|
|
single-token connector it writes:
|
|
- `slack:team:<team_id>` — that workspace's bot token + bot_user_id (used for
|
|
replies and to ignore the bot's own posts);
|
|
- `slack:default` flipped to `mode="relay"` so the gateway builds the
|
|
`SlackRelayAdapter` (Socket Mode's manual bot_token/app_token untouched if
|
|
the user later switches back). Existing allow-list preserved.
|
|
"""
|
|
team_id = form.get("team_id", "")
|
|
bot_token = form.get("access_token", "")
|
|
if not team_id or not bot_token:
|
|
return {"ok": False, "error": "missing team_id or bot token"}
|
|
# A reinstall replaces the token but must not reset authorization state.
|
|
existing = secrets.get(f"slack:team:{team_id}") or {}
|
|
allowed = set(existing.get("allowed_users") or [])
|
|
installer = form.get("slack_user_id", "")
|
|
if installer:
|
|
# Pre-add the installer (UX-027): connecting the workspace is consent to
|
|
# talk to your own bot — without this, the connector's very first mention
|
|
# comes from the installer and parks.
|
|
allowed.add(installer)
|
|
secrets.put(
|
|
f"slack:team:{team_id}",
|
|
{
|
|
"type": "oauth",
|
|
"managed": True,
|
|
"bot_token": bot_token,
|
|
"bot_user_id": form.get("bot_user_id", ""),
|
|
# The INSTALLER's Slack member id (authed_user) — who this workspace's
|
|
# outbound posts speak for (attribution.py resolves + caches the name).
|
|
"slack_user_id": installer,
|
|
"team_id": team_id,
|
|
"account": form.get("account", ""),
|
|
# The workspace's slack.com subdomain (broker resolves it via auth.test)
|
|
# — the unique human handle when two workspaces share a display name.
|
|
"domain": form.get("team_domain", ""),
|
|
"scope": form.get("scope", ""),
|
|
"connection_id": form.get("connection_id", ""),
|
|
"allowed_users": sorted(allowed),
|
|
"allow_all": bool(existing.get("allow_all")),
|
|
"sender_name": existing.get("sender_name", ""),
|
|
},
|
|
)
|
|
default = secrets.get("slack:default") or {}
|
|
default.update({"type": "oauth", "managed": True, "mode": "relay", "enabled": True})
|
|
secrets.put("slack:default", default)
|
|
return {"ok": True, "account": form.get("account") or team_id}
|
|
|
|
|
|
def disconnect_connector(secrets: SecretStore, name: str) -> dict[str, Any]:
|
|
dropped_accounts = False
|
|
from . import accounts as _accounts
|
|
|
|
if _accounts.is_account_connector(name):
|
|
for account_id, _profile in _accounts.list_accounts(secrets, name):
|
|
dropped_accounts = (
|
|
secrets.delete(_accounts.prefix(name) + account_id) or dropped_accounts
|
|
)
|
|
if name == "gmail":
|
|
# Whole-connector disconnect drops every mailbox (per-account removal
|
|
# lives on the Gmail page); filters go too — an explicit full reset.
|
|
from . import gmail_accounts
|
|
|
|
for email, _profile in gmail_accounts.list_accounts(secrets):
|
|
dropped_accounts = (
|
|
secrets.delete(gmail_accounts.PREFIX + email) or dropped_accounts
|
|
)
|
|
if name == "google_calendar":
|
|
from . import gcal_accounts
|
|
|
|
for email, _profile in gcal_accounts.list_accounts(secrets):
|
|
dropped_accounts = (
|
|
secrets.delete(gcal_accounts.PREFIX + email) or dropped_accounts
|
|
)
|
|
if name == "hubspot":
|
|
from . import hubspot_portals
|
|
|
|
for hub_id, _profile in hubspot_portals.list_portals(secrets):
|
|
dropped_accounts = (
|
|
secrets.delete(hubspot_portals.PREFIX + hub_id) or dropped_accounts
|
|
)
|
|
if name == "github":
|
|
from . import github_installs
|
|
|
|
for installation_id, _profile in github_installs.list_installs(secrets):
|
|
dropped_accounts = (
|
|
secrets.delete(github_installs.PREFIX + installation_id)
|
|
or dropped_accounts
|
|
)
|
|
profile = secrets.get(f"{name}:default") or {}
|
|
if profile.get("mode") == "mcp":
|
|
# MCP-backed connect: forget the OAuth tokens + DCR registration and remove
|
|
# the seeded server entry, so a reconnect runs a fresh flow.
|
|
from ..mcp import config as mcp_config
|
|
from ..mcp import oauth as mcp_oauth
|
|
|
|
dropped_accounts = mcp_oauth.sign_out(name, secrets) or dropped_accounts
|
|
mcp_config.delete_global_server(name)
|
|
return {"ok": secrets.delete(f"{name}:default") or dropped_accounts}
|