diff --git a/CHANGELOG.md b/CHANGELOG.md index bef4b315..87955d0f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,8 @@ product overview. Managers can describe/hide their channel's agent. Metadata refreshes at startup and nightly; recommendations respect the requester and reply audience and never transfer work automatically. +- Show connected Composio apps and accounts directly in Slack Settings → You and the channel MCP + page, with automatic loading, refresh and account pagination. - 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. diff --git a/FEATURES.md b/FEATURES.md index efb22afa..1778bcfa 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -20,6 +20,12 @@ ## Composio connection visibility +- Slack Settings → **You** shows the viewer's personal connected Composio applications and + accounts directly below their Composio credential. The channel's **MCP** page shows the shared + connections and whether they use the channel or organization identity. Both lists load after + the native modal opens, offer Refresh, and page through all reported accounts in compact rows. + Credential forms remain undisturbed; late loads cannot overwrite newer navigation. Each read + rechecks viewer ownership, current channel access and the resolved credential scope. - 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 diff --git a/TEST-PLAN.md b/TEST-PLAN.md index 583e80b6..a07c47ad 100644 --- a/TEST-PLAN.md +++ b/TEST-PLAN.md @@ -33,6 +33,28 @@ names/purposes used for keyword preselection may wait for the next nightly metadata refresh; current curated profiles and shared skill changes are searched immediately. +## Slack Settings Composio account visibility (2026-10-21) + +- [x] Regression: `node --test test/slack-composio-inventory-view.test.js + test/slack-composio-inventory-controller.test.js test/slack-composio-inventory-cache.test.js + test/channel-settings-modal.test.js test/slack-settings-redesign.test.js`. Account fixtures + contain two Gmail aliases, another app, more than six accounts, and hostile provider labels. + Controller fixtures hold discovery open, revoke access, rotate credentials and navigate before + completion. Cache tests cover viewer/scope separation, key rotation, expiry and single-flight. +- [ ] Native Slack acceptance, **engine-independent**: use an approved member and an admin in an + isolated channel fixture with a personal key and a distinct shared key. Engines do not + execute Settings reads; the same footer/modal works for both. Open reply ⚙️ Settings → You. + **Pass:** personal apps/accounts appear beneath Composio, with account labels, statuses, total + counts, Refresh and Previous/Next controls. Page through every fixture account; switching to + MCP shows only the channel/shared inventory and its source. A DM suppresses shared inventory. + No separate admin website or account authorizations are required. Repeat on mobile. +- [ ] Slow/failure acceptance, **engine-independent**: hold a provider lookup, switch to another + Settings page or open an inline credential form, then resolve it. **Pass:** neither page nor + typed form is replaced. Expire a key/fail one app lookup; unavailable/partial is distinct from + no connections and Refresh recovers. Remove the viewer's channel access or rotate their key + during lookup; old account data cannot be painted afterward. Cross-owner and forged-scope + buttons are refused. Saving/replacing/removing a key reloads the matching list; the Personal + provider-completeness caveat remains visible. Restore fixture grants and keys. ## Composio connection inventory (2026-10-21) diff --git a/documentation/src/content/docs/configuration/connections.md b/documentation/src/content/docs/configuration/connections.md index 19905f45..f27cd786 100644 --- a/documentation/src/content/docs/configuration/connections.md +++ b/documentation/src/content/docs/configuration/connections.md @@ -40,6 +40,11 @@ SDK mode is an **Enterprise / Beta** option configured with a separate SDK API k ## See connected apps and accounts +In Slack, open the reply's **⚙️ Settings → You** page to see your personal Composio apps and +accounts below your Composio credential. The **MCP** page shows the conversation's shared +connections. Lists load automatically after the modal opens; use **Refresh** to reload and +**Previous accounts / Next accounts** for longer lists. No admin website is needed. + 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 diff --git a/src/slack/app.js b/src/slack/app.js index 56a1ce3c..0784f276 100644 --- a/src/slack/app.js +++ b/src/slack/app.js @@ -50,6 +50,8 @@ import { channelCodexLoginStatus } from "../gateway/channel-codex-login.js"; import { resolveMakeToolboxUpdate } from "../gateway/make-toolbox.js"; import { channelVpnConfigured, getChannelVpnStatus, setChannelVpnEnabled, unconfiguredChannelVpnStatus } from "../gateway/channel-vpn-control.js"; import { logChannelPolicyChange } from "../config/channel-audit.js"; +import { settingsComposioCache } from "./composio-inventory-cache.js"; +import { COMPOSIO_INVENTORY_ACTION_ID } from "./composio-inventory-view.js"; import { createTtlSet } from "./util.js"; import { refreshDirectory } from "./directory.js"; @@ -976,7 +978,9 @@ export async function handleSettingsFormSubmission({ ack, body, view, client }, return; } saved = true; - await client.views.update({ view_id: view.id, view: await rootView(fresh.entry, meta, { ...state, form: "", confirm: "" }, fresh.userIsAdmin, { notice: result.notice }) }); + const nextState = { ...state, form: "", confirm: "", composioPage: 0 }; + const updated = await client.views.update({ view_id: view.id, view: await rootView(fresh.entry, meta, nextState, fresh.userIsAdmin, { notice: result.notice }) }); + await hydrateComposioSettings(client, updated?.view, nextState); } catch (error) { clearTimeout(timer); const meta = (await getChannelMeta(local.entry.slug).catch(() => null)) || local.meta; @@ -1541,6 +1545,10 @@ async function settingsRootView(entry, meta, state, userIsAdmin, { tab = state.t // What only one page needs is resolved only for that page: the member roster costs Slack API // calls, the Cloud MCP catalog a slow probe on a cold cache. const page = normalizeSettingsTab(tab); + const inventoryInput = page === "you" || page === "mcp" + ? await settingsComposioRequest(entry, meta, { ...state, tab: page }) : null; + const inventoryIdentity = inventoryInput ? settingsComposioCache.key(inventoryInput) : ""; + if (inventoryInput) snapshot.composioInventory = settingsComposioCache.peek(inventoryInput); if (page === "access") { const { people, focus } = await accessPeople(meta, state.channelId, state.memberFocus || ""); snapshot.people = people; @@ -1549,14 +1557,100 @@ async function settingsRootView(entry, meta, state, userIsAdmin, { tab = state.t if (page === "mcp" && userIsAdmin) snapshot.cloudMcp = await cloudPickerData(meta, quick ? 600 : 2500); if (page === "skills") snapshot.skillPicker = channelSkillPicker(meta, { userId: state.ownerId, userIsAdmin }); if (page === "you") snapshot.mySkillPicker = await mySkillPicker(state.ownerId, userIsAdmin); + const editOptions = channelSettingsEditOptions(meta, userIsAdmin, { authorId: state.ownerId, isApprovedUser: await isApproved(state.ownerId) }); + // A cached list is private too. Recheck local authority after other async page work and omit + // it if the credential/scope changed during rendering. Membership was checked by the caller. + if (inventoryInput && settingsClient) { + const fresh = await channelSettingsContext(settingsClient, { channelId: state.channelId, + userId: state.ownerId, expectedSlug: entry.slug }); + const current = await settingsComposioRequest(fresh.entry, fresh.meta, { ...state, tab: page }); + if (settingsComposioCache.key(current) !== inventoryIdentity) snapshot.composioInventory = undefined; + } return buildChannelSettingsView({ ...snapshot, vpn: vpn ?? unconfiguredChannelVpnStatus(meta) }, { ...state, tab }, { channelName: entry.name, tab, notice, - ...channelSettingsEditOptions(meta, userIsAdmin, { authorId: state.ownerId, isApprovedUser: await isApproved(state.ownerId) }), + ...editOptions, }); } +// Scope comes only from the admitted modal and its owner, never a browser-supplied account id. +export async function settingsComposioRequest(entry, meta, state, { user = getUser } = {}) { + if (state.tab === "you") return { kind: "user", id: state.ownerId, record: (await user(state.ownerId)) || {} }; + if (state.tab !== "mcp") throw new Error("Open You or MCP to view Composio connections."); + return { kind: "channel", id: state.channelId, + record: { ...entry, ...meta, isDM: Boolean(entry?.isDM || meta?.isDM || entry?.type === "im" || meta?.type === "im") } }; +} + +export async function channelComposioSettingsContext(client, state, userId) { + if (!userId || state.ownerId !== userId) throw new Error("This settings view isn't yours. Open your own from a recent reply."); + if (!["you", "mcp"].includes(state.tab)) throw new Error("Open You or MCP to view Composio connections."); + const args = { channelId: state.channelId, userId, expectedSlug: state.slug }; + await channelSettingsContext(client, { ...args, verifyMembership: true }); + return channelSettingsContext(client, args); +} + +// The native page opens immediately. Account discovery runs afterward, guarded by the exact +// view hash so a slow response cannot replace a newer page, form, or selection. +export async function hydrateComposioSettings(client, view, state, { + context = channelComposioSettingsContext, request = settingsComposioRequest, + cache = settingsComposioCache, rootView = settingsRootView, +} = {}) { + if (!view?.id || !["you", "mcp"].includes(state.tab) || state.form) return; + try { + const first = await context(client, state, state.ownerId); + const input = await request(first.entry, first.meta, state); + if (cache.peek(input)) return; + const identity = cache.key(input); + await cache.load(input); + const fresh = await context(client, state, state.ownerId); + if (cache.key(await request(fresh.entry, fresh.meta, state)) !== identity) return; + const rendered = await rootView(fresh.entry, fresh.meta, state, fresh.userIsAdmin); + const final = await context(client, state, state.ownerId); + if (cache.key(await request(final.entry, final.meta, state)) !== identity) return; + await client.views.update({ view_id: view.id, ...(view.hash ? { hash: view.hash } : {}), view: rendered }); + } catch (error) { + if (error?.data?.error === "hash_conflict") return; + await client.views.update({ view_id: view.id, ...(view.hash ? { hash: view.hash } : {}), + view: buildChannelSettingsErrorView("Couldn't load connections, or access changed. Reopen Settings and try again."), + }).catch(() => {}); + } +} + +export async function handleComposioSettingsAction({ ack, body, action, client }, { + context = channelComposioSettingsContext, request = settingsComposioRequest, + cache = settingsComposioCache, rootView = settingsRootView, hydrate = hydrateComposioSettings, +} = {}) { + await ack(); + try { + if (body?.view?.callback_id !== "cg_channel_settings_modal" || action?.action_id !== COMPOSIO_INVENTORY_ACTION_ID) throw new Error("Expired settings control."); + const state = parseSettingsMetadata(body.view.private_metadata); + const command = settingsCommand(action); + if (state.form) return; // An inventory click must never replace an open credential form. + if (state.ownerId !== body?.user?.id || command.c !== state.channelId || command.s !== state.slug || command.u !== state.ownerId + || !["you", "mcp"].includes(state.tab) || !["composio_refresh", "composio_page"].includes(command.o)) throw new Error("Expired settings control."); + const current = await context(client, state, body.user.id); + const input = await request(current.entry, current.meta, state); + if (command.o === "composio_refresh") cache.invalidate(input); + const page = command.o === "composio_refresh" ? 0 : command.page; + if (!Number.isSafeInteger(page) || page < 0 || page > 10_000) throw new Error("Expired settings page."); + const next = { ...state, composioPage: page }; + const identity = cache.key(input); + const rendered = await rootView(current.entry, current.meta, next, current.userIsAdmin); + const fresh = await context(client, next, body.user.id); + if (cache.key(await request(fresh.entry, fresh.meta, next)) !== identity) return; + const updated = await client.views.update({ view_id: body.view.id, ...(body.view.hash ? { hash: body.view.hash } : {}), + view: rendered, + }); + await hydrate(client, updated?.view, next, { context, request, cache, rootView }); + } catch (error) { + if (body?.view?.id && error?.data?.error !== "hash_conflict") await client.views.update({ + view_id: body.view.id, ...(body.view.hash ? { hash: body.view.hash } : {}), + view: buildChannelSettingsErrorView("Couldn't refresh connections, or this settings view expired. Reopen Settings and try again."), + }).catch(() => {}); + } +} + // One Automations-page control, applied by the caller only after channelSettingsContext admitted // the clicker. The authority is the one the agent's own schedule tools give any authorized user. // The row is looked up among THIS channel's schedules, so an id from another channel (or a row @@ -1662,6 +1756,7 @@ async function openChannelSettings(client, triggerId, { channelId, userId, threa // Only a provisioned service needs the status helper; every other conversation already rendered // its final "Not configured" row, so opening Settings costs no subprocess and no second update. if (tab === "agent" && channelVpnConfigured(meta) && opened.view?.id) await hydrateChannelVpnSettings(client, opened.view, state); + await hydrateComposioSettings(client, opened.view, state); await logEvent("channel_settings_opened", { channel: channelId, author: userId, slug: entry.slug }); } @@ -2013,6 +2108,10 @@ async function connectAndWire(app) { }); const handleChannelSettingsAction = async ({ ack, body, action, client }) => { + if (action?.action_id === COMPOSIO_INVENTORY_ACTION_ID) { + await handleComposioSettingsAction({ ack, body, action, client }); + return; + } if ([CHANNEL_SETTINGS_VPN_TOGGLE_ACTION_ID, CHANNEL_SETTINGS_VPN_REFRESH_ACTION_ID].includes(action?.action_id)) { await handleChannelVpnSettingsAction({ ack, body, action, client }); return; @@ -2066,19 +2165,22 @@ async function connectAndWire(app) { if (command.o === "tab") { const tab = String(command.p || "agent"); // A page change closes any open form or pending confirmation. - const updated = await updateCurrent(await settingsRootView(entry, meta, { ...state, tab, form: "", confirm: "", memberFocus: "" }, userIsAdmin), { guardHash: false }); + const updated = await updateCurrent(await settingsRootView(entry, meta, { ...state, tab, form: "", confirm: "", memberFocus: "", composioPage: 0 }, userIsAdmin), { guardHash: false }); if (normalizeSettingsTab(tab) === "agent" && channelVpnConfigured(meta) && updated.view?.id) { await hydrateChannelVpnSettings(client, updated.view, { ...state, tab }); } + await hydrateComposioSettings(client, updated?.view, { ...state, tab: normalizeSettingsTab(tab), form: "", composioPage: 0 }); return; } // ── Redesigned pages (2026-10) ────────────────────────────────────────────────────────── const ctx = { entry, meta, clicker, userIsAdmin, userIsApproved }; - const repaint = async (next = {}, { notice = "" } = {}) => updateCurrent( - await settingsRootView(entry, next.meta || meta, { ...state, ...next.state }, userIsAdmin, { notice }), - { guardHash: false }, - ); + const repaint = async (next = {}, { notice = "" } = {}) => { + const nextState = { ...state, ...next.state }; + const updated = await updateCurrent(await settingsRootView(entry, next.meta || meta, nextState, userIsAdmin, { notice }), { guardHash: false }); + await hydrateComposioSettings(client, updated?.view, nextState); + return updated; + }; if (actionId === CHANNEL_SETTINGS_FORM_OPEN_ACTION_ID) { assertSettingsFormAllowed(command.f, ctx); diff --git a/src/slack/channel-settings.js b/src/slack/channel-settings.js index 8a217c52..12e6f6db 100644 --- a/src/slack/channel-settings.js +++ b/src/slack/channel-settings.js @@ -7,6 +7,7 @@ import { channelMode, modeLabel } from "../gateway/modes.js"; import { MIN_MASKABLE_LENGTH } from "../config/channel-env.js"; import { nextCronRun } from "../util/cron.js"; import { daemonTimeZone, zonedStamp } from "../util/timezone.js"; +import { composioInventoryBlocks } from "./composio-inventory-view.js"; export const CHANNEL_SETTINGS_MODE_PREFIX = "cg_channel_settings_mode_"; export const CHANNEL_SETTINGS_OPTION_PREFIX = "cg_channel_settings_option_"; @@ -195,6 +196,7 @@ export function settingsMetadata(state = {}) { ...(state.confirm ? { x: String(state.confirm).slice(0, 200) } : {}), // The person the Access page's search picked (`y`). ...(state.memberFocus ? { y: String(state.memberFocus).slice(0, 32) } : {}), + ...(Number.isSafeInteger(state.composioPage) && state.composioPage > 0 ? { cp: Math.min(state.composioPage, 10_000) } : {}), }); } @@ -216,6 +218,7 @@ export function parseSettingsMetadata(raw) { ...(typeof value.fs === "string" && /^[0-9a-f-]{36}$/.test(value.fs) ? { formSecretId: value.fs } : {}), confirm: typeof value.x === "string" ? value.x : "", memberFocus: typeof value.y === "string" && /^[UW][A-Z0-9]+$/.test(value.y) ? value.y : "", + ...(Number.isSafeInteger(value.cp) && value.cp > 0 ? { composioPage: Math.min(value.cp, 10_000) } : {}), }; } @@ -771,6 +774,10 @@ function connectionRows(snapshot, state, scope, opts) { const form = `composio:${scope}`; blocks.push(...withForm(editableRow(`*${scope === "c" ? "Shared account" : "Your account"}* ${inlineCode(scope === "c" ? "composio-agent" : "composio-user")}\n${status}`, form, state, { connected: Boolean(saved?.configured) }), form, snapshot, state)); } + if (!state.form) blocks.push(...composioInventoryBlocks(snapshot.composioInventory, { + page: state.composioPage || 0, + command: (operation, extra = {}) => actionValue(operation, { c: state.channelId, s: state.slug, u: state.ownerId, ...extra }), + })); // Make toolbox (a conversation's only) if (scope === "c") { blocks.push(header("Make toolbox")); diff --git a/src/slack/composio-inventory-cache.js b/src/slack/composio-inventory-cache.js new file mode 100644 index 00000000..d8ba510e --- /dev/null +++ b/src/slack/composio-inventory-cache.js @@ -0,0 +1,53 @@ +// Short-lived, metadata-only inventory for Slack's private Settings views. Credentials form a +// digest, never a cache value/key in cleartext. Every caller still authorizes its viewer live. +import { createHash } from "node:crypto"; +import { loadComposioInventory } from "../gateway/composio-inventory.js"; +import { getComposioMode, getComposioSdkApiKey, getDefaultComposioToken } from "../config/settings.js"; +import { composioUrl } from "../gateway/mcp-catalog.js"; + +const currentConfig = () => ({ mode: getComposioMode(), sdkApiKey: getComposioSdkApiKey(), + defaultToken: getDefaultComposioToken(), url: composioUrl(), workspaceId: process.env.CG_SLACK_TEAM_ID || "" }); + +export function createSettingsComposioCache({ load = loadComposioInventory, config = currentConfig, + now = Date.now, ttlMs = 60_000, maxEntries = 32 } = {}) { + const entries = new Map(); + const describe = (request) => { + const resolved = config(); + const record = request.record || {}; + const key = createHash("sha256").update(JSON.stringify([request.kind, request.id, + record.composioToken || "", Boolean(record.noDefaultTokens), Boolean(record.isDM || record.type === "im"), resolved])).digest("hex"); + return { key, resolved }; + }; + const fresh = (key) => { + const entry = entries.get(key); + if (entry && !entry.promise && entry.expires <= now()) { entries.delete(key); return undefined; } + return entry; + }; + return { + key: (request) => describe(request).key, + peek(request) { return fresh(describe(request).key)?.result; }, + invalidate(request) { entries.delete(describe(request).key); }, + async load(request, { force = false } = {}) { + const { key, resolved } = describe(request); + const existing = fresh(key); + if (existing?.promise) return existing.promise; + if (!force && existing?.result) return existing.result; + const entry = { expires: 0, result: undefined, promise: null }; + entry.promise = Promise.resolve().then(() => load(request, { config: resolved })).catch(() => ({ + mode: resolved.mode === "sdk" ? "sdk" : "personal", source: "none", state: "unavailable", + message: "Composio connections could not be loaded. Try refreshing.", accounts: [], + })).then((result) => { + entry.result = result; + entry.expires = now() + ttlMs; + entry.promise = null; + return result; + }); + entries.delete(key); + entries.set(key, entry); + while (entries.size > maxEntries) entries.delete(entries.keys().next().value); + return entry.promise; + }, + }; +} + +export const settingsComposioCache = createSettingsComposioCache(); diff --git a/src/slack/composio-inventory-view.js b/src/slack/composio-inventory-view.js new file mode 100644 index 00000000..b1dac6b0 --- /dev/null +++ b/src/slack/composio-inventory-view.js @@ -0,0 +1,92 @@ +// Pure Block Kit view of the public, read-only inventory. Account metadata is always plain text: +// provider aliases and owner names must never become Slack mentions, links, or formatting. +export const COMPOSIO_INVENTORY_ACTION_ID = "cg_channel_settings_composio_inventory"; +export const COMPOSIO_INVENTORY_PAGE_SIZE = 6; + +const SOURCE_LABELS = Object.freeze({ + user: "Personal connections", + channel: "Channel connections", + org: "Organization connections", + "sdk-user": "Personal connections · organization Composio SDK", + "sdk-channel": "Channel connections · organization Composio SDK", + "none-dm": "Direct message · personal connections only", + "none-no-org-default": "Organization fallback disabled", + none: "No connection configured", +}); + +function boundedText(value, limit) { + const clean = typeof value === "string" ? value.replace(/[\u0000-\u001f\u007f]/g, " ").trim() : ""; + return clean.length > limit ? `${clean.slice(0, limit - 1)}…` : clean; +} + +function plain(text, limit = 3000) { + return { type: "plain_text", text: text.length > limit ? `${text.slice(0, limit - 1)}…` : text, emoji: false }; +} + +export function normalizeComposioInventoryPage(page, accountCount) { + const count = Number.isFinite(accountCount) ? Math.max(0, Math.floor(accountCount)) : 0; + const last = Math.max(0, Math.ceil(count / COMPOSIO_INVENTORY_PAGE_SIZE) - 1); + const numeric = typeof page === "number" || typeof page === "string" ? Number(page) : NaN; + return Number.isFinite(numeric) ? Math.max(0, Math.min(last, Math.floor(numeric))) : 0; +} + +function accountText(account) { + const app = boundedText(account?.app, 256) || "Unknown application"; + const alias = boundedText(account?.alias, 512); + const owner = boundedText(account?.account, 512); + const id = boundedText(account?.id, 512); + const status = boundedText(account?.status, 80) || "Unknown status"; + const accountType = boundedText(account?.accountType, 80); + const identity = [alias, owner && owner !== alias ? owner : ""].filter(Boolean).join(" · ") || id || "Account identity unavailable"; + return `${app} — ${identity}\n${[status, account?.isDefault === true ? "Default" : "", accountType].filter(Boolean).join(" · ")}`; +} + +function defaultCommand(operation, extra = {}) { + return JSON.stringify({ o: operation, ...extra }); +} + +export function composioInventoryBlocks(inventory, { page = 0, command = defaultCommand } = {}) { + const loading = inventory == null; + const state = loading ? "loading" : inventory.state; + const accounts = Array.isArray(inventory?.accounts) ? inventory.accounts : []; + const current = normalizeComposioInventoryPage(page, accounts.length); + const pages = Math.max(1, Math.ceil(accounts.length / COMPOSIO_INVENTORY_PAGE_SIZE)); + const apps = new Set(accounts.map((account) => boundedText(account?.app, 256) || "Unknown application")).size; + const source = Object.hasOwn(SOURCE_LABELS, inventory?.source) ? SOURCE_LABELS[inventory.source] : "Connection source unavailable"; + const count = `${apps} ${apps === 1 ? "application" : "applications"} · ${accounts.length} ${accounts.length === 1 ? "account" : "accounts"}`; + const summary = loading ? "*Connected apps and accounts*\nLoading connections…" : `*Connected apps and accounts*\n${count}\n${source}`; + const button = (label, operation, extra) => ({ + type: "button", text: plain(label, 75), action_id: COMPOSIO_INVENTORY_ACTION_ID, + value: command(operation, extra), + }); + const blocks = [{ + type: "section", text: { type: "mrkdwn", text: summary }, + accessory: button("Refresh", "composio_refresh"), + }]; + + for (const account of accounts.slice(current * COMPOSIO_INVENTORY_PAGE_SIZE, (current + 1) * COMPOSIO_INVENTORY_PAGE_SIZE)) { + blocks.push({ type: "section", text: plain(accountText(account)) }); + } + + const notices = []; + if (state === "loading") notices.push("Loading the applications and accounts reported by Composio."); + else if (state === "partial") notices.push("Some connections could not be loaded. This list is incomplete."); + else if (state === "unconfigured") notices.push("No Composio connection is configured for this identity."); + else if (state !== "ready") notices.push("Connections could not be loaded. Try Refresh."); + else if (!accounts.length) notices.push("Composio reported no connected accounts."); + const message = boundedText(inventory?.message, 1000); + if (message && state !== "ready") notices.push(message); + if (inventory?.mode === "personal") notices.push("Composio may omit inactive or unpublished connections."); + if (accounts.length) { + const first = current * COMPOSIO_INVENTORY_PAGE_SIZE + 1; + const last = Math.min(accounts.length, first + COMPOSIO_INVENTORY_PAGE_SIZE - 1); + notices.push(`Accounts ${first}–${last} of ${accounts.length} · Page ${current + 1} of ${pages}`); + } + if (notices.length) blocks.push({ type: "context", elements: [plain(notices.join("\n"), 2000)] }); + + // Slack requires action_id uniqueness inside a block. Keep each navigation button in its own + // small actions block so every control can share the controller's inventory action id. + if (current > 0) blocks.push({ type: "actions", elements: [button("Previous accounts", "composio_page", { page: current - 1 })] }); + if (current + 1 < pages) blocks.push({ type: "actions", elements: [button("Next accounts", "composio_page", { page: current + 1 })] }); + return blocks; +} diff --git a/test/channel-settings-modal.test.js b/test/channel-settings-modal.test.js index 8eeb0461..d75727cc 100644 --- a/test/channel-settings-modal.test.js +++ b/test/channel-settings-modal.test.js @@ -1,6 +1,7 @@ import test from "node:test"; import assert from "node:assert/strict"; import { ensureTestEnv } from "./helpers.js"; +import { COMPOSIO_INVENTORY_ACTION_ID } from "../src/slack/composio-inventory-view.js"; ensureTestEnv(); @@ -820,3 +821,22 @@ test("Cloud MCP manager marks inadmissible definitions and offers only removal f assert.equal(rows.find(row => row.text.text.includes("*healthy*")).accessory.text.text, "Activate"); assert.match(rendered(view), /unavailable:.*is not defined/); }); + +test("native You and MCP pages show the scoped account list beneath Composio and preserve inline forms", () => { + const inventory = { mode: "personal", source: "user", state: "ready", accounts: [ + { app: "gmail", id: "ca-viewer", alias: "Work", account: "viewer@example.test", status: "ACTIVE" }, + ] }; + const you = buildChannelSettingsView({ ...snapshot, composioInventory: inventory }, { ...state, tab: "you" }); + const at = (view, text) => view.blocks.findIndex((block) => block.text?.text?.includes(text)); + assert.ok(at(you, "gmail — Work") > at(you, "Composio")); + assert.ok(at(you, "gmail — Work") < at(you, "Toolbox")); + assert.match(rendered(you), /Personal connections|viewer@example.test/); + const refresh = elements(you).find((element) => element.action_id === COMPOSIO_INVENTORY_ACTION_ID); + assert.equal(parseActionValue(refresh.value).u, state.ownerId); + const mcp = buildChannelSettingsView({ ...snapshot, composioInventory: { ...inventory, source: "org" } }, { ...state, tab: "mcp" }); + assert.match(rendered(mcp), /Organization connections/); + const editing = buildChannelSettingsView({ ...snapshot, composioInventory: inventory }, { ...state, tab: "you", form: "composio:p" }); + assert.equal(editing.submit.text, "Save"); + assert.equal(elements(editing).some((element) => element.action_id === COMPOSIO_INVENTORY_ACTION_ID), false); + assert.doesNotMatch(rendered(editing), /viewer@example.test/); +}); diff --git a/test/slack-composio-inventory-cache.test.js b/test/slack-composio-inventory-cache.test.js new file mode 100644 index 00000000..e948e29e --- /dev/null +++ b/test/slack-composio-inventory-cache.test.js @@ -0,0 +1,70 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { ensureTestEnv } from "./helpers.js"; +ensureTestEnv(); +const { createSettingsComposioCache } = await import("../src/slack/composio-inventory-cache.js"); + +const input = { kind: "user", id: "UVIEWER", record: { composioToken: "private-token" } }; +const ready = { state: "ready", source: "user", accounts: [{ app: "gmail", id: "mail" }] }; +const deferred = () => { let resolve; const promise = new Promise((r) => { resolve = r; }); return { promise, resolve }; }; + +test("inventory cache coalesces slow reads, uses its TTL and refreshes explicitly", async () => { + let time = 100; let calls = 0; + const pending = deferred(); + const cache = createSettingsComposioCache({ config: () => ({ mode: "personal" }), now: () => time, ttlMs: 60, + load: async () => { calls++; return calls === 1 ? pending.promise : ready; } }); + const first = cache.load(input); const second = cache.load(input); + assert.equal(cache.peek(input), undefined); + await Promise.resolve(); assert.equal(calls, 1); + pending.resolve(ready); + assert.deepEqual(await Promise.all([first, second]), [ready, ready]); + time = 159; assert.equal(cache.peek(input), ready); + assert.equal(await cache.load(input), ready); assert.equal(calls, 1); + time = 160; assert.equal(cache.peek(input), undefined); + await cache.load(input); assert.equal(calls, 2); + await cache.load(input, { force: true }); assert.equal(calls, 3); + cache.invalidate(input); assert.equal(cache.peek(input), undefined); + await cache.load(input); assert.equal(calls, 4); +}); + +test("cache keys separate viewer/channel identities and every credential or routing rotation", async () => { + let config = { mode: "personal", defaultToken: "default-secret", sdkApiKey: "sdk-secret", url: "https://one.example", workspaceId: "TONE" }; + const cache = createSettingsComposioCache({ config: () => config, load: async () => ready }); + const key = cache.key(input); + assert.match(key, /^[a-f0-9]{64}$/); + assert.doesNotMatch(key, /private-token|default-secret|sdk-secret/); + await cache.load(input); + for (const change of [ + { kind: "channel" }, { id: "UOTHER" }, { record: { composioToken: "new-token" } }, + { record: { ...input.record, noDefaultTokens: true } }, { record: { ...input.record, isDM: true } }, + { record: { ...input.record, type: "im" } }, + ]) { assert.notEqual(cache.key({ ...input, ...change }), key); assert.equal(cache.peek({ ...input, ...change }), undefined); } + for (const [field, value] of Object.entries({ mode: "sdk", defaultToken: "rotated", sdkApiKey: "rotated", url: "https://two.example", workspaceId: "TTWO" })) { + const before = config; config = { ...config, [field]: value }; + assert.notEqual(cache.key(input), key); assert.equal(cache.peek(input), undefined); config = before; + } +}); + +test("in-flight invalidation and key rotation never repopulate the current identity", async () => { + const pending = deferred(); let calls = 0; let config = { mode: "personal", defaultToken: "before" }; + const cache = createSettingsComposioCache({ config: () => config, load: async () => ++calls === 1 ? pending.promise : ready }); + const old = cache.load(input); + cache.invalidate(input); + await cache.load(input); + pending.resolve({ ...ready, accounts: [{ id: "stale" }] }); await old; + assert.equal(cache.peek(input), ready); + config = { ...config, defaultToken: "after" }; + assert.equal(cache.peek(input), undefined); +}); + +test("cache bounds retained identities and converts private backend errors to safe public state", async () => { + const cache = createSettingsComposioCache({ maxEntries: 2, config: () => ({ mode: "sdk" }), load: async (request) => { + if (request.id === "FAIL") throw new Error("private-token https://secret.example/auth"); + return ready; + } }); + await cache.load(input); await cache.load({ ...input, id: "UTWO" }); await cache.load({ ...input, id: "UTHREE" }); + assert.equal(cache.peek(input), undefined); + const failure = await cache.load({ ...input, id: "FAIL" }); + assert.equal(failure.state, "unavailable"); assert.equal(failure.mode, "sdk"); assert.deepEqual(failure.accounts, []); + assert.doesNotMatch(JSON.stringify(failure), /private-token|secret.example/); +}); diff --git a/test/slack-composio-inventory-controller.test.js b/test/slack-composio-inventory-controller.test.js new file mode 100644 index 00000000..e17b04ac --- /dev/null +++ b/test/slack-composio-inventory-controller.test.js @@ -0,0 +1,173 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { ensureTestEnv } from "./helpers.js"; +ensureTestEnv(); +const { settingsComposioRequest, channelComposioSettingsContext, hydrateComposioSettings, handleComposioSettingsAction } = await import("../src/slack/app.js"); +const { settingsMetadata } = await import("../src/slack/channel-settings.js"); +const { COMPOSIO_INVENTORY_ACTION_ID } = await import("../src/slack/composio-inventory-view.js"); +const { createSettingsComposioCache } = await import("../src/slack/composio-inventory-cache.js"); +const { loadComposioInventory } = await import("../src/gateway/composio-inventory.js"); +const store = await import("../src/config/store.js"); +const state = { channelId: "CCOMPOSIOSETTINGS", slug: "composio-settings", ownerId: "UCOMPOSIOVIEWER", tab: "you" }; +const publicResult = { state: "ready", source: "user", accounts: [{ app: "gmail", id: "account", account: "viewer@example.test" }] }; +const fixtureContext = async () => ({ entry: { name: "Inventory test" }, meta: {}, userIsAdmin: false }); +const fixtureRequest = async (_entry, _meta, current) => ({ kind: "user", id: current.ownerId, record: { composioToken: "private-credential" } }); +function fixture(operation = "composio_refresh", current = state) { + const updates = []; const events = []; + const params = { + ack: async () => { events.push("ack"); }, + action: { action_id: COMPOSIO_INVENTORY_ACTION_ID, value: JSON.stringify({ c: current.channelId, s: current.slug, u: current.ownerId, o: operation, page: 1 }) }, + body: { user: { id: current.ownerId }, view: { id: "VINVENTORY", hash: "opening-hash", callback_id: "cg_channel_settings_modal", private_metadata: settingsMetadata(current) } }, + client: { views: { update: async (payload) => { updates.push(payload); return { view: { ...payload.view, id: "VINVENTORY", hash: "updated-hash" } }; } } }, + }; + return { params, updates, events }; +} +const safeCache = (options = {}) => createSettingsComposioCache({ config: () => ({ mode: "personal" }), load: async () => publicResult, ...options }); +const render = (cache) => async (entry, meta, current) => ({ private_metadata: settingsMetadata(current), blocks: [{ type: "section", text: { type: "plain_text", text: JSON.stringify(cache.peek(await fixtureRequest(entry, meta, current)) || "Loading connections") } }] }); + +test("You resolves exactly the modal owner, while MCP resolves only the current channel", async () => { + const seen = []; const user = async (id) => { seen.push(id); return { composioToken: "viewer-token" }; }; + const personal = await settingsComposioRequest({ channelId: "FORGED", composioToken: "channel-token" }, {}, { ...state, id: "FORGED" }, { user }); + assert.deepEqual(personal, { kind: "user", id: state.ownerId, record: { composioToken: "viewer-token" } }); + assert.deepEqual(seen, [state.ownerId]); + const channel = await settingsComposioRequest({ type: "channel", composioToken: "old" }, { composioToken: "current" }, { ...state, tab: "mcp" }, { user: async () => assert.fail("must not fetch another personal identity") }); + assert.equal(channel.kind, "channel"); assert.equal(channel.id, state.channelId); assert.equal(channel.record.composioToken, "current"); + await assert.rejects(() => settingsComposioRequest({}, {}, { ...state, tab: "skills" }, { user }), /Open You or MCP/); +}); + +test("DM channel inventory suppresses shared connections even with configured fallback", async () => { + for (const entry of [{ isDM: true }, { type: "im" }]) { + const request = await settingsComposioRequest(entry, { composioToken: "channel-secret" }, { ...state, tab: "mcp" }); + const result = await loadComposioInventory(request, { config: { mode: "personal", defaultToken: "org-secret" }, connect: async () => assert.fail("DM must not discover shared accounts") }); + assert.equal(result.source, "none-dm"); assert.deepEqual(result.accounts, []); + } +}); + +test("inventory actions ACK before slow authorization, repaint loading, then hydrate refreshed accounts", async () => { + const { params, updates, events } = fixture(); const cache = safeCache(); await cache.load(await fixtureRequest({}, {}, state)); + await handleComposioSettingsAction(params, { cache, request: fixtureRequest, rootView: render(cache), context: async () => { + assert.equal(events[0], "ack"); events.push("authorization"); return fixtureContext(); + } }); + assert.match(JSON.stringify(updates[0]), /Loading connections/); + assert.equal(updates[0].hash, "opening-hash"); + assert.match(JSON.stringify(updates.at(-1)), /viewer@example.test/); + assert.equal(updates.at(-1).hash, "updated-hash"); + assert.equal(JSON.parse(updates[0].view.private_metadata).cp, undefined, "refresh returns to page one"); +}); + +test("page navigation uses the loaded cache without another provider read", async () => { + const current = { ...state, composioPage: 0 }; const { params, updates } = fixture("composio_page", current); + let reads = 0; const cache = safeCache({ load: async () => { reads++; return publicResult; } }); + await cache.load(await fixtureRequest({}, {}, current)); + await handleComposioSettingsAction(params, { context: fixtureContext, request: fixtureRequest, cache, rootView: render(cache) }); + assert.equal(reads, 1); assert.equal(updates.length, 1); + const metadata = JSON.parse(updates[0].view.private_metadata); + assert.equal(metadata.cp, 1); +}); + +test("stale inventory controls preserve an open inline form without replacing draft inputs", async () => { + for (const operation of ["composio_page", "composio_refresh"]) { + const { params, updates, events } = fixture(operation, { ...state, form: "composio" }); + await handleComposioSettingsAction(params, { context: async () => assert.fail("must leave the draft alone"), rootView: async () => assert.fail("must not replace draft inputs") }); + assert.deepEqual(events, ["ack"]); assert.deepEqual(updates, []); + } +}); + +test("missing or forged owner/channel/slug/action bindings are rejected before discovery", async () => { + for (const change of [ + (p) => { p.body.user.id = "UOTHER"; }, + (p) => { p.body.view.private_metadata = "{}"; }, + (p) => { p.body.view.callback_id = "different"; }, + (p) => { p.body.view.private_metadata = settingsMetadata({ ...state, tab: "skills" }); }, + (p) => { p.action.action_id = "different"; }, + ...[{ u: "UOTHER" }, { c: "COTHER" }, { s: "other" }, { o: "connect_account" }, { o: "composio_page", page: -1 }, { o: "composio_page", page: 1.5 }].map((patch) => (p) => { p.action.value = JSON.stringify({ ...JSON.parse(p.action.value), ...patch }); }), + ]) { + const { params, updates, events } = fixture(); change(params); + await handleComposioSettingsAction(params, { context: fixtureContext, request: fixtureRequest, cache: { invalidate: () => {}, peek: () => assert.fail("must not read inventory"), load: () => assert.fail("must not load inventory") }, rootView: async () => assert.fail("must not render accounts") }); + assert.equal(events[0], "ack"); assert.match(JSON.stringify(updates), /expired/); + assert.doesNotMatch(JSON.stringify(updates), /viewer@example.test|private-credential/); + } +}); + +async function liveFixture() { + await store.ensureRoot(); + const entry = await store.upsertChannelEntry(state.channelId, { name: state.slug, type: "channel", isDM: false }); + await store.setUser(state.ownerId, { approved: true, isAdmin: false }); + await store.saveChannelMeta(entry.slug, { ...store.defaultChannelMeta({ channelId: entry.channelId, name: entry.name }), access: "approved" }); + return { entry, current: { ...state, slug: entry.slug }, client: { conversations: { members: async () => ({ members: [state.ownerId] }) } } }; +} + +test("live inventory authority rejects foreign viewer, unrelated tab, departure, forged slug and revocation during membership read", async () => { + const { current, client } = await liveFixture(); + await channelComposioSettingsContext(client, current, state.ownerId); + await assert.rejects(() => channelComposioSettingsContext(client, current, "UOTHER"), /isn't yours/); + await assert.rejects(() => channelComposioSettingsContext(client, { ...current, tab: "skills" }, state.ownerId), /Open You or MCP/); + await assert.rejects(() => channelComposioSettingsContext(client, { ...current, slug: "forged" }, state.ownerId), /expired|match|changed|belongs/i); + client.conversations.members = async () => ({ members: [] }); + await assert.rejects(() => channelComposioSettingsContext(client, current, state.ownerId), /no longer a member/); + client.conversations.members = async () => { + await store.setUser(state.ownerId, { approved: false, isAdmin: false }); return { members: [state.ownerId] }; + }; + await assert.rejects(() => channelComposioSettingsContext(client, current, state.ownerId), /not authorized/); +}); + +test("revocation before or after a slow account read never sends account data", async () => { + for (const revokeAt of [1, 2, 3]) { + const { params, updates } = fixture(); let calls = 0; let reads = 0; + const cache = safeCache({ load: async () => { reads++; return publicResult; } }); + await hydrateComposioSettings(params.client, params.body.view, state, { cache, request: fixtureRequest, rootView: render(cache), context: async () => { + if (++calls === revokeAt) throw new Error("access revoked"); return fixtureContext(); + } }); + assert.equal(reads, revokeAt === 1 ? 0 : 1); + assert.doesNotMatch(JSON.stringify(updates), /viewer@example.test|private-credential/); + } +}); + +test("credential rotation during discovery or rendering discards the previous account inventory", async () => { + for (const rotateDuringRender of [false, true]) { + const { params, updates } = fixture(); let token = "before"; + const request = async () => ({ kind: "user", id: state.ownerId, record: { composioToken: token } }); + const cache = safeCache({ load: async () => { if (!rotateDuringRender) token = "after"; return publicResult; } }); + await hydrateComposioSettings(params.client, params.body.view, state, { context: fixtureContext, request, cache, rootView: async () => { + token = "after"; return { blocks: [{ text: { text: "viewer@example.test" } }] }; + } }); + assert.deepEqual(updates, []); + } +}); + +test("cached page actions recheck live access and credentials after asynchronous rendering", async () => { + for (const revoke of [true, false]) { + const { params, updates } = fixture("composio_page"); let rendered = false; let token = "before"; + const request = async () => ({ kind: "user", id: state.ownerId, record: { composioToken: token } }); + const cache = safeCache(); await cache.load(await request()); + await handleComposioSettingsAction(params, { request, cache, context: async () => { + if (rendered && revoke) throw new Error("revoked during render"); return fixtureContext(); + }, rootView: async () => { rendered = true; if (!revoke) token = "after"; return { blocks: [{ text: { text: "viewer@example.test" } }] }; } }); + assert.doesNotMatch(JSON.stringify(updates), /viewer@example.test/); + } +}); + +test("live global revocation during provider discovery prevents account delivery", async () => { + const { current, client } = await liveFixture(); const { params, updates } = fixture("composio_refresh", current); + params.client.conversations = client.conversations; + const cache = safeCache({ load: async () => { await store.setUser(state.ownerId, { approved: false, isAdmin: false }); return publicResult; } }); + await hydrateComposioSettings(params.client, params.body.view, current, { cache, request: fixtureRequest, rootView: render(cache) }); + assert.doesNotMatch(JSON.stringify(updates), /viewer@example.test/); + assert.match(JSON.stringify(updates), /access changed/); +}); + +test("hydration preserves view hashes and stops after a conflict without overwriting newer navigation", async () => { + const { params, updates } = fixture(); + params.client.views.update = async (payload) => { updates.push(payload); throw Object.assign(new Error("changed view"), { data: { error: "hash_conflict" } }); }; + const cache = safeCache(); + await hydrateComposioSettings(params.client, params.body.view, state, { context: fixtureContext, request: fixtureRequest, cache, rootView: render(cache) }); + assert.equal(updates.length, 1); assert.equal(updates[0].hash, "opening-hash"); +}); + +test("hydration skips unrelated tabs and open forms without reading connections", async () => { + for (const current of [{ ...state, tab: "skills" }, { ...state, form: "composio" }]) { + const { params, updates } = fixture(); + await hydrateComposioSettings(params.client, params.body.view, current, { context: async () => assert.fail("no authority lookup for unrelated/open form") }); + assert.deepEqual(updates, []); + } +}); diff --git a/test/slack-composio-inventory-view.test.js b/test/slack-composio-inventory-view.test.js new file mode 100644 index 00000000..cdf1b54b --- /dev/null +++ b/test/slack-composio-inventory-view.test.js @@ -0,0 +1,117 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + COMPOSIO_INVENTORY_ACTION_ID, COMPOSIO_INVENTORY_PAGE_SIZE, + composioInventoryBlocks, normalizeComposioInventoryPage, +} from "../src/slack/composio-inventory-view.js"; + +const account = (id, extra = {}) => ({ app: "gmail", id, alias: `mail-${id}`, account: `${id}@example.test`, status: "ACTIVE", ...extra }); +const ready = (accounts, extra = {}) => ({ mode: "personal", source: "user", state: "ready", accounts, ...extra }); +const buttons = (blocks) => blocks.flatMap((block) => [block.accessory, ...(block.elements || [])].filter((element) => element?.type === "button")); +const rows = (blocks) => blocks.filter((block) => block.type === "section" && block.text.type === "plain_text").map((block) => block.text.text); +const text = (blocks) => blocks.flatMap((block) => [block.text?.text, ...(block.elements || []).map((element) => element.text)]).filter(Boolean).join("\n"); + +test("inventory renders applications, aliases, owner identities, status, defaults, types and identity fallback", () => { + const blocks = composioInventoryBlocks(ready([ + account("first", { isDefault: true, accountType: "oauth" }), + account("second", { status: "EXPIRED" }), + account("id-only", { app: "slack", alias: "", account: "" }), + ])); + assert.match(text(blocks), /2 applications · 3 accounts/); + assert.match(text(blocks), /Personal connections/); + assert.match(rows(blocks)[0], /gmail — mail-first · first@example\.test\nACTIVE · Default · oauth/); + assert.match(rows(blocks)[1], /EXPIRED/); + assert.match(rows(blocks)[2], /slack — id-only/); + assert.match(text(blocks), /may omit inactive or unpublished/); + assert.deepEqual(buttons(blocks).map((button) => JSON.parse(button.value)), [{ o: "composio_refresh" }]); +}); + +test("loading, empty, partial, unavailable and unconfigured states remain honest and refreshable", () => { + for (const [inventory, expected] of [ + [undefined, /Loading connections/], + [ready([]), /reported no connected accounts/], + [ready([account("known")], { state: "partial" }), /list is incomplete/], + [ready([], { state: "unavailable", message: "Provider temporarily unavailable" }), /Provider temporarily unavailable/], + [ready([], { state: "unconfigured", source: "none" }), /No Composio connection is configured/], + ]) { + const blocks = composioInventoryBlocks(inventory); + assert.match(text(blocks), expected); + assert.equal(buttons(blocks)[0].text.text, "Refresh"); + assert.equal(buttons(blocks)[0].action_id, COMPOSIO_INVENTORY_ACTION_ID); + } + assert.equal(rows(composioInventoryBlocks(ready([account("known")], { state: "partial" }))).length, 1); +}); + +test("source labels distinguish personal, channel, organization, SDK and direct-message identities", () => { + for (const [source, label] of [ + ["user", "Personal connections"], ["channel", "Channel connections"], ["org", "Organization connections"], + ["sdk-user", "Personal connections · organization Composio SDK"], + ["sdk-channel", "Channel connections · organization Composio SDK"], + ["none-dm", "Direct message · personal connections only"], ["none-no-org-default", "Organization fallback disabled"], + ]) assert.match(text(composioInventoryBlocks(ready([], { source }))), new RegExp(label)); + assert.doesNotMatch(text(composioInventoryBlocks(ready([], { mode: "sdk" }))), /may omit/); +}); + +test("seventy accounts remain reachable through native pagination without dropping rows", () => { + const accounts = Array.from({ length: 70 }, (_, index) => account(String(index))); + const visited = []; + let page = 0; + for (;;) { + const blocks = composioInventoryBlocks(ready(accounts), { page }); + const accountRows = rows(blocks); + assert.ok(accountRows.length <= COMPOSIO_INVENTORY_PAGE_SIZE); + assert.ok(blocks.length <= 10, "bounded modal space on mobile"); + visited.push(...accountRows.map((row) => row.match(/mail-(\d+)/)[1])); + const previous = buttons(blocks).find((button) => button.text.text === "Previous accounts"); + assert.equal(Boolean(previous), page > 0); + if (previous) assert.equal(JSON.parse(previous.value).page, page - 1); + const next = buttons(blocks).find((button) => button.text.text === "Next accounts"); + if (!next) break; + assert.equal(JSON.parse(next.value).o, "composio_page"); + const nextPage = JSON.parse(next.value).page; + assert.equal(nextPage, page + 1); + page = nextPage; + } + assert.deepEqual(visited, accounts.map((entry) => entry.id)); + assert.match(text(composioInventoryBlocks(ready(accounts), { page })), /Accounts 67–70 of 70 · Page 12 of 12/); +}); + +test("arbitrary provider text stays inert and all Block Kit text and button limits hold", () => { + const malicious = " <@U123> *bold* `code` & HTML"; + const blocks = composioInventoryBlocks(ready([ + account("unsafe", { app: malicious, alias: malicious, account: malicious, status: malicious, accountType: malicious }), + account("large", { app: "a".repeat(5000), alias: "b".repeat(5000), account: "c".repeat(5000), status: "s".repeat(5000), accountType: "t".repeat(5000) }), + ], { source: malicious, state: "partial", message: malicious.repeat(100) })); + assert.match(rows(blocks)[0], //, "raw label remains readable as plain text"); + for (const block of blocks) { + if (block.text) { + assert.ok(block.text.text.length <= 3000); + if (block.text.type === "mrkdwn") assert.doesNotMatch(block.text.text, /|<@U123>|https:\/\//); + } + for (const element of block.elements || []) { + if (element.type === "plain_text") assert.ok(element.text.length <= 2000); + assert.notEqual(element.type, "mrkdwn"); + } + } + for (const button of buttons(blocks)) assert.ok(button.text.text.length <= 75); +}); + +test("page normalization clamps stale and forged page requests and passes scoped commands unchanged", () => { + for (const page of [undefined, NaN, Infinity, -Infinity, {}, [], null, -100, "invalid"]) { + assert.equal(normalizeComposioInventoryPage(page, 70), 0); + } + assert.equal(normalizeComposioInventoryPage(999, 70), 11); + assert.equal(normalizeComposioInventoryPage("2", 70), 2); + assert.equal(normalizeComposioInventoryPage(2.9, 70), 2); + assert.equal(normalizeComposioInventoryPage(3, 0), 0); + const calls = []; + const blocks = composioInventoryBlocks(ready(Array.from({ length: 14 }, (_, i) => account(String(i)))), { + page: 1, command(operation, extra = {}) { calls.push({ operation, extra }); return JSON.stringify({ o: operation, c: "channel", ...extra }); }, + }); + assert.deepEqual(calls, [ + { operation: "composio_refresh", extra: {} }, + { operation: "composio_page", extra: { page: 0 } }, + { operation: "composio_page", extra: { page: 2 } }, + ]); + for (const button of buttons(blocks)) assert.equal(JSON.parse(button.value).c, "channel"); +});