diff --git a/cloudflare/composio-broker/src/index.test.ts b/cloudflare/composio-broker/src/index.test.ts index e58866f66..301c0ea41 100644 --- a/cloudflare/composio-broker/src/index.test.ts +++ b/cloudflare/composio-broker/src/index.test.ts @@ -1,6 +1,56 @@ -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; -import { parseSession, sha256 } from "./index"; +import { + authorize, + connectedServices, + connectionStatus, + createSession, + disconnectAccount, + ensureSession, + normalizeAccountAlias, + parseSession, + sha256, +} from "./index"; + +const multiAccount = { + enable: true, + max_accounts_per_toolkit: 5, + require_explicit_selection: true, +}; + +function session(id: string, userId: string, configured = true) { + return { + session_id: id, + mcp: { url: `https://mcp.composio.dev/${id}` }, + config: { user_id: userId, ...(configured ? { multi_account: multiAccount } : {}) }, + }; +} + +function testEnv(fetchCalls: Array<{ url: string; init?: RequestInit }>) { + const dbRuns: Array<{ sql: string; values: unknown[] }> = []; + const env = { + COMPOSIO_API_BASE: "https://backend.composio.dev/api/v3.1", + COMPOSIO_API_KEY: "ak_test", + SESSION_LIMITER: { limit: async () => ({ success: true }) }, + DB: { + prepare(sql: string) { + return { + bind(...values: unknown[]) { + return { + run: async () => { + dbRuns.push({ sql, values }); + }, + }; + }, + }; + }, + }, + }; + const ctx = { waitUntil(promise: Promise) { void promise; } }; + return { env, ctx, dbRuns, fetchCalls }; +} + +afterEach(() => vi.unstubAllGlobals()); describe("connected-apps broker boundaries", () => { it("accepts only HTTPS Composio MCP endpoints", () => { @@ -11,6 +61,8 @@ describe("connected-apps broker boundaries", () => { sessionId: "session-1", url: "https://mcp.composio.dev/session", headers: { "x-session": "one" }, + userId: undefined, + multiAccountConfigured: false, }); expect(() => parseSession({ session_id: "session-1", mcp: { url: "https://attacker.example/mcp" } })).toThrow(/untrusted/i); expect(() => parseSession({ session_id: "session-1", mcp: { url: "http://mcp.composio.dev/session" } })).toThrow(/untrusted/i); @@ -19,4 +71,200 @@ describe("connected-apps broker boundaries", () => { it("hashes installation tokens before storage", async () => { await expect(sha256("openmausbot")).resolves.toBe("63c74f70a9d4681c334e84001935955a75245ea5b16b9c37c808e85c69963705"); }); + + it("creates Sessions with explicit multi-account selection", async () => { + const fetchCalls: Array<{ url: string; init?: RequestInit }> = []; + const { env } = testEnv(fetchCalls); + vi.stubGlobal("fetch", async (input: string | URL | Request, init?: RequestInit) => { + fetchCalls.push({ url: String(input), init }); + return Response.json(session("trs_new", "omb_user"), { status: 201 }); + }); + + await expect(createSession(env as never, "omb_user")).resolves.toMatchObject({ + sessionId: "trs_new", + multiAccountConfigured: true, + }); + expect(JSON.parse(String(fetchCalls[0].init?.body))).toMatchObject({ + user_id: "omb_user", + multi_account: multiAccount, + }); + }); + + it("upgrades a legacy Session without changing the installation's Composio user", async () => { + const fetchCalls: Array<{ url: string; init?: RequestInit }> = []; + const { env, ctx, dbRuns } = testEnv(fetchCalls); + vi.stubGlobal("fetch", async (input: string | URL | Request, init?: RequestInit) => { + const url = String(input); + fetchCalls.push({ url, init }); + if (init?.method === "POST") return Response.json(session("trs_new", "omb_stable"), { status: 201 }); + return Response.json(session("trs_legacy", "omb_stable", false)); + }); + + await expect(ensureSession({ + id: "install-1", + composio_user_id: "omb_stable", + session_id: "trs_legacy", + disabled_at: null, + }, env as never, ctx as never)).resolves.toMatchObject({ sessionId: "trs_new", multiAccountConfigured: true }); + const creation = fetchCalls.find((call) => call.init?.method === "POST"); + expect(JSON.parse(String(creation?.init?.body))).toMatchObject({ user_id: "omb_stable", multi_account: multiAccount }); + expect(dbRuns.some((run) => run.values[0] === "trs_new" && run.values[2] === "install-1")).toBe(true); + }); + + it("returns every account and deletes only an owned account ID", async () => { + const fetchCalls: Array<{ url: string; init?: RequestInit }> = []; + const { env, ctx } = testEnv(fetchCalls); + const accounts = { + items: [ + { id: "ca_work", alias: "work", toolkit: { slug: "gmail" }, status: "ACTIVE", updated_at: "2026-08-21T10:00:00Z" }, + { id: "ca_personal", alias: "personal", toolkit: { slug: "gmail" }, status: "INITIALIZING", updated_at: "2026-08-21T11:00:00Z" }, + ], + next_cursor: "accounts-page-2", + }; + let connectedAccountsUnavailable = false; + vi.stubGlobal("fetch", async (input: string | URL | Request, init?: RequestInit) => { + const url = String(input); + fetchCalls.push({ url, init }); + if (url.includes("/tool_router/session/trs_multi/toolkits")) { + const query = new URL(url).searchParams; + if (query.get("cursor") === "toolkits-page-2") { + return Response.json({ + items: [ + { slug: "publicsearch", is_no_auth: true }, + { slug: "selectedonly", connected_account: { id: "ca_session_only", status: "ACTIVE" } }, + ], + }); + } + const body = { + items: [{ slug: "gmail", connected_account: { id: "ca_work", status: "ACTIVE" } }], + next_cursor: query.has("toolkits") ? undefined : "toolkits-page-2", + }; + return Response.json(body); + } + if (url.endsWith("/tool_router/session/trs_multi/link") && init?.method === "POST") { + return Response.json({ redirect_url: "https://connect.composio.dev/link/gmail" }, { status: 201 }); + } + if (url.includes("/tool_router/session/trs_multi")) return Response.json(session("trs_multi", "omb_stable")); + if (url.includes("/connected_accounts?") && !init?.method) { + if (connectedAccountsUnavailable) { + return Response.json({ error: "connected-account read not granted" }, { status: 403 }); + } + if (url.includes("cursor=accounts-page-2")) { + return Response.json({ + items: [ + { id: "ca_toolkit_41", alias: "overflow", toolkit: { slug: "toolkit_41" }, status: "ACTIVE", updated_at: "2026-08-21T12:00:00Z" }, + ], + }); + } + return Response.json(accounts); + } + if (url.includes("/connected_accounts/ca_work") && init?.method === "DELETE") return Response.json({ success: true }); + return Response.json({ error: "not found" }, { status: 404 }); + }); + const installation = { + id: "install-1", + composio_user_id: "omb_stable", + session_id: "trs_multi", + disabled_at: null, + }; + + const statusResponse = await connectionStatus( + new URL("https://broker.example/v1/connectors?services=gmail"), + installation, + env as never, + ctx as never, + ); + await expect(statusResponse.json()).resolves.toEqual({ + services: { + gmail: { + connected: true, + pending: true, + status: "ACTIVE", + accounts: [ + { id: "ca_personal", alias: "personal", status: "INITIALIZING" }, + { id: "ca_work", alias: "work", status: "ACTIVE" }, + ], + }, + }, + }); + const connectedResponse = await connectedServices(installation, env as never, ctx as never); + await expect(connectedResponse.json()).resolves.toMatchObject({ + configured: true, + services: { + toolkit_41: { + connected: true, + pending: false, + status: "ACTIVE", + accounts: [{ id: "ca_toolkit_41", alias: "overflow", status: "ACTIVE" }], + }, + publicsearch: { + connected: true, + pending: false, + status: "ACTIVE", + accounts: [], + }, + selectedonly: { + connected: true, + pending: false, + status: "ACTIVE", + accounts: [{ id: "ca_session_only", status: "ACTIVE" }], + }, + }, + }); + const inventoryCall = fetchCalls.find((call) => + call.url.includes("/connected_accounts?") && !call.url.includes("toolkit_slugs=") + ); + expect(inventoryCall).toBeDefined(); + expect(fetchCalls.some((call) => + call.url.includes("/connected_accounts?") + && !call.url.includes("toolkit_slugs=") + && call.url.includes("cursor=accounts-page-2") + )).toBe(true); + expect(fetchCalls.some((call) => + call.url.includes("/tool_router/session/trs_multi/toolkits?") + && !call.url.includes("toolkits=") + && call.url.includes("cursor=toolkits-page-2") + )).toBe(true); + + connectedAccountsUnavailable = true; + const fallbackResponse = await connectedServices(installation, env as never, ctx as never); + await expect(fallbackResponse.json()).resolves.toMatchObject({ + configured: true, + services: { + gmail: { + connected: true, + status: "ACTIVE", + accounts: [{ id: "ca_work", status: "ACTIVE" }], + }, + publicsearch: { connected: true, status: "ACTIVE", accounts: [] }, + selectedonly: { + connected: true, + status: "ACTIVE", + accounts: [{ id: "ca_session_only", status: "ACTIVE" }], + }, + }, + }); + connectedAccountsUnavailable = false; + await expect((await disconnectAccount("gmail", "ca_work", installation, env as never, ctx as never)).json()) + .resolves.toEqual({ removed: 1 }); + await expect((await disconnectAccount("gmail", "ca_not_owned", installation, env as never, ctx as never)).json()) + .resolves.toEqual({ removed: 0 }); + expect(fetchCalls.filter((call) => call.init?.method === "DELETE")).toHaveLength(1); + + const missingAlias = await authorize("gmail", undefined, installation, env as never, ctx as never); + expect(missingAlias.status).toBe(400); + await expect(missingAlias.json()).resolves.toEqual({ + error: "Add an account alias so the existing connection is not replaced", + }); + const authorized = await authorize("gmail", "second", installation, env as never, ctx as never); + expect(authorized.status).toBe(200); + await expect(authorized.json()).resolves.toEqual({ url: "https://connect.composio.dev/link/gmail" }); + const linkCall = fetchCalls.find((call) => call.url.endsWith("/tool_router/session/trs_multi/link")); + expect(JSON.parse(String(linkCall?.init?.body))).toEqual({ toolkit: "gmail", alias: "second" }); + }); + + it("validates aliases at the broker boundary", () => { + expect(normalizeAccountAlias(" work gmail ")).toBe("work gmail"); + expect(() => normalizeAccountAlias("bad\nalias")).toThrow(/printable/i); + }); }); diff --git a/cloudflare/composio-broker/src/index.ts b/cloudflare/composio-broker/src/index.ts index e08a92d44..96acb50da 100644 --- a/cloudflare/composio-broker/src/index.ts +++ b/cloudflare/composio-broker/src/index.ts @@ -1,3 +1,5 @@ +import { z } from "zod"; + interface InstallationRow { id: string; composio_user_id: string; @@ -9,17 +11,115 @@ interface ComposioSession { sessionId: string; url: string; headers: Record; + userId?: string; + multiAccountConfigured: boolean; +} + +interface ConnectedAccountSummary { + id: string; + alias?: string; + status: string; +} + +interface ConnectorServiceState { + connected: boolean; + pending: boolean; + status: string; + accounts: ConnectedAccountSummary[]; +} + +interface AccountLinkRequest { + toolkit: string; + alias?: string; } +const sessionWireSchema = z.object({ + session_id: z.string().min(1), + mcp: z.object({ + url: z.string().min(1), + headers: z.record(z.string(), z.string()).optional(), + }), + config: z.object({ + user_id: z.string().optional(), + multi_account: z.object({ + enable: z.boolean().optional(), + max_accounts_per_toolkit: z.number().optional(), + require_explicit_selection: z.boolean().optional(), + }).optional(), + }).optional(), +}); +type SessionWire = z.infer; + +const connectedAccountResponseSchema = z.object({ + id: z.string().optional(), + alias: z.string().nullable().optional(), + status: z.string().optional(), + updated_at: z.string().optional(), + toolkit: z.object({ slug: z.string().optional() }).optional(), +}); +type ConnectedAccountResponse = z.infer; +const connectedAccountsPageSchema = z.object({ + items: z.array(connectedAccountResponseSchema), + next_cursor: z.string().nullable().optional(), +}); + +const toolkitItemSchema = z.object({ + slug: z.string().optional(), + is_no_auth: z.boolean().optional(), + connected_account: z.object({ id: z.string().optional(), status: z.string().optional() }).optional(), +}); +type ToolkitItem = z.infer; +const toolkitPageSchema = z.object({ + items: z.array(toolkitItemSchema).optional(), + next_cursor: z.string().nullable().optional(), +}); +const linkResponseSchema = z.object({ redirect_url: z.string().optional() }); +const aliasRequestSchema = z.object({ alias: z.string().nullable().optional() }); +const upstreamErrorSchema = z.object({ + message: z.string().optional(), + error: z.union([ + z.string(), + z.object({ message: z.string().optional(), error: z.string().optional() }), + ]).optional(), +}); + const JSON_HEADERS = { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" }; const MAX_MCP_BODY = 2 * 1024 * 1024; +const MULTI_ACCOUNT_CONFIG = { + enable: true, + max_accounts_per_toolkit: 5, + require_explicit_selection: true, +} as const; +// Workers on the free plan get 50 subrequests per request, and the connected +// inventory runs two paginated sweeps back to back — 20 pages each keeps the +// worst case at ~40 fetches with headroom for the session lookup. At 100 +// accounts per page nobody real is near the ceiling. +const MAX_CONNECTED_ACCOUNT_PAGES = 20; +const ACCOUNT_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/; +const printableAliasSchema = z.string().min(1).max(64).refine((value) => { + for (const character of value) { + const codePoint = character.codePointAt(0); + if (codePoint === undefined || codePoint < 32 || codePoint === 127) return false; + } + return true; +}); -function json(value: unknown, status = 200) { +type JsonValue = null | undefined | boolean | number | string | ConnectedAccountSummary | ConnectorServiceState | JsonValue[] | JsonObject; +type JsonObject = { [key: string]: JsonValue }; + +function json(value: JsonValue, status = 200) { return new Response(JSON.stringify(value), { status, headers: JSON_HEADERS }); } -function isRecord(value: unknown): value is Record { - return value !== null && typeof value === "object" && !Array.isArray(value); +function normalizeAccountAlias(value: string | null | undefined): string | undefined { + if (value === undefined || value === null || value === "") return undefined; + const parsed = z.string().safeParse(value); + if (!parsed.success) throw new Error("Account alias must be text"); + const alias = parsed.data.trim(); + if (!printableAliasSchema.safeParse(alias).success) { + throw new Error("Account alias must be 1-64 printable characters"); + } + return alias; } function randomToken() { @@ -33,30 +133,37 @@ async function sha256(value: string) { return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join(""); } -function parseSession(value: unknown): ComposioSession { - if (!isRecord(value) || typeof value.session_id !== "string" || !isRecord(value.mcp)) { - throw new Error("Composio returned an invalid session"); - } - if (typeof value.mcp.url !== "string") throw new Error("Composio returned no MCP URL"); +function parseSession(value: SessionWire): ComposioSession { const url = new URL(value.mcp.url); if (url.protocol !== "https:" || (url.hostname !== "composio.dev" && !url.hostname.endsWith(".composio.dev"))) { throw new Error("Composio returned an untrusted MCP URL"); } const headers: Record = {}; - if (isRecord(value.mcp.headers)) { + if (value.mcp.headers) { for (const [name, header] of Object.entries(value.mcp.headers)) { - if (typeof header !== "string" || /^(host|cookie|content-length)$/i.test(name)) continue; + if (/^(host|cookie|content-length)$/i.test(name)) continue; headers[name] = header; } } - return { sessionId: value.session_id, url: url.toString(), headers }; + const config = value.config; + const multi = config?.multi_account; + return { + sessionId: value.session_id, + url: url.toString(), + headers, + userId: config?.user_id, + // Only `enable` gates reuse: the cap and selection flags are requested at + // creation, and recreating a Session would post the same config and get + // the same echo back — strict equality here can only churn, never fix. + multiAccountConfigured: multi?.enable === true, + }; } async function upstreamError(response: Response, fallback: string) { const text = await response.text().catch(() => ""); try { - const body = JSON.parse(text) as { message?: unknown; error?: unknown }; - const nested = isRecord(body.error) ? body.error.message ?? body.error.error : body.error; + const body = upstreamErrorSchema.parse(JSON.parse(text)); + const nested = body.error instanceof Object ? body.error.message ?? body.error.error : body.error; return String(body.message ?? nested ?? fallback).slice(0, 240); } catch { return text.trim().slice(0, 240) || fallback; @@ -64,14 +171,13 @@ async function upstreamError(response: Response, fallback: string) { } function composioRequest(env: Env, path: string, init?: RequestInit) { + const headers = new Headers(init?.headers); + headers.set("accept", "application/json"); + headers.set("x-api-key", env.COMPOSIO_API_KEY); + if (init?.body) headers.set("content-type", "application/json"); return fetch(`${env.COMPOSIO_API_BASE}${path}`, { ...init, - headers: { - accept: "application/json", - "x-api-key": env.COMPOSIO_API_KEY, - ...(init?.body ? { "content-type": "application/json" } : {}), - ...init?.headers, - }, + headers, signal: init?.signal ?? AbortSignal.timeout(30_000), }); } @@ -80,7 +186,7 @@ async function getSession(env: Env, sessionId: string) { const response = await composioRequest(env, `/tool_router/session/${encodeURIComponent(sessionId)}`); if (response.status === 404) return null; if (!response.ok) throw new Error(await upstreamError(response, `Session lookup failed (${response.status})`)); - return parseSession(await response.json()); + return parseSession(sessionWireSchema.parse(await response.json())); } async function createSession(env: Env, userId: string) { @@ -93,19 +199,32 @@ async function createSession(env: Env, userId: string) { enable_wait_for_connections: true, enable_connection_removal: true, }, + multi_account: MULTI_ACCOUNT_CONFIG, }), }); if (!response.ok) throw new Error(await upstreamError(response, `Session creation failed (${response.status})`)); - return parseSession(await response.json()); + return parseSession(sessionWireSchema.parse(await response.json())); } +/** Session ids this isolate already tried to upgrade once. If the fresh + * Session STILL doesn't echo multi-account, Composio isn't granting it — + * serve single-account behavior instead of recreating a Session and writing + * D1 on every request. */ +const multiAccountUpgradeAttempted = new Set(); + async function ensureSession(installation: InstallationRow, env: Env, ctx: ExecutionContext) { if (!(await env.SESSION_LIMITER.limit({ key: installation.id })).success) { throw new Response(JSON.stringify({ error: "too many connected-app requests" }), { status: 429, headers: JSON_HEADERS }); } let session = installation.session_id ? await getSession(env, installation.session_id) : null; - if (!session) { + if (session && !session.multiAccountConfigured && multiAccountUpgradeAttempted.has(session.sessionId)) { + return session; + } + if (!session?.multiAccountConfigured) { + // Connected accounts are attached to this stable Composio user ID. A new + // Session upgrades legacy installations without relinking OAuth grants. session = await createSession(env, installation.composio_user_id); + multiAccountUpgradeAttempted.add(session.sessionId); await env.DB.prepare("UPDATE installations SET session_id = ?, last_seen_at = ? WHERE id = ?") .bind(session.sessionId, Date.now(), installation.id) .run(); @@ -114,7 +233,7 @@ async function ensureSession(installation: InstallationRow, env: Env, ctx: Execu env.DB.prepare("UPDATE installations SET last_seen_at = ? WHERE id = ?") .bind(Date.now(), installation.id) .run() - .catch((error: unknown) => console.error(JSON.stringify({ message: "last-seen update failed", id: installation.id, error: String(error) }))), + .catch((error: Error) => console.error(JSON.stringify({ message: "last-seen update failed", id: installation.id, error: error.message }))), ); } return session; @@ -151,15 +270,15 @@ async function proxyMcp(request: Request, installation: InstallationRow, env: En const body = await request.arrayBuffer(); if (body.byteLength > MAX_MCP_BODY) return json({ error: "MCP request is too large" }, 413); const session = await ensureSession(installation, env, ctx); + const upstreamHeaders = new Headers(session.headers); + upstreamHeaders.set("x-api-key", env.COMPOSIO_API_KEY); + upstreamHeaders.set("content-type", request.headers.get("content-type") ?? "application/json"); + upstreamHeaders.set("accept", "application/json, text/event-stream"); + const incomingMcpSession = request.headers.get("mcp-session-id"); + if (incomingMcpSession) upstreamHeaders.set("mcp-session-id", incomingMcpSession); const response = await fetch(session.url, { method: "POST", - headers: { - ...session.headers, - "x-api-key": env.COMPOSIO_API_KEY, - "content-type": request.headers.get("content-type") ?? "application/json", - accept: "application/json, text/event-stream", - ...(request.headers.get("mcp-session-id") ? { "mcp-session-id": request.headers.get("mcp-session-id")! } : {}), - }, + headers: upstreamHeaders, body, signal: AbortSignal.timeout(10 * 60_000), }); @@ -183,31 +302,198 @@ async function catalog(env: Env) { }); } +async function listConnectedAccounts(env: Env, userId: string, slugs: string[]) { + const accounts: ConnectedAccountResponse[] = []; + const seenCursors = new Set(); + let cursor: string | undefined; + for (let page = 0; page < MAX_CONNECTED_ACCOUNT_PAGES; page += 1) { + const params = new URLSearchParams({ + limit: "50", + user_ids: userId, + order_by: "updated_at", + order_direction: "desc", + }); + if (slugs.length) params.set("toolkit_slugs", slugs.join(",")); + if (cursor) params.set("cursor", cursor); + const response = await composioRequest(env, `/connected_accounts?${params}`); + if (!response.ok) throw new Error(await upstreamError(response, `Account lookup failed (${response.status})`)); + const body = connectedAccountsPageSchema.parse(await response.json()); + accounts.push(...body.items); + const next = body.next_cursor || undefined; + if (!next || seenCursors.has(next)) return accounts; + seenCursors.add(next); + cursor = next; + } + throw new Error("Connected-account inventory exceeded the pagination safety limit"); +} + +async function listSessionToolkits( + env: Env, + sessionId: string, +): Promise { + const toolkits: ToolkitItem[] = []; + const seenCursors = new Set(); + let cursor: string | undefined; + for (let page = 0; page < MAX_CONNECTED_ACCOUNT_PAGES; page += 1) { + const params = new URLSearchParams({ limit: "50" }); + if (cursor) params.set("cursor", cursor); + const response = await composioRequest( + env, + `/tool_router/session/${encodeURIComponent(sessionId)}/toolkits?${params}`, + ); + if (!response.ok) throw new Error(await upstreamError(response, "Toolkit inventory unavailable")); + const body = toolkitPageSchema.parse(await response.json()); + toolkits.push(...(body.items ?? [])); + const next = body.next_cursor || undefined; + if (!next || seenCursors.has(next)) return toolkits; + seenCursors.add(next); + cursor = next; + } + throw new Error("Toolkit inventory exceeded the pagination safety limit"); +} + +function summarizeAccounts(accounts: ConnectedAccountResponse[], slugs: string[]) { + const requested = new Set(slugs.map((slug) => slug.toLowerCase())); + const bySlug = new Map>(); + for (const account of accounts) { + const slug = account.toolkit?.slug?.toLowerCase(); + if (!slug || (requested.size && !requested.has(slug)) || !account.id || !ACCOUNT_ID.test(account.id)) continue; + const alias = account.alias?.trim() ?? ""; + const summary: ConnectedAccountSummary & { updatedAt: string } = { + id: account.id, + status: account.status || "UNKNOWN", + updatedAt: account.updated_at ?? "", + }; + if (printableAliasSchema.safeParse(alias).success) summary.alias = alias; + const list = bySlug.get(slug) ?? []; + list.push(summary); + bySlug.set(slug, list); + } + for (const list of bySlug.values()) list.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)); + return bySlug; +} + +function publicAccount({ id, alias, status }: ConnectedAccountSummary): ConnectedAccountSummary { + const account: ConnectedAccountSummary = { id, status }; + if (alias) account.alias = alias; + return account; +} + +function serviceStateFromAccounts(accounts: ConnectedAccountSummary[]): ConnectorServiceState { + const active = accounts.find((account) => /^active$/i.test(account.status)); + const pending = accounts.find((account) => /^(initiated|initializing|pending)$/i.test(account.status)); + const selected = active ?? pending ?? accounts[0]; + return { + connected: Boolean(active), + pending: Boolean(pending), + status: selected?.status ?? "not_connected", + accounts: accounts.map(publicAccount), + }; +} + +function allServiceStates( + accountsBySlug: ReadonlyMap, + toolkits: ToolkitItem[], +): Record { + const services = new Map( + [...accountsBySlug].map(([slug, accounts]) => [slug, serviceStateFromAccounts(accounts)]), + ); + for (const toolkit of toolkits) { + const slug = toolkit.slug?.toLowerCase(); + const selected = toolkit.connected_account; + const selectedId = selected?.id && ACCOUNT_ID.test(selected.id) ? selected.id : undefined; + if (!slug || (!toolkit.is_no_auth && !selectedId)) continue; + const existingAccounts = accountsBySlug.get(slug) ?? []; + const accounts = [...existingAccounts]; + if (selectedId && !accounts.some((account) => account.id === selectedId)) { + accounts.push({ id: selectedId, status: selected?.status ?? "ACTIVE" }); + } + const accountState = serviceStateFromAccounts(accounts); + const status = toolkit.is_no_auth ? "ACTIVE" : selected?.status ?? accountState.status; + services.set(slug, { + connected: toolkit.is_no_auth === true || accountState.connected || /^active$/i.test(status), + pending: accountState.pending || /^(initiated|initializing|pending)$/i.test(status), + status, + accounts: accountState.accounts, + }); + } + return Object.fromEntries(services); +} + +async function connectedServices( + installation: InstallationRow, + env: Env, + ctx: ExecutionContext, +) { + const session = await ensureSession(installation, env, ctx); + const [toolkits, accounts] = await Promise.all([ + listSessionToolkits(env, session.sessionId), + listConnectedAccounts(env, installation.composio_user_id, []).catch(() => []), + ]); + return json({ + configured: true, + services: allServiceStates(summarizeAccounts(accounts, []), toolkits), + }); +} + async function connectionStatus(url: URL, installation: InstallationRow, env: Env, ctx: ExecutionContext) { const slugs = [...new Set((url.searchParams.get("services") ?? "").split(",").map((slug) => slug.toLowerCase()).filter(Boolean))].slice(0, 50); const session = await ensureSession(installation, env, ctx); - const response = await composioRequest( - env, - `/tool_router/session/${encodeURIComponent(session.sessionId)}/toolkits?${new URLSearchParams({ limit: "50", toolkits: slugs.join(",") })}`, - ); + const [response, accounts] = await Promise.all([ + composioRequest( + env, + `/tool_router/session/${encodeURIComponent(session.sessionId)}/toolkits?${new URLSearchParams({ limit: "50", toolkits: slugs.join(",") })}`, + ), + listConnectedAccounts(env, installation.composio_user_id, slugs).catch(() => []), + ]); if (!response.ok) return json({ error: await upstreamError(response, "Connection status unavailable") }, 502); - const body = await response.json() as { items?: Array<{ slug?: string; is_no_auth?: boolean; connected_account?: { status?: string } }> }; + const body = toolkitPageSchema.parse(await response.json()); const items = new Map((body.items ?? []).map((item) => [item.slug?.toLowerCase(), item])); + const accountsBySlug = summarizeAccounts(accounts, slugs); return json({ services: Object.fromEntries(slugs.map((slug) => { const item = items.get(slug); - const status = item?.connected_account?.status ?? (item?.is_no_auth ? "ACTIVE" : "not_connected"); - return [slug, { connected: item?.is_no_auth === true || /^active$/i.test(status), pending: /^(initiated|initializing|pending)$/i.test(status), status }]; + const serviceAccounts = accountsBySlug.get(slug) ?? []; + const accountState = serviceStateFromAccounts(serviceAccounts); + const status = item?.connected_account?.status ?? (item?.is_no_auth ? "ACTIVE" : accountState.status); + return [slug, { + connected: item?.is_no_auth === true || accountState.connected || /^active$/i.test(status), + pending: accountState.pending || /^(initiated|initializing|pending)$/i.test(status), + status, + accounts: accountState.accounts, + }]; })) }); } -async function authorize(slug: string, installation: InstallationRow, env: Env, ctx: ExecutionContext) { +async function authorize( + slug: string, + alias: string | undefined, + installation: InstallationRow, + env: Env, + ctx: ExecutionContext, +) { const session = await ensureSession(installation, env, ctx); + // Listing can be denied to the broker's key scope; authorize must still + // work, with the alias guardrails degrading to first-account behavior. + const accounts = await listConnectedAccounts(env, installation.composio_user_id, [slug]).catch(() => []); + const serviceAccounts = accounts.filter((account) => account.toolkit?.slug?.toLowerCase() === slug); + const usableAccounts = serviceAccounts.filter((account) => /^(active|initiated|initializing|pending)$/i.test(account.status ?? "")); + if (usableAccounts.length >= MULTI_ACCOUNT_CONFIG.max_accounts_per_toolkit) { + return json({ error: `${slug} already has the maximum of ${MULTI_ACCOUNT_CONFIG.max_accounts_per_toolkit} accounts` }, 409); + } + if (usableAccounts.length > 0 && !alias) { + return json({ error: "Add an account alias so the existing connection is not replaced" }, 400); + } + if (alias && serviceAccounts.some((account) => account.alias?.trim().toLowerCase() === alias.toLowerCase())) { + return json({ error: `Account alias "${alias}" is already in use for ${slug}` }, 409); + } + const linkRequest: AccountLinkRequest = { toolkit: slug }; + if (alias) linkRequest.alias = alias; const response = await composioRequest(env, `/tool_router/session/${encodeURIComponent(session.sessionId)}/link`, { method: "POST", - body: JSON.stringify({ toolkit: slug }), + body: JSON.stringify(linkRequest), }); if (!response.ok) return json({ error: await upstreamError(response, "Authorization unavailable") }, 502); - const body = await response.json() as { redirect_url?: string }; + const body = linkResponseSchema.parse(await response.json()); if (!body.redirect_url) return json({ error: "Composio returned no authorization link" }, 502); const redirect = new URL(body.redirect_url); if (redirect.protocol !== "https:" || (redirect.hostname !== "composio.dev" && !redirect.hostname.endsWith(".composio.dev"))) { @@ -223,7 +509,7 @@ async function disconnect(slug: string, installation: InstallationRow, env: Env, `/tool_router/session/${encodeURIComponent(session.sessionId)}/toolkits?${new URLSearchParams({ limit: "50", toolkits: slug })}`, ); if (!list.ok) return json({ error: await upstreamError(list, "Connection lookup unavailable") }, 502); - const body = await list.json() as { items?: Array<{ slug?: string; connected_account?: { id?: string } }> }; + const body = toolkitPageSchema.parse(await list.json()); const id = body.items?.find((item) => item.slug?.toLowerCase() === slug)?.connected_account?.id; if (!id) return json({ removed: 0 }); const response = await composioRequest(env, `/connected_accounts/${encodeURIComponent(id)}?revoke_on_delete=true`, { method: "DELETE" }); @@ -231,6 +517,51 @@ async function disconnect(slug: string, installation: InstallationRow, env: Env, return json({ removed: 1 }); } +async function disconnectAccount( + slug: string, + accountId: string, + installation: InstallationRow, + env: Env, + ctx: ExecutionContext, +) { + if (!ACCOUNT_ID.test(accountId)) return json({ error: "Invalid connected-account ID" }, 400); + await ensureSession(installation, env, ctx); + const accounts = await listConnectedAccounts(env, installation.composio_user_id, [slug]); + const owned = accounts.some((account) => + account.id === accountId && account.toolkit?.slug?.toLowerCase() === slug + ); + if (!owned) return json({ removed: 0 }); + const response = await composioRequest( + env, + `/connected_accounts/${encodeURIComponent(accountId)}?revoke_on_delete=true`, + { method: "DELETE" }, + ); + if (!response.ok) return json({ error: await upstreamError(response, "Disconnect failed") }, 502); + return json({ removed: 1 }); +} + +async function requestAlias(request: Request) { + if (!request.body) return undefined; + const declared = Number(request.headers.get("content-length") ?? "0"); + if (declared > 2048) throw new Response(JSON.stringify({ error: "request body is too large" }), { status: 413, headers: JSON_HEADERS }); + let body: z.infer; + try { + const raw = await request.text(); + if (new TextEncoder().encode(raw).byteLength > 2048) { + throw new Response(JSON.stringify({ error: "request body is too large" }), { status: 413, headers: JSON_HEADERS }); + } + body = aliasRequestSchema.parse(JSON.parse(raw)); + } catch (error) { + if (error instanceof Response) throw error; + throw new Response(JSON.stringify({ error: "invalid JSON body" }), { status: 400, headers: JSON_HEADERS }); + } + try { + return normalizeAccountAlias(body.alias); + } catch (error) { + throw new Response(JSON.stringify({ error: error instanceof Error ? error.message : String(error) }), { status: 400, headers: JSON_HEADERS }); + } +} + async function route(request: Request, env: Env, ctx: ExecutionContext) { const url = new URL(request.url); if (request.method === "GET" && url.pathname === "/health") return json({ service: "openmausbot-composio", ready: Boolean(env.COMPOSIO_API_KEY) }); @@ -241,9 +572,14 @@ async function route(request: Request, env: Env, ctx: ExecutionContext) { if (request.method === "GET" && url.pathname === "/v1/me") return json({ installationId: installation.id }); if (request.method === "POST" && url.pathname === "/v1/mcp") return proxyMcp(request, installation, env, ctx); if (request.method === "GET" && url.pathname === "/v1/catalog") return catalog(env); + if (request.method === "GET" && url.pathname === "/v1/connectors/connected") return connectedServices(installation, env, ctx); if (request.method === "GET" && url.pathname === "/v1/connectors") return connectionStatus(url, installation, env, ctx); + const accountMatch = url.pathname.match(/^\/v1\/connectors\/([a-z0-9][a-z0-9_-]{0,80})\/accounts\/([A-Za-z0-9][A-Za-z0-9_-]{0,127})$/); + if (accountMatch && request.method === "DELETE") { + return disconnectAccount(accountMatch[1], accountMatch[2], installation, env, ctx); + } const match = url.pathname.match(/^\/v1\/connectors\/([a-z0-9][a-z0-9_-]{0,80})(?:\/(authorize))?$/); - if (match?.[2] && request.method === "POST") return authorize(match[1], installation, env, ctx); + if (match?.[2] && request.method === "POST") return authorize(match[1], await requestAlias(request), installation, env, ctx); if (match && !match[2] && request.method === "DELETE") return disconnect(match[1], installation, env, ctx); return json({ error: "not found" }, 404); } @@ -260,4 +596,14 @@ export default { }, } satisfies ExportedHandler; -export { parseSession, sha256 }; +export { + authorize, + connectedServices, + connectionStatus, + createSession, + disconnectAccount, + ensureSession, + normalizeAccountAlias, + parseSession, + sha256, +}; diff --git a/companion/src/routes.ts b/companion/src/routes.ts index 678ca7ae3..260c89601 100644 --- a/companion/src/routes.ts +++ b/companion/src/routes.ts @@ -86,6 +86,14 @@ const ALLOWED: ReadonlyArray<{ method: string; path: RegExp }> = [ { method: "GET", path: /^\/api\/threads\/[\w-]+\/export$/ }, { method: "POST", path: /^\/api\/threads\/[\w-]+\/respond$/ }, { method: "GET", path: /^\/api\/search$/ }, + + // Multi-account Composio management exposes opaque ids and aliases only. + // Revocation stays on the Mac: the account DELETE route is deliberately + // absent — a paired phone can see and add accounts, never remove one. + { method: "GET", path: /^\/api\/connectors\/catalog$/ }, + { method: "GET", path: /^\/api\/connectors\/connected$/ }, + { method: "GET", path: /^\/api\/connectors$/ }, + { method: "POST", path: /^\/api\/connectors\/[\w-]+\/authorize$/ }, ]; /** Route families worth naming in the refusal. diff --git a/companion/test/routes.test.ts b/companion/test/routes.test.ts index 08d390556..8b213aff4 100644 --- a/companion/test/routes.test.ts +++ b/companion/test/routes.test.ts @@ -57,6 +57,10 @@ describe("what the app may do", () => { ["GET", "/api/threads/th_1/export"], ["POST", "/api/threads/th_1/respond"], ["GET", "/api/search"], + ["GET", "/api/connectors/catalog"], + ["GET", "/api/connectors/connected"], + ["GET", "/api/connectors"], + ["POST", "/api/connectors/slack/authorize"], ]; for (const [method, path] of calls) { @@ -74,7 +78,6 @@ describe("what it may not", () => { ["POST", "/api/local-computer/start"], ["POST", "/api/webhooks"], ["POST", "/api/webhooks/wh_1/rotate"], - ["GET", "/api/connectors"], ["DELETE", "/api/connectors/gmail"], ["GET", "/api/routines"], ["POST", "/api/teams/import"], @@ -112,6 +115,11 @@ describe("what it may not", () => { expect(allowed("POST", "/api/threads/th_1/messages")).toBe(false); expect(allowed("GET", "/api/groups/room-1")).toBe(false); expect(allowed("PATCH", "/api/bots/bot_123")).toBe(false); + expect(allowed("DELETE", "/api/connectors/slack")).toBe(false); + expect(allowed("GET", "/api/connectors/connected/all")).toBe(false); + // revocation is a Mac-only affordance: the phone can list and add + // accounts but the account DELETE route is deliberately not allowed + expect(allowed("DELETE", "/api/connectors/slack/accounts/ca_123")).toBe(false); expect(allowed("PATCH", "/api/groups/room-1")).toBe(false); }); diff --git a/docs/composio.md b/docs/composio.md index e4dbd4fbc..f3134b449 100644 --- a/docs/composio.md +++ b/docs/composio.md @@ -1,6 +1,6 @@ # Connect apps through Composio -OpenMausBot uses one Composio project API key and one reusable Composio Session. That project key is the only Composio credential users need to provide. +OpenMausBot uses one Composio project API key and one reusable Composio Session. That project key is the only Composio credential users need to provide. The Session enables Composio's multi-account mode with explicit account selection, so one OpenMausBot installation can keep several Slack, Gmail, Calendar, or other accounts connected without silently replacing the first one. ## Packaged desktop app @@ -9,6 +9,9 @@ OpenMausBot uses one Composio project API key and one reusable Composio Session. 3. Copy a project key beginning with `ak_`. 4. In OpenMausBot, open **App Settings → Connections** and save it under **Composio project key**. 5. Open **Connected apps** and choose Gmail, GitHub, Slack, or another service. Authentication happens in your normal browser. +6. To connect another account for the same app, choose **Add account**, give it a unique label such as `work` or `personal`, and finish the second authorization in your browser. + +The Connected tab lists every account separately. **Disconnect** revokes only the account named on that row. OpenMausBot requires a label for a second account and configures Composio to require explicit selection when more than one account could run a tool; a new OAuth flow never silently becomes the default for an existing connection. The desktop app validates the key before saving it. The key is encrypted using Electron's operating-system-backed `safeStorage`; the local JSON configuration stores only the non-secret Composio user and Session identifiers. @@ -33,3 +36,41 @@ COMPOSIO_API_KEY=ak_your_project_key pnpm dev:server The browser-only development UI can also save a key to the owner-only `~/.openmausbot/config.json` file. Using the environment variable is preferred for headless and shared development machines. OpenMausBot creates a stable random user identifier for the installation, stores the returned Session identifier, and reuses that Session across launches. No Gmail, GitHub, Slack, or other provider tokens are stored by OpenMausBot; Composio owns their connection lifecycle. + +Sessions created by older OpenMausBot versions are upgraded in place by creating a multi-account Session for the same stable Composio user. Connected accounts belong to that user, so existing grants remain available while the new Session adds explicit multi-account routing. Each toolkit is capped at five usable accounts. + +## Multiple Google and Slack accounts + +Yes. Gmail, Google Calendar, Google Drive, and the other Google toolkits can each hold multiple labeled authorizations, and Slack can hold multiple labeled workspace/account authorizations. Accounts are scoped to the OpenMausBot installation's stable Composio user and appear by alias and connected-account ID in **Connected apps**. + +If a provider or restricted Composio project policy prevents another authorization, the safe fallback is a separate OpenMausBot installation/configuration with its own Composio user. Re-authorizing the same single-account Session is not a safe workaround: it can change which grant is selected. Do not share raw provider tokens or place them in bot prompts. + +The hosted/managed connected-apps broker exposes the same account-aware response shape and account-specific removal routes as the self-hosted project-key mode; it does not send broker or provider credentials to the renderer. + +## Renderer-neutral connection inventory + +Desktop, web, and mobile clients can load the complete account inventory in one request: + +```http +GET /api/connectors/connected +``` + +```json +{ + "configured": true, + "services": { + "gmail": { + "connected": true, + "pending": false, + "status": "ACTIVE", + "accounts": [ + { "id": "ca_123", "alias": "work", "status": "ACTIVE" } + ] + } + } +} +``` + +This operation cursor-paginates both the Session toolkit state and the user's connected accounts directly. It merges no-auth toolkits and the Session-selected account with the full multi-account inventory, without deriving service slugs from marketplace cards, so account visibility is independent of catalog ordering and pagination. If a scoped project key can read the Session but cannot list raw connected accounts, the response safely falls back to the Session-selected and no-auth toolkit inventory rather than making those services appear disconnected. The managed broker provides the same behavior and response at `GET /v1/connectors/connected`; the local server adds the normal `configured: false` empty response when no connection service is configured. Responses expose only connected-account IDs, user-supplied aliases, and lifecycle status—never project keys, broker tokens, provider tokens, or write-only authorization fields. + +The existing scoped `GET /api/connectors?services=gmail,slack` operation remains available for lightweight post-OAuth polling and backward compatibility. diff --git a/docs/screenshots/composio-multi-account.png b/docs/screenshots/composio-multi-account.png new file mode 100644 index 000000000..39729046a Binary files /dev/null and b/docs/screenshots/composio-multi-account.png differ diff --git a/server/composio.test.ts b/server/composio.test.ts index 39f92d759..ce80a86df 100644 --- a/server/composio.test.ts +++ b/server/composio.test.ts @@ -4,9 +4,12 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest"; import type { AppConfig } from "./config.ts"; import { authorizeService, + connectedServices, connectionStatus, mcpIntegration, + normalizeAccountAlias, prepareProjectSession, + removeAccount, removeService, } from "./composio.ts"; @@ -14,6 +17,7 @@ let api: Server; let base = ""; const calls: Array<{ method: string; path: string; query: string; body: any }> = []; let malformedConnectedAccounts = false; +let connectedAccountsUnavailable = false; beforeAll(async () => { api = createServer(async (req, res) => { @@ -33,7 +37,7 @@ beforeAll(async () => { return res.end(JSON.stringify({ session_id: "trs_test", mcp: { type: "http", url: "https://app.composio.dev/tool_router/v3/trs_test/mcp" }, - config: { user_id: body.user_id }, + config: { user_id: body.user_id, multi_account: body.multi_account }, })); } if (req.method === "GET" && url.pathname === "/api/v3.1/tool_router/session/trs_test") { @@ -41,35 +45,73 @@ beforeAll(async () => { return res.end(JSON.stringify({ session_id: "trs_test", mcp: { type: "http", url: "https://app.composio.dev/tool_router/v3/trs_test/mcp" }, - config: { user_id: "openmausbot_existing" }, + config: { + user_id: "openmausbot_existing", + multi_account: { + enable: true, + max_accounts_per_toolkit: 5, + require_explicit_selection: true, + }, + }, })); } - if (req.method === "GET" && url.pathname.endsWith("/toolkits")) { + if (req.method === "GET" && url.pathname === "/api/v3.1/tool_router/session/trs_legacy") { res.writeHead(200, { "content-type": "application/json" }); return res.end(JSON.stringify({ + session_id: "trs_legacy", + mcp: { type: "http", url: "https://app.composio.dev/tool_router/v3/trs_legacy/mcp" }, + config: { user_id: "openmausbot_legacy" }, + })); + } + if (req.method === "GET" && url.pathname.endsWith("/toolkits")) { + res.writeHead(200, { "content-type": "application/json" }); + if (url.searchParams.get("cursor") === "toolkits-page-2") { + return res.end(JSON.stringify({ + items: [ + { slug: "publicsearch", is_no_auth: true }, + { slug: "selectedonly", connected_account: { id: "ca_session_only", status: "ACTIVE" } }, + ], + })); + } + const page = { items: [ { slug: "github", connected_account: { id: "ca_github", status: "ACTIVE" } }, { slug: "gmail", is_no_auth: true }, { slug: "slack" }, ], - })); + next_cursor: url.searchParams.has("toolkits") ? undefined : "toolkits-page-2", + }; + return res.end(JSON.stringify(page)); } if (req.method === "GET" && url.pathname === "/api/v3.1/connected_accounts") { + if (connectedAccountsUnavailable) { + res.writeHead(403, { "content-type": "application/json" }); + return res.end(JSON.stringify({ error: "connected-account read not granted" })); + } res.writeHead(200, { "content-type": "application/json" }); if (malformedConnectedAccounts) return res.end(JSON.stringify({ items: {} })); + if (url.searchParams.get("cursor") === "accounts-page-2") { + return res.end(JSON.stringify({ + items: [ + { id: "ca_toolkit_41", alias: "overflow", toolkit: { slug: "toolkit_41" }, status: "ACTIVE", updated_at: "2026-08-17T10:00:00Z" }, + ], + })); + } return res.end(JSON.stringify({ items: [ - { toolkit: { slug: "github" }, status: "ACTIVE", updated_at: "2026-08-17T08:00:00Z" }, - { toolkit: { slug: "notion" }, status: "INITIATED", updated_at: "2026-08-17T08:01:00Z" }, - { toolkit: { slug: "linear" }, status: "EXPIRED", updated_at: "2026-08-17T08:02:00Z" }, + { id: "ca_github_work", alias: "work", toolkit: { slug: "github" }, status: "ACTIVE", updated_at: "2026-08-17T08:00:00Z" }, + { id: "ca_github_personal", alias: "personal", toolkit: { slug: "github" }, status: "ACTIVE", updated_at: "2026-08-17T09:00:00Z" }, + { id: "ca_notion", alias: "team", toolkit: { slug: "notion" }, status: "INITIATED", updated_at: "2026-08-17T08:01:00Z" }, + { id: "ca_linear", toolkit: { slug: "linear" }, status: "EXPIRED", updated_at: "2026-08-17T08:02:00Z" }, ], + next_cursor: "accounts-page-2", })); } if (req.method === "POST" && url.pathname.endsWith("/link")) { res.writeHead(201, { "content-type": "application/json" }); return res.end(JSON.stringify({ redirect_url: `https://connect.composio.dev/link/${body.toolkit}` })); } - if (req.method === "DELETE" && url.pathname === "/api/v3.1/connected_accounts/ca_github") { + if (req.method === "DELETE" && url.pathname.startsWith("/api/v3.1/connected_accounts/ca_")) { res.writeHead(200, { "content-type": "application/json" }); return res.end(JSON.stringify({ success: true })); } @@ -106,6 +148,11 @@ describe.sequential("Composio Sessions", () => { enable_wait_for_connections: true, enable_connection_removal: true, }, + multi_account: { + enable: true, + max_accounts_per_toolkit: 5, + require_explicit_selection: true, + }, }); const reused = await prepareProjectSession("ak_test", created); @@ -116,6 +163,33 @@ describe.sequential("Composio Sessions", () => { }); }); + it("recreates a legacy Session with the same Composio user ID", async () => { + const upgraded = await prepareProjectSession("ak_test", { + apiKey: "ak_test", + userId: "stale-local-user-id", + sessionId: "trs_legacy", + }); + expect(upgraded).toEqual({ + apiKey: "ak_test", + userId: "openmausbot_legacy", + sessionId: "trs_test", + }); + expect(calls.filter((call) => call.method === "POST" && call.path.endsWith("/session")).at(-1)?.body).toMatchObject({ + user_id: "openmausbot_legacy", + multi_account: { + enable: true, + max_accounts_per_toolkit: 5, + require_explicit_selection: true, + }, + }); + }); + + it("validates account aliases before sending them upstream", () => { + expect(normalizeAccountAlias(" personal gmail ")).toBe("personal gmail"); + expect(() => normalizeAccountAlias("bad\nalias")).toThrow(/printable/i); + expect(() => normalizeAccountAlias("x".repeat(65))).toThrow(/1-64/i); + }); + it("mounts the Session MCP endpoint with the project key header", async () => { const cfg: AppConfig = { composio: { apiKey: "ak_test", userId: "openmausbot_existing", sessionId: "trs_test" }, @@ -145,15 +219,45 @@ describe.sequential("Composio Sessions", () => { composio: { apiKey: "ak_test", userId: "openmausbot_existing", sessionId: "trs_test" }, }; await expect(connectionStatus(cfg, ["github", "gmail", "slack", "notion", "linear"])).resolves.toEqual({ - github: { connected: true, pending: false, status: "ACTIVE" }, - gmail: { connected: true, pending: false, status: "ACTIVE" }, - slack: { connected: false, pending: false, status: "not_connected" }, - notion: { connected: false, pending: true, status: "INITIATED" }, - linear: { connected: false, pending: false, status: "EXPIRED" }, + github: { + connected: true, + pending: false, + status: "ACTIVE", + accounts: [ + { id: "ca_github_personal", alias: "personal", status: "ACTIVE" }, + { id: "ca_github_work", alias: "work", status: "ACTIVE" }, + // the Session-selected account is synthesized when the raw list + // omits it — same rule as the inventory path + { id: "ca_github", status: "ACTIVE" }, + ], + }, + gmail: { connected: true, pending: false, status: "ACTIVE", accounts: [] }, + slack: { connected: false, pending: false, status: "not_connected", accounts: [] }, + notion: { + connected: false, + pending: true, + status: "INITIATED", + accounts: [{ id: "ca_notion", alias: "team", status: "INITIATED" }], + }, + linear: { + connected: false, + pending: false, + status: "EXPIRED", + accounts: [{ id: "ca_linear", status: "EXPIRED" }], + }, }); - await expect(authorizeService(cfg, "github")).resolves.toEqual({ + await expect(authorizeService(cfg, "github")).rejects.toThrow(/alias.*not replaced/i); + await expect(authorizeService(cfg, "github", "work")).rejects.toThrow(/already in use/i); + await expect(authorizeService(cfg, "github", "personal-two")).resolves.toEqual({ url: "https://connect.composio.dev/link/github", }); + expect(calls.filter((call) => call.method === "POST" && call.path.endsWith("/link")).at(-1)?.body).toEqual({ + toolkit: "github", + alias: "personal-two", + }); + await expect(removeAccount(cfg, "github", "ca_github_personal")).resolves.toEqual({ removed: 1 }); + await expect(removeAccount(cfg, "github", "ca_other_user")).resolves.toEqual({ removed: 0 }); + await expect(removeAccount(cfg, "github", "../other")).rejects.toThrow(/invalid connected-account ID/i); await expect(removeService(cfg, "github")).resolves.toEqual({ removed: 1 }); expect(calls.some( (call) => call.method === "DELETE" @@ -162,6 +266,74 @@ describe.sequential("Composio Sessions", () => { )).toBe(true); }); + it("enumerates connected services independently of catalog position", async () => { + const cfg: AppConfig = { + composio: { apiKey: "ak_test", userId: "openmausbot_existing", sessionId: "trs_test" }, + }; + const callCount = calls.length; + + await expect(connectedServices(cfg)).resolves.toMatchObject({ + toolkit_41: { + connected: true, + pending: false, + status: "ACTIVE", + accounts: [{ id: "ca_toolkit_41", alias: "overflow", status: "ACTIVE" }], + }, + github: { + accounts: [ + { id: "ca_github_personal", alias: "personal", status: "ACTIVE" }, + { id: "ca_github_work", alias: "work", status: "ACTIVE" }, + { id: "ca_github", status: "ACTIVE" }, + ], + }, + publicsearch: { + connected: true, + pending: false, + status: "ACTIVE", + accounts: [], + }, + selectedonly: { + connected: true, + pending: false, + status: "ACTIVE", + accounts: [{ id: "ca_session_only", status: "ACTIVE" }], + }, + }); + + const inventoryCalls = calls.slice(callCount).filter((call) => call.path.endsWith("/connected_accounts")); + expect(inventoryCalls).toHaveLength(2); + expect(inventoryCalls[0]?.query).not.toContain("toolkit_slugs="); + expect(inventoryCalls[1]?.query).toContain("cursor=accounts-page-2"); + const toolkitCalls = calls.slice(callCount).filter((call) => call.path.endsWith("/toolkits")); + expect(toolkitCalls).toHaveLength(2); + expect(toolkitCalls[1]?.query).toContain("cursor=toolkits-page-2"); + }); + + it("falls back to complete Session toolkit state without connected-account read permission", async () => { + const cfg: AppConfig = { + composio: { apiKey: "ak_test", userId: "openmausbot_existing", sessionId: "trs_test" }, + }; + connectedAccountsUnavailable = true; + try { + await expect(connectedServices(cfg)).resolves.toMatchObject({ + github: { + connected: true, + status: "ACTIVE", + accounts: [{ id: "ca_github", status: "ACTIVE" }], + }, + gmail: { connected: true, status: "ACTIVE", accounts: [] }, + publicsearch: { connected: true, status: "ACTIVE", accounts: [] }, + selectedonly: { + connected: true, + status: "ACTIVE", + accounts: [{ id: "ca_session_only", status: "ACTIVE" }], + }, + }); + } finally { + connectedAccountsUnavailable = false; + } + }); + it("falls back to session toolkit state when connected-account items is malformed", async () => { const cfg: AppConfig = { composio: { apiKey: "ak_test", userId: "openmausbot_existing", sessionId: "trs_test" }, @@ -169,8 +341,10 @@ describe.sequential("Composio Sessions", () => { malformedConnectedAccounts = true; try { await expect(connectionStatus(cfg, ["github", "slack"])).resolves.toEqual({ - github: { connected: true, pending: false, status: "ACTIVE" }, - slack: { connected: false, pending: false, status: "not_connected" }, + // the malformed list degrades to [], but the Session still names its + // selected account — synthesized so a poll never wipes the row + github: { connected: true, pending: false, status: "ACTIVE", accounts: [{ id: "ca_github", status: "ACTIVE" }] }, + slack: { connected: false, pending: false, status: "not_connected", accounts: [] }, }); } finally { malformedConnectedAccounts = false; diff --git a/server/composio.ts b/server/composio.ts index c48619f7b..406286891 100644 --- a/server/composio.ts +++ b/server/composio.ts @@ -2,6 +2,7 @@ // Session owns connection state, auth links and the MCP endpoint. import { saveConfig, type AppConfig } from "./config.ts"; import { randomUUID } from "node:crypto"; +import { z } from "zod"; import { SPAWNED_PROXIES } from "./proxy-paths.ts"; const DEFAULT_BACKEND_ORIGIN = "https://backend.composio.dev"; @@ -14,12 +15,91 @@ function toolkitBase() { return (process.env.OMB_COMPOSIO_TOOLKITS_API ?? `${DEFAULT_BACKEND_ORIGIN}/api/v3`).replace(/\/$/, ""); } -interface SessionResponse { - session_id: string; - mcp: { type: "http" | "sse"; url: string }; - config?: { user_id?: string }; +const sessionResponseSchema = z.object({ + session_id: z.string().min(1), + mcp: z.object({ type: z.enum(["http", "sse"]), url: z.string().min(1) }), + config: z.object({ + user_id: z.string().optional(), + multi_account: z.object({ + enable: z.boolean().optional(), + max_accounts_per_toolkit: z.number().optional(), + require_explicit_selection: z.boolean().optional(), + }).optional(), + }).optional(), +}); +type SessionResponse = z.infer; + +export interface ConnectedAccountSummary { + id: string; + alias?: string; + status: string; +} + +export interface ConnectorServiceState { + connected: boolean; + pending: boolean; + status: string; + accounts: ConnectedAccountSummary[]; } +interface AccountLinkRequest { + toolkit: string; + alias?: string; +} + +const connectedAccountResponseSchema = z.object({ + id: z.string().optional(), + alias: z.string().nullable().optional(), + status: z.string().optional(), + updated_at: z.string().optional(), + toolkit: z.object({ slug: z.string().optional() }).optional(), +}); +type ConnectedAccountResponse = z.infer; + +const connectedAccountsPageSchema = z.object({ + items: z.array(connectedAccountResponseSchema), + next_cursor: z.string().nullable().optional(), +}); + +const toolkitItemSchema = z.object({ + slug: z.string().optional(), + is_no_auth: z.boolean().optional(), + connected_account: z.object({ id: z.string().optional(), status: z.string().optional() }).optional(), +}); +type ToolkitItem = z.infer; +const toolkitPageSchema = z.object({ + items: z.array(toolkitItemSchema).optional(), + next_cursor: z.string().nullable().optional(), +}); + +const connectorServiceSchema = z.object({ + connected: z.boolean(), + pending: z.boolean().optional(), + status: z.string().optional(), + accounts: z.array(z.object({ id: z.string(), alias: z.string().optional(), status: z.string() })).optional(), +}); +const connectorServicesResponseSchema = z.object({ services: z.record(z.string(), connectorServiceSchema).optional() }); +const removalResponseSchema = z.object({ removed: z.number() }); +const authUrlResponseSchema = z.object({ url: z.string().optional() }); +const linkResponseSchema = z.object({ redirect_url: z.string().optional() }); + +const MULTI_ACCOUNT_CONFIG = { + enable: true, + max_accounts_per_toolkit: 5, + require_explicit_selection: true, +} as const; +const MAX_CONNECTED_ACCOUNT_PAGES = 100; +const ACCOUNT_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/; +const printableAliasSchema = z.string().min(1).max(64).refine((value) => { + for (const character of value) { + const codePoint = character.codePointAt(0); + if (codePoint === undefined || codePoint < 32 || codePoint === 127) return false; + } + return true; +}); + +type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }; + export interface ComposioMcpIntegration { command: string; args: string[]; @@ -56,22 +136,20 @@ export function configured(cfg: AppConfig): boolean { async function brokerRequest(path: string, init?: RequestInit): Promise { const broker = brokerAccess(); if (!broker) throw new Error("The connected-apps service is unavailable"); + const headers = new Headers(init?.headers); + headers.set("authorization", `Bearer ${broker.token}`); + if (init?.body) headers.set("content-type", "application/json"); return fetch(`${broker.url}${path}`, { ...init, - headers: { - authorization: `Bearer ${broker.token}`, - ...(init?.body ? { "content-type": "application/json" } : {}), - ...init?.headers, - }, + headers, signal: init?.signal ?? AbortSignal.timeout(30_000), }); } function projectHeaders(apiKey: string, json = false) { - return { - "x-api-key": apiKey, - ...(json ? { "content-type": "application/json" } : {}), - }; + const headers = new Headers({ "x-api-key": apiKey }); + if (json) headers.set("content-type", "application/json"); + return headers; } async function responseError(res: Response, fallback: string) { @@ -84,8 +162,13 @@ async function responseError(res: Response, fallback: string) { } } -function trustedAuthUrl(value: unknown, slug: string): string { - if (typeof value !== "string") throw new Error(`Connected-apps service returned no authorization link for ${slug}`); +async function throwBrokerError(res: Response, fallback: string): Promise { + const status = res.status >= 400 && res.status < 500 ? res.status : 502; + throw Object.assign(new Error(await responseError(res, fallback)), { status }); +} + +function trustedAuthUrl(value: string | undefined, slug: string): string { + if (!value) throw new Error(`Connected-apps service returned no authorization link for ${slug}`); const url = new URL(value); if (url.protocol !== "https:" || (url.hostname !== "composio.dev" && !url.hostname.endsWith(".composio.dev"))) { throw new Error("Connected-apps service returned an untrusted authorization link"); @@ -93,6 +176,48 @@ function trustedAuthUrl(value: unknown, slug: string): string { return url.toString(); } +function parseSessionResponse(session: SessionResponse): SessionResponse { + const mcp = new URL(session.mcp.url); + if (mcp.protocol !== "https:" || (mcp.hostname !== "composio.dev" && !mcp.hostname.endsWith(".composio.dev"))) { + throw new Error("Composio returned an untrusted Session MCP URL"); + } + return { ...session, mcp: { ...session.mcp, url: mcp.toString() } }; +} + +function supportsMultiAccount(session: SessionResponse): boolean { + // Only `enable` gates reuse. The cap and selection flags are what we ASK + // for at creation; if Composio clamps or omits them in the echo, recreating + // the Session would post the same config and get the same echo back — a + // strict equality check here can only manufacture a recreate-per-request + // loop, never fix anything. + return session.config?.multi_account?.enable === true; +} + +/** Session ids this boot already tried to upgrade once. If the fresh Session + * STILL doesn't echo multi-account, Composio isn't granting it — run with + * what we have (single-account behavior) instead of recreating a Session and + * rewriting config.json on every request. */ +const multiAccountUpgradeAttempted = new Set(); + +function inputError(message: string, status = 400) { + return Object.assign(new Error(message), { status }); +} + +export function normalizeAccountAlias(value: string | null | undefined): string | undefined { + if (value === undefined || value === null || value === "") return undefined; + const parsed = z.string().safeParse(value); + if (!parsed.success) throw inputError("Account alias must be text"); + const alias = parsed.data.trim(); + if (!printableAliasSchema.safeParse(alias).success) { + throw inputError("Account alias must be 1-64 printable characters"); + } + return alias; +} + +function validAccountId(value: string | undefined): value is string { + return Boolean(value && ACCOUNT_ID.test(value)); +} + async function getProjectSession(apiKey: string, sessionId: string): Promise { const res = await fetch(`${apiBase()}/tool_router/session/${encodeURIComponent(sessionId)}`, { headers: projectHeaders(apiKey), @@ -100,7 +225,7 @@ async function getProjectSession(apiKey: string, sessionId: string): Promise { if (!composio?.apiKey) throw new Error("No Composio project key configured"); if (composio.sessionId) { const existing = await getProjectSession(composio.apiKey, composio.sessionId); - if (existing) return existing; + if (existing && (supportsMultiAccount(existing) || multiAccountUpgradeAttempted.has(existing.session_id))) { + return existing; + } } // A missing/deleted session is recreated and its non-secret identifiers are // persisted so an edited config/env setup does not recreate it every launch. const prepared = await prepareProjectSession(composio.apiKey, composio); + multiAccountUpgradeAttempted.add(prepared.sessionId); composio.userId = prepared.userId; composio.sessionId = prepared.sessionId; saveConfig({ composio: { userId: prepared.userId, sessionId: prepared.sessionId } }); @@ -186,7 +319,7 @@ export async function mcpIntegration( export async function relayMcp( cfg: AppConfig, - payload: unknown, + payload: JsonValue, transportSessionId?: string, ): Promise<{ status: number; bytes: Uint8Array; contentType: string; transportSessionId?: string }> { const broker = brokerAccess(); @@ -223,12 +356,173 @@ export async function relayMcp( }; } -/** Connection status per service slug: { slack: { connected, status } }. */ +async function listConnectedAccounts( + apiKey: string, + userId: string, + slugs: string[], +): Promise { + const accounts: ConnectedAccountResponse[] = []; + const seenCursors = new Set(); + let cursor: string | undefined; + + // Five accounts per toolkit can exceed one provider page when a user has + // many apps. Follow Composio's cursor instead of silently dropping entries. + for (let page = 0; page < MAX_CONNECTED_ACCOUNT_PAGES; page += 1) { + const params = new URLSearchParams({ + limit: "50", + user_ids: userId, + order_by: "updated_at", + order_direction: "desc", + }); + if (slugs.length) params.set("toolkit_slugs", slugs.join(",")); + if (cursor) params.set("cursor", cursor); + const response = await fetch(`${apiBase()}/connected_accounts?${params}`, { + headers: projectHeaders(apiKey), + signal: AbortSignal.timeout(15_000), + }); + if (!response.ok) throw new Error(await responseError(response, `Composio accounts: HTTP ${response.status}`)); + const body = connectedAccountsPageSchema.parse(await response.json()); + accounts.push(...body.items); + const next = body.next_cursor || undefined; + if (!next || seenCursors.has(next)) return accounts; + seenCursors.add(next); + cursor = next; + } + throw new Error("Composio account inventory exceeded the pagination safety limit"); +} + +async function listSessionToolkits( + apiKey: string, + sessionId: string, +): Promise { + const toolkits: ToolkitItem[] = []; + const seenCursors = new Set(); + let cursor: string | undefined; + for (let page = 0; page < MAX_CONNECTED_ACCOUNT_PAGES; page += 1) { + const params = new URLSearchParams({ limit: "50" }); + if (cursor) params.set("cursor", cursor); + const response = await fetch( + `${apiBase()}/tool_router/session/${encodeURIComponent(sessionId)}/toolkits?${params}`, + { headers: projectHeaders(apiKey), signal: AbortSignal.timeout(15_000) }, + ); + if (!response.ok) throw new Error(await responseError(response, `Composio toolkits: HTTP ${response.status}`)); + const body = toolkitPageSchema.parse(await response.json()); + toolkits.push(...(body.items ?? [])); + const next = body.next_cursor || undefined; + if (!next || seenCursors.has(next)) return toolkits; + seenCursors.add(next); + cursor = next; + } + throw new Error("Composio toolkit inventory exceeded the pagination safety limit"); +} + +function summarizeAccounts(accounts: ConnectedAccountResponse[], slugs: string[]) { + const requested = new Set(slugs.map((slug) => slug.toLowerCase())); + const bySlug = new Map>(); + for (const account of accounts) { + const slug = account.toolkit?.slug?.toLowerCase(); + if (!slug || (requested.size && !requested.has(slug)) || !validAccountId(account.id)) continue; + const alias = account.alias?.trim() ?? ""; + const summary: ConnectedAccountSummary & { updatedAt: string } = { + id: account.id, + status: account.status || "UNKNOWN", + updatedAt: account.updated_at ?? "", + }; + if (printableAliasSchema.safeParse(alias).success) summary.alias = alias; + const list = bySlug.get(slug) ?? []; + list.push(summary); + bySlug.set(slug, list); + } + for (const list of bySlug.values()) list.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)); + return bySlug; +} + +function publicAccount({ id, alias, status }: ConnectedAccountSummary): ConnectedAccountSummary { + const account: ConnectedAccountSummary = { id, status }; + if (alias) account.alias = alias; + return account; +} + +function serviceStateFromAccounts( + accounts: ConnectedAccountSummary[], +): ConnectorServiceState { + const active = accounts.find((account) => /^active$/i.test(account.status)); + const pending = accounts.find((account) => /^(initiated|initializing|pending)$/i.test(account.status)); + const selected = active ?? pending ?? accounts[0]; + return { + connected: Boolean(active), + pending: Boolean(pending), + status: selected?.status ?? "not_connected", + accounts: accounts.map(publicAccount), + }; +} + +function allServiceStates( + accountsBySlug: ReadonlyMap, + toolkits: ToolkitItem[], +): Record { + const services = new Map( + [...accountsBySlug].map(([slug, accounts]) => [slug, serviceStateFromAccounts(accounts)]), + ); + for (const toolkit of toolkits) { + const slug = toolkit.slug?.toLowerCase(); + const selected = toolkit.connected_account; + const selectedId = validAccountId(selected?.id) ? selected.id : undefined; + if (!slug || (!toolkit.is_no_auth && !selectedId)) continue; + const existingAccounts = accountsBySlug.get(slug) ?? []; + const accounts = [...existingAccounts]; + if (selectedId && !accounts.some((account) => account.id === selectedId)) { + accounts.push({ id: selectedId, status: selected?.status ?? "ACTIVE" }); + } + const accountState = serviceStateFromAccounts(accounts); + const status = toolkit.is_no_auth ? "ACTIVE" : selected?.status ?? accountState.status; + services.set(slug, { + connected: toolkit.is_no_auth === true || accountState.connected || /^active$/i.test(status), + pending: accountState.pending || /^(initiated|initializing|pending)$/i.test(status), + status, + accounts: accountState.accounts, + }); + } + return Object.fromEntries(services); +} + +/** + * Enumerate the user's complete connected-account inventory without depending + * on marketplace ordering or catalog pagination. + */ +export async function connectedServices(cfg: AppConfig): Promise> { + if (brokerAccess()) { + const response = await brokerRequest("/v1/connectors/connected"); + if (!response.ok) await throwBrokerError(response, `Connected apps: HTTP ${response.status}`); + const body = connectorServicesResponseSchema.parse(await response.json()); + return Object.fromEntries( + Object.entries(body.services ?? {}).map(([slug, state]) => [slug, { + connected: state.connected, + pending: state.pending ?? false, + status: state.status ?? (state.connected ? "ACTIVE" : "not_connected"), + accounts: state.accounts ?? [], + }]), + ); + } + if (!cfg.composio?.apiKey) throw new Error("Connected apps are unavailable"); + const session = await ensureProjectSession(cfg); + const userId = session.config?.user_id ?? cfg.composio.userId; + if (!userId) throw new Error("Composio Session returned no user ID"); + const [toolkits, accounts] = await Promise.all([ + listSessionToolkits(cfg.composio.apiKey, session.session_id), + // Scoped project keys can grant Session reads without granting the raw + // connected-account list. The Session still proves which selected/no-auth + // toolkits belong to this installation, so retain that safe fallback. + listConnectedAccounts(cfg.composio.apiKey, userId, []).catch(() => []), + ]); + return allServiceStates(summarizeAccounts(accounts, []), toolkits); +} + export async function connectionStatus(cfg: AppConfig, slugs: string[]) { if (brokerAccess() || !cfg.composio?.apiKey) { const response = await brokerRequest(`/v1/connectors?${new URLSearchParams({ services: slugs.join(",") })}`); - if (!response.ok) throw new Error(await responseError(response, `Connected apps: HTTP ${response.status}`)); - const body = (await response.json()) as { services?: Record }; + if (!response.ok) await throwBrokerError(response, `Connected apps: HTTP ${response.status}`); + const body = connectorServicesResponseSchema.parse(await response.json()); return body.services ?? {}; } const session = await ensureProjectSession(cfg); @@ -246,59 +540,45 @@ export async function connectionStatus(cfg: AppConfig, slugs: string[]) { // keys may omit connected-account read permission, so this is additive: // the normal session result remains the fallback. userId - ? fetch( - `${apiBase()}/connected_accounts?${new URLSearchParams({ limit: "50", user_ids: userId })}`, - { headers: projectHeaders(cfg.composio.apiKey), signal: AbortSignal.timeout(15_000) }, - ) - .then(async (accountRes) => { - if (!accountRes.ok) return []; - const accountBody = (await accountRes.json()) as { - items?: Array<{ toolkit?: { slug?: string }; status?: string; updated_at?: string }>; - }; - return Array.isArray(accountBody?.items) ? accountBody.items : []; - }) - .catch(() => []) + ? listConnectedAccounts(cfg.composio.apiKey, userId, slugs).catch(() => []) : Promise.resolve([]), ]); if (!res.ok) throw new Error(await responseError(res, `Composio toolkits: HTTP ${res.status}`)); - const body = (await res.json()) as { items?: Array<{ slug?: string; is_no_auth?: boolean; connected_account?: { status?: string } }> }; + const body = toolkitPageSchema.parse(await res.json()); const bySlug = new Map((body.items ?? []).map((item) => [item.slug?.toLowerCase(), item])); - const accountBySlug = new Map(); - for (const account of accounts) { - const slug = account.toolkit?.slug?.toLowerCase(); - if (!slug || !slugs.some((candidate) => candidate.toLowerCase() === slug)) continue; - const current = accountBySlug.get(slug); - // Prefer an active account. Otherwise the API is newest-first, but keep - // the timestamp comparison explicit so response ordering cannot lie. - if ( - !current - || /^active$/i.test(account.status ?? "") - || (!/^active$/i.test(current.status ?? "") && (account.updated_at ?? "") > (current.updated_at ?? "")) - ) { - accountBySlug.set(slug, account); - } - } + const accountsBySlug = summarizeAccounts(accounts, slugs); return Object.fromEntries( slugs.map((slug) => { const item = bySlug.get(slug.toLowerCase()); - const account = accountBySlug.get(slug.toLowerCase()); + const serviceAccounts = accountsBySlug.get(slug.toLowerCase()) ?? []; + // Mirror allServiceStates: a scoped key can be denied the raw account + // list while the Session still names its selected account. Synthesize + // that account here too, so a status poll never wipes the row the + // inventory paths render (merge replaces a slug's state wholesale). + const selected = item?.connected_account; + const selectedId = validAccountId(selected?.id) ? selected.id : undefined; + const withSelected = selectedId && !serviceAccounts.some((account) => account.id === selectedId) + ? [...serviceAccounts, { id: selectedId, status: selected?.status ?? "ACTIVE" }] + : serviceAccounts; + const accountState = serviceStateFromAccounts(withSelected); const state = item?.connected_account?.status - ?? (item?.is_no_auth ? "ACTIVE" : account?.status ?? "not_connected"); + ?? (item?.is_no_auth ? "ACTIVE" : accountState.status); return [slug, { - connected: item?.is_no_auth === true || /^active$/i.test(state), - pending: /^(initiated|initializing|pending)$/i.test(state), + connected: item?.is_no_auth === true || accountState.connected || /^active$/i.test(state), + pending: accountState.pending || /^(initiated|initializing|pending)$/i.test(state), status: state, + accounts: accountState.accounts, }]; }), ); } -/** Disconnect a service: remove every connected account for the slug. */ +/** Backward-compatible service disconnect: removes the Session-selected account. */ export async function removeService(cfg: AppConfig, slug: string) { if (brokerAccess() || !cfg.composio?.apiKey) { const response = await brokerRequest(`/v1/connectors/${encodeURIComponent(slug)}`, { method: "DELETE" }); - if (!response.ok) throw new Error(await responseError(response, `Connected apps: HTTP ${response.status}`)); - return response.json() as Promise<{ removed: number }>; + if (!response.ok) await throwBrokerError(response, `Connected apps: HTTP ${response.status}`); + return removalResponseSchema.parse(await response.json()); } const session = await ensureProjectSession(cfg); const params = new URLSearchParams({ limit: "50", toolkits: slug }); @@ -307,7 +587,7 @@ export async function removeService(cfg: AppConfig, slug: string) { { headers: projectHeaders(cfg.composio.apiKey), signal: AbortSignal.timeout(15_000) }, ); if (!list.ok) throw new Error(await responseError(list, `Composio toolkits: HTTP ${list.status}`)); - const body = (await list.json()) as { items?: Array<{ slug?: string; connected_account?: { id?: string } }> }; + const body = toolkitPageSchema.parse(await list.json()); const id = body.items?.find((item) => item.slug?.toLowerCase() === slug.toLowerCase())?.connected_account?.id; if (!id) return { removed: 0 }; const removed = await fetch( @@ -318,23 +598,72 @@ export async function removeService(cfg: AppConfig, slug: string) { return { removed: 1 }; } +/** Disconnect exactly one account after proving it belongs to this user/toolkit. */ +export async function removeAccount(cfg: AppConfig, slug: string, accountId: string) { + if (!validAccountId(accountId)) throw inputError("Invalid connected-account ID"); + if (brokerAccess() || !cfg.composio?.apiKey) { + const response = await brokerRequest( + `/v1/connectors/${encodeURIComponent(slug)}/accounts/${encodeURIComponent(accountId)}`, + { method: "DELETE" }, + ); + if (!response.ok) await throwBrokerError(response, `Connected apps: HTTP ${response.status}`); + return removalResponseSchema.parse(await response.json()); + } + const session = await ensureProjectSession(cfg); + const userId = session.config?.user_id ?? cfg.composio.userId; + if (!userId) throw new Error("Composio Session has no user ID"); + const accounts = await listConnectedAccounts(cfg.composio.apiKey, userId, [slug]); + const owned = accounts.some((account) => + account.id === accountId && account.toolkit?.slug?.toLowerCase() === slug.toLowerCase() + ); + if (!owned) return { removed: 0 }; + const removed = await fetch( + `${apiBase()}/connected_accounts/${encodeURIComponent(accountId)}?revoke_on_delete=true`, + { method: "DELETE", headers: projectHeaders(cfg.composio.apiKey), signal: AbortSignal.timeout(30_000) }, + ); + if (!removed.ok) throw new Error(await responseError(removed, `Composio disconnect: HTTP ${removed.status}`)); + return { removed: 1 }; +} + /** Mint a browser auth link for one service. Returns { url } or throws. */ -export async function authorizeService(cfg: AppConfig, slug: string) { +export async function authorizeService(cfg: AppConfig, slug: string, requestedAlias?: string | null) { + const alias = normalizeAccountAlias(requestedAlias); if (brokerAccess() || !cfg.composio?.apiKey) { - const response = await brokerRequest(`/v1/connectors/${encodeURIComponent(slug)}/authorize`, { method: "POST" }); - if (!response.ok) throw new Error(await responseError(response, `Connected apps: HTTP ${response.status}`)); - const body = (await response.json()) as { url?: string }; + const request: RequestInit = { method: "POST" }; + if (alias) request.body = JSON.stringify({ alias }); + const response = await brokerRequest(`/v1/connectors/${encodeURIComponent(slug)}/authorize`, request); + if (!response.ok) await throwBrokerError(response, `Connected apps: HTTP ${response.status}`); + const body = authUrlResponseSchema.parse(await response.json()); return { url: trustedAuthUrl(body.url, slug) }; } const session = await ensureProjectSession(cfg); + const userId = session.config?.user_id ?? cfg.composio.userId; + if (!userId) throw new Error("Composio Session has no user ID"); + // A scoped key may be denied account listing — authorization must still + // work (it always did pre-multi-account), so the alias guardrails degrade + // to first-account behavior, the same fallback every inventory path takes. + const accounts = await listConnectedAccounts(cfg.composio.apiKey, userId, [slug]).catch(() => []); + const serviceAccounts = accounts.filter((account) => account.toolkit?.slug?.toLowerCase() === slug.toLowerCase()); + const usableAccounts = serviceAccounts.filter((account) => /^(active|initiated|initializing|pending)$/i.test(account.status ?? "")); + if (usableAccounts.length >= MULTI_ACCOUNT_CONFIG.max_accounts_per_toolkit) { + throw inputError(`${slug} already has the maximum of ${MULTI_ACCOUNT_CONFIG.max_accounts_per_toolkit} accounts`, 409); + } + if (usableAccounts.length > 0 && !alias) { + throw inputError("Add an account alias so the existing connection is not replaced"); + } + if (alias && serviceAccounts.some((account) => account.alias?.trim().toLowerCase() === alias.toLowerCase())) { + throw inputError(`Account alias "${alias}" is already in use for ${slug}`, 409); + } + const linkRequest: AccountLinkRequest = { toolkit: slug }; + if (alias) linkRequest.alias = alias; const res = await fetch(`${apiBase()}/tool_router/session/${encodeURIComponent(session.session_id)}/link`, { method: "POST", headers: projectHeaders(cfg.composio.apiKey, true), - body: JSON.stringify({ toolkit: slug }), + body: JSON.stringify(linkRequest), signal: AbortSignal.timeout(30_000), }); if (!res.ok) throw new Error(await responseError(res, `Composio authorization: HTTP ${res.status}`)); - const body = (await res.json()) as { redirect_url?: string }; + const body = linkResponseSchema.parse(await res.json()); return { url: trustedAuthUrl(body.redirect_url, slug) }; } diff --git a/server/index.ts b/server/index.ts index cc1e01b0b..612d4f943 100644 --- a/server/index.ts +++ b/server/index.ts @@ -4062,6 +4062,12 @@ const server = createServer(async (req, res) => { const { cards, source } = await composio.listToolkits(cfg); return json(res, 200, { configured: composio.configured(cfg), mode: composio.connectionMode(cfg), source, cards }); } + if (method === "GET" && path === "/api/connectors/connected") { + if (!composio.configured(cfg)) { + return json(res, 200, { configured: false, services: {} }); + } + return json(res, 200, { configured: true, services: await composio.connectedServices(cfg) }); + } if (method === "GET" && path === "/api/connectors") { const services = (url.searchParams.get("services") ?? "").split(",").filter(Boolean); if (!composio.configured(cfg)) { @@ -4071,7 +4077,12 @@ const server = createServer(async (req, res) => { return json(res, 200, { configured: true, services: status }); } m = path.match(/^\/api\/connectors\/([\w-]+)\/authorize$/); - if (m && method === "POST") return json(res, 200, await composio.authorizeService(cfg, m[1])); + if (m && method === "POST") { + const body = await readBody(req); + return json(res, 200, await composio.authorizeService(cfg, m[1], body.alias)); + } + m = path.match(/^\/api\/connectors\/([\w-]+)\/accounts\/([A-Za-z0-9][A-Za-z0-9_-]{0,127})$/); + if (m && method === "DELETE") return json(res, 200, await composio.removeAccount(cfg, m[1], m[2])); m = path.match(/^\/api\/connectors\/([\w-]+)$/); if (m && method === "DELETE") return json(res, 200, await composio.removeService(cfg, m[1])); diff --git a/src/components/PluginsPanel.test.ts b/src/components/PluginsPanel.test.ts index c497f9d72..21a1724e6 100644 --- a/src/components/PluginsPanel.test.ts +++ b/src/components/PluginsPanel.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from "vitest"; -import { mergeCurrentConnectorStatus, type ConnectorStatus } from "./PluginsPanel"; +import { + disconnectAccountConfirmation, + mergeCompleteConnectorStatus, + mergeCurrentConnectorStatus, + type ConnectorStatus, +} from "./PluginsPanel"; describe("connected-app status races", () => { it("does not let an older not_connected response erase a newer OAuth attempt", async () => { @@ -38,4 +43,73 @@ describe("connected-app status races", () => { expect(merged.gmail).toEqual({ connected: true, pending: false, status: "ACTIVE" }); }); + + it("keeps a connected account beyond the first 40 marketplace cards", () => { + const catalog = Array.from({ length: 45 }, (_, index) => `toolkit_${index + 1}`); + const accountSlug = catalog[40]; + expect(catalog.slice(0, 40)).not.toContain(accountSlug); + + const merged = mergeCompleteConnectorStatus( + {}, + { + [accountSlug]: { + connected: true, + pending: false, + status: "ACTIVE", + accounts: [{ id: "ca_toolkit_41", alias: "overflow", status: "ACTIVE" }], + }, + }, + new Map(), + new Map(), + ); + + expect(merged[accountSlug]?.accounts).toEqual([ + { id: "ca_toolkit_41", alias: "overflow", status: "ACTIVE" }, + ]); + }); + + it("clears externally removed accounts without overwriting a newer OAuth attempt", () => { + const generations = new Map([["gmail", 2]]); + const removed = mergeCompleteConnectorStatus( + { gmail: { connected: true, accounts: [{ id: "ca_old", status: "ACTIVE" }] } }, + {}, + generations, + new Map(generations), + ); + expect(removed.gmail).toEqual({ + connected: false, + pending: false, + status: "not_connected", + accounts: [], + }); + + const preserved = mergeCompleteConnectorStatus( + { gmail: { connected: false, pending: true, status: "INITIATED" } }, + {}, + new Map([["gmail", 3]]), + generations, + ); + expect(preserved.gmail).toEqual({ connected: false, pending: true, status: "INITIATED" }); + + // The generation guard specifically: a connected entry WITH accounts is + // exactly what the clearing branch targets, so only the advanced + // generation can save it — a freshly-connected account must survive a + // stale /connected response racing the Connect click. + const racing = mergeCompleteConnectorStatus( + { gmail: { connected: true, accounts: [{ id: "ca_new", status: "ACTIVE" }] } }, + {}, + new Map([["gmail", 3]]), + generations, + ); + expect(racing.gmail).toEqual({ connected: true, accounts: [{ id: "ca_new", status: "ACTIVE" }] }); + }); + + it("names the exact account and limits disconnect confirmation to that account", () => { + expect(disconnectAccountConfirmation("Gmail", { id: "ca_work", alias: "work" })).toBe( + "Disconnect “work” (ca_work) from Gmail? Only this Gmail account will be revoked. Your other Gmail accounts will stay connected.", + ); + expect(disconnectAccountConfirmation("GitHub", { id: "ca_personal" })).toContain( + "Disconnect “ca_personal” from GitHub? Only this GitHub account will be revoked.", + ); + }); }); diff --git a/src/components/PluginsPanel.tsx b/src/components/PluginsPanel.tsx index befdd2352..08e81174c 100644 --- a/src/components/PluginsPanel.tsx +++ b/src/components/PluginsPanel.tsx @@ -19,6 +19,19 @@ export interface ConnectorStatus { connected: boolean; pending?: boolean; status?: string; + accounts?: Array<{ + id: string; + alias?: string; + status: string; + }>; +} + +export function disconnectAccountConfirmation( + service: string, + account: { id: string; alias?: string }, +) { + const identity = account.alias ? `“${account.alias}” (${account.id})` : `“${account.id}”`; + return `Disconnect ${identity} from ${service}? Only this ${service} account will be revoked. Your other ${service} accounts will stay connected.`; } export function mergeCurrentConnectorStatus( @@ -35,6 +48,22 @@ export function mergeCurrentConnectorStatus( return next; } +export function mergeCompleteConnectorStatus( + current: Record, + incoming: Record, + latestGenerations: ReadonlyMap, + requestGenerations: ReadonlyMap, +) { + const next = { ...current }; + for (const [slug, state] of Object.entries(current)) { + if (incoming[slug]) continue; + if (!state.connected && !state.accounts?.length) continue; + if ((latestGenerations.get(slug) ?? 0) !== (requestGenerations.get(slug) ?? 0)) continue; + next[slug] = { connected: false, pending: false, status: "not_connected", accounts: [] }; + } + return mergeCurrentConnectorStatus(next, incoming, latestGenerations, requestGenerations); +} + function ServiceIcon({ card }: { card: ToolkitCard }) { // 0 = official logo, 1 = favicon by domain, 2 = monogram const [stage, setStage] = useState(card.logo ? 0 : card.domain ? 1 : 2); @@ -67,6 +96,8 @@ export function PluginsPanel() { const [mode, setMode] = useState<"managed" | "self-hosted" | "unavailable">("unavailable"); const [status, setStatus] = useState>({}); const [pendingUrls, setPendingUrls] = useState>({}); + const [aliasSlug, setAliasSlug] = useState(null); + const [aliasDraft, setAliasDraft] = useState(""); const [busySlug, setBusySlug] = useState(null); const [refreshing, setRefreshing] = useState(false); const [error, setError] = useState(null); @@ -94,7 +125,34 @@ export function PluginsPanel() { )); for (const [slug, state] of Object.entries(services)) { const isCurrent = (statusGenerations.current.get(slug) ?? 0) === (requestGenerations.get(slug) ?? 0); - if (isCurrent && state.connected) setPendingUrls((current) => { + if (isCurrent && state.connected && !state.pending) setPendingUrls((current) => { + if (!current[slug]) return current; + const next = { ...current }; + delete next[slug]; + return next; + }); + } + return services; + }) + .catch(() => ({})) + .finally(() => setRefreshing(false)); + }, []); + + const refreshConnectedStatus = useCallback((): Promise> => { + const requestGenerations = new Map(statusGenerations.current); + setRefreshing(true); + return api("/api/connectors/connected") + .then((r) => { + const services: Record = r.services ?? {}; + setStatus((current) => mergeCompleteConnectorStatus( + current, + services, + statusGenerations.current, + requestGenerations, + )); + for (const [slug, state] of Object.entries(services)) { + const isCurrent = (statusGenerations.current.get(slug) ?? 0) === (requestGenerations.get(slug) ?? 0); + if (isCurrent && state.connected && !state.pending) setPendingUrls((current) => { if (!current[slug]) return current; const next = { ...current }; delete next[slug]; @@ -121,13 +179,13 @@ export function PluginsPanel() { setSource(r.source ?? "curated"); setConfigured(Boolean(r.configured)); setMode(r.mode ?? "unavailable"); - if (r.configured) void refreshStatus((r.cards ?? []).map((c: ToolkitCard) => c.slug).slice(0, 40)); + if (r.configured) void refreshConnectedStatus(); }) .catch((e) => alive && setError(e.message)); return () => { alive = false; }; - }, [refreshStatus]); + }, [refreshConnectedStatus]); useEffect(() => { const returnFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null; @@ -194,7 +252,7 @@ export function PluginsPanel() { const timer = setInterval(() => { void refreshStatus([slug]).then((services) => { const state = services[slug]; - if (++tries >= 24 || state?.connected || (state?.status && /^(expired|failed)$/i.test(state.status))) { + if (++tries >= 24 || (state?.connected && !state.pending) || (state?.status && /^(expired|failed)$/i.test(state.status))) { clearInterval(timer); pollTimers.current.delete(slug); } @@ -203,14 +261,26 @@ export function PluginsPanel() { pollTimers.current.set(slug, timer); }; - const connect = async (slug: string) => { + const connect = async (slug: string, alias?: string) => { statusGenerations.current.set(slug, (statusGenerations.current.get(slug) ?? 0) + 1); setBusySlug(slug); setError(null); try { - const { url } = await api(`/api/connectors/${slug}/authorize`, { method: "POST" }); + const request: RequestInit = { method: "POST" }; + if (alias) request.body = JSON.stringify({ alias }); + const { url } = await api(`/api/connectors/${slug}/authorize`, request); setPendingUrls((current) => ({ ...current, [slug]: url })); - setStatus((current) => ({ ...current, [slug]: { connected: false, pending: true, status: "INITIATED" } })); + setStatus((current) => ({ + ...current, + [slug]: { + ...current[slug], + connected: current[slug]?.connected ?? false, + pending: true, + status: "INITIATED", + }, + })); + setAliasSlug(null); + setAliasDraft(""); startPolling(slug); await openConnectUrl(url); } catch (e) { @@ -220,9 +290,9 @@ export function PluginsPanel() { } }; - const disconnect = (slug: string) => { + const disconnectAccount = (slug: string, accountId: string) => { setBusySlug(slug); - api(`/api/connectors/${slug}`, { method: "DELETE" }) + api(`/api/connectors/${slug}/accounts/${encodeURIComponent(accountId)}`, { method: "DELETE" }) .then(() => refreshStatus([slug])) .catch((e) => setError(e.message)) .finally(() => setBusySlug(null)); @@ -231,8 +301,10 @@ export function PluginsPanel() { const matching = (cards ?? []).filter( (c) => !search || `${c.label} ${c.slug} ${c.blurb}`.toLowerCase().includes(search.toLowerCase()), ); - const visible = matching.filter((card) => tab === "marketplace" || status[card.slug]?.connected); - const connectedCount = Object.values(status).filter((service) => service.connected).length; + const visible = matching.filter((card) => + tab === "marketplace" || status[card.slug]?.connected || Boolean(status[card.slug]?.accounts?.length) + ); + const connectedCount = Object.values(status).filter((service) => service.connected || service.accounts?.length).length; const close = () => dispatch({ type: "togglePlugins", open: false }); return ( @@ -255,7 +327,7 @@ export function PluginsPanel() {
- + {accounts.length > 0 && ( +
+ {accounts.map((account) => { + const active = /^active$/i.test(account.status); + return ( +
+
+
+ {active && } + {account.alias || account.id} +
+
+ {account.alias ? `${account.id} · ` : ""}{account.status.toLowerCase()} +
+
+ +
+ ); + })} +
+ )} + {addingAccount && ( +
{ + event.preventDefault(); + const alias = aliasDraft.trim(); + if (!alias) { + setError("Enter a label for the account, such as work or personal."); + return; + } + void connect(card.slug, alias); + }} + > + setAliasDraft(event.target.value)} + placeholder="Account label (work, personal…)" + aria-label={`Label for another ${card.label} account`} + className="min-w-0 flex-1 rounded-lg bg-raised px-3 py-2 text-[12px] text-ink placeholder:text-ink-secondary focus:outline-none focus:ring-1 focus:ring-accent" + /> + +
+ )} ); })}