From c4909e0ac08ef6f3e5ada7b7a01942446b97bbea Mon Sep 17 00:00:00 2001 From: Tiberiu Socaci Date: Wed, 21 Oct 2026 11:07:21 +0300 Subject: [PATCH] feat: show Composio app and account inventories Signed-off-by: Tiberiu Socaci --- CHANGELOG.md | 3 + FEATURES.md | 15 + TEST-PLAN.md | 34 +++ .../content/docs/configuration/connections.md | 15 + public/admin-composio.js | 69 +++++ public/app.js | 6 + public/index.html | 4 + public/styles.css | 7 + src/gateway/composio-inventory.js | 248 +++++++++++++++ src/web/routes/channels.js | 14 + src/web/routes/users.js | 13 +- test/composio-inventory-routes.test.js | 108 +++++++ test/composio-inventory-ui.test.js | 152 +++++++++ test/composio-inventory.test.js | 289 ++++++++++++++++++ 14 files changed, 976 insertions(+), 1 deletion(-) create mode 100644 public/admin-composio.js create mode 100644 src/gateway/composio-inventory.js create mode 100644 test/composio-inventory-routes.test.js create mode 100644 test/composio-inventory-ui.test.js create mode 100644 test/composio-inventory.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 28741341..ee253302 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,9 @@ product overview. ## Unreleased +- Show connected Composio apps and accounts on admin user pages and channel Connections pages, + with account statuses, shared connection source, refresh controls and explicit provider limitations. + - Supply each attempt's resolved admin permission bypass decision to the assistant, so it can distinguish its actual launch permissions from the channel's Admin label and writable tools. Permission policies and container boundaries are unchanged. diff --git a/FEATURES.md b/FEATURES.md index f03cb1cd..72062fd7 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -1,5 +1,20 @@ # ChannelGate — Features +## Composio connection visibility + +- Admin UI user pages list that person's personal Composio applications and accounts. Channel + Connections pages list the shared identity and identify channel credentials versus organization + defaults; personal accounts remain on user pages. Each row shows the app, account alias/available + account identity, connection ID, status, default selection and account type when provided. +- Lists load on demand and have a Refresh connections button. Personal mode discovers the apps + published by Composio and reads each app's account metadata; a visible caveat explains that + inactive/unpublished connections may be absent. Missing or incomplete provider metadata is + reported explicitly. SDK mode lists all account pages for the exact stable user/channel identity + under the existing Enterprise entitlement, without creating sessions or authorizations. +- Inventory reads retain personal-only and channel → organization resolution, refusal of + organization defaults, and DM suppression of shared identities. Responses contain allowlisted + metadata only, never connector keys, account credential payloads or business records. + - Slack Settings renders populated Cloud MCP and skill pickers on the MCP, Skills and You pages without a native Slack `invalid_arguments` error; selection still saves immediately. diff --git a/TEST-PLAN.md b/TEST-PLAN.md index 28adb05c..41fb4e4b 100644 --- a/TEST-PLAN.md +++ b/TEST-PLAN.md @@ -1,5 +1,39 @@ # ChannelGate — Test Plan +## Composio connection inventory (2026-10-21) + +- [x] Regression: `node --test test/composio-inventory.test.js test/composio-inventory-ui.test.js + test/composio-inventory-routes.test.js`. Provider fixtures include two Gmail accounts, a second + app, unrelated discovered toolkits, credential/business payloads in account data, paginated + tools and SDK accounts, failed lookups and missing app declarations. Scope fixtures cover a + personal key, a distinct channel key, an organization default, no-defaults and a DM. +- [x] Chromium source-fixture checks at 1440×1100 and 390×844: actual user/channel templates, + stylesheet and inventory module with controlled API responses render every account, escape HTML + labels, recover through Refresh, and contain narrow-table scrolling without page overflow or + JavaScript errors. These source fixtures do not certify live production Composio credentials. +- [ ] Live Admin UI, **engine-independent**: no engine is invoked by these metadata reads. + Use an isolated admin fixture with a user whose Personal Composio key reports two Gmail accounts + and one other app; use a channel with a different key, another inheriting the org default, a + no-defaults channel without a key, and a DM. Open Users → fixture user, then each channel's + Connections tab. **Pass:** all provider-published account rows, aliases/available identities, + status/default/type and source appear; user accounts never fall back to shared accounts; shared + accounts never appear in the DM; disabled org defaults do not dial Composio. Refresh reflects a + provider-side metadata change. Save a replacement key and confirm the list uses the saved key. + Personal mode keeps its inactive/unpublished-app caveat visible. No app authorization starts. +- [ ] Live SDK fixture, **engine-independent**: active Enterprise license and SDK organization + key, stable `slack::user:` and channel identities with distinct accounts, including an + expired account and enough accounts to force pagination (or a controlled paginated provider + fixture). Open both pages and refresh. **Pass:** every account page belongs to the exact scoped + identity; expired status remains visible; no session mapping, account or authorization is created. + An unavailable/unentitled SDK configuration reports an explanatory state without token fallback. +- [ ] Failure/browser fixture, **engine-independent**: deny/expire the provider key, omit the + Personal published-app declaration, fail one declared app lookup, and return an alias containing + HTML. **Pass:** unavailable/partial is distinct from no accounts, retries recover, no raw provider + error/credential/business payload reaches browser responses, aliases render as text, and opening + a second user/channel while a request is pending cannot paint the previous scope into it. + Unauthenticated API requests are refused by the admin stack. Repeat in a narrow user drawer. + + ## Slack cancellation progress ordering (2026-10-16) - [x] Regression: `test/slack-stop-progress.test.js` holds actual pipeline runtime preparation diff --git a/documentation/src/content/docs/configuration/connections.md b/documentation/src/content/docs/configuration/connections.md index fd880283..19905f45 100644 --- a/documentation/src/content/docs/configuration/connections.md +++ b/documentation/src/content/docs/configuration/connections.md @@ -38,6 +38,21 @@ For a shared task, name the account: “Create this task using the team's connec SDK mode is an **Enterprise / Beta** option configured with a separate SDK API key. It uses managed Composio sessions rather than independently supplied Personal-mode tokens. Switching the mode retains the stored tokens but makes those tokens inactive while SDK mode is selected. Review the resulting session identities instead of assuming the previous shared account remains selected. +## See connected apps and accounts + +Open a person in **Users** to see their personal Composio apps and account metadata. Open a +conversation's **MCP Connections** tab to see its shared apps and accounts, including whether +the connection comes from that conversation or the organization default. Rows show account +labels, available account identities, status, default selection and account type when Composio +provides them. Use **Refresh connections** after changing an account in Composio; save any key +changes in ChannelGate first. + +Personal mode shows the app list Composio publishes for the saved key and the accounts reported +for each app. Inactive or unpublished apps may be absent; incomplete and unavailable results are +identified on the page. SDK mode lists every account page for the exact stable identity. +Viewing either list does not connect apps or start an authorization flow. These lists are part +of the authenticated admin UI; they do not create a separate end-user web login. + ## Save effects and limitations Connections are assembled for each run, so saved token and selection changes apply to subsequent messages without restarting the daemon. Lean mode can suppress optional connectors. A server's appearance in a picker does not prove its login works; test the actual intended identity. diff --git a/public/admin-composio.js b/public/admin-composio.js new file mode 100644 index 00000000..011093e8 --- /dev/null +++ b/public/admin-composio.js @@ -0,0 +1,69 @@ +// Read-only connection inventory shared by the user drawer and channel Connections page. +import { api } from "./admin-api.js"; +import { escapeHtml } from "./admin-view.js"; + +const SOURCES = { + user: "Personal connections · composio-user", + channel: "Channel connections · composio-agent", + org: "Organization default · composio-agent", + "sdk-user": "Personal connections · composio-user · SDK", + "sdk-channel": "Channel connections · composio-agent · SDK", + "none-dm": "Direct message · personal connections only", + "none-no-org-default": "Organization defaults disabled", +}; + +export function composioInventoryMarkup(result) { + const accounts = Array.isArray(result.accounts) ? result.accounts : []; + const rows = accounts.map((account) => { + const label = account.alias || account.account || account.id || "Account label unavailable"; + const details = [account.alias && account.account ? account.account : "", account.id, account.accountType].filter(Boolean); + return `${escapeHtml(account.app || "Unknown app")}${escapeHtml(label)}${account.isDefault ? " · default" : ""}${details.length ? `
${details.map(escapeHtml).join(" · ")}` : ""}${escapeHtml(account.status || "UNKNOWN")}`; + }).join(""); + const count = `${new Set(accounts.map((account) => account.app)).size} apps · ${accounts.length} accounts`; + const stamp = result.checkedAt ? new Date(result.checkedAt) : null; + const checked = stamp && Number.isFinite(stamp.getTime()) ? ` · Checked ${stamp.toLocaleTimeString()}` : ""; + const source = SOURCES[result.source] || "Composio connections"; + return `

${escapeHtml(source)}

+

${escapeHtml(result.message || "")}

+ ${accounts.length ? `

${escapeHtml(count + checked)}

${rows}
ApplicationAccountStatus
` : result.state === "ready" ? "

No accounts reported by Composio.

" : ""} + ${result.mode === "personal" ? "

Shows the apps and accounts Composio publishes for this key. Inactive or unpublished connections may not appear.

" : ""}`; +} + +export function mountComposioInventory(root, endpoint, { request = api, autoLoad = true } = {}) { + root.innerHTML = `

Connected Composio apps

Open this section to load connections.

`; + const button = root.querySelector(".composio-refresh"); + const output = root.querySelector(".composio-inventory-result"); + let loading = false; + let loaded = false; + let refreshPending = false; + const load = async (refresh = false) => { + if (loading) { + // Saving a new key while the previous lookup runs must not leave the old inventory shown. + if (refresh) refreshPending = true; + return; + } + if (loaded && !refresh) return; + loading = true; + button.disabled = true; + output.textContent = "Loading Composio apps and accounts…"; + try { + const result = await request(endpoint + (refresh ? "?refresh=1" : "")); + if (!refreshPending) { + output.innerHTML = composioInventoryMarkup(result); + loaded = true; + } + } catch { + if (!refreshPending) output.textContent = "Could not load Composio connections. Try Refresh connections."; + } finally { + loading = false; + button.disabled = false; + if (refreshPending) { + refreshPending = false; + void load(true); + } + } + }; + button.addEventListener("click", () => load(true)); + if (autoLoad) void load(); + return load; +} diff --git a/public/app.js b/public/app.js index c54e4909..33e0db9e 100644 --- a/public/app.js +++ b/public/app.js @@ -24,6 +24,7 @@ import { mountSkillAssignmentPicker } from "./skill-assignment-picker.js"; import { mountUserPicker } from "./admin-user-picker.js"; import { mountSecretEditor, secretEditorMarkup } from "./admin-secrets.js"; import { customMcpEditorMarkup, mountCustomMcpEditor } from "./admin-custom-mcps.js"; +import { mountComposioInventory } from "./admin-composio.js"; import { describeEvent, eventLabel, isAdminEvent } from "./admin-events.js"; import { automationRunMarkup, automationSummaryMarkup, automationStatusLabel, automationStatusTone } from "./admin-schedules.js"; @@ -2369,6 +2370,7 @@ function renderChannelDetail(ch) { const composioLabel = card.querySelector(".ch-composio-label").value; const toolboxLabel = card.querySelector(".ch-toolboxtoken-label").value; const makeToolboxKey = tokenValue(makeToolboxKeyInput); + const previousNoDefaultTokens = Boolean(ch.meta?.noDefaultTokens); try { const result = await api(`/api/channels/${encodeURIComponent(ch.channelId)}/meta`, { method: "PUT", @@ -2414,6 +2416,7 @@ function renderChannelDetail(ch) { // The server response is the validated, committed record. Reconcile the cached channel from // that whole record so a later SPA re-render cannot resurrect stale MCP/skill selections. ch.meta = reconcileChannelMeta(ch.meta, result.meta); + if (composioTok || previousNoDefaultTokens !== Boolean(ch.meta.noDefaultTokens)) void loadComposioConnections(true); initializeMcpBox(mcpsBox, { claude: ch.meta.allowedMcps || [], codex: ch.meta.allowedCodexMcps || [] }); if (!mcpsBox.dataset.mcpLoading) paintAllMcpBoxes(mcpsBox); updateChecksCount(mcpsBox, mcpsCount); @@ -2552,6 +2555,7 @@ function renderChannelDetail(ch) { // Page tabs are view toggles; all Access/MCP Connections/Cloud MCP/Environment/Skills/Runtime controls stay in the DOM so the one savebar // persists them. Instructions & Memory lazy-load on first open and keep their own Save. + const loadComposioConnections = mountComposioInventory(card.querySelector(".ch-composio-inventory"), `/api/channels/${encodeURIComponent(ch.channelId)}/composio-connections`, { autoLoad: false }); const subtabs = card.querySelectorAll(".subtab"); const subpanes = card.querySelectorAll(".subpane"); for (const tab of subtabs) { @@ -2560,6 +2564,7 @@ function renderChannelDetail(ch) { for (const p of subpanes) p.classList.toggle("active", p.dataset.pane === tab.dataset.pane); if (tab.dataset.pane === "memory") loadMemory(); if (tab.dataset.pane === "instructions") loadInstructions(); + if (tab.dataset.pane === "connections") void loadComposioConnections(); }); } @@ -3307,6 +3312,7 @@ function openUserDrawer(id) { attachReveal(drawer.querySelector(".ud-toolboxtoken"), { has: u.hasToolboxToken, last4: u.toolboxTokenLast4, fetch: revealSecret("user", "toolboxToken", id) }); drawer.querySelector(".ud-composio-label").value = u.composioTokenLabel || ""; drawer.querySelector(".ud-toolboxtoken-label").value = u.toolboxTokenLabel || ""; + mountComposioInventory(drawer.querySelector(".ud-composio-inventory"), `/api/users/${encodeURIComponent(id)}/composio-connections`); drawer.querySelector(".ud-close").addEventListener("click", closeUserDrawer); diff --git a/public/index.html b/public/index.html index e4db7ea2..158c2e7e 100644 --- a/public/index.html +++ b/public/index.html @@ -1289,6 +1289,8 @@

+
+

Shared accounts for this conversation. Each person's personal accounts are listed on their user page. Save key changes before refreshing. Lean mode can omit connections during a run.

+
+

This person's personal accounts. Shared accounts are listed on each channel's Connections page. Save key changes before refreshing.

diff --git a/public/styles.css b/public/styles.css index 28e7d03e..66c871d3 100644 --- a/public/styles.css +++ b/public/styles.css @@ -1279,3 +1279,10 @@ button.clear-tok.armed { background: rgba(229, 96, 77, .14); border-color: rgba( .conv-source-filter { display: flex; align-items: center; gap: 8px; margin: 8px 12px; } .conv-source-filter label { color: var(--muted); font-size: 11px; } .conv-source-filter select { min-width: 0; flex: 1; font-size: 12px; padding: 6px 8px; } +/* Provider metadata is untrusted text; long aliases and ids must fit narrow user drawers. */ +.composio-table-wrap { overflow-x: auto; margin-top: 10px; } +.composio-table { width: 100%; min-width: 340px; border-collapse: collapse; font-size: 12px; } +.composio-table th, .composio-table td { text-align: left; vertical-align: top; padding: 8px; border-bottom: 1px solid var(--line); overflow-wrap: anywhere; } +.composio-table th, .composio-table td:first-child, .composio-table td:last-child { white-space: nowrap; } +.composio-table small { color: var(--muted); } +.ud-composio-inventory .card-foot, .ch-composio-inventory .card-foot { flex-wrap: wrap; gap: 8px; } diff --git a/src/gateway/composio-inventory.js b/src/gateway/composio-inventory.js new file mode 100644 index 00000000..20810cd5 --- /dev/null +++ b/src/gateway/composio-inventory.js @@ -0,0 +1,248 @@ +// Read-only account metadata for the admin UI. Never creates SDK sessions, connected accounts, +// or authorization links. Consumer SEARCH session IDs correlate read-only discovery requests. +import { Composio } from "@composio/core"; +import { getComposioMode, getComposioSdkApiKey, getDefaultComposioToken } from "../config/settings.js"; +import { requireComposioSdkEntitlement } from "../ee/composio-entitlement.js"; +import { composioIdentity } from "../ee/composio-sessions.js"; +import { connectRemoteClient } from "../mcp/remote-relay.js"; +import { publicPinnedFetch } from "../mcp/public-fetch.js"; +import { composioUrl } from "./mcp-catalog.js"; +import { resolveComposioConnections } from "./run-integrations.js"; + +const MAX_PAGES = 50; +const MAX_APPS = 200; +const MAX_ACCOUNTS = 10_000; +const SLUG = /^[a-z0-9][a-z0-9_-]{0,127}$/; +const FAILURE = "Composio connections could not be loaded. Try refreshing."; + +function text(value, limit = 256) { + return typeof value === "string" ? value.replace(/[\u0000-\u001f\u007f]/g, " ").slice(0, limit).trim() : ""; +} + +function safeAccount(app, value) { + if (!value || typeof value !== "object") return null; + const id = text(value.id); + if (!id) return null; + // user_info may contain arbitrary business records. Only these scalar identity fields escape. + const info = value.user_info; + const account = text(value.state?.val?.displayName) || + text(info?.email) || text(info?.emailAddress) || text(info?.user?.emailAddress) || + text(info?.name) || text(info?.displayName) || text(info?.user?.displayName); + return { + app, + id, + alias: text(value.alias), + account, + status: value.isDisabled === true || value.is_disabled === true ? "DISABLED" : text(value.status, 40) || "UNKNOWN", + isDefault: value.is_default === true || value.isDefault === true, + accountType: text(value.account_type ?? value.experimental?.accountType ?? value.experimental?.account_type, 40), + }; +} + +function addAccounts(target, app, values) { + if (!Array.isArray(values)) throw new Error("invalid accounts"); + for (const value of values) { + const account = safeAccount(app, value); + if (!account) throw new Error("invalid account"); + target.set(`${app}\0${account.id}`, account); + if (target.size > MAX_ACCOUNTS) throw new Error("account limit"); + } +} + +async function bounded(work, timeoutMs, onLate) { + const controller = new AbortController(); + let timer; + let expired = false; + const pending = Promise.resolve().then(() => work(controller.signal)).then((value) => { + if (expired) onLate?.(value); + return value; + }); + try { + return await Promise.race([pending, new Promise((_, reject) => { + timer = setTimeout(() => { + expired = true; + controller.abort(); + reject(new Error("inventory timeout")); + }, timeoutMs); + })]); + } finally { + clearTimeout(timer); + } +} + +function publishedApps(description) { + const match = String(description || "").match(/User has manually connected the apps:\s*([^\n]*?)\.\s*Prefer these apps/i); + if (!match) return null; + const slugs = match[1].split(",").map((s) => s.trim()); + if (slugs.length === 1 && !slugs[0]) return []; + if (slugs.length > MAX_APPS || slugs.some((slug) => !SLUG.test(slug))) return null; + return [...new Set(slugs)]; +} + +function searchPayload(response) { + // MCP servers can return JSON text, structuredContent, or nested data envelopes. + const queue = [response]; + for (let i = 0; i < queue.length && i < 30; i++) { + const item = queue[i]; + if (!item || typeof item !== "object") continue; + if (item.isError === true || item.successful === false || item.success === false) throw new Error("search failed"); + if (Array.isArray(item.toolkit_connection_statuses)) return item.toolkit_connection_statuses; + for (const key of ["structuredContent", "data", "result", "response"]) if (item[key]) queue.push(item[key]); + for (const block of item.content || []) { + if (block.type === "text") { + try { queue.push(JSON.parse(block.text)); } catch { /* Other text is not inventory. */ } + } + } + } + throw new Error("missing inventory"); +} + +async function personalAccounts({ token, url, connect, timeoutMs, totalTimeoutMs }) { + let client; + const accounts = new Map(); + const deadline = Date.now() + totalTimeoutMs; + let deadlineExpired = false; + const request = (work, onLate) => { + const remaining = deadline - Date.now(); + if (deadlineExpired || remaining <= 0) throw new Error("inventory deadline"); + return bounded(work, Math.min(timeoutMs, remaining), onLate).catch((error) => { + if (remaining <= timeoutMs && error.message === "inventory timeout") deadlineExpired = true; + throw error; + }); + }; + try { + client = await request((signal) => connect({ url, headers: { "x-consumer-api-key": token }, publicOnly: true, retryDelaysMs: [], + // The transport's own signal and the connect deadline both abort the pinned HTTPS dial. + fetch: (input, init = {}) => publicPinnedFetch(input, { + ...init, signal: init.signal ? AbortSignal.any([init.signal, signal]) : signal, + }), + }), + (late) => Promise.resolve(late.close()).catch(() => {})); + let cursor; + let searchTool; + const seen = new Set(); + for (let page = 0; ; page++) { + if (page >= MAX_PAGES) throw new Error("tool page limit"); + const response = await request((signal) => client.listTools(cursor ? { cursor } : {}, { signal, timeout: timeoutMs })); + if (!Array.isArray(response?.tools)) throw new Error("invalid tool list"); + searchTool ||= response.tools.find((tool) => tool.name === "COMPOSIO_SEARCH_TOOLS"); + cursor = response.nextCursor; + if (!cursor) break; + if (typeof cursor !== "string" || seen.has(cursor)) throw new Error("repeated tool cursor"); + seen.add(cursor); + } + const apps = searchTool && publishedApps(searchTool.description); + if (!apps) return { state: "unavailable", message: "Composio does not publish a connected application list for this connection.", accounts: [] }; + let unresolved = 0; + let next = 0; + await Promise.all(Array.from({ length: Math.min(4, apps.length) }, async () => { + while (next < apps.length) { + const app = apps[next++]; + try { + const response = await request((signal) => client.callTool({ + name: "COMPOSIO_SEARCH_TOOLS", + arguments: { queries: [{ use_case: `Inspect ${app} connected account status and aliases` }], search_strategy: "tool_search", session: { generate_id: true } }, + }, undefined, { signal, timeout: timeoutMs })); + const statuses = searchPayload(response); + const status = statuses.find((entry) => entry?.toolkit === app); + if (!status || !Array.isArray(status.accounts) || !status.accounts.length) throw new Error("unresolved app"); + addAccounts(accounts, app, status.accounts); + } catch { unresolved++; } + } + })); + return { + state: unresolved ? "partial" : "ready", + message: unresolved + ? "Some application accounts could not be loaded. Showing the accounts Composio reported." + : "Applications and accounts reported by Composio. Inactive or unpublished connections may be omitted.", + accounts: [...accounts.values()], + }; + } finally { + if (client) await bounded(() => client.close(), Math.min(timeoutMs, 2_000)).catch(() => {}); + } +} + +async function sdkAccounts({ kind, id, config, sdkClient, timeoutMs, totalTimeoutMs }) { + const identity = composioIdentity({ workspaceId: config.workspaceId, kind, id }); + const client = sdkClient || new Composio({ + apiKey: config.sdkApiKey, userApiKey: null, orgApiKey: null, allowTracking: false, + }); + const accounts = new Map(); + const seen = new Set(); + const deadline = Date.now() + totalTimeoutMs; + let cursor; + for (let page = 0; ; page++) { + if (page >= MAX_PAGES) throw new Error("account page limit"); + const remaining = deadline - Date.now(); + if (remaining <= 0) throw new Error("inventory deadline"); + const result = await bounded((signal) => client.connectedAccounts.list({ + userIds: [identity], accountType: "ALL", limit: 100, ...(cursor ? { cursor } : {}), + }, { signal }), Math.min(timeoutMs, remaining)); + if (!Array.isArray(result?.items)) throw new Error("invalid accounts"); + for (const item of result.items) { + // Some SDK versions omit the owner field. If present, it must match our exact filter. + for (const field of ["userId", "user_id"]) { + if (item && Object.hasOwn(item, field) && item[field] !== identity) throw new Error("account identity mismatch"); + } + const app = item?.toolkit?.slug; + if (typeof app !== "string" || !SLUG.test(app)) throw new Error("invalid app"); + addAccounts(accounts, app, [item]); + } + cursor = result.nextCursor; + if (!cursor) break; + if (typeof cursor !== "string" || seen.has(cursor)) throw new Error("repeated account cursor"); + seen.add(cursor); + } + return { state: "ready", message: "Connected accounts for this Composio identity.", accounts: [...accounts.values()] }; +} + +/** Configuration and network clients can be injected without touching gateway state in tests. */ +export async function loadComposioInventory({ kind, id, record = {} } = {}, options = {}) { + const config = options.config || { + mode: getComposioMode(), defaultToken: getDefaultComposioToken(), sdkApiKey: getComposioSdkApiKey(), + url: composioUrl(), workspaceId: process.env.CG_SLACK_TEAM_ID || "", + }; + const mode = config.mode === "sdk" ? "sdk" : "personal"; + const checkedAt = new Date((options.now || Date.now)()).toISOString(); + const timeoutMs = Math.max(10, Math.min(30_000, options.timeoutMs || 15_000)); + const totalTimeoutMs = Math.max(10, Math.min(60_000, options.totalTimeoutMs || 60_000)); + const isDM = Boolean(record.isDM || record.type === "im"); + let source = mode === "sdk" ? `sdk-${kind}` : "none"; + const secrets = [...new Set([record.composioToken, config.sdkApiKey, config.defaultToken])] + .filter((value) => typeof value === "string" && value.length); + const scrub = (value) => { + if (typeof value === "string") return secrets.reduce((safe, secret) => safe.split(secret).join("[redacted]"), value); + if (Array.isArray(value)) return value.map(scrub); + if (value && typeof value === "object") return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, scrub(entry)])); + return value; + }; + const answer = (result) => scrub({ mode, source, ...result, checkedAt }); + try { + if (!["user", "channel"].includes(kind)) throw new Error("invalid kind"); + if (kind === "channel" && isDM) { + source = "none-dm"; + return answer({ state: "unconfigured", message: "Direct messages use the author's personal connections.", accounts: [] }); + } + if (mode === "sdk") { + try { await (options.requireEntitlement || requireComposioSdkEntitlement)(); } + catch { return answer({ state: "unavailable", message: "Composio SDK connections require an active Enterprise license.", accounts: [] }); } + if (!config.sdkApiKey) return answer({ state: "unconfigured", message: "The organization Composio SDK key is not configured.", accounts: [] }); + if (typeof config.workspaceId !== "string" || !config.workspaceId.trim()) { + return answer({ state: "unavailable", message: "The Slack workspace identity required for Composio SDK connections is not available.", accounts: [] }); + } + return answer(await sdkAccounts({ kind, id, config, sdkClient: options.sdkClient, timeoutMs, totalTimeoutMs })); + } + const resolved = resolveComposioConnections({ + clean: false, userToken: kind === "user" ? record.composioToken : "", + channelToken: kind === "channel" ? record.composioToken : "", defaultToken: config.defaultToken, + noOrg: Boolean(record.noDefaultTokens), isDM, + }); + const selected = kind === "user" ? resolved.user : resolved.shared; + source = selected.source; + if (!selected.token) return answer({ state: "unconfigured", message: "No Composio connection is configured for this identity.", accounts: [] }); + return answer(await personalAccounts({ token: selected.token, url: config.url || composioUrl(), + connect: options.connect || connectRemoteClient, timeoutMs, totalTimeoutMs })); + } catch { + return answer({ state: "unavailable", message: FAILURE, accounts: [] }); + } +} diff --git a/src/web/routes/channels.js b/src/web/routes/channels.js index f3090e6e..bfb8fd17 100644 --- a/src/web/routes/channels.js +++ b/src/web/routes/channels.js @@ -72,6 +72,7 @@ import { assertValidEgressRawHosts } from "../../gateway/egress/catalog-rules.js import { getChannelVpnStatus, setChannelVpnEnabled } from "../../gateway/channel-vpn-control.js"; import { clearThreadRuntimeOverrides } from "../../gateway/thread-engine.js"; import { isAuthenticated } from "../auth.js"; +import { loadComposioInventory } from "../../gateway/composio-inventory.js"; const WEB_ADMIN_ACTOR = "admin UI"; @@ -116,6 +117,7 @@ export function createChannelsRouter({ testMakeToolbox = listMakeToolboxTools, getVpnStatus = getChannelVpnStatus, setVpnEnabled = setChannelVpnEnabled, + composioInventory = loadComposioInventory, } = {}) { const router = Router(); @@ -125,6 +127,18 @@ export function createChannelsRouter({ return entry; }; + // Resolve only this conversation's shared identity; personal accounts stay on user pages. + router.get("/channels/:channelId/composio-connections", async (req, res, next) => { + res.set("Cache-Control", "no-store"); + try { + const entry = await registeredChannel(req, res); + if (!entry) return; + const meta = await getChannelMeta(entry.slug); + const record = { ...entry, ...meta, isDM: Boolean(entry.isDM || meta?.isDM || entry.type === "im" || meta?.type === "im") }; + res.json(await composioInventory({ kind: "channel", id: req.params.channelId, record })); + } catch (error) { next(error); } + }); + router.get("/channels/:channelId/codex-login", async (req, res, next) => { try { if (!(await registeredChannel(req, res))) return; diff --git a/src/web/routes/users.js b/src/web/routes/users.js index 91b6dcdf..eb2b1ff1 100644 --- a/src/web/routes/users.js +++ b/src/web/routes/users.js @@ -14,6 +14,7 @@ import { listUserEnv, patchUserEnv } from "../../config/scoped-env.js"; import { listEnvVars, normalizeEnvName, swapRuleFieldsFrom } from "../../config/channel-env.js"; import { listCustomMcps } from "../../gateway/custom-mcps.js"; import { listUserCustomMcps, removeUserCustomMcp, saveUserCustomMcp } from "../../gateway/custom-mcp-store.js"; +import { loadComposioInventory } from "../../gateway/composio-inventory.js"; // The user listing is built field by field, never `...u`. That is the property that matters here: // a stored user record can carry secrets this file has never heard of — a personal token from an @@ -78,9 +79,19 @@ export function filterUsersForSearch(users, query) { })); } -export function createUsersRouter() { +export function createUsersRouter({ composioInventory = loadComposioInventory } = {}) { const router = Router(); + // Admin-authenticated metadata only. A person's inventory never falls back to shared keys. + router.get("/users/:userId/composio-connections", async (req, res, next) => { + res.set("Cache-Control", "no-store"); + try { + const record = await getUser(req.params.userId); + if (!record) return res.status(404).json({ error: "unknown user" }); + res.json(await composioInventory({ kind: "user", id: req.params.userId, record })); + } catch (error) { next(error); } + }); + // ── Users ───────────────────────────────────────────────────────────────── router.get("/users", async (req, res, next) => { try { diff --git a/test/composio-inventory-routes.test.js b/test/composio-inventory-routes.test.js new file mode 100644 index 00000000..ec85b316 --- /dev/null +++ b/test/composio-inventory-routes.test.js @@ -0,0 +1,108 @@ +import test, { after } from "node:test"; +import assert from "node:assert/strict"; +import express from "express"; +import { ensureTestEnv } from "./helpers.js"; + +ensureTestEnv(); +const { setUser, upsertChannelEntry, saveChannelMeta, getUser } = await import("../src/config/store.js"); +const { createUsersRouter } = await import("../src/web/routes/users.js"); +const { createChannelsRouter } = await import("../src/web/routes/channels.js"); +const { saveSettings } = await import("../src/config/settings.js"); +const { noPasswordLockdown, authMiddleware, handleLogin } = await import("../src/web/auth.js"); + +saveSettings({ adminPassword: "inventory-test-password", apiKey: "inventory-test-run-key" }); +await setUser("U_INVENTORY", { name: "Inventory user", composioToken: "private-user-token" }); +await setUser("U_OTHER", { name: "Other user", composioToken: "other-private-user-token" }); +const channel = await upsertChannelEntry("C_INVENTORY", { name: "inventory-fixture", type: "channel", isDM: false }); +await saveChannelMeta(channel.slug, { channelId: "C_INVENTORY", composioToken: "private-channel-token", noDefaultTokens: true }); +for (const [id, entryDM, metaDM, entryType, metaType] of [ + ["D_ENTRY_DM", true, false, "im", "channel"], + ["D_META_DM", false, true, "channel", "channel"], + ["D_ENTRY_TYPE", false, false, "im", "channel"], + ["D_META_TYPE", false, false, "channel", "im"], +]) { + const entry = await upsertChannelEntry(id, { name: id.toLowerCase(), type: entryType, isDM: entryDM }); + await saveChannelMeta(entry.slug, { channelId: id, type: metaType, isDM: metaDM, composioToken: "dm-must-stay-disabled" }); +} + +const calls = []; +const inventory = async (selection) => { + calls.push(selection); + return { state: "ready", source: selection.kind, accounts: [{ app: "gmail", id: `ca-${selection.id}` }] }; +}; +const app = express(); +app.use(express.json()); +app.post("/api/login", handleLogin); +app.use(noPasswordLockdown, authMiddleware); +app.use("/api", createUsersRouter({ composioInventory: inventory })); +app.use("/api", createChannelsRouter({ composioInventory: inventory })); +const server = await new Promise((resolve) => { + const running = app.listen(0, "127.0.0.1", () => resolve(running)); +}); +after(() => server.close()); +const base = `http://127.0.0.1:${server.address().port}`; +const login = await fetch(`${base}/api/login`, { + method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ password: "inventory-test-password" }), +}); +assert.equal(login.status, 200); +const cookie = login.headers.get("set-cookie").split(";")[0]; +async function get(path, headers = { cookie }) { + const response = await fetch(`${base}/api${path}`, { headers }); + return { response, json: await response.json() }; +} + +test("user inventory is scoped to the selected user's stored record and never returns stored credentials", async () => { + const before = await getUser("U_INVENTORY"); + const { response, json } = await get("/users/U_INVENTORY/composio-connections"); + assert.equal(response.status, 200); + assert.equal(response.headers.get("cache-control"), "no-store"); + assert.deepEqual(json.accounts, [{ app: "gmail", id: "ca-U_INVENTORY" }]); + assert.deepEqual(calls.at(-1), { kind: "user", id: "U_INVENTORY", record: before }); + assert.doesNotMatch(JSON.stringify(json), /private-user-token|other-private-user-token|composioToken/); + assert.deepEqual(await getUser("U_INVENTORY"), before, "viewing inventory does not mutate the user"); +}); + +test("channel inventory merges its entry with current metadata and retains policy scope", async () => { + const { response, json } = await get("/channels/C_INVENTORY/composio-connections?refresh=1"); + assert.equal(response.status, 200); + assert.equal(response.headers.get("cache-control"), "no-store"); + const selection = calls.at(-1); + assert.equal(selection.kind, "channel"); + assert.equal(selection.id, "C_INVENTORY"); + assert.equal(selection.record.slug, channel.slug); + assert.equal(selection.record.composioToken, "private-channel-token"); + assert.equal(selection.record.noDefaultTokens, true); + assert.equal(selection.record.isDM, false); + assert.doesNotMatch(JSON.stringify(json), /private-channel-token|composioToken/); +}); + +test("DM identity remains disabled when either entry or metadata supplies the DM marker", async () => { + for (const id of ["D_ENTRY_DM", "D_META_DM", "D_ENTRY_TYPE", "D_META_TYPE"]) { + const { response } = await get(`/channels/${id}/composio-connections`); + assert.equal(response.status, 200); + assert.equal(calls.at(-1).record.isDM, true, `${id} preserves the direct-message boundary`); + } +}); + +test("unknown users and channels return 404 without invoking any provider", async () => { + const count = calls.length; + for (const path of ["/users/U_UNKNOWN/composio-connections", "/channels/C_UNKNOWN/composio-connections"]) { + const { response, json } = await get(path); + assert.equal(response.status, 404); + assert.equal(response.headers.get("cache-control"), "no-store"); + assert.match(json.error, /unknown/); + } + assert.equal(calls.length, count); +}); + +test("both inventories require the admin session; the run API key does not grant access", async () => { + const count = calls.length; + for (const path of ["/users/U_INVENTORY/composio-connections", "/channels/C_INVENTORY/composio-connections"]) { + for (const headers of [{}, { "x-api-key": "inventory-test-run-key" }, { authorization: "Bearer inventory-test-run-key" }]) { + const { response, json } = await get(path, headers); + assert.equal(response.status, 401); + assert.doesNotMatch(JSON.stringify(json), /ca-U_INVENTORY|private-user-token|private-channel-token/); + } + } + assert.equal(calls.length, count, "authentication rejects before provider lookup"); +}); diff --git a/test/composio-inventory-ui.test.js b/test/composio-inventory-ui.test.js new file mode 100644 index 00000000..dc529dc6 --- /dev/null +++ b/test/composio-inventory-ui.test.js @@ -0,0 +1,152 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { composioInventoryMarkup, mountComposioInventory } from "../public/admin-composio.js"; + +const ready = (accounts = [], extra = {}) => ({ + state: "ready", source: "user", mode: "personal", message: "Reported by provider", accounts, ...extra, +}); + +// Model the DOM property relevant to switching records: replacing innerHTML creates new child +// nodes. An old request may finish, but its retained child must no longer be the visible node. +function rootFixture() { + const root = {}; + Object.defineProperty(root, "innerHTML", { set(value) { + this.markup = value; + this.button = { disabled: false, addEventListener(_event, fn) { this.click = fn; } }; + this.output = { innerHTML: "", textContent: "" }; + } }); + root.querySelector = (selector) => selector === ".composio-refresh" ? root.button : root.output; + return root; +} + +function deferred() { + let resolve, reject; + const promise = new Promise((yes, no) => { resolve = yes; reject = no; }); + return { promise, resolve, reject }; +} + +test("inventory shows every account across applications, including default and disabled accounts", () => { + const markup = composioInventoryMarkup(ready([ + { app: "gmail", id: "ca-work", alias: "Work", account: "work@example.test", status: "ACTIVE", isDefault: true }, + { app: "gmail", id: "ca-personal", account: "personal@example.test", status: "DISABLED", accountType: "PERSONAL" }, + { app: "slack", id: "ca-team", alias: "Team", status: "ACTIVE" }, + ])); + for (const label of ["2 apps · 3 accounts", "Work", "work@example.test", "personal@example.test", "Team", "ca-work", "DISABLED", "PERSONAL", "default"]) { + assert.ok(markup.includes(label), `renders ${label}`); + } + assert.equal((markup.match(//g) || []).length, 4, "one header and all three account rows"); +}); + +test("provider labels, identities, statuses and messages are escaped in HTML", () => { + const payload = '&\'malicious'; + const markup = composioInventoryMarkup(ready([ + { app: payload, id: payload, alias: payload, account: payload, status: payload, accountType: payload }, + ], { message: payload, source: payload })); + assert.doesNotMatch(markup, / { + assert.match(composioInventoryMarkup(ready()), /No accounts reported/); + for (const state of ["unavailable", "unconfigured", "partial"]) { + const markup = composioInventoryMarkup(ready([], { state, message: `${state} explanation` })); + assert.match(markup, new RegExp(`${state} explanation`)); + assert.doesNotMatch(markup, /No accounts reported/, `${state} is not a confirmed empty account list`); + } + const partial = composioInventoryMarkup(ready([{ app: "gmail", id: "ca-one" }], { state: "partial", message: "Some applications could not be loaded" })); + assert.match(partial, /Some applications could not be loaded/); + assert.match(partial, /ca-one/); +}); + +test("source attribution distinguishes personal, channel, organization defaults and direct messages", () => { + for (const [source, label] of [ + ["user", "Personal connections · composio-user"], + ["channel", "Channel connections · composio-agent"], + ["org", "Organization default · composio-agent"], + ["sdk-user", "Personal connections · composio-user · SDK"], + ["sdk-channel", "Channel connections · composio-agent · SDK"], + ["none-dm", "Direct message · personal connections only"], + ]) assert.ok(composioInventoryMarkup(ready([], { source })).includes(label)); +}); + +test("loading is lazy when requested and concurrent opens do not duplicate provider calls", async () => { + const root = rootFixture(), pending = deferred(), calls = []; + const load = mountComposioInventory(root, "/api/users/U_A/composio-connections", { + autoLoad: false, request: (url) => { calls.push(url); return pending.promise; }, + }); + assert.deepEqual(calls, []); + assert.match(root.markup, /aria-live="polite"/); + const running = load(); + assert.equal(root.button.disabled, true); + assert.match(root.output.textContent, /Loading/); + await load(); + assert.equal(calls.length, 1); + pending.resolve(ready()); + await running; + assert.equal(root.button.disabled, false); + await load(); + assert.equal(calls.length, 1, "reopening already loaded section reuses its result"); +}); + +test("saving configuration during a lookup queues a fresh request and discards the previous identity's response", async () => { + const root = rootFixture(), previous = deferred(), current = deferred(), calls = []; + const load = mountComposioInventory(root, "/api/channels/C_A/composio-connections", { + autoLoad: false, request: (url) => { + calls.push(url); + return calls.length === 1 ? previous.promise : current.promise; + }, + }); + const running = load(); + await load(true); + await load(true); + assert.equal(calls.length, 1, "queued changes do not overlap provider lookups"); + previous.resolve(ready([{ app: "gmail", id: "ca-old-identity" }])); + await running; + assert.equal(calls.length, 2, "forced refresh survives the first request finishing"); + assert.equal(calls[1], "/api/channels/C_A/composio-connections?refresh=1"); + assert.doesNotMatch(root.output.innerHTML, /ca-old-identity/); + assert.equal(root.button.disabled, true, "loading stays visible while the new lookup runs"); + current.resolve(ready([{ app: "slack", id: "ca-new-identity" }])); + await new Promise((resolve) => setImmediate(resolve)); + assert.match(root.output.innerHTML, /ca-new-identity/); + assert.equal(root.button.disabled, false); +}); + +test("refresh bypasses the loaded guard and retries transport failures without displaying raw errors", async () => { + const root = rootFixture(), calls = []; + const load = mountComposioInventory(root, "/api/channels/C_A/composio-connections", { + autoLoad: false, request: async (url) => { + calls.push(url); + if (calls.length === 1) throw new Error("PRIVATE_PROVIDER_CREDENTIAL"); + return ready([{ app: "gmail", id: "ca-recovered" }]); + }, + }); + await load(); + assert.match(root.output.textContent, /Could not load/); + assert.doesNotMatch(root.output.textContent, /PRIVATE_PROVIDER_CREDENTIAL/); + assert.equal(root.button.disabled, false); + await root.button.click(); + assert.match(root.output.innerHTML, /ca-recovered/); + assert.deepEqual(calls, ["/api/channels/C_A/composio-connections", "/api/channels/C_A/composio-connections?refresh=1"]); + await load(true); + assert.equal(calls.length, 3); +}); + +test("switching selected records isolates a late response from the newly visible inventory", async () => { + const root = rootFixture(), pending = deferred(); + const loadFirst = mountComposioInventory(root, "/api/users/U_FIRST/composio-connections", { + autoLoad: false, request: () => pending.promise, + }); + const first = loadFirst(); + const firstOutput = root.output; + const loadSecond = mountComposioInventory(root, "/api/users/U_SECOND/composio-connections", { + autoLoad: false, request: async () => ready([{ app: "gmail", id: "ca-second" }]), + }); + await loadSecond(); + pending.resolve(ready([{ app: "slack", id: "ca-first-private" }])); + await first; + assert.notEqual(firstOutput, root.output); + assert.match(root.output.innerHTML, /ca-second/); + assert.doesNotMatch(root.output.innerHTML, /ca-first-private/); +}); diff --git a/test/composio-inventory.test.js b/test/composio-inventory.test.js new file mode 100644 index 00000000..aef5e78b --- /dev/null +++ b/test/composio-inventory.test.js @@ -0,0 +1,289 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { ensureTestEnv } from "./helpers.js"; + +ensureTestEnv(); +const { loadComposioInventory } = await import("../src/gateway/composio-inventory.js"); +const config = { mode: "personal", defaultToken: "org-secret", url: "https://connect.composio.dev/mcp", workspaceId: "T123" }; + +function fixture({ apps = ["gmail", "slack"], search, pages } = {}) { + const calls = []; + let closed = 0; + const client = { + async listTools(params) { + calls.push({ method: "list", params }); + if (pages) return pages(params); + return { tools: [{ name: "COMPOSIO_SEARCH_TOOLS", description: `User has manually connected the apps: ${apps.join(", ")}. Prefer these apps when intent is unclear.` }] }; + }, + async callTool(params, _schema, options) { + calls.push({ method: "call", params, timeout: options.timeout }); + const app = params.arguments.queries[0].use_case.split(" ")[1]; + if (search) return search(app); + return { content: [{ type: "text", text: JSON.stringify({ data: { successful: true, data: { + toolkit_connection_statuses: [{ toolkit: app, has_active_connection: true, accounts: [ + { id: `${app}-1`, alias: "work", status: "ACTIVE", is_default: true, account_type: "PRIVATE", user_info: { email: "one@example.test", token: "provider-secret", data: ["business-record"] } }, + { id: `${app}-2`, alias: "personal", status: "EXPIRED", user_info: { user: { emailAddress: "two@example.test" } } }, + ] }, { toolkit: "unrelated", accounts: [{ id: "never-show" }] }], + } } }) }] }; + }, + async close() { closed++; }, + }; + return { calls, client, closed: () => closed, connect: async (args) => { calls.push({ method: "connect", args }); return client; } }; +} + +test("personal inventory uses all published applications and accounts, exposes only safe metadata and closes", async () => { + const remote = fixture(); + const result = await loadComposioInventory({ kind: "user", id: "U123", record: { composioToken: "user-secret", cleanMode: true } }, { config, connect: remote.connect, now: () => 0 }); + assert.equal(result.state, "ready"); + assert.equal(result.source, "user"); + assert.equal(result.checkedAt, "1970-01-01T00:00:00.000Z"); + assert.equal(result.accounts.length, 4); + assert.deepEqual(result.accounts[0], { app: "gmail", id: "gmail-1", alias: "work", account: "one@example.test", status: "ACTIVE", isDefault: true, accountType: "PRIVATE" }); + assert.equal(result.accounts[1].account, "two@example.test"); + assert.doesNotMatch(JSON.stringify(result), /secret|business-record|never-show|user_info/); + assert.equal(remote.calls[0].args.headers["x-consumer-api-key"], "user-secret"); + assert.equal(remote.calls[0].args.publicOnly, true); + assert.ok(remote.calls.filter((c) => c.method === "call").every((c) => c.params.name === "COMPOSIO_SEARCH_TOOLS")); + assert.ok(remote.calls.filter((c) => c.method === "call").every((c) => c.params.arguments.session.generate_id === true)); + assert.equal(remote.closed(), 1); +}); + +test("personal scope matches runtime user-only, channel override, org fallback, no-default and DM rules", async () => { + for (const [kind, record, expected, token] of [ + ["user", {}, "none", null], + ["channel", { composioToken: "channel-secret" }, "channel", "channel-secret"], + ["channel", {}, "org", "org-secret"], + ["channel", { noDefaultTokens: true }, "none-no-org-default", null], + ["channel", { isDM: true, composioToken: "channel-secret" }, "none-dm", null], + ["channel", { type: "im" }, "none-dm", null], + ]) { + const remote = fixture({ apps: [] }); + const result = await loadComposioInventory({ kind, id: "C123", record }, { config, connect: remote.connect }); + assert.equal(result.source, expected); + assert.equal(result.state, token ? "ready" : "unconfigured"); + assert.equal(remote.calls.find((c) => c.method === "connect")?.args.headers["x-consumer-api-key"] ?? null, token); + } +}); + +test("tools/list pagination locates the publication and rejects repeated cursor", async () => { + const remote = fixture({ pages: ({ cursor }) => cursor + ? { tools: [{ name: "COMPOSIO_SEARCH_TOOLS", description: "User has manually connected the apps: gmail. Prefer these apps when intent is unclear." }] } + : { tools: [], nextCursor: "page2" } }); + assert.equal((await loadComposioInventory({ kind: "user", record: { composioToken: "key" } }, { config, connect: remote.connect })).state, "ready"); + assert.deepEqual(remote.calls.filter((c) => c.method === "list").map((c) => c.params), [{}, { cursor: "page2" }]); + const looping = fixture({ pages: () => ({ tools: [], nextCursor: "same" }) }); + assert.equal((await loadComposioInventory({ kind: "user", record: { composioToken: "key" } }, { config, connect: looping.connect })).state, "unavailable"); + assert.equal(looping.closed(), 1); +}); + +test("truthy legacy DM and no-default flags suppress shared inventory exactly as runtime does", async () => { + for (const [record, source] of [[{ noDefaultTokens: "legacy-true" }, "none-no-org-default"], [{ isDM: 1, composioToken: "channel-secret" }, "none-dm"]]) { + const result = await loadComposioInventory({ kind: "channel", id: "C123", record }, { + config, connect: async () => assert.fail("suppressed shared credentials must never be used"), + }); + assert.equal(result.state, "unconfigured"); + assert.equal(result.source, source); + assert.deepEqual(result.accounts, []); + } +}); + +test("missing or invalid provider publication is unavailable instead of a false empty inventory", async () => { + for (const description of ["search tools", "User has manually connected the apps: gmail, https://secret.invalid. Prefer these apps when intent is unclear."]) { + const remote = fixture({ pages: () => ({ tools: [{ name: "COMPOSIO_SEARCH_TOOLS", description }] }) }); + const result = await loadComposioInventory({ kind: "user", record: { composioToken: "key" } }, { config, connect: remote.connect }); + assert.equal(result.state, "unavailable"); + assert.deepEqual(result.accounts, []); + assert.equal(remote.calls.filter((c) => c.method === "call").length, 0); + assert.equal(remote.closed(), 1); + } +}); + +test("one unresolved toolkit keeps successful metadata and returns partial without provider errors", async () => { + const remote = fixture({ search: (app) => { + if (app === "gmail") throw new Error("Authorization: user-secret"); + return { structuredContent: { data: { toolkit_connection_statuses: [{ toolkit: "slack", accounts: [{ id: "slack-1", status: "ACTIVE" }] }] } } }; + } }); + const result = await loadComposioInventory({ kind: "user", record: { composioToken: "key" } }, { config, connect: remote.connect }); + assert.equal(result.state, "partial"); + assert.equal(result.accounts.length, 1); + assert.doesNotMatch(JSON.stringify(result), /Authorization|user-secret/); + assert.equal(remote.closed(), 1); +}); + +test("provider failures are generic and always close the established connection", async () => { + const remote = fixture({ pages: () => { throw new Error("credential-in-error"); } }); + const result = await loadComposioInventory({ kind: "user", record: { composioToken: "key" } }, { config, connect: remote.connect }); + assert.equal(result.state, "unavailable"); + assert.doesNotMatch(JSON.stringify(result), /credential-in-error/); + assert.equal(remote.closed(), 1); +}); + +test("consumer account requests are bounded to four concurrent calls", async () => { + let live = 0; + let max = 0; + const remote = fixture({ apps: Array.from({ length: 9 }, (_, i) => `app${i}`), search: async (app) => { + max = Math.max(max, ++live); + await new Promise((resolve) => setTimeout(resolve, 2)); + live--; + return { structuredContent: { toolkit_connection_statuses: [{ toolkit: app, accounts: [{ id: app }] }] } }; + } }); + const result = await loadComposioInventory({ kind: "user", record: { composioToken: "key" } }, { config, connect: remote.connect }); + assert.equal(result.accounts.length, 9); + assert.equal(max, 4); +}); + +test("SDK reads all pages using the exact stable user identity without creating sessions", async () => { + const calls = []; + let checked = 0; + const sdkClient = { connectedAccounts: { list: async (query) => { + assert.equal(checked, 1); + calls.push(query); + return { items: [{ id: query.cursor ? "ca2" : "ca1", toolkit: { slug: "gmail" }, alias: "work", status: "ACTIVE", isDisabled: Boolean(query.cursor), + state: { val: { displayName: "Account Name", access_token: "private-token" } }, data: { token: "private-token" } }], nextCursor: query.cursor ? null : "page2" }; + } } }; + const result = await loadComposioInventory({ kind: "user", id: "U123" }, { + config: { ...config, mode: "sdk", sdkApiKey: "sdk-secret" }, sdkClient, + requireEntitlement: () => checked++, + }); + assert.equal(result.state, "ready"); + assert.equal(result.source, "sdk-user"); + assert.deepEqual(calls, [ + { userIds: ["slack:T123:user:U123"], accountType: "ALL", limit: 100 }, + { userIds: ["slack:T123:user:U123"], accountType: "ALL", limit: 100, cursor: "page2" }, + ]); + assert.equal(result.accounts.length, 2); + assert.equal(result.accounts[0].account, "Account Name"); + assert.equal(result.accounts[1].status, "DISABLED"); + assert.doesNotMatch(JSON.stringify(result), /sdk-secret|private-token|access_token/); +}); + +test("SDK entitlement denial prevents every client call", async () => { + let calls = 0; + const result = await loadComposioInventory({ kind: "channel", id: "C123" }, { + config: { ...config, mode: "sdk", sdkApiKey: "secret" }, + sdkClient: { connectedAccounts: { list: async () => { calls++; } } }, + requireEntitlement: () => { throw new Error("private-entitlement-error"); }, + }); + assert.equal(result.state, "unavailable"); + assert.equal(calls, 0); + assert.match(result.message, /Enterprise license/); + assert.doesNotMatch(JSON.stringify(result), /private-entitlement-error/); +}); + +test("SDK channel identity is distinct and repeated account cursors fail closed", async () => { + const calls = []; + const result = await loadComposioInventory({ kind: "channel", id: "C123" }, { + config: { ...config, mode: "sdk", sdkApiKey: "secret" }, requireEntitlement() {}, + sdkClient: { connectedAccounts: { list: async (query) => { + calls.push(query); + return { items: [{ id: "ca1", toolkit: { slug: "gmail" } }], nextCursor: "repeat" }; + } } }, + }); + assert.equal(result.state, "unavailable"); + assert.deepEqual(result.accounts, []); + assert.ok(calls.every((call) => call.userIds[0] === "slack:T123:channel:C123")); + assert.equal(calls.length, 2); +}); + +test("SDK empty inventory is ready; missing key and DM inventory never touch the client", async () => { + let calls = 0; + const options = { config: { ...config, mode: "sdk", sdkApiKey: "key" }, requireEntitlement() {}, + sdkClient: { connectedAccounts: { list: async () => { calls++; return { items: [], nextCursor: null }; } } } }; + assert.equal((await loadComposioInventory({ kind: "user", id: "U123" }, options)).state, "ready"); + assert.equal((await loadComposioInventory({ kind: "channel", id: "D123", record: { isDM: true } }, options)).source, "none-dm"); + assert.equal((await loadComposioInventory({ kind: "user", id: "U123" }, { ...options, config: { ...options.config, sdkApiKey: "" } })).state, "unconfigured"); + assert.equal(calls, 1); +}); + +test("consumer search timeouts return partial and release the client", async () => { + const remote = fixture({ apps: ["gmail"], search: () => new Promise(() => {}) }); + const result = await loadComposioInventory({ kind: "user", record: { composioToken: "key" } }, { config, connect: remote.connect, timeoutMs: 10 }); + assert.equal(result.state, "partial"); + assert.equal(remote.closed(), 1); +}); + +test("a connection completing after timeout is still closed", async () => { + const remote = fixture(); + let finish; + const result = await loadComposioInventory({ kind: "user", record: { composioToken: "key" } }, { + config, timeoutMs: 10, connect: () => new Promise((resolve) => { finish = resolve; }), + }); + assert.equal(result.state, "unavailable"); + finish(remote.client); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(remote.closed(), 1); +}); + +test("SDK pagination has a finite page limit even with unique cursors", async () => { + let calls = 0; + const result = await loadComposioInventory({ kind: "user", id: "U123" }, { + config: { ...config, mode: "sdk", sdkApiKey: "key" }, requireEntitlement() {}, + sdkClient: { connectedAccounts: { list: async () => ({ items: [], nextCursor: `page-${++calls}` }) } }, + }); + assert.equal(result.state, "unavailable"); + assert.equal(calls, 50); +}); + +test("malicious account metadata cannot echo known configuration credentials", async () => { + const remote = fixture({ apps: ["gmail"], search: () => ({ structuredContent: { toolkit_connection_statuses: [{ + toolkit: "gmail", accounts: [{ id: "user-secret", alias: "org-secret", user_info: { email: "sdk-secret" }, status: "ACTIVE" }], + }] } }) }); + const result = await loadComposioInventory({ kind: "user", record: { composioToken: "user-secret" } }, { + config: { ...config, sdkApiKey: "sdk-secret" }, connect: remote.connect, + }); + assert.equal(result.state, "ready"); + assert.doesNotMatch(JSON.stringify(result), /user-secret|org-secret|sdk-secret/); + assert.deepEqual(result.accounts.map(({ id, alias, account }) => ({ id, alias, account })), [{ id: "[redacted]", alias: "[redacted]", account: "[redacted]" }]); +}); + +test("SDK rejects mismatched and nonstring owner fields, and requires the workspace identity", async () => { + for (const owner of [{ userId: "somebody-else" }, { user_id: "somebody-else" }, { userId: 123 }]) { + const result = await loadComposioInventory({ kind: "user", id: "U123" }, { + config: { ...config, mode: "sdk", sdkApiKey: "key" }, requireEntitlement() {}, + sdkClient: { connectedAccounts: { list: async () => ({ items: [{ id: "ca1", toolkit: { slug: "gmail" }, ...owner }] }) } }, + }); + assert.equal(result.state, "unavailable"); + assert.deepEqual(result.accounts, []); + } + const missing = await loadComposioInventory({ kind: "user", id: "U123" }, { + config: { mode: "sdk", sdkApiKey: "key" }, requireEntitlement() {}, + sdkClient: { connectedAccounts: { list: async () => assert.fail("must not call without workspace") } }, + }); + assert.equal(missing.state, "unavailable"); + assert.match(missing.message, /workspace identity/); +}); + +test("an overall consumer deadline preserves collected rows and stops starting further searches", async () => { + let started = 0; + const remote = fixture({ apps: Array.from({ length: 12 }, (_, i) => `app${i}`), search: (app) => { + started++; + if (app !== "app0") return new Promise(() => {}); + return { structuredContent: { toolkit_connection_statuses: [{ toolkit: app, accounts: [{ id: "kept" }] }] } }; + } }); + const result = await loadComposioInventory({ kind: "user", record: { composioToken: "key" } }, { + config, connect: remote.connect, timeoutMs: 1_000, totalTimeoutMs: 20, + }); + assert.equal(result.state, "partial"); + assert.deepEqual(result.accounts.map(({ id }) => id), ["kept"]); + assert.ok(started <= 5, `only the first wave and one completed-slot replacement should start: ${started}`); + assert.equal(remote.closed(), 1); +}); + +test("SDK total deadline aborts pagination and never presents an incomplete list as complete", async () => { + let calls = 0; + let finalSignal; + const result = await loadComposioInventory({ kind: "user", id: "U123" }, { + config: { ...config, mode: "sdk", sdkApiKey: "key" }, requireEntitlement() {}, + timeoutMs: 1_000, totalTimeoutMs: 15, + sdkClient: { connectedAccounts: { list: async (_query, { signal }) => { + calls++; + if (calls === 1) return { items: [{ id: "partial-account", toolkit: { slug: "gmail" } }], nextCursor: "page2" }; + finalSignal = signal; + return new Promise(() => {}); + } } }, + }); + assert.equal(result.state, "unavailable"); + assert.deepEqual(result.accounts, []); + assert.equal(calls, 2); + assert.equal(finalSignal.aborted, true); +});