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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
15 changes: 15 additions & 0 deletions FEATURES.md
Original file line number Diff line number Diff line change
@@ -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.

Expand Down
34 changes: 34 additions & 0 deletions TEST-PLAN.md
Original file line number Diff line number Diff line change
@@ -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:<team>:user:<id>` 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
Expand Down
15 changes: 15 additions & 0 deletions documentation/src/content/docs/configuration/connections.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
69 changes: 69 additions & 0 deletions public/admin-composio.js
Original file line number Diff line number Diff line change
@@ -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 `<tr><td>${escapeHtml(account.app || "Unknown app")}</td><td><strong>${escapeHtml(label)}</strong>${account.isDefault ? " <small>· default</small>" : ""}${details.length ? `<br><small>${details.map(escapeHtml).join(" · ")}</small>` : ""}</td><td>${escapeHtml(account.status || "UNKNOWN")}</td></tr>`;
}).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 `<p class="desc">${escapeHtml(source)}</p>
<p class="state">${escapeHtml(result.message || "")}</p>
${accounts.length ? `<p class="state">${escapeHtml(count + checked)}</p><div class="composio-table-wrap"><table class="composio-table"><thead><tr><th scope="col">Application</th><th scope="col">Account</th><th scope="col">Status</th></tr></thead><tbody>${rows}</tbody></table></div>` : result.state === "ready" ? "<p class=\"state\">No accounts reported by Composio.</p>" : ""}
${result.mode === "personal" ? "<p class=\"hint\">Shows the apps and accounts Composio publishes for this key. Inactive or unpublished connections may not appear.</p>" : ""}`;
}

export function mountComposioInventory(root, endpoint, { request = api, autoLoad = true } = {}) {
root.innerHTML = `<div class="card-foot"><h4>Connected Composio apps</h4><button type="button" class="ghost composio-refresh">Refresh connections</button></div><div class="composio-inventory-result" role="status" aria-live="polite"><p class="state">Open this section to load connections.</p></div>`;
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;
}
6 changes: 6 additions & 0 deletions public/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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) {
Expand All @@ -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();
});
}

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

Expand Down
4 changes: 4 additions & 0 deletions public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -1289,6 +1289,8 @@ <h2 class="ch-name"></h2>

<!-- ── CONNECTIONS ────────────────────────────────────────────────── -->
<div class="subpane" data-pane="connections">
<div class="setcard ch-composio-inventory"></div>
<p class="hint">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.</p>
<div class="tok-fields">
<label class="field">
<span>Shared Composio token (<code>composio</code>; personal <code>composio-user</code> stays available)</span>
Expand Down Expand Up @@ -1507,6 +1509,8 @@ <h3 class="ud-name-title"></h3>
<input class="ud-composio" type="password" placeholder="Composio token (x-consumer-api-key)" />
<em class="state ud-composio-state"></em>
</div>
<div class="ud-row ud-composio-inventory"></div>
<p class="hint">This person's personal accounts. Shared accounts are listed on each channel's Connections page. Save key changes before refreshing.</p>
<div class="ud-row">
<label class="fldlab">Toolbox token</label>
<input class="ud-toolboxtoken-label tok-label" type="text" placeholder="owner — whose account? (label, optional)" autocomplete="off" />
Expand Down
7 changes: 7 additions & 0 deletions public/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Loading
Loading