Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
6 changes: 6 additions & 0 deletions FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 22 additions & 0 deletions TEST-PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
5 changes: 5 additions & 0 deletions documentation/src/content/docs/configuration/connections.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
116 changes: 109 additions & 7 deletions src/slack/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -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
Expand Down Expand Up @@ -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 });
}

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
7 changes: 7 additions & 0 deletions src/slack/channel-settings.js
Original file line number Diff line number Diff line change
Expand Up @@ -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_";
Expand Down Expand Up @@ -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) } : {}),
});
}

Expand All @@ -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) } : {}),
};
}

Expand Down Expand Up @@ -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"));
Expand Down
Loading
Loading