mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-22 01:24:52 +02:00
[eric] OAuth + MCP polish: more reliable connect flow, Discord shim,
connected MCPs sort to top, Haiku-overflow warning, misc fixes, security risks fixes
This commit is contained in:
@@ -31,3 +31,5 @@ backend/.venv/
|
||||
openswarm-cloud
|
||||
.openswarm-cloud
|
||||
.claude/
|
||||
# Local-only operator helpers (never commit)
|
||||
scripts/set-fly-*.sh
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
"""Stdio MCP shim that forwards Discord tool calls to the OpenSwarm cloud.
|
||||
|
||||
Run as: python -m backend.apps.discord_mcp_shim
|
||||
"""
|
||||
from backend.apps.discord_mcp_shim.server import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Module-level entrypoint so `python -m backend.apps.discord_mcp_shim` works."""
|
||||
from backend.apps.discord_mcp_shim.server import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,459 @@
|
||||
"""Stdio MCP shim for the Discord integration.
|
||||
|
||||
Carries no credentials. Each tool call is forwarded as a small HTTPS
|
||||
request that includes a per-install identifier (used for rate-limiting).
|
||||
The shim refuses operations against guilds not in OPENSWARM_DISCORD_GUILD_IDS
|
||||
(set at spawn time from the user's authorized guild list).
|
||||
|
||||
stdlib-only on purpose so the subprocess starts fast.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
PROXY_BASE = os.environ.get("OPENSWARM_OAUTH_BASE_URL", "https://api.openswarm.com").rstrip("/")
|
||||
INSTALL_ID = os.environ.get("OPENSWARM_INSTALL_ID", "")
|
||||
ALLOWED_GUILDS = set(
|
||||
g for g in (os.environ.get("OPENSWARM_DISCORD_GUILD_IDS", "") or "").split(",") if g
|
||||
)
|
||||
|
||||
|
||||
# -- MCP tool definitions (exposed to the agent) ---------------------------
|
||||
# Names match the original mcp-discord surface so prompts that referenced
|
||||
# `discord_send` etc. keep working. inputSchema deliberately matches what
|
||||
# the original package documented.
|
||||
|
||||
TOOLS = [
|
||||
{
|
||||
"name": "discord_login",
|
||||
"description": "Verify the Discord bot helper is reachable. Returns the bot's joined guilds.",
|
||||
"inputSchema": {"type": "object", "properties": {}},
|
||||
},
|
||||
{
|
||||
"name": "discord_get_server_info",
|
||||
"description": "Get metadata for a Discord guild (server) the bot is a member of.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {"guild_id": {"type": "string"}},
|
||||
"required": ["guild_id"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "discord_list_channels",
|
||||
"description": "List all channels in a Discord guild.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {"guild_id": {"type": "string"}},
|
||||
"required": ["guild_id"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "discord_create_text_channel",
|
||||
"description": "Create a new text channel in a guild.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"guild_id": {"type": "string"},
|
||||
"name": {"type": "string"},
|
||||
"parent_id": {"type": "string", "description": "Optional category ID"},
|
||||
"topic": {"type": "string"},
|
||||
},
|
||||
"required": ["guild_id", "name"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "discord_create_category",
|
||||
"description": "Create a new category (parent) in a guild.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"guild_id": {"type": "string"},
|
||||
"name": {"type": "string"},
|
||||
},
|
||||
"required": ["guild_id", "name"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "discord_edit_category",
|
||||
"description": "Rename or modify a category channel.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"channel_id": {"type": "string"},
|
||||
"name": {"type": "string"},
|
||||
},
|
||||
"required": ["channel_id"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "discord_delete_category",
|
||||
"description": "Delete a category channel.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {"channel_id": {"type": "string"}},
|
||||
"required": ["channel_id"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "discord_delete_channel",
|
||||
"description": "Delete a channel by ID.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {"channel_id": {"type": "string"}},
|
||||
"required": ["channel_id"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "discord_send",
|
||||
"description": "Send a message to a Discord channel.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"channel_id": {"type": "string"},
|
||||
"content": {"type": "string"},
|
||||
},
|
||||
"required": ["channel_id", "content"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "discord_read_messages",
|
||||
"description": "Read recent messages from a Discord channel (most recent first).",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"channel_id": {"type": "string"},
|
||||
"limit": {"type": "integer", "default": 50, "description": "1-100"},
|
||||
},
|
||||
"required": ["channel_id"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "discord_add_reaction",
|
||||
"description": "Add an emoji reaction to a message (as the bot).",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"channel_id": {"type": "string"},
|
||||
"message_id": {"type": "string"},
|
||||
"emoji": {
|
||||
"type": "string",
|
||||
"description": "Unicode emoji (e.g. 👍) or name:id custom emoji",
|
||||
},
|
||||
},
|
||||
"required": ["channel_id", "message_id", "emoji"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "discord_add_multiple_reactions",
|
||||
"description": "Add multiple emoji reactions to a message.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"channel_id": {"type": "string"},
|
||||
"message_id": {"type": "string"},
|
||||
"emojis": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
},
|
||||
},
|
||||
"required": ["channel_id", "message_id", "emojis"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "discord_get_forum_channels",
|
||||
"description": "List forum-type channels in a guild.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {"guild_id": {"type": "string"}},
|
||||
"required": ["guild_id"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "discord_create_forum_post",
|
||||
"description": "Create a forum thread/post in a forum channel.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"forum_id": {"type": "string"},
|
||||
"name": {"type": "string", "description": "Thread title"},
|
||||
"content": {"type": "string", "description": "First message body"},
|
||||
},
|
||||
"required": ["forum_id", "name", "content"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "discord_get_forum_post",
|
||||
"description": "Get a single message from a forum post.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"channel_id": {"type": "string"},
|
||||
"message_id": {"type": "string"},
|
||||
},
|
||||
"required": ["channel_id", "message_id"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "discord_reply_to_forum",
|
||||
"description": "Reply to a forum thread.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"channel_id": {"type": "string"},
|
||||
"content": {"type": "string"},
|
||||
},
|
||||
"required": ["channel_id", "content"],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
# -- HTTP plumbing ---------------------------------------------------------
|
||||
|
||||
def _call(
|
||||
method: str,
|
||||
path: str,
|
||||
*,
|
||||
body: dict | None = None,
|
||||
query: dict | None = None,
|
||||
timeout: float = 30.0,
|
||||
) -> tuple[int, dict | str]:
|
||||
"""Single hop to the Discord helper service. Returns (status, parsed-body-or-text).
|
||||
|
||||
install_id header attribution is mandatory server-side; if empty we fail
|
||||
locally so the user gets a clear error instead of an opaque 401.
|
||||
"""
|
||||
if not INSTALL_ID:
|
||||
return 0, "OPENSWARM_INSTALL_ID env var not set — cannot call Discord proxy"
|
||||
|
||||
url = f"{PROXY_BASE}/api/discord{path}"
|
||||
if query:
|
||||
url += "?" + urllib.parse.urlencode({k: v for k, v in query.items() if v is not None})
|
||||
|
||||
headers = {
|
||||
"X-OpenSwarm-Install-Id": INSTALL_ID,
|
||||
"Accept": "application/json",
|
||||
}
|
||||
data: bytes | None = None
|
||||
if body is not None:
|
||||
headers["Content-Type"] = "application/json"
|
||||
data = json.dumps(body).encode("utf-8")
|
||||
|
||||
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
text = resp.read().decode("utf-8", errors="replace")
|
||||
try:
|
||||
return resp.status, json.loads(text) if text else {}
|
||||
except json.JSONDecodeError:
|
||||
return resp.status, text
|
||||
except urllib.error.HTTPError as e:
|
||||
text = ""
|
||||
try:
|
||||
text = e.read().decode("utf-8", errors="replace") if e.fp else ""
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
return e.code, json.loads(text) if text else {}
|
||||
except json.JSONDecodeError:
|
||||
return e.code, text or str(e)
|
||||
except urllib.error.URLError as e:
|
||||
return 0, f"Helper service unreachable: {e.reason}"
|
||||
except Exception as e:
|
||||
return 0, f"Request failed: {e!r}"
|
||||
|
||||
|
||||
def _err(text: str) -> dict:
|
||||
return {"content": [{"type": "text", "text": f"Error: {text}"}], "isError": True}
|
||||
|
||||
|
||||
def _ok(payload) -> dict:
|
||||
if isinstance(payload, str):
|
||||
return {"content": [{"type": "text", "text": payload}]}
|
||||
return {"content": [{"type": "text", "text": json.dumps(payload, indent=2, default=str)}]}
|
||||
|
||||
|
||||
def _check_guild(guild_id: str) -> str | None:
|
||||
"""Return an error string if guild_id is outside the user-authorized set, else None.
|
||||
|
||||
The set is sourced from OPENSWARM_DISCORD_GUILD_IDS env var (CSV) which
|
||||
tools_lib.py populates from the tool's oauth_tokens.guilds. If the env
|
||||
var is empty (no guild authorization yet), allow all — agent shouldn't
|
||||
be able to spawn this MCP without an OAuth flow having happened.
|
||||
"""
|
||||
if not ALLOWED_GUILDS:
|
||||
return None # nothing to enforce yet
|
||||
if guild_id not in ALLOWED_GUILDS:
|
||||
return (
|
||||
f"Guild {guild_id} is not authorized for this OpenSwarm install. "
|
||||
f"Authorized guilds: {sorted(ALLOWED_GUILDS)}"
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
# -- Tool implementations --------------------------------------------------
|
||||
|
||||
def handle_tool_call(name: str, args: dict) -> dict:
|
||||
if name == "discord_login":
|
||||
status, body = _call("GET", "/users/@me/guilds")
|
||||
if status != 200:
|
||||
return _err(f"Discord proxy unreachable (HTTP {status}): {body}")
|
||||
return _ok({"connected": True, "guilds": body})
|
||||
|
||||
if name == "discord_get_server_info":
|
||||
gid = str(args.get("guild_id", ""))
|
||||
if (e := _check_guild(gid)): return _err(e)
|
||||
status, body = _call("GET", f"/guilds/{gid}")
|
||||
return _ok(body) if status == 200 else _err(f"HTTP {status}: {body}")
|
||||
|
||||
if name == "discord_list_channels":
|
||||
gid = str(args.get("guild_id", ""))
|
||||
if (e := _check_guild(gid)): return _err(e)
|
||||
status, body = _call("GET", f"/guilds/{gid}/channels")
|
||||
return _ok(body) if status == 200 else _err(f"HTTP {status}: {body}")
|
||||
|
||||
if name == "discord_create_text_channel":
|
||||
gid = str(args.get("guild_id", ""))
|
||||
if (e := _check_guild(gid)): return _err(e)
|
||||
payload: dict = {"name": args.get("name", ""), "type": 0}
|
||||
if args.get("parent_id"): payload["parent_id"] = args["parent_id"]
|
||||
if args.get("topic"): payload["topic"] = args["topic"]
|
||||
status, body = _call("POST", f"/guilds/{gid}/channels", body=payload)
|
||||
return _ok(body) if status in (200, 201) else _err(f"HTTP {status}: {body}")
|
||||
|
||||
if name == "discord_create_category":
|
||||
gid = str(args.get("guild_id", ""))
|
||||
if (e := _check_guild(gid)): return _err(e)
|
||||
status, body = _call("POST", f"/guilds/{gid}/channels", body={"name": args.get("name", ""), "type": 4})
|
||||
return _ok(body) if status in (200, 201) else _err(f"HTTP {status}: {body}")
|
||||
|
||||
if name == "discord_edit_category":
|
||||
cid = str(args.get("channel_id", ""))
|
||||
payload: dict = {}
|
||||
if args.get("name"): payload["name"] = args["name"]
|
||||
status, body = _call("PATCH", f"/channels/{cid}", body=payload)
|
||||
return _ok(body) if status == 200 else _err(f"HTTP {status}: {body}")
|
||||
|
||||
if name == "discord_delete_category" or name == "discord_delete_channel":
|
||||
cid = str(args.get("channel_id", ""))
|
||||
status, body = _call("DELETE", f"/channels/{cid}")
|
||||
return _ok({"deleted": True}) if status in (200, 204) else _err(f"HTTP {status}: {body}")
|
||||
|
||||
if name == "discord_send":
|
||||
cid = str(args.get("channel_id", ""))
|
||||
content = str(args.get("content", ""))
|
||||
status, body = _call("POST", f"/channels/{cid}/messages", body={"content": content})
|
||||
return _ok(body) if status in (200, 201) else _err(f"HTTP {status}: {body}")
|
||||
|
||||
if name == "discord_read_messages":
|
||||
cid = str(args.get("channel_id", ""))
|
||||
limit = max(1, min(int(args.get("limit", 50) or 50), 100))
|
||||
status, body = _call("GET", f"/channels/{cid}/messages", query={"limit": limit})
|
||||
return _ok(body) if status == 200 else _err(f"HTTP {status}: {body}")
|
||||
|
||||
if name == "discord_add_reaction":
|
||||
cid = str(args.get("channel_id", ""))
|
||||
mid = str(args.get("message_id", ""))
|
||||
emoji = str(args.get("emoji", ""))
|
||||
# Discord's URL needs the emoji urlencoded; passes through.
|
||||
status, body = _call("PUT", f"/channels/{cid}/messages/{mid}/reactions/{urllib.parse.quote(emoji, safe='')}/@me")
|
||||
return _ok({"added": emoji}) if status in (200, 204) else _err(f"HTTP {status}: {body}")
|
||||
|
||||
if name == "discord_add_multiple_reactions":
|
||||
cid = str(args.get("channel_id", ""))
|
||||
mid = str(args.get("message_id", ""))
|
||||
emojis = args.get("emojis", []) or []
|
||||
results = []
|
||||
for e in emojis:
|
||||
status, body = _call("PUT", f"/channels/{cid}/messages/{mid}/reactions/{urllib.parse.quote(str(e), safe='')}/@me")
|
||||
results.append({"emoji": e, "ok": status in (200, 204), "status": status})
|
||||
return _ok({"reactions": results})
|
||||
|
||||
if name == "discord_get_forum_channels":
|
||||
gid = str(args.get("guild_id", ""))
|
||||
if (e := _check_guild(gid)): return _err(e)
|
||||
status, body = _call("GET", f"/guilds/{gid}/channels")
|
||||
if status != 200: return _err(f"HTTP {status}: {body}")
|
||||
# Filter to type 15 (forum). Discord channel types reference:
|
||||
# GUILD_FORUM = 15
|
||||
forums = [ch for ch in (body or []) if isinstance(ch, dict) and ch.get("type") == 15]
|
||||
return _ok(forums)
|
||||
|
||||
if name == "discord_create_forum_post":
|
||||
fid = str(args.get("forum_id", ""))
|
||||
status, body = _call("POST", f"/channels/{fid}/threads", body={
|
||||
"name": args.get("name", ""),
|
||||
"message": {"content": args.get("content", "")},
|
||||
})
|
||||
return _ok(body) if status in (200, 201) else _err(f"HTTP {status}: {body}")
|
||||
|
||||
if name == "discord_get_forum_post":
|
||||
cid = str(args.get("channel_id", ""))
|
||||
mid = str(args.get("message_id", ""))
|
||||
status, body = _call("GET", f"/channels/{cid}/messages/{mid}")
|
||||
return _ok(body) if status == 200 else _err(f"HTTP {status}: {body}")
|
||||
|
||||
if name == "discord_reply_to_forum":
|
||||
cid = str(args.get("channel_id", ""))
|
||||
content = str(args.get("content", ""))
|
||||
status, body = _call("POST", f"/channels/{cid}/messages", body={"content": content})
|
||||
return _ok(body) if status in (200, 201) else _err(f"HTTP {status}: {body}")
|
||||
|
||||
return _err(f"Unknown tool: {name}")
|
||||
|
||||
|
||||
# -- JSON-RPC stdio loop ---------------------------------------------------
|
||||
|
||||
def _send(id_, result=None, error=None):
|
||||
msg = {"jsonrpc": "2.0", "id": id_}
|
||||
if error is not None:
|
||||
msg["error"] = error
|
||||
else:
|
||||
msg["result"] = result
|
||||
sys.stdout.write(json.dumps(msg) + "\n")
|
||||
sys.stdout.flush()
|
||||
|
||||
|
||||
def main():
|
||||
for line in sys.stdin:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
msg = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
method = msg.get("method")
|
||||
id_ = msg.get("id")
|
||||
params = msg.get("params", {}) or {}
|
||||
|
||||
if method == "initialize":
|
||||
_send(id_, {
|
||||
"protocolVersion": "2024-11-05",
|
||||
"capabilities": {"tools": {}},
|
||||
"serverInfo": {"name": "openswarm-discord", "version": "1.0.0"},
|
||||
})
|
||||
elif method == "notifications/initialized":
|
||||
pass
|
||||
elif method == "tools/list":
|
||||
_send(id_, {"tools": TOOLS})
|
||||
elif method == "tools/call":
|
||||
name = params.get("name", "")
|
||||
args = params.get("arguments", {}) or {}
|
||||
try:
|
||||
_send(id_, handle_tool_call(name, args))
|
||||
except Exception as e:
|
||||
_send(id_, _err(f"shim crashed: {e!r}"))
|
||||
elif method == "ping":
|
||||
_send(id_, {})
|
||||
elif id_ is not None:
|
||||
_send(id_, error={"code": -32601, "message": f"Method not found: {method}"})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+349
-384
@@ -16,18 +16,16 @@ import httpx
|
||||
from dotenv import load_dotenv
|
||||
from fastapi import HTTPException, Query
|
||||
from fastapi.responses import HTMLResponse
|
||||
from pydantic import BaseModel
|
||||
from backend.config.Apps import SubApp
|
||||
from backend.apps.tools_lib.models import ToolDefinition, ToolCreate, ToolUpdate, BUILTIN_TOOLS
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Default Google OAuth credentials for the OpenSwarm project.
|
||||
# These are public credentials for a desktop/web OAuth client (safe to embed per Google's docs).
|
||||
# Users can override via GOOGLE_OAUTH_CLIENT_ID / GOOGLE_OAUTH_CLIENT_SECRET env vars.
|
||||
_DEFAULT_GOOGLE_CLIENT_ID = "6741219524-8vpt07arcc5rvkdb4j1b6v9g53469ugq.apps.googleusercontent.com"
|
||||
_DEFAULT_GOOGLE_CLIENT_SECRET = "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"
|
||||
os.environ.setdefault("GOOGLE_OAUTH_CLIENT_ID", _DEFAULT_GOOGLE_CLIENT_ID)
|
||||
os.environ.setdefault("GOOGLE_OAUTH_CLIENT_SECRET", _DEFAULT_GOOGLE_CLIENT_SECRET)
|
||||
# Base URL for the OAuth helper service. Override via env in dev if needed.
|
||||
OPENSWARM_OAUTH_BASE_URL = os.environ.get(
|
||||
"OPENSWARM_OAUTH_BASE_URL", "https://api.openswarm.com"
|
||||
).rstrip("/")
|
||||
|
||||
from backend.config.paths import BACKEND_DIR, DATA_ROOT, TOOLS_DIR as DATA_DIR, BUILTIN_PERMISSIONS_PATH as BUILTIN_PERMS_PATH
|
||||
|
||||
@@ -44,35 +42,27 @@ async def tools_lib_lifespan():
|
||||
|
||||
tools_lib = SubApp("tools", tools_lib_lifespan)
|
||||
|
||||
# Most providers go through a small HTTP claim handoff. Google uses a direct
|
||||
# local callback. Both flows return an auto-closing HTML page when done.
|
||||
|
||||
# Google OAuth (local callback flow).
|
||||
GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth"
|
||||
GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token"
|
||||
GOOGLE_USERINFO_URL = "https://www.googleapis.com/oauth2/v2/userinfo"
|
||||
GOOGLE_SCOPES = [
|
||||
"openid",
|
||||
"https://www.googleapis.com/auth/userinfo.email",
|
||||
"email",
|
||||
"profile",
|
||||
"https://www.googleapis.com/auth/gmail.modify",
|
||||
"https://www.googleapis.com/auth/calendar",
|
||||
"https://www.googleapis.com/auth/drive",
|
||||
"https://www.googleapis.com/auth/contacts.readonly",
|
||||
"https://www.googleapis.com/auth/documents",
|
||||
"https://www.googleapis.com/auth/spreadsheets",
|
||||
"https://www.googleapis.com/auth/presentations",
|
||||
]
|
||||
|
||||
AIRTABLE_AUTH_URL = "https://airtable.com/oauth2/v1/authorize"
|
||||
AIRTABLE_TOKEN_URL = "https://airtable.com/oauth2/v1/token"
|
||||
AIRTABLE_SCOPES = [
|
||||
"data.records:read", "data.records:write",
|
||||
"data.recordComments:read", "data.recordComments:write",
|
||||
"schema.bases:read", "schema.bases:write",
|
||||
"user.email:read",
|
||||
]
|
||||
|
||||
HUBSPOT_AUTH_URL = "https://mcp-na2.hubspot.com/oauth/authorize/user"
|
||||
HUBSPOT_TOKEN_URL = "https://api.hubapi.com/oauth/v1/token"
|
||||
|
||||
DISCORD_AUTH_URL = "https://discord.com/oauth2/authorize"
|
||||
DISCORD_TOKEN_URL = "https://discord.com/api/oauth2/token"
|
||||
|
||||
|
||||
# Maps state -> {tool_id, code_verifier (for PKCE flows)}
|
||||
# Per-flow state for the local Google OAuth callback. Keyed by the OAuth
|
||||
# `state` param; value is {tool_id} (PKCE verifier not used for Google).
|
||||
_pending_oauth: dict[str, dict] = {}
|
||||
|
||||
|
||||
@@ -142,193 +132,84 @@ async def list_tools():
|
||||
|
||||
@tools_lib.router.get("/oauth/callback")
|
||||
async def oauth_callback(code: str = Query(...), state: str = Query("")):
|
||||
"""Local Google OAuth callback (v1.0.25 flow, Google-only in v1.0.26).
|
||||
|
||||
Notion / Airtable / HubSpot / Discord all flow through the cloud and
|
||||
land at /oauth/cloud-claim instead. This endpoint stays Google-only
|
||||
because the production Google OAuth client has localhost registered
|
||||
and the user doesn't control the console to add a cloud URL.
|
||||
"""
|
||||
pending = _pending_oauth.pop(state, None)
|
||||
if not pending:
|
||||
return HTMLResponse("<html><body><h2>Invalid OAuth state</h2></body></html>", status_code=400)
|
||||
|
||||
tool_id = pending if isinstance(pending, str) else pending["tool_id"]
|
||||
code_verifier = pending.get("code_verifier") if isinstance(pending, dict) else None
|
||||
return HTMLResponse(
|
||||
"<html><body><h2>Invalid OAuth state</h2></body></html>",
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
tool_id = pending["tool_id"] if isinstance(pending, dict) else pending
|
||||
tool = _load(tool_id)
|
||||
|
||||
client_id = os.environ.get("GOOGLE_OAUTH_CLIENT_ID", "")
|
||||
client_secret = os.environ.get("GOOGLE_OAUTH_CLIENT_SECRET", "")
|
||||
if not client_id or not client_secret:
|
||||
return HTMLResponse(
|
||||
"<html><body><h2>Google OAuth not configured</h2><p>"
|
||||
"GOOGLE_OAUTH_CLIENT_ID/SECRET missing from .env</p></body></html>",
|
||||
status_code=500,
|
||||
)
|
||||
_port = os.environ.get("OPENSWARM_PORT", "8324")
|
||||
redirect_uri = f"http://localhost:{_port}/api/tools/oauth/callback"
|
||||
|
||||
if tool.name.lower() == "airtable":
|
||||
# Airtable OAuth: PKCE flow
|
||||
client_id = os.environ.get("AIRTABLE_OAUTH_CLIENT_ID", "")
|
||||
client_secret = os.environ.get("AIRTABLE_OAUTH_CLIENT_SECRET", "")
|
||||
import base64
|
||||
credentials = base64.b64encode(f"{client_id}:{client_secret}".encode()).decode()
|
||||
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||
resp = await client.post(AIRTABLE_TOKEN_URL, data={
|
||||
"grant_type": "authorization_code",
|
||||
"code": code,
|
||||
"redirect_uri": redirect_uri,
|
||||
"code_verifier": code_verifier or "",
|
||||
"client_id": client_id,
|
||||
}, headers={
|
||||
"Authorization": f"Basic {credentials}",
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
})
|
||||
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||
resp = await client.post(GOOGLE_TOKEN_URL, data={
|
||||
"code": code,
|
||||
"client_id": client_id,
|
||||
"client_secret": client_secret,
|
||||
"redirect_uri": redirect_uri,
|
||||
"grant_type": "authorization_code",
|
||||
})
|
||||
if resp.status_code != 200:
|
||||
logger.warning("Google OAuth token exchange failed: %s", resp.text[:240])
|
||||
return HTMLResponse(
|
||||
f"<html><body><h2>Token exchange failed</h2><pre>{resp.text}</pre></body></html>",
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
if resp.status_code != 200:
|
||||
logger.warning(f"Airtable OAuth token exchange failed: {resp.text}")
|
||||
return HTMLResponse(f"<html><body><h2>Token exchange failed</h2><pre>{resp.text}</pre></body></html>", status_code=400)
|
||||
tokens = resp.json()
|
||||
access_token = tokens.get("access_token", "")
|
||||
tool.oauth_tokens = {
|
||||
"access_token": access_token,
|
||||
"refresh_token": tokens.get("refresh_token", ""),
|
||||
"token_expiry": time.time() + tokens.get("expires_in", 3600),
|
||||
}
|
||||
tool.auth_type = "oauth2"
|
||||
tool.auth_status = "connected"
|
||||
|
||||
tokens = resp.json()
|
||||
tool.oauth_tokens = {
|
||||
"access_token": tokens.get("access_token", ""),
|
||||
"refresh_token": tokens.get("refresh_token", ""),
|
||||
"token_expiry": time.time() + tokens.get("expires_in", 7200),
|
||||
}
|
||||
tool.auth_type = "oauth2"
|
||||
tool.auth_status = "connected"
|
||||
tool.connected_account_email = "Airtable account"
|
||||
|
||||
elif tool.name.lower() == "hubspot":
|
||||
# HubSpot OAuth 2.1: PKCE flow
|
||||
client_id = os.environ.get("HUBSPOT_OAUTH_CLIENT_ID", "")
|
||||
client_secret = os.environ.get("HUBSPOT_OAUTH_CLIENT_SECRET", "")
|
||||
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||
resp = await client.post(HUBSPOT_TOKEN_URL, data={
|
||||
"grant_type": "authorization_code",
|
||||
"code": code,
|
||||
"redirect_uri": redirect_uri,
|
||||
"client_id": client_id,
|
||||
"client_secret": client_secret,
|
||||
"code_verifier": code_verifier or "",
|
||||
}, headers={
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
})
|
||||
|
||||
if resp.status_code != 200:
|
||||
logger.warning(f"HubSpot OAuth token exchange failed: {resp.text}")
|
||||
return HTMLResponse(f"<html><body><h2>Token exchange failed</h2><pre>{resp.text}</pre></body></html>", status_code=400)
|
||||
|
||||
tokens = resp.json()
|
||||
tool.oauth_tokens = {
|
||||
"access_token": tokens.get("access_token", ""),
|
||||
"refresh_token": tokens.get("refresh_token", ""),
|
||||
"token_expiry": time.time() + tokens.get("expires_in", 1800),
|
||||
}
|
||||
tool.auth_type = "oauth2"
|
||||
tool.auth_status = "connected"
|
||||
tool.connected_account_email = "HubSpot account"
|
||||
|
||||
elif tool.name.lower() == "discord":
|
||||
# Discord bot install OAuth: exchange code, capture guild_id of the
|
||||
# server the user added the bot to. Multiple connect calls APPEND
|
||||
# additional guild_ids so users can authorize multiple servers.
|
||||
client_id = os.environ.get("DISCORD_OAUTH_CLIENT_ID", "")
|
||||
client_secret = os.environ.get("DISCORD_OAUTH_CLIENT_SECRET", "")
|
||||
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||
resp = await client.post(DISCORD_TOKEN_URL, data={
|
||||
"grant_type": "authorization_code",
|
||||
"code": code,
|
||||
"redirect_uri": redirect_uri,
|
||||
"client_id": client_id,
|
||||
"client_secret": client_secret,
|
||||
}, headers={
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
})
|
||||
|
||||
if resp.status_code != 200:
|
||||
logger.warning(f"Discord OAuth token exchange failed: {resp.text}")
|
||||
return HTMLResponse(f"<html><body><h2>Token exchange failed</h2><pre>{resp.text}</pre></body></html>", status_code=400)
|
||||
|
||||
tokens = resp.json()
|
||||
guild = tokens.get("guild") or {}
|
||||
new_guild_id = guild.get("id", "")
|
||||
new_guild_name = guild.get("name", "")
|
||||
existing = tool.oauth_tokens.get("guilds") or []
|
||||
# Append unless this guild was already authorized
|
||||
if new_guild_id and not any(g.get("id") == new_guild_id for g in existing):
|
||||
existing.append({"id": new_guild_id, "name": new_guild_name})
|
||||
tool.oauth_tokens = {
|
||||
# Bot token lives in .env, NEVER stored on the tool. We only
|
||||
# track the list of authorized guilds for scope enforcement.
|
||||
"guilds": existing,
|
||||
}
|
||||
tool.auth_type = "oauth2"
|
||||
tool.auth_status = "connected"
|
||||
names = ", ".join(g.get("name", "") for g in existing if g.get("name"))
|
||||
tool.connected_account_email = f"{len(existing)} server{'s' if len(existing) != 1 else ''}" + (f" · {names}" if names else "")
|
||||
|
||||
elif tool.name.lower() == "notion":
|
||||
# Notion OAuth: Basic auth with client_id:secret
|
||||
notion_client_id = os.environ.get("NOTION_OAUTH_CLIENT_ID", "")
|
||||
notion_client_secret = os.environ.get("NOTION_OAUTH_CLIENT_SECRET", "")
|
||||
import base64
|
||||
credentials = base64.b64encode(f"{notion_client_id}:{notion_client_secret}".encode()).decode()
|
||||
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||
resp = await client.post("https://api.notion.com/v1/oauth/token", json={
|
||||
"grant_type": "authorization_code",
|
||||
"code": code,
|
||||
"redirect_uri": redirect_uri,
|
||||
}, headers={
|
||||
"Authorization": f"Basic {credentials}",
|
||||
"Content-Type": "application/json",
|
||||
})
|
||||
|
||||
if resp.status_code != 200:
|
||||
logger.warning(f"Notion OAuth token exchange failed: {resp.text}")
|
||||
return HTMLResponse(f"<html><body><h2>Token exchange failed</h2><pre>{resp.text}</pre></body></html>", status_code=400)
|
||||
|
||||
tokens = resp.json()
|
||||
tool.oauth_tokens = {
|
||||
"access_token": tokens.get("access_token", ""),
|
||||
}
|
||||
tool.auth_type = "oauth2"
|
||||
tool.auth_status = "connected"
|
||||
tool.connected_account_email = tokens.get("workspace_name", "Notion workspace")
|
||||
else:
|
||||
# Google OAuth
|
||||
client_id = os.environ.get("GOOGLE_OAUTH_CLIENT_ID", "")
|
||||
client_secret = os.environ.get("GOOGLE_OAUTH_CLIENT_SECRET", "")
|
||||
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||
resp = await client.post(GOOGLE_TOKEN_URL, data={
|
||||
"code": code,
|
||||
"client_id": client_id,
|
||||
"client_secret": client_secret,
|
||||
"redirect_uri": redirect_uri,
|
||||
"grant_type": "authorization_code",
|
||||
})
|
||||
|
||||
if resp.status_code != 200:
|
||||
logger.warning(f"OAuth token exchange failed: {resp.text}")
|
||||
return HTMLResponse(f"<html><body><h2>Token exchange failed</h2><pre>{resp.text}</pre></body></html>", status_code=400)
|
||||
|
||||
tokens = resp.json()
|
||||
access_token = tokens.get("access_token", "")
|
||||
tool.oauth_tokens = {
|
||||
"access_token": access_token,
|
||||
"refresh_token": tokens.get("refresh_token", ""),
|
||||
"token_expiry": time.time() + tokens.get("expires_in", 3600),
|
||||
}
|
||||
tool.auth_type = "oauth2"
|
||||
tool.auth_status = "connected"
|
||||
|
||||
if access_token:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as info_client:
|
||||
info_resp = await info_client.get(
|
||||
GOOGLE_USERINFO_URL,
|
||||
headers={"Authorization": f"Bearer {access_token}"},
|
||||
)
|
||||
if info_resp.status_code == 200:
|
||||
tool.connected_account_email = info_resp.json().get("email")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to fetch Google userinfo: {e}")
|
||||
if access_token:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as info_client:
|
||||
info_resp = await info_client.get(
|
||||
GOOGLE_USERINFO_URL,
|
||||
headers={"Authorization": f"Bearer {access_token}"},
|
||||
)
|
||||
if info_resp.status_code == 200:
|
||||
tool.connected_account_email = info_resp.json().get("email")
|
||||
except Exception as e:
|
||||
logger.warning("Failed to fetch Google userinfo: %s", e)
|
||||
|
||||
_save(tool)
|
||||
return _connected_html()
|
||||
|
||||
|
||||
def _connected_html() -> HTMLResponse:
|
||||
"""v1.0.25-style auto-close page. Same markup so the UX is unchanged."""
|
||||
return HTMLResponse("""
|
||||
<html><body>
|
||||
<h2 style="font-family:sans-serif;color:#22c55e">Connected successfully!</h2>
|
||||
<p style="font-family:sans-serif;color:#666">You can close this window.</p>
|
||||
<script>
|
||||
if (window.opener) window.opener.postMessage({type:'oauth_complete', tool_id:'""" + tool_id + """'}, '*');
|
||||
setTimeout(() => window.close(), 1500);
|
||||
if (window.opener) window.opener.postMessage({type:'oauth_complete'}, '*');
|
||||
setTimeout(function(){ window.close(); }, 1500);
|
||||
</script>
|
||||
</body></html>
|
||||
""")
|
||||
@@ -491,15 +372,24 @@ def derive_mcp_config(tool: ToolDefinition) -> Optional[dict]:
|
||||
if client_secret:
|
||||
env["GOOGLE_WORKSPACE_CLIENT_SECRET"] = client_secret
|
||||
|
||||
# Discord: bot token is loaded from .env at MCP launch time. It is NEVER
|
||||
# stored on the tool definition or exposed to the frontend. The tool only
|
||||
# tracks the list of authorized guild IDs (in oauth_tokens.guilds) which
|
||||
# are used by the agent system prompt to scope what the agent may access.
|
||||
# Discord MCP runs as a small Python shim (backend.apps.discord_mcp_shim).
|
||||
# We pass install_id + base URL via env so the shim subprocess doesn't
|
||||
# need to import backend.config.* itself.
|
||||
if tool.name.lower() == "discord" and config.get("type") == "stdio":
|
||||
bot_token = os.environ.get("DISCORD_BOT_TOKEN", "")
|
||||
if bot_token:
|
||||
env = config.setdefault("env", {})
|
||||
env["DISCORD_TOKEN"] = bot_token
|
||||
from backend.config.install_id import get_install_id
|
||||
env = config.setdefault("env", {})
|
||||
env["OPENSWARM_OAUTH_BASE_URL"] = OPENSWARM_OAUTH_BASE_URL
|
||||
env["OPENSWARM_INSTALL_ID"] = get_install_id()
|
||||
# Pass the authorized guild IDs so the shim can scope-enforce.
|
||||
guild_ids = [g.get("id", "") for g in (tool.oauth_tokens.get("guilds") or []) if g.get("id")]
|
||||
if guild_ids:
|
||||
env["OPENSWARM_DISCORD_GUILD_IDS"] = ",".join(guild_ids)
|
||||
# The shim runs as a subprocess and needs to import
|
||||
# `backend.apps.discord_mcp_shim` — set PYTHONPATH to the project
|
||||
# root (parent of the backend/ dir) so that import resolves.
|
||||
_project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
|
||||
existing_pp = env.get("PYTHONPATH") or os.environ.get("PYTHONPATH", "")
|
||||
env["PYTHONPATH"] = (_project_root + os.pathsep + existing_pp) if existing_pp else _project_root
|
||||
|
||||
# Microsoft 365 MCP: use a stable token cache path shared across process spawns
|
||||
if tool.name.lower() == "microsoft 365" and config.get("type") == "stdio":
|
||||
@@ -511,6 +401,17 @@ def derive_mcp_config(tool: ToolDefinition) -> Optional[dict]:
|
||||
|
||||
if config.get("type") == "stdio":
|
||||
if config.get("command"):
|
||||
# `python` (no version suffix) doesn't exist on a stock macOS,
|
||||
# so a tool config that asks for "python" silently fails to
|
||||
# spawn — Claude Agent SDK then exposes zero tools from that
|
||||
# MCP. We resolve to the actual interpreter running the
|
||||
# backend (sys.executable), which is guaranteed to exist and
|
||||
# have backend modules importable. `python3` and absolute
|
||||
# paths pass through unchanged.
|
||||
if config["command"] == "python":
|
||||
resolved_python = sys.executable or shutil.which("python3") or shutil.which("python")
|
||||
if resolved_python:
|
||||
config["command"] = resolved_python
|
||||
# Check for bundled npm MCP servers — use Electron's Node.js instead of npx
|
||||
if config["command"] in ("npx", "bunx"):
|
||||
pkg_name = next((a for a in (config.get("args") or []) if not a.startswith("-")), None)
|
||||
@@ -965,7 +866,24 @@ _m365_login_processes: dict[str, dict] = {} # tool_id -> {proc, device_code, st
|
||||
|
||||
|
||||
def _m365_server_script() -> str:
|
||||
"""Return the on-disk path to the bundled MS365 MCP server entry.
|
||||
|
||||
v1.0.26 replaced the heavy backend/npm-servers/softeria-ms-365-mcp-server/
|
||||
node_modules tree (~93MB / 11k files) with a single esbuild bundle at
|
||||
backend/mcp-bundles/softeria-ms-365-mcp-server/dist/index.js (4.7MB).
|
||||
The new path mirrors the SDK's internal layout (dist/index.js + sibling
|
||||
package.json) because cli.js reads __dirname/../package.json for the
|
||||
--version flag — see scripts/build-app.sh `build_mcp_bundle_dir`.
|
||||
"""
|
||||
_backend = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
bundle = os.path.join(
|
||||
_backend, "mcp-bundles", "softeria-ms-365-mcp-server", "dist", "index.js",
|
||||
)
|
||||
if os.path.isfile(bundle):
|
||||
return bundle
|
||||
# Fallback for any user still on a v1.0.25 install whose backend/ folder
|
||||
# was left over from before the bundle migration. Will return the legacy
|
||||
# path; if that doesn't exist either, the caller raises a clear error.
|
||||
return os.path.join(
|
||||
_backend, "npm-servers", "softeria-ms-365-mcp-server",
|
||||
"node_modules", "@softeria", "ms-365-mcp-server", "dist", "index.js",
|
||||
@@ -1148,101 +1066,216 @@ async def oauth_disconnect(tool_id: str):
|
||||
return {"ok": True, "tool": tool.model_dump()}
|
||||
|
||||
|
||||
# Tool name → provider key for the OAuth helper service. Google is not in
|
||||
# this map (it uses the direct local callback); anything else falls back to
|
||||
# the local Google flow.
|
||||
_TOOL_NAME_TO_PROVIDER = {
|
||||
"airtable": "airtable",
|
||||
"hubspot": "hubspot",
|
||||
"discord": "discord",
|
||||
"notion": "notion",
|
||||
}
|
||||
|
||||
|
||||
def _proxied_provider_for(tool: ToolDefinition) -> Optional[str]:
|
||||
return _TOOL_NAME_TO_PROVIDER.get(tool.name.lower())
|
||||
|
||||
|
||||
@tools_lib.router.post("/{tool_id}/oauth/start")
|
||||
async def oauth_start(tool_id: str):
|
||||
"""Return the OAuth start URL for this tool."""
|
||||
tool = _load(tool_id)
|
||||
proxied = _proxied_provider_for(tool)
|
||||
_port = os.environ.get("OPENSWARM_PORT", "8324")
|
||||
|
||||
if proxied:
|
||||
from backend.config.install_id import get_install_id
|
||||
install_id = get_install_id()
|
||||
params = {
|
||||
"install_id": install_id,
|
||||
"tool_id": tool_id,
|
||||
"local_port": _port,
|
||||
}
|
||||
auth_url = (
|
||||
f"{OPENSWARM_OAUTH_BASE_URL}/api/oauth/{proxied}/start?"
|
||||
f"{urlencode(params)}"
|
||||
)
|
||||
return {"auth_url": auth_url}
|
||||
|
||||
# Local Google flow. State is a one-shot CSRF nonce keyed to the tool.
|
||||
client_id = os.environ.get("GOOGLE_OAUTH_CLIENT_ID", "")
|
||||
if not client_id:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="GOOGLE_OAUTH_CLIENT_ID not set in backend .env",
|
||||
)
|
||||
state = secrets.token_urlsafe(24)
|
||||
_pending_oauth[state] = {"tool_id": tool_id}
|
||||
redirect_uri = f"http://localhost:{_port}/api/tools/oauth/callback"
|
||||
state = tool_id
|
||||
|
||||
if tool.name.lower() == "airtable":
|
||||
client_id = os.environ.get("AIRTABLE_OAUTH_CLIENT_ID", "")
|
||||
if not client_id:
|
||||
raise HTTPException(status_code=400, detail="AIRTABLE_OAUTH_CLIENT_ID not set in backend .env")
|
||||
# PKCE: generate code_verifier and code_challenge
|
||||
code_verifier = secrets.token_urlsafe(96)
|
||||
code_challenge = hashlib.sha256(code_verifier.encode()).digest()
|
||||
import base64
|
||||
code_challenge_b64 = base64.urlsafe_b64encode(code_challenge).rstrip(b"=").decode()
|
||||
_pending_oauth[state] = {"tool_id": tool_id, "code_verifier": code_verifier}
|
||||
params = {
|
||||
"client_id": client_id,
|
||||
"redirect_uri": redirect_uri,
|
||||
"response_type": "code",
|
||||
"scope": " ".join(AIRTABLE_SCOPES),
|
||||
"state": state,
|
||||
"code_challenge": code_challenge_b64,
|
||||
"code_challenge_method": "S256",
|
||||
}
|
||||
auth_url = f"{AIRTABLE_AUTH_URL}?{urlencode(params)}"
|
||||
elif tool.name.lower() == "hubspot":
|
||||
client_id = os.environ.get("HUBSPOT_OAUTH_CLIENT_ID", "")
|
||||
if not client_id:
|
||||
raise HTTPException(status_code=400, detail="HUBSPOT_OAUTH_CLIENT_ID not set in backend .env")
|
||||
code_verifier = secrets.token_urlsafe(96)
|
||||
code_challenge = hashlib.sha256(code_verifier.encode()).digest()
|
||||
import base64
|
||||
code_challenge_b64 = base64.urlsafe_b64encode(code_challenge).rstrip(b"=").decode()
|
||||
_pending_oauth[state] = {"tool_id": tool_id, "code_verifier": code_verifier}
|
||||
params = {
|
||||
"client_id": client_id,
|
||||
"redirect_uri": redirect_uri,
|
||||
"code_challenge": code_challenge_b64,
|
||||
"code_challenge_method": "S256",
|
||||
"state": state,
|
||||
}
|
||||
auth_url = f"{HUBSPOT_AUTH_URL}?{urlencode(params)}"
|
||||
elif tool.name.lower() == "discord":
|
||||
client_id = os.environ.get("DISCORD_OAUTH_CLIENT_ID", "")
|
||||
if not client_id:
|
||||
raise HTTPException(status_code=400, detail="DISCORD_OAUTH_CLIENT_ID not set in backend .env")
|
||||
permissions = os.environ.get("DISCORD_BOT_PERMISSIONS", "0")
|
||||
_pending_oauth[state] = {"tool_id": tool_id}
|
||||
params = {
|
||||
"client_id": client_id,
|
||||
"redirect_uri": redirect_uri,
|
||||
"response_type": "code",
|
||||
"scope": "bot identify",
|
||||
"permissions": permissions,
|
||||
"state": state,
|
||||
}
|
||||
auth_url = f"{DISCORD_AUTH_URL}?{urlencode(params)}"
|
||||
elif tool.name.lower() == "notion":
|
||||
_pending_oauth[state] = {"tool_id": tool_id}
|
||||
client_id = os.environ.get("NOTION_OAUTH_CLIENT_ID", "")
|
||||
if not client_id:
|
||||
raise HTTPException(status_code=400, detail="NOTION_OAUTH_CLIENT_ID not set in backend .env")
|
||||
params = {
|
||||
"client_id": client_id,
|
||||
"redirect_uri": redirect_uri,
|
||||
"response_type": "code",
|
||||
"owner": "user",
|
||||
"state": state,
|
||||
}
|
||||
auth_url = f"https://api.notion.com/v1/oauth/authorize?{urlencode(params)}"
|
||||
else:
|
||||
_pending_oauth[state] = {"tool_id": tool_id}
|
||||
client_id = os.environ.get("GOOGLE_OAUTH_CLIENT_ID", "")
|
||||
if not client_id:
|
||||
raise HTTPException(status_code=400, detail="GOOGLE_OAUTH_CLIENT_ID not set in backend .env")
|
||||
params = {
|
||||
"client_id": client_id,
|
||||
"redirect_uri": redirect_uri,
|
||||
"response_type": "code",
|
||||
"scope": " ".join(GOOGLE_SCOPES),
|
||||
"access_type": "offline",
|
||||
"prompt": "consent",
|
||||
"state": state,
|
||||
}
|
||||
auth_url = f"{GOOGLE_AUTH_URL}?{urlencode(params)}"
|
||||
|
||||
params = {
|
||||
"client_id": client_id,
|
||||
"redirect_uri": redirect_uri,
|
||||
"response_type": "code",
|
||||
"scope": " ".join(GOOGLE_SCOPES),
|
||||
"access_type": "offline",
|
||||
"prompt": "consent",
|
||||
"state": state,
|
||||
}
|
||||
auth_url = f"{GOOGLE_AUTH_URL}?{urlencode(params)}"
|
||||
return {"auth_url": auth_url}
|
||||
|
||||
|
||||
@tools_lib.router.get("/oauth/cloud-claim")
|
||||
async def oauth_cloud_claim(
|
||||
session_id: str = Query(...),
|
||||
tool_id: str = Query(...),
|
||||
):
|
||||
"""Browser-facing callback for the proxied OAuth flow.
|
||||
|
||||
Receives a single-use session_id, exchanges it for the tokens (using
|
||||
install_id as the binding), persists them, and serves an auto-close page.
|
||||
"""
|
||||
from backend.config.install_id import get_install_id
|
||||
|
||||
install_id = get_install_id()
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||
resp = await client.post(
|
||||
f"{OPENSWARM_OAUTH_BASE_URL}/api/oauth/session/{session_id}/claim",
|
||||
json={"install_id": install_id},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception("Cloud OAuth claim threw: %s", e)
|
||||
return HTMLResponse(
|
||||
f"<html><body><h2>Connection failed</h2><pre>{e}</pre>"
|
||||
f"<p>Please retry from OpenSwarm.</p></body></html>",
|
||||
status_code=502,
|
||||
)
|
||||
|
||||
if resp.status_code in (404, 410):
|
||||
return HTMLResponse(
|
||||
"<html><body><h2>Session expired</h2>"
|
||||
"<p>Please retry from OpenSwarm.</p></body></html>",
|
||||
status_code=410,
|
||||
)
|
||||
if resp.status_code == 403:
|
||||
return HTMLResponse(
|
||||
"<html><body><h2>OAuth session not bound to this install</h2>"
|
||||
"<p>Please retry from OpenSwarm.</p></body></html>",
|
||||
status_code=403,
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
logger.warning("Cloud OAuth claim failed: HTTP %d %s", resp.status_code, resp.text[:200])
|
||||
return HTMLResponse(
|
||||
f"<html><body><h2>Cloud OAuth claim failed</h2><pre>{resp.text}</pre></body></html>",
|
||||
status_code=502,
|
||||
)
|
||||
|
||||
data = resp.json()
|
||||
tokens = data.get("tokens", {}) or {}
|
||||
tool = _load(tool_id)
|
||||
_persist_cloud_tokens(tool, tokens)
|
||||
_save(tool)
|
||||
return _connected_html()
|
||||
|
||||
|
||||
def _persist_cloud_tokens(tool: ToolDefinition, tokens: dict) -> None:
|
||||
"""Normalise the cloud's claim response into tool.oauth_tokens.
|
||||
|
||||
Per-provider shaping mirrors what the v1.0.25 local-callback flow used
|
||||
to write — the rest of the app (refresh helpers, MCP env injection)
|
||||
expects exactly this shape.
|
||||
"""
|
||||
name = tool.name.lower()
|
||||
if name == "discord":
|
||||
new_guilds = (tokens.get("_guilds") or []) if isinstance(tokens, dict) else []
|
||||
existing = tool.oauth_tokens.get("guilds") or []
|
||||
for g in new_guilds:
|
||||
if g.get("id") and not any(e.get("id") == g["id"] for e in existing):
|
||||
existing.append({"id": g["id"], "name": g.get("name", "")})
|
||||
tool.oauth_tokens = {"guilds": existing}
|
||||
names = ", ".join(g.get("name", "") for g in existing if g.get("name"))
|
||||
tool.connected_account_email = (
|
||||
f"{len(existing)} server{'s' if len(existing) != 1 else ''}"
|
||||
+ (f" · {names}" if names else "")
|
||||
)
|
||||
elif name == "notion":
|
||||
tool.oauth_tokens = {"access_token": tokens.get("access_token", "")}
|
||||
tool.connected_account_email = tokens.get("workspace_name", "Notion workspace")
|
||||
else:
|
||||
tool.oauth_tokens = {
|
||||
"access_token": tokens.get("access_token", ""),
|
||||
"refresh_token": tokens.get("refresh_token", ""),
|
||||
"token_expiry": time.time() + (tokens.get("expires_in") or 3600),
|
||||
}
|
||||
tool.connected_account_email = (
|
||||
tokens.get("hub_domain") # HubSpot
|
||||
or tokens.get("workspace_name")
|
||||
or f"{tool.name} account"
|
||||
)
|
||||
tool.auth_type = "oauth2"
|
||||
tool.auth_status = "connected"
|
||||
|
||||
|
||||
async def _refresh_via_proxy(provider: str, tool: ToolDefinition, default_expiry: int) -> Optional[str]:
|
||||
"""Refresh an OAuth access_token by POSTing the refresh_token to the
|
||||
helper service. Per-provider wrappers below pass a default expires_in
|
||||
fallback for providers that don't return one.
|
||||
"""
|
||||
if tool.auth_type != "oauth2":
|
||||
return None
|
||||
refresh_token = tool.oauth_tokens.get("refresh_token")
|
||||
if not refresh_token:
|
||||
return None
|
||||
expiry = tool.oauth_tokens.get("token_expiry", 0)
|
||||
if time.time() < expiry - 60:
|
||||
return tool.oauth_tokens.get("access_token")
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||
resp = await client.post(
|
||||
f"{OPENSWARM_OAUTH_BASE_URL}/api/oauth/{provider}/refresh",
|
||||
json={"refresh_token": refresh_token},
|
||||
)
|
||||
if resp.status_code == 401:
|
||||
# Provider rejected — user revoked at the provider's side. Mark
|
||||
# as needing re-auth so the UI prompts a Reconnect.
|
||||
tool.auth_status = "expired"
|
||||
_save(tool)
|
||||
logger.warning(f"{provider} refresh rejected (user revoked); marking tool as expired")
|
||||
return None
|
||||
if resp.status_code != 200:
|
||||
logger.warning(f"{provider} cloud refresh failed: HTTP %d %s", resp.status_code, resp.text[:200])
|
||||
return None
|
||||
|
||||
data = (resp.json() or {}).get("tokens") or {}
|
||||
new_token = data.get("access_token", "")
|
||||
if not new_token:
|
||||
return None
|
||||
tool.oauth_tokens["access_token"] = new_token
|
||||
tool.oauth_tokens["token_expiry"] = time.time() + (data.get("expires_in") or default_expiry)
|
||||
if data.get("refresh_token"):
|
||||
# Some providers (HubSpot, Airtable) rotate refresh_tokens on every
|
||||
# refresh. Persist the new one or future refreshes will fail.
|
||||
tool.oauth_tokens["refresh_token"] = data["refresh_token"]
|
||||
# Backfill identity label on first successful refresh after upgrade.
|
||||
if not tool.connected_account_email and data.get("email"):
|
||||
tool.connected_account_email = data["email"]
|
||||
_save(tool)
|
||||
return new_token
|
||||
except Exception as e:
|
||||
logger.warning(f"{provider} cloud refresh exception for tool {tool.id}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
async def refresh_google_token(tool: ToolDefinition) -> Optional[str]:
|
||||
"""Refresh an expired Google OAuth token. Returns the fresh access_token or None."""
|
||||
"""Refresh an expired Google OAuth token using the local client_secret.
|
||||
|
||||
Google stays on the v1.0.25 local flow because we don't control the
|
||||
Google Cloud Console for the production OAuth client and can't add the
|
||||
cloud's redirect URI. The client_secret ships in the production .env —
|
||||
standard "public OAuth app" pattern.
|
||||
"""
|
||||
if tool.auth_type != "oauth2":
|
||||
return None
|
||||
refresh_token = tool.oauth_tokens.get("refresh_token")
|
||||
@@ -1265,106 +1298,38 @@ async def refresh_google_token(tool: ToolDefinition) -> Optional[str]:
|
||||
"refresh_token": refresh_token,
|
||||
"grant_type": "refresh_token",
|
||||
})
|
||||
if resp.status_code == 200:
|
||||
data = resp.json()
|
||||
new_token = data["access_token"]
|
||||
tool.oauth_tokens["access_token"] = new_token
|
||||
tool.oauth_tokens["token_expiry"] = time.time() + data.get("expires_in", 3600)
|
||||
|
||||
if not tool.connected_account_email:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as info_client:
|
||||
info_resp = await info_client.get(
|
||||
GOOGLE_USERINFO_URL,
|
||||
headers={"Authorization": f"Bearer {new_token}"},
|
||||
)
|
||||
if info_resp.status_code == 200:
|
||||
tool.connected_account_email = info_resp.json().get("email")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
_save(tool)
|
||||
return new_token
|
||||
if resp.status_code != 200:
|
||||
logger.warning("Google token refresh failed: HTTP %d %s", resp.status_code, resp.text[:200])
|
||||
return None
|
||||
data = resp.json()
|
||||
new_token = data.get("access_token", "")
|
||||
if not new_token:
|
||||
return None
|
||||
tool.oauth_tokens["access_token"] = new_token
|
||||
tool.oauth_tokens["token_expiry"] = time.time() + data.get("expires_in", 3600)
|
||||
if not tool.connected_account_email:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as info_client:
|
||||
info_resp = await info_client.get(
|
||||
GOOGLE_USERINFO_URL,
|
||||
headers={"Authorization": f"Bearer {new_token}"},
|
||||
)
|
||||
if info_resp.status_code == 200:
|
||||
tool.connected_account_email = info_resp.json().get("email")
|
||||
except Exception:
|
||||
pass
|
||||
_save(tool)
|
||||
return new_token
|
||||
except Exception as e:
|
||||
logger.warning(f"Google token refresh failed for tool {tool.id}: {e}")
|
||||
return None
|
||||
logger.warning("Google token refresh exception for tool %s: %s", tool.id, e)
|
||||
return None
|
||||
|
||||
|
||||
async def refresh_airtable_token(tool: ToolDefinition) -> Optional[str]:
|
||||
"""Refresh an expired Airtable OAuth token. Returns the fresh access_token or None."""
|
||||
if tool.auth_type != "oauth2":
|
||||
return None
|
||||
refresh_token = tool.oauth_tokens.get("refresh_token")
|
||||
if not refresh_token:
|
||||
return None
|
||||
expiry = tool.oauth_tokens.get("token_expiry", 0)
|
||||
if time.time() < expiry - 60:
|
||||
return tool.oauth_tokens.get("access_token")
|
||||
|
||||
client_id = os.environ.get("AIRTABLE_OAUTH_CLIENT_ID", "")
|
||||
client_secret = os.environ.get("AIRTABLE_OAUTH_CLIENT_SECRET", "")
|
||||
if not client_id or not client_secret:
|
||||
return None
|
||||
|
||||
try:
|
||||
import base64
|
||||
credentials = base64.b64encode(f"{client_id}:{client_secret}".encode()).decode()
|
||||
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||
resp = await client.post(AIRTABLE_TOKEN_URL, data={
|
||||
"grant_type": "refresh_token",
|
||||
"refresh_token": refresh_token,
|
||||
"client_id": client_id,
|
||||
}, headers={
|
||||
"Authorization": f"Basic {credentials}",
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
})
|
||||
if resp.status_code == 200:
|
||||
data = resp.json()
|
||||
tool.oauth_tokens["access_token"] = data["access_token"]
|
||||
tool.oauth_tokens["token_expiry"] = time.time() + data.get("expires_in", 7200)
|
||||
if data.get("refresh_token"):
|
||||
tool.oauth_tokens["refresh_token"] = data["refresh_token"]
|
||||
_save(tool)
|
||||
return data["access_token"]
|
||||
except Exception as e:
|
||||
logger.warning(f"Airtable token refresh failed for tool {tool.id}: {e}")
|
||||
return None
|
||||
"""Refresh an expired Airtable OAuth access_token."""
|
||||
return await _refresh_via_proxy("airtable", tool, default_expiry=7200)
|
||||
|
||||
|
||||
async def refresh_hubspot_token(tool: ToolDefinition) -> Optional[str]:
|
||||
"""Refresh an expired HubSpot OAuth token. Returns the fresh access_token or None."""
|
||||
if tool.auth_type != "oauth2":
|
||||
return None
|
||||
refresh_token = tool.oauth_tokens.get("refresh_token")
|
||||
if not refresh_token:
|
||||
return None
|
||||
expiry = tool.oauth_tokens.get("token_expiry", 0)
|
||||
if time.time() < expiry - 60:
|
||||
return tool.oauth_tokens.get("access_token")
|
||||
|
||||
client_id = os.environ.get("HUBSPOT_OAUTH_CLIENT_ID", "")
|
||||
client_secret = os.environ.get("HUBSPOT_OAUTH_CLIENT_SECRET", "")
|
||||
if not client_id or not client_secret:
|
||||
return None
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||
resp = await client.post(HUBSPOT_TOKEN_URL, data={
|
||||
"grant_type": "refresh_token",
|
||||
"refresh_token": refresh_token,
|
||||
"client_id": client_id,
|
||||
"client_secret": client_secret,
|
||||
}, headers={
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
})
|
||||
if resp.status_code == 200:
|
||||
data = resp.json()
|
||||
tool.oauth_tokens["access_token"] = data["access_token"]
|
||||
tool.oauth_tokens["token_expiry"] = time.time() + data.get("expires_in", 1800)
|
||||
if data.get("refresh_token"):
|
||||
tool.oauth_tokens["refresh_token"] = data["refresh_token"]
|
||||
_save(tool)
|
||||
return data["access_token"]
|
||||
except Exception as e:
|
||||
logger.warning(f"HubSpot token refresh failed for tool {tool.id}: {e}")
|
||||
return None
|
||||
"""Refresh an expired HubSpot OAuth access_token."""
|
||||
return await _refresh_via_proxy("hubspot", tool, default_expiry=1800)
|
||||
|
||||
@@ -95,6 +95,10 @@ _AUTH_EXEMPT_EXACT = {
|
||||
# Without this exemption the redirect lands a 401 page in the user's
|
||||
# browser — see tools_lib.py:1156 where redirect_uri is constructed.
|
||||
"/api/tools/oauth/callback",
|
||||
# Browser-redirect target for the proxied OAuth claim handoff. Browser
|
||||
# has no way to inject our bearer token; the install_id check inside
|
||||
# the handler is what binds the request to this user.
|
||||
"/api/tools/oauth/cloud-claim",
|
||||
"/api/version",
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
"""Per-install identifier.
|
||||
|
||||
A UUID4 generated on first run, persisted at ``<DATA_ROOT>/install_id``
|
||||
with 0600 perms. Used to bind an in-flight OAuth claim to the install
|
||||
that started it, so a leaked session_id alone is useless.
|
||||
|
||||
Not a secret. Not a user identity. Not stable across reinstalls
|
||||
(reinstalling generates a new ID, by design — the previous install's
|
||||
in-flight OAuth flows shouldn't follow the user across reinstalls).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import uuid
|
||||
|
||||
from backend.config.paths import DATA_ROOT
|
||||
|
||||
_INSTALL_ID_FILE = os.path.join(DATA_ROOT, "install_id")
|
||||
_cached: str | None = None
|
||||
|
||||
|
||||
def get_install_id() -> str:
|
||||
"""Return the persistent install_id, generating + persisting on first call.
|
||||
|
||||
Idempotent across processes — if the file already exists we read it.
|
||||
Concurrent first-call from two processes is safe: both write a UUID,
|
||||
last-writer-wins, neither side cares which one is canonical.
|
||||
"""
|
||||
global _cached
|
||||
if _cached:
|
||||
return _cached
|
||||
|
||||
try:
|
||||
with open(_INSTALL_ID_FILE, "r", encoding="utf-8") as f:
|
||||
existing = f.read().strip()
|
||||
if _looks_like_uuid(existing):
|
||||
_cached = existing
|
||||
return _cached
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
except Exception:
|
||||
# Corrupt file — overwrite below.
|
||||
pass
|
||||
|
||||
fresh = str(uuid.uuid4())
|
||||
os.makedirs(os.path.dirname(_INSTALL_ID_FILE) or ".", exist_ok=True)
|
||||
# 0600 so other accounts on the same machine can't read it. We're not
|
||||
# treating it as a secret, but no reason to be sloppy.
|
||||
fd = os.open(_INSTALL_ID_FILE, os.O_CREAT | os.O_WRONLY | os.O_TRUNC, 0o600)
|
||||
try:
|
||||
os.write(fd, fresh.encode("utf-8"))
|
||||
finally:
|
||||
os.close(fd)
|
||||
_cached = fresh
|
||||
return _cached
|
||||
|
||||
|
||||
def _looks_like_uuid(s: str) -> bool:
|
||||
if len(s) != 36:
|
||||
return False
|
||||
try:
|
||||
uuid.UUID(s)
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
+27
-6
@@ -30,10 +30,25 @@ let pendingDeepLink = null;
|
||||
|
||||
function forwardDeepLinkToRenderer(url) {
|
||||
if (!url) return;
|
||||
// openswarm:// URLs split by host: "auth" → subscription token,
|
||||
// "oauth/{provider}/complete" → OAuth claim. Each goes to its own
|
||||
// IPC channel so the renderer can route without parsing twice.
|
||||
let channel = 'openswarm:auth-url';
|
||||
try {
|
||||
const u = new URL(url);
|
||||
if (u.host === 'oauth' && u.pathname.endsWith('/complete')) {
|
||||
channel = 'openswarm:oauth-claim';
|
||||
}
|
||||
} catch (_) {
|
||||
// Malformed URL — fall back to legacy channel; renderer ignores anything
|
||||
// it doesn't recognise.
|
||||
}
|
||||
if (mainWindow && mainWindow.webContents && !mainWindow.webContents.isLoading()) {
|
||||
mainWindow.webContents.send('openswarm:auth-url', url);
|
||||
mainWindow.webContents.send(channel, url);
|
||||
} else {
|
||||
pendingDeepLink = url;
|
||||
// Stash both URL and target channel so we can flush correctly when
|
||||
// the renderer is ready. Replaces the simple string with a {channel,url}.
|
||||
pendingDeepLink = { channel, url };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -558,10 +573,15 @@ function createWindow() {
|
||||
});
|
||||
|
||||
// Once the renderer has loaded, flush any deep-link URL we captured before
|
||||
// the window existed (cold-launch via openswarm://).
|
||||
// the window existed (cold-launch via openswarm://). pendingDeepLink may
|
||||
// be a string (legacy) OR a {channel, url} object (v1.0.26+ OAuth claims).
|
||||
mainWindow.webContents.once('did-finish-load', () => {
|
||||
if (pendingDeepLink) {
|
||||
mainWindow.webContents.send('openswarm:auth-url', pendingDeepLink);
|
||||
if (typeof pendingDeepLink === 'string') {
|
||||
mainWindow.webContents.send('openswarm:auth-url', pendingDeepLink);
|
||||
} else {
|
||||
mainWindow.webContents.send(pendingDeepLink.channel, pendingDeepLink.url);
|
||||
}
|
||||
pendingDeepLink = null;
|
||||
}
|
||||
});
|
||||
@@ -647,9 +667,10 @@ function killBackend() {
|
||||
app.whenReady().then(async () => {
|
||||
// Cold-launch: if the OS opened us via openswarm:// (Windows/Linux it's
|
||||
// in argv; macOS fires open-url AFTER whenReady which we handle above)
|
||||
// buffer the URL for when mainWindow loads.
|
||||
// route through forwardDeepLinkToRenderer so the URL gets stashed under
|
||||
// its correct IPC channel (auth-url vs oauth-claim).
|
||||
const initialDeepLink = extractOpenswarmUrl(process.argv);
|
||||
if (initialDeepLink) pendingDeepLink = initialDeepLink;
|
||||
if (initialDeepLink) forwardDeepLinkToRenderer(initialDeepLink);
|
||||
|
||||
if (process.platform === 'darwin' && !isPackaged) {
|
||||
try { app.dock.setIcon(iconPath); } catch (_) {}
|
||||
|
||||
@@ -73,6 +73,14 @@ const { contextBridge, ipcRenderer } = require('electron');
|
||||
return () => ipcRenderer.removeListener('openswarm:auth-url', listener);
|
||||
},
|
||||
|
||||
// OAuth claim deep-link channel. Receives openswarm://oauth/{provider}/complete
|
||||
// after the user finishes an OAuth flow in their browser.
|
||||
onOauthClaim: (cb) => {
|
||||
const listener = (_event, url) => cb(url);
|
||||
ipcRenderer.on('openswarm:oauth-claim', listener);
|
||||
return () => ipcRenderer.removeListener('openswarm:oauth-claim', listener);
|
||||
},
|
||||
|
||||
// OAuth popup callback. Fires when any child webContents navigates to
|
||||
// localhost:20128/callback?code=... — main.js watches for this and
|
||||
// forwards the parsed params here. Used as a belt-and-suspenders
|
||||
|
||||
@@ -42,6 +42,7 @@ import ToolCallBubble, { ToolPair } from './ToolCallBubble';
|
||||
import ToolGroupBubble, { RenderItem, ToolGroup, isToolGroup, isToolPair } from './ToolGroupBubble';
|
||||
import ApprovalBar, { BatchApprovalBar } from './ApprovalBar';
|
||||
import ChatInput, { ChatInputHandle } from './ChatInput';
|
||||
import { ErrorSlime } from '@/app/components/ErrorSlime';
|
||||
import { ContextPath } from '@/app/components/DirectoryBrowser';
|
||||
import DiffViewer from './DiffViewer';
|
||||
import { setGlowingBrowserCards, fadeGlowingBrowserCards, clearGlowingBrowserCards } from '@/shared/state/dashboardLayoutSlice';
|
||||
@@ -142,6 +143,10 @@ const AgentChat: React.FC<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
|
||||
const session = useAppSelector((state) => (id ? state.agents.sessions[id] : undefined));
|
||||
const modesMap = useAppSelector((state) => state.modes.items);
|
||||
const modelsByProvider = useAppSelector((state) => state.models.byProvider);
|
||||
// Used by the "too many connected apps for Haiku" warning rendered above
|
||||
// ChatInput. Each connected MCP adds a meaningful chunk of tool-schema
|
||||
// tokens to every request; Haiku 4.5's 200K window can't hold 5+ of them.
|
||||
const toolItems = useAppSelector((state) => state.tools.items);
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
const chatInputRef = useRef<ChatInputHandle>(null);
|
||||
const isAtBottomRef = useRef(true);
|
||||
@@ -1196,6 +1201,50 @@ const AgentChat: React.FC<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
{(() => {
|
||||
// Proactive Haiku-overflow warning. Each connected MCP adds
|
||||
// a sizeable tools-schema chunk to every Claude request;
|
||||
// Haiku 4.5's window is 5x smaller than Sonnet/Opus, so 5+
|
||||
// simultaneously-enabled MCPs reliably push a one-line
|
||||
// message past the limit. We surface this BEFORE the user
|
||||
// sends so they don't waste a turn on "Prompt is too long".
|
||||
const isHaiku = (model || '').toLowerCase().startsWith('haiku');
|
||||
const enabledMcpCount = Object.values(toolItems).filter(
|
||||
(t) => t.enabled && t.mcp_config && Object.keys(t.mcp_config).length > 0,
|
||||
).length;
|
||||
if (!isHaiku || enabledMcpCount < 5) return null;
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
mx: 2,
|
||||
mb: 1,
|
||||
p: 1.5,
|
||||
borderRadius: `${c.radius.lg}px`,
|
||||
border: `1px solid ${c.status.warning}40`,
|
||||
bgcolor: `${c.status.warning}10`,
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
gap: 1.2,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ flexShrink: 0, mt: 0.2 }}>
|
||||
<ErrorSlime size={20} />
|
||||
</Box>
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
<Typography sx={{ fontSize: '0.86rem', fontWeight: 600, color: c.text.primary, mb: 0.4 }}>
|
||||
Haiku may run out of room with {enabledMcpCount} apps connected
|
||||
</Typography>
|
||||
<Typography sx={{ fontSize: '0.78rem', color: c.text.secondary, lineHeight: 1.45 }}>
|
||||
Haiku is the fastest Claude model but holds the least at once.
|
||||
Each connected app adds instructions Claude has to read first.
|
||||
If your message fails with “Prompt is too long,” turn off a few
|
||||
apps (Microsoft 365 is the heaviest) or switch to Sonnet/Opus —
|
||||
both have 5× more room.
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
})()}
|
||||
<ChatInput
|
||||
ref={chatInputRef}
|
||||
onSend={handleSend}
|
||||
|
||||
@@ -66,7 +66,7 @@ const StreamingCursor: React.FC = () => {
|
||||
const ELEMENT_SEPARATOR = '\n\n---\nSelected UI Elements:\n';
|
||||
|
||||
interface OpenSwarmErrorInfo {
|
||||
kind: 'cap' | 'auth' | 'network';
|
||||
kind: 'cap' | 'auth' | 'network' | 'too_many_tools';
|
||||
title: string;
|
||||
detail: string;
|
||||
ctaLabel?: string;
|
||||
@@ -106,6 +106,25 @@ function parseOpenSwarmError(text: string): OpenSwarmErrorInfo | null {
|
||||
detail: 'That request timed out after a few retries. Send the message again to continue.',
|
||||
};
|
||||
}
|
||||
// Too many MCP tool definitions for the chosen model's input window.
|
||||
// Classic case: user has 5+ apps connected (M365 alone has 141 actions),
|
||||
// chose Haiku (200K context), and even a one-line message can't fit
|
||||
// because the tool schemas alone push past the limit. Bigger models
|
||||
// (Sonnet/Opus, 1M) absorb it fine.
|
||||
if (/Prompt is too long|prompt_too_long|input length and `max_tokens`|context length/i.test(text)) {
|
||||
return {
|
||||
kind: 'too_many_tools',
|
||||
title: 'Too many connected apps for this model',
|
||||
detail:
|
||||
"Haiku is fast but has the smallest memory of the three Claude models. " +
|
||||
"Each connected app adds instructions Claude has to read before it can answer, " +
|
||||
"and you've added more than Haiku can hold in one go. Either turn off a few apps " +
|
||||
"(Microsoft 365 is the heaviest by far), or switch to Sonnet or Opus — both have " +
|
||||
"5× more room.",
|
||||
ctaLabel: 'Open Settings',
|
||||
ctaAction: 'settings',
|
||||
};
|
||||
}
|
||||
// Auth / subscription problems
|
||||
if (/No active subscription|Subscription canceled|Subscription past_due|Invalid.*token|Missing bearer token/i.test(text)) {
|
||||
return {
|
||||
|
||||
@@ -1368,7 +1368,7 @@ const Tools: React.FC = () => {
|
||||
{uninstalledIntegrations.map((ig) => {
|
||||
const isLoading = !!integrationLoading[ig.id];
|
||||
return (
|
||||
<Card key={ig.id} sx={{ bgcolor: c.bg.surface, border: `1px solid ${c.border.subtle}`, borderRadius: 2, boxShadow: c.shadow.sm, transition: 'border-color 0.2s, box-shadow 0.2s' }}>
|
||||
<Card key={ig.id} sx={{ order: 2, bgcolor: c.bg.surface, border: `1px solid ${c.border.subtle}`, borderRadius: 2, boxShadow: c.shadow.sm, transition: 'border-color 0.2s, box-shadow 0.2s' }}>
|
||||
<CardContent sx={{ py: 1.5, px: 2, '&:last-child': { pb: 1.5 } }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<Box sx={{
|
||||
@@ -1566,7 +1566,7 @@ const Tools: React.FC = () => {
|
||||
const isDisabled = tool.enabled === false;
|
||||
|
||||
return (
|
||||
<Card key={tool.id} sx={{ bgcolor: c.bg.surface, border: `1px solid ${isExpanded ? c.accent.primary : c.border.subtle}`, borderRadius: 2, boxShadow: c.shadow.sm, '&:hover': { borderColor: isDisabled ? c.border.subtle : c.accent.primary, boxShadow: isDisabled ? undefined : '0 0 0 1px rgba(174,86,48,0.12)' }, transition: 'border-color 0.2s, box-shadow 0.2s' }}>
|
||||
<Card key={tool.id} sx={{ order: tool.auth_status === 'connected' ? 0 : 1, bgcolor: c.bg.surface, border: `1px solid ${isExpanded ? c.accent.primary : c.border.subtle}`, borderRadius: 2, boxShadow: c.shadow.sm, '&:hover': { borderColor: isDisabled ? c.border.subtle : c.accent.primary, boxShadow: isDisabled ? undefined : '0 0 0 1px rgba(174,86,48,0.12)' }, transition: 'border-color 0.2s, box-shadow 0.2s' }}>
|
||||
<CardContent sx={{ py: 1.5, px: 2, '&:last-child': { pb: 1.5 } }}>
|
||||
<Box
|
||||
sx={{ display: 'flex', alignItems: 'center', gap: 2, cursor: isDisabled ? 'default' : 'pointer' }}
|
||||
|
||||
@@ -2,6 +2,8 @@ import { useEffect } from 'react';
|
||||
import { useAppDispatch } from '@/shared/hooks';
|
||||
import { activateSubscription } from '@/shared/state/settingsSlice';
|
||||
import { fetchModels } from '@/shared/state/modelsSlice';
|
||||
import { fetchTools } from '@/shared/state/toolsSlice';
|
||||
import { API_BASE } from '@/shared/config';
|
||||
import { trackEvent } from '@/shared/analytics';
|
||||
|
||||
// Listens for openswarm://auth?token=...&plan=...&expires=... URLs coming
|
||||
@@ -15,9 +17,11 @@ export function useDeepLink(): void {
|
||||
|
||||
useEffect(() => {
|
||||
const api = (window as any).openswarm as OpenSwarmAPI | undefined;
|
||||
if (!api?.onAuthUrl) return;
|
||||
// Both listeners are optional — useDeepLink no-ops in browser/web context
|
||||
// where window.openswarm is undefined.
|
||||
if (!api) return;
|
||||
|
||||
const unsubscribe = api.onAuthUrl((rawUrl: string) => {
|
||||
const unsubscribe = api.onAuthUrl?.((rawUrl: string) => {
|
||||
try {
|
||||
// openswarm://auth?token=... (host = "auth", search carries fields)
|
||||
const url = new URL(rawUrl);
|
||||
@@ -62,6 +66,51 @@ export function useDeepLink(): void {
|
||||
}
|
||||
});
|
||||
|
||||
return unsubscribe;
|
||||
// OAuth claim deep-link listener. The Electron main process routes
|
||||
// openswarm://oauth/{provider}/complete to its own IPC channel so we
|
||||
// can claim tokens immediately rather than routing through Settings.
|
||||
let unsubscribeOauth: (() => void) | undefined;
|
||||
if (api?.onOauthClaim) {
|
||||
unsubscribeOauth = api.onOauthClaim(async (rawUrl: string) => {
|
||||
try {
|
||||
const url = new URL(rawUrl);
|
||||
// Expected: openswarm://oauth/{provider}/complete?session_id=...&tool_id=...
|
||||
if (url.host !== 'oauth' || !url.pathname.endsWith('/complete')) {
|
||||
console.warn('[deep-link] Unexpected oauth-claim URL:', rawUrl);
|
||||
return;
|
||||
}
|
||||
const sessionId = url.searchParams.get('session_id');
|
||||
const toolId = url.searchParams.get('tool_id');
|
||||
if (!sessionId || !toolId) {
|
||||
console.warn('[deep-link] Missing session_id or tool_id in', rawUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
trackEvent('oauth.deep_link_received', { provider: url.pathname.split('/')[1] || 'unknown' });
|
||||
|
||||
const resp = await fetch(`${API_BASE}/tools/oauth/claim`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ session_id: sessionId, tool_id: toolId }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const text = await resp.text();
|
||||
console.error('[deep-link] OAuth claim failed:', resp.status, text);
|
||||
trackEvent('oauth.claim_failed', { status: resp.status });
|
||||
return;
|
||||
}
|
||||
trackEvent('oauth.claim_succeeded');
|
||||
// Refresh tools so the UI reflects the newly-connected tool.
|
||||
dispatch(fetchTools());
|
||||
} catch (e) {
|
||||
console.error('[deep-link] OAuth claim threw:', e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
unsubscribe?.();
|
||||
unsubscribeOauth?.();
|
||||
};
|
||||
}, [dispatch]);
|
||||
}
|
||||
|
||||
Vendored
+2
@@ -48,6 +48,8 @@ declare global {
|
||||
openExternal: (url: string) => Promise<void>;
|
||||
// Deep-link listener — fires when OS opens the app with openswarm://... URL.
|
||||
onAuthUrl?: (cb: (url: string) => void) => () => void;
|
||||
// OAuth claim deep-link listener — fires for openswarm://oauth/{provider}/complete.
|
||||
onOauthClaim?: (cb: (url: string) => void) => () => void;
|
||||
}
|
||||
|
||||
interface Window {
|
||||
|
||||
File diff suppressed because one or more lines are too long
+19
-27
@@ -299,35 +299,27 @@ Copy-Excluded `
|
||||
(Join-Path $ProjectRoot 'backend') (Join-Path $Staging 'backend') `
|
||||
@{ Dirs = @('__pycache__','.venv','tools','tests'); Files = @('*.pyc','.env','.env.*') }
|
||||
|
||||
# Generate a SAFE production .env containing ONLY OAuth provider credentials.
|
||||
# Drops APPLE_*/GH_TOKEN/BACKEND_PORT/etc. so signing keys + personal tokens
|
||||
# never ship to users. Per-user API keys (Anthropic/OpenAI/Gemini) come from
|
||||
# the in-app Settings UI. Backend's tools_lib.py:34 auto-loads this file.
|
||||
$ShipEnvKeys = @(
|
||||
'GOOGLE_OAUTH_CLIENT_ID','GOOGLE_OAUTH_CLIENT_SECRET',
|
||||
'NOTION_OAUTH_CLIENT_ID','NOTION_OAUTH_CLIENT_SECRET',
|
||||
'AIRTABLE_OAUTH_CLIENT_ID','AIRTABLE_OAUTH_CLIENT_SECRET',
|
||||
'HUBSPOT_OAUTH_CLIENT_ID','HUBSPOT_OAUTH_CLIENT_SECRET',
|
||||
'DISCORD_OAUTH_CLIENT_ID','DISCORD_OAUTH_CLIENT_SECRET',
|
||||
'DISCORD_BOT_TOKEN'
|
||||
)
|
||||
$DevEnvPath = Join-Path $ProjectRoot 'backend\.env'
|
||||
$ShipEnvPath = Join-Path $Staging 'backend\.env'
|
||||
if (Test-Path $DevEnvPath) {
|
||||
$kept = Get-Content $DevEnvPath | Where-Object {
|
||||
$line = $_
|
||||
$keep = $false
|
||||
foreach ($k in $ShipEnvKeys) {
|
||||
if ($line -match "^$k=") { $keep = $true; break }
|
||||
}
|
||||
$keep
|
||||
}
|
||||
Set-Content -Path $ShipEnvPath -Value $kept
|
||||
Write-Host "Staged OAuth credentials: $($kept.Count) keys (release secrets + personal API keys excluded)"
|
||||
# Production .env: only the OAuth helper base URL + local Google credentials.
|
||||
$ShipOauthBaseUrl = if ($env:OPENSWARM_OAUTH_BASE_URL_OVERRIDE) {
|
||||
$env:OPENSWARM_OAUTH_BASE_URL_OVERRIDE
|
||||
} else {
|
||||
Write-Host "WARNING: backend\.env not found -- packaged build will have no OAuth credentials configured."
|
||||
Set-Content -Path $ShipEnvPath -Value '' -NoNewline
|
||||
'https://api.openswarm.com'
|
||||
}
|
||||
$GoogleClientIdShip = $env:GOOGLE_OAUTH_CLIENT_ID
|
||||
$GoogleClientSecretShip = $env:GOOGLE_OAUTH_CLIENT_SECRET
|
||||
if (-not $GoogleClientIdShip -or -not $GoogleClientSecretShip) {
|
||||
Write-Host "ERROR: GOOGLE_OAUTH_CLIENT_ID/SECRET missing in backend\.env -- required for Google MCP." -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
$ShipEnvPath = Join-Path $Staging 'backend\.env'
|
||||
New-Item -ItemType Directory -Force -Path (Split-Path $ShipEnvPath -Parent) | Out-Null
|
||||
@(
|
||||
"# OAuth helper base URL + local Google OAuth credentials.",
|
||||
"OPENSWARM_OAUTH_BASE_URL=$ShipOauthBaseUrl",
|
||||
"GOOGLE_OAUTH_CLIENT_ID=$GoogleClientIdShip",
|
||||
"GOOGLE_OAUTH_CLIENT_SECRET=$GoogleClientSecretShip"
|
||||
) | Set-Content -Path $ShipEnvPath
|
||||
Write-Host "Staged production .env: OPENSWARM_OAUTH_BASE_URL + Google client_id/secret"
|
||||
New-Item -ItemType Directory -Force -Path (Join-Path $Staging 'backend\data\tools') | Out-Null
|
||||
|
||||
Copy-Excluded `
|
||||
|
||||
+18
-20
@@ -291,27 +291,25 @@ rsync -a \
|
||||
--exclude='.env' --exclude='.env.*' --exclude='**/.env' --exclude='**/.env.*' \
|
||||
"$PROJECT_ROOT/backend/" "$STAGING_DIR/backend/"
|
||||
|
||||
# Generate a SAFE production .env containing ONLY the OAuth provider
|
||||
# credentials (the "OpenSwarm public OAuth app" identifiers — these are
|
||||
# the desktop-app pattern where the client secret isn't truly secret).
|
||||
# We deliberately DROP everything else from the dev .env:
|
||||
# - APPLE_ID / APPLE_APP_SPECIFIC_PASSWORD / APPLE_TEAM_ID (signing creds — must NOT ship)
|
||||
# - GH_TOKEN (release upload token — must NOT ship)
|
||||
# - BACKEND_PORT / DISCORD_BOT_PERMISSIONS (dev-only or trivially recomputable)
|
||||
# Backend's tools_lib.py:34 calls load_dotenv() on this path at startup,
|
||||
# so the OAuth flows pick these up automatically. Per-user API keys
|
||||
# (Anthropic, OpenAI, Gemini) come from the user's Settings UI, not from .env.
|
||||
SHIP_ENV_KEYS='^(GOOGLE_OAUTH_CLIENT_ID|GOOGLE_OAUTH_CLIENT_SECRET|NOTION_OAUTH_CLIENT_ID|NOTION_OAUTH_CLIENT_SECRET|AIRTABLE_OAUTH_CLIENT_ID|AIRTABLE_OAUTH_CLIENT_SECRET|HUBSPOT_OAUTH_CLIENT_ID|HUBSPOT_OAUTH_CLIENT_SECRET|DISCORD_OAUTH_CLIENT_ID|DISCORD_OAUTH_CLIENT_SECRET|DISCORD_BOT_TOKEN)='
|
||||
if [[ -f "$PROJECT_ROOT/backend/.env" ]]; then
|
||||
# Only emit lines whose key matches the allow-list. Comment lines and
|
||||
# blank lines are dropped (they're not needed in the shipped file).
|
||||
grep -E "$SHIP_ENV_KEYS" "$PROJECT_ROOT/backend/.env" > "$STAGING_DIR/backend/.env" || true
|
||||
KEY_COUNT=$(wc -l < "$STAGING_DIR/backend/.env" | tr -d ' ')
|
||||
echo "Staged OAuth credentials: $KEY_COUNT keys (release secrets + personal API keys excluded)"
|
||||
else
|
||||
echo "WARNING: backend/.env not found — packaged build will have no OAuth credentials configured."
|
||||
: > "$STAGING_DIR/backend/.env"
|
||||
# Production .env: only the OAuth helper base URL + the local Google
|
||||
# OAuth credentials (Google's standard "public OAuth app" pattern).
|
||||
# Signing keys, dev-only vars, and provider client_secrets for everything
|
||||
# else are intentionally not shipped.
|
||||
SHIP_OAUTH_BASE_URL="${OPENSWARM_OAUTH_BASE_URL_OVERRIDE:-https://api.openswarm.com}"
|
||||
GOOGLE_CLIENT_ID_SHIP="${GOOGLE_OAUTH_CLIENT_ID:-}"
|
||||
GOOGLE_CLIENT_SECRET_SHIP="${GOOGLE_OAUTH_CLIENT_SECRET:-}"
|
||||
if [[ -z "$GOOGLE_CLIENT_ID_SHIP" || -z "$GOOGLE_CLIENT_SECRET_SHIP" ]]; then
|
||||
echo "ERROR: GOOGLE_OAUTH_CLIENT_ID/SECRET missing in $ENV_FILE — required for Google MCP."
|
||||
exit 1
|
||||
fi
|
||||
mkdir -p "$STAGING_DIR/backend"
|
||||
cat > "$STAGING_DIR/backend/.env" <<EOF
|
||||
# OAuth helper base URL + local Google OAuth credentials.
|
||||
OPENSWARM_OAUTH_BASE_URL=${SHIP_OAUTH_BASE_URL}
|
||||
GOOGLE_OAUTH_CLIENT_ID=${GOOGLE_CLIENT_ID_SHIP}
|
||||
GOOGLE_OAUTH_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET_SHIP}
|
||||
EOF
|
||||
echo "Staged production .env"
|
||||
# Create empty tools directory so the app has a place to write
|
||||
mkdir -p "$STAGING_DIR/backend/data/tools"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user