);
}
- const connectors = [...connectorTools(plugins.data).entries()];
+ const admin = me?.role === "admin";
+ const tools = connectorTools(plugins.data);
+ const states = apps.data ?? [];
+ /*
+ * Every app the Bot holds a grant for, by the app's own name, and then any connector the grant
+ * snapshot names that the app-access read did not — which is none on a healthy deployment, and
+ * keeps a row from vanishing on one where the second read failed.
+ */
+ const described = new Set(states.map((app) => app.key));
+ const rest = [...tools.entries()].filter(([key]) => !described.has(key));
const skills = plugins.data.skills;
+ const count = states.length + rest.length + skills.length;
return (
- {connectors.length === 0 && skills.length === 0 ? (
+ {apps.error ? (
+
+ Whether each app is ready for you could not be loaded.
+
+ ) : null}
+ {count === 0 ? (
Nothing granted yet. An administrator grants apps from the Plugins
screens; until then this Bot can converse, and nothing more.
) : (
- {connectors.map(([key, labels], index) => (
+ {states.map((app, index) => {
+ const labels = tools.get(app.key) ?? [];
+ return (
+
+ {index > 0 ? : null}
+
+
+
+
+
+ {app.displayName}
+
+ {app.state === "ready"
+ ? [appAccessSentence(app.state, app.displayName)]
+ .concat(labels.length > 0 ? [toolList(labels)] : [])
+ .join(" ")
+ : appAccessSentence(app.state, app.displayName)}
+
+
+
+ {app.state === "ready" ? (
+
+ ) : (
+
+ )}
+ {admin ? (
+
+ ) : null}
+
+
+
+ );
+ })}
+ {rest.map(([key, labels], index) => (
- {index > 0 ? : null}
-
- ) : undefined
- }
- size="sm"
- >
+ {states.length + index > 0 ? : null}
+ {connectorName(key)}
-
- {labels.slice(0, 4).join(", ")}
- {labels.length > 4 ? ` and ${labels.length - 4} more` : ""}
-
+ {toolList(labels)}
-
- {labels.length} {labels.length === 1 ? "tool" : "tools"}
-
- {me?.role === "admin" ? (
-
+
+ {admin ? (
+
) : null}
@@ -212,7 +278,7 @@ function Granted({ agentId }: { agentId: string }) {
))}
{skills.map((skill, index) => (
- {connectors.length + index > 0 ? : null}
+ {states.length + rest.length + index > 0 ? : null}
@@ -232,3 +298,49 @@ function Granted({ agentId }: { agentId: string }) {
);
}
+
+/** The first few tool labels, and how many more. */
+function toolList(labels: string[]): string {
+ return `${labels.slice(0, 4).join(", ")}${labels.length > 4 ? ` and ${labels.length - 4} more` : ""}`;
+}
+
+function ToolCount({ count }: { count: number }) {
+ return (
+
+ {count} {count === 1 ? "tool" : "tools"}
+
+ );
+}
+
+/**
+ * Where an administrator decides which of the app's tools this Bot holds.
+ *
+ * A CHEVRON OF ITS OWN RATHER THAN THE WHOLE ROW. The row used to be the link, which was right while
+ * it held nothing else; now it can hold Connect or Request access, and a button inside a link is two
+ * controls answering one click. The destination is unchanged.
+ */
+function ManageLink({
+ agentId,
+ appKey,
+ title,
+}: {
+ agentId: string;
+ appKey: string;
+ title: string;
+}) {
+ return (
+
+ }
+ size="icon-sm"
+ variant="ghost"
+ >
+
+
+ );
+}
diff --git a/app/src/components/channels/connect-app-card.tsx b/app/src/components/channels/connect-app-card.tsx
index 89f712d54d..56361a9e84 100644
--- a/app/src/components/channels/connect-app-card.tsx
+++ b/app/src/components/channels/connect-app-card.tsx
@@ -1,8 +1,8 @@
-import { useMutation, useQuery } from "@tanstack/react-query";
+import { useQuery } from "@tanstack/react-query";
import { Link } from "@tanstack/react-router";
-import { useEffect, useRef, useState } from "react";
+import { useEffect, useRef } from "react";
+import { useConnectInNewTab } from "@/components/plugins/connect-in-new-tab";
import { Button } from "@/components/ui/button";
-import { connectAccountMutationOptions } from "@/lib/plugins/mutations";
import {
connectionsQueryOptions,
personalConnections,
@@ -21,6 +21,11 @@ import { queryClient } from "@/query-client";
* WATCHING, NOT TRUSTING. The return trip proves nothing — it is an ordinary redirect in a tab this
* one cannot read — so nothing here treats "they came back" as "it worked". The card polls this
* person's own connections while it waits and only says connected when the server lists one.
+ *
+ * NOW THE FALLBACK RATHER THAN THE CARD. A refusal names the Bot it came from and `AppAccessCard`
+ * draws it from the app's live state for this person on that Bot. This one is drawn only where no
+ * Bot can be named — a refusal written before refusals carried one, on a surface that declared none
+ * — and it can only offer Connect, which is all such a refusal ever meant.
*/
export function ConnectAppCard({
serverId,
@@ -31,12 +36,15 @@ export function ConnectAppCard({
title: string;
message: string;
}) {
- const [waiting, setWaiting] = useState(false);
- /** This app takes a key somebody types, so there is no page to send them to. */
- const [needsKey, setNeedsKey] = useState(false);
- /** The authorization url, when the browser refused to open the tab holding it. */
- const [blockedUrl, setBlockedUrl] = useState(null);
- const connect = useMutation(connectAccountMutationOptions());
+ const {
+ open: openTab,
+ waiting,
+ stopWaiting,
+ needsKey,
+ blockedUrl,
+ pending,
+ error,
+ } = useConnectInNewTab();
const connections = useQuery({
...connectionsQueryOptions(),
// Only while a consent is open in the other tab; otherwise this is an ordinary cached read.
@@ -59,53 +67,13 @@ export function ConnectAppCard({
const was = useRef(connected);
useEffect(() => {
if (connected && !was.current) {
- setWaiting(false);
+ stopWaiting();
void queryClient.invalidateQueries();
}
was.current = connected;
- }, [connected]);
-
- /**
- * The tab, opened on the click itself rather than on the answer to it.
- *
- * A BROWSER ONLY LETS A PAGE OPEN A TAB WHILE A CLICK IS STILL RECENT. Chrome's transient
- * activation lasts about five seconds, and the request that mints the authorization url has to
- * finish first — so on a slow deployment `window.open` was called with the activation already
- * expired, the tab was blocked, and the card cheerfully said to finish in a tab that did not
- * exist. The blank tab is claimed first and navigated once the url arrives.
- *
- * `noopener` CANNOT BE PASSED HERE: it makes `window.open` answer null, and null is a handle
- * nothing can navigate later. The opener is severed on the handle instead, which is the same
- * guarantee by a different route.
- */
- const claimTab = (): Window | null => {
- const tab = window.open("", "_blank");
- if (tab) tab.opener = null;
- return tab;
- };
+ }, [connected, stopWaiting]);
- const open = () => {
- const tab = claimTab();
- connect.mutate(serverId, {
- onSuccess: (authorizationUrl) => {
- /*
- * No url means this app is connected by typing a key rather than by visiting a page — the
- * route answers `{ fields }` and the mutation unwraps nothing. Saying so, with the page
- * that has the form, beats a button that silently does nothing.
- */
- if (!authorizationUrl) {
- tab?.close();
- setNeedsKey(true);
- return;
- }
- setWaiting(true);
- if (tab) tab.location.replace(authorizationUrl);
- // The tab was blocked. The url is still good, so offer it as something to click.
- else setBlockedUrl(authorizationUrl);
- },
- onError: () => tab?.close(),
- });
- };
+ const open = () => openTab(serverId);
return (
{/* The heading above already names the app; the button says what pressing it does. */}
-
diff --git a/app/src/components/plugins/app-access-action.tsx b/app/src/components/plugins/app-access-action.tsx
new file mode 100644
index 0000000000..25d1fa9eb9
--- /dev/null
+++ b/app/src/components/plugins/app-access-action.tsx
@@ -0,0 +1,236 @@
+import { useMutation } from "@tanstack/react-query";
+import { Link } from "@tanstack/react-router";
+import { useEffect, useRef } from "react";
+import { useConnectInNewTab } from "@/components/plugins/connect-in-new-tab";
+import { Button } from "@/components/ui/button";
+import {
+ cancelAppAccessRequestMutationOptions,
+ requestAppAccessMutationOptions,
+} from "@/lib/app-access/mutations";
+import {
+ type AppAccess,
+ type AppAccessState,
+ appAccessKeys,
+} from "@/lib/app-access/queries";
+import { queryClient } from "@/query-client";
+
+/**
+ * One work app's state for the person looking, and the one thing they can do about it.
+ *
+ * ONE PIECE, DRAWN IN TWO PLACES. A Bot's page lists every app the Bot holds with a row each, and a
+ * conversation draws a card when the Bot needed an app it could not use. Both say the same thing —
+ * whether this person can use this app through this Bot, whose move it is if not, and the button
+ * for that move — so both draw it from here, and a sentence corrected for one is corrected for both.
+ *
+ * WHO CAN FIX IT IS THE WHOLE SENTENCE. Six states reduce to three answers: nothing to do, a step
+ * that is yours (connect, reconnect), and a step only an administrator can take (switch it on,
+ * grant it, connect the team account). The copy names which, because "Gmail is unavailable" sends
+ * somebody hunting through their own settings for a switch that is not there.
+ *
+ * THE STATE IS THE SERVER'S. It arrives on `access` and is drawn as it is; nothing here works out
+ * from `granted` or `accountMode` what the state ought to be. The one exception is the caller's: a
+ * card may hand in an `access` whose state it overrode from the refusal it was drawn for — see
+ * `AppAccessCard` — and that is the caller's decision, made where the refusal is in hand.
+ */
+
+/** What the person is told about one app, in words that say whose move it is. */
+export function appAccessSentence(
+ state: AppAccessState,
+ displayName: string,
+): string {
+ switch (state) {
+ case "ready":
+ return "Ready to use.";
+ case "not_connected":
+ return `You haven't connected your ${displayName} account yet.`;
+ case "reconnect":
+ return `Your ${displayName} connection needs reconnecting.`;
+ case "shared_not_connected":
+ return `Your organisation's ${displayName} account isn't connected yet. Only an administrator can connect it.`;
+ case "disabled":
+ return `Turned off by an administrator. Only they can turn ${displayName} on.`;
+ case "not_granted":
+ return `This Bot hasn't been given ${displayName}. Only an administrator can give it.`;
+ }
+}
+
+/**
+ * The button for the person's next move, and what it says while it happens.
+ *
+ * - `ready` — nothing.
+ * - `not_connected` / `reconnect` — the person's own connection. Connect starts it in a new tab
+ * with the connect route Settings presses; an app connected with a typed key, and a Composio app
+ * that already has a row (which the connect route refuses until it is disconnected), go to the
+ * app's own page in Settings instead, where the form and Disconnect are.
+ * - the three an administrator fixes — Request access, which files the request the server decides
+ * the kind of, and then Requested with a way to withdraw it.
+ */
+export function AppAccessAction({
+ agentId,
+ access,
+}: {
+ agentId: string;
+ access: AppAccess;
+}) {
+ if (access.state === "ready") return null;
+ if (access.state === "not_connected" || access.state === "reconnect")
+ return ;
+ return ;
+}
+
+/** The person's own account: connect it, or reconnect it. */
+function ConnectAction({ access }: { access: AppAccess }) {
+ const label = access.state === "reconnect" ? "Reconnect" : "Connect";
+ const tab = useConnectInNewTab();
+ const { waiting, stopWaiting } = tab;
+
+ /*
+ * WATCHING THE SERVER WHILE A CONSENT IS OPEN in the other tab, because the return trip proves
+ * nothing. The whole app-access view is refreshed every two seconds until this app stops being
+ * this person's to connect — which is what unmounts this component — and then everything once,
+ * because the grants a Bot is offered are read per conversation and the turn after this has to
+ * be asked against a store that knows about the new account.
+ */
+ useEffect(() => {
+ if (!waiting) return;
+ const timer = setInterval(() => {
+ void queryClient.invalidateQueries({ queryKey: appAccessKeys.all });
+ }, 2_000);
+ return () => clearInterval(timer);
+ }, [waiting]);
+ const waited = useRef(false);
+ waited.current ||= waiting;
+ useEffect(
+ () => () => {
+ if (waited.current) void queryClient.invalidateQueries();
+ },
+ [],
+ );
+
+ /*
+ * A FORM, OR A CONNECTION THAT ALREADY HAS A ROW, IS SETTINGS' JOB. A typed key has nowhere to be
+ * typed but the app's page, and a Composio app whose row still stands — a refused key, an account
+ * the vendor lost — is refused by the connect route until it is disconnected there. Only this
+ * deployment's own OAuth flow takes a fresh consent over a withdrawn one.
+ */
+ const viaSettings =
+ access.connect === "key" ||
+ (access.state === "reconnect" && access.connect !== "oauth") ||
+ tab.needsKey;
+ if (viaSettings)
+ return (
+
+ }
+ size="sm"
+ variant="outline"
+ >
+ {label}
+
+ );
+
+ return (
+
+ );
+}
+
+/** A step only an administrator can take: ask for it, and withdraw the ask. */
+function RequestAction({
+ agentId,
+ access,
+}: {
+ agentId: string;
+ access: AppAccess;
+}) {
+ const request = useMutation(requestAppAccessMutationOptions(queryClient));
+ const cancel = useMutation(
+ cancelAppAccessRequestMutationOptions(queryClient),
+ );
+ const error = request.error ?? cancel.error;
+
+ /*
+ * Nothing to ask for where the server names no request: a state a caller overrode from a refusal
+ * the server does not know about (the team account lost at the vendor), which the server would
+ * answer 409 because as far as it knows nothing is wrong. The sentence still says whose move it is.
+ */
+ if (!access.request && !access.requestKind) return null;
+
+ return (
+
+ );
+}
diff --git a/app/src/components/plugins/connect-in-new-tab.ts b/app/src/components/plugins/connect-in-new-tab.ts
new file mode 100644
index 0000000000..a96ac0120a
--- /dev/null
+++ b/app/src/components/plugins/connect-in-new-tab.ts
@@ -0,0 +1,89 @@
+import { useMutation } from "@tanstack/react-query";
+import { useCallback, useState } from "react";
+import { connectAccountMutationOptions } from "@/lib/plugins/mutations";
+
+/**
+ * Start this person's own connection to one app in a new tab, the way Settings starts it.
+ *
+ * THE SAME WRITE AS SETTINGS, A DIFFERENT TAB. `connectAccountMutationOptions` is the connect route
+ * the connected-accounts page presses, returning to that page when the vendor is done, so the
+ * confirm that writes the connection down is the one that page already runs. What differs is where
+ * the consent screen opens: Settings navigates its own tab, because the person went there to
+ * connect; a card in a conversation or a row on a Bot's page opens a new one, because the person is
+ * in the middle of something and a full navigation several redirects long would take them out of
+ * it. This tab stays where it was, and whoever drew the button watches the server for the
+ * connection to appear — the return trip proves nothing, being an ordinary redirect in a tab this
+ * one cannot read.
+ *
+ * Lifted out of `ConnectAppCard`, which had it inline, so the Bot page and the app-access card start
+ * a connection with the same code rather than a second copy of the popup rules below.
+ */
+export function useConnectInNewTab() {
+ const [waiting, setWaiting] = useState(false);
+ /** This app takes a key somebody types, so there is no page to send them to. */
+ const [needsKey, setNeedsKey] = useState(false);
+ /** The authorization url, when the browser refused to open the tab holding it. */
+ const [blockedUrl, setBlockedUrl] = useState(null);
+ const connect = useMutation(connectAccountMutationOptions());
+ // Stable, so a caller can list it in an effect's dependencies without re-running it every render.
+ const stopWaiting = useCallback(() => setWaiting(false), []);
+
+ /**
+ * The tab, opened on the click itself rather than on the answer to it.
+ *
+ * A BROWSER ONLY LETS A PAGE OPEN A TAB WHILE A CLICK IS STILL RECENT. Chrome's transient
+ * activation lasts about five seconds, and the request that mints the authorization url has to
+ * finish first — so on a slow deployment `window.open` was called with the activation already
+ * expired, the tab was blocked, and the card cheerfully said to finish in a tab that did not
+ * exist. The blank tab is claimed first and navigated once the url arrives.
+ *
+ * `noopener` CANNOT BE PASSED HERE: it makes `window.open` answer null, and null is a handle
+ * nothing can navigate later. The opener is severed on the handle instead, which is the same
+ * guarantee by a different route.
+ */
+ const claimTab = (): Window | null => {
+ const tab = window.open("", "_blank");
+ if (tab) tab.opener = null;
+ return tab;
+ };
+
+ const open = (serverId: string) => {
+ const tab = claimTab();
+ connect.mutate(serverId, {
+ onSuccess: (authorizationUrl) => {
+ /*
+ * No url means this app is connected by typing a key rather than by visiting a page — the
+ * route answers `{ fields }` and the mutation unwraps nothing. Saying so, with the page
+ * that has the form, beats a button that silently does nothing.
+ */
+ if (!authorizationUrl) {
+ tab?.close();
+ setNeedsKey(true);
+ return;
+ }
+ setWaiting(true);
+ if (tab) tab.location.replace(authorizationUrl);
+ // The tab was blocked. The url is still good, so offer it as something to click.
+ else setBlockedUrl(authorizationUrl);
+ },
+ onError: () => tab?.close(),
+ });
+ };
+
+ return {
+ open,
+ /** A consent is open in the other tab; whoever drew the button should be watching. */
+ waiting,
+ /** Called once the server lists the connection, so the button stops saying it is waiting. */
+ stopWaiting,
+ needsKey,
+ blockedUrl,
+ pending: connect.isPending,
+ /** The connect route's own sentence, when it refused. */
+ error: connect.isError
+ ? connect.error instanceof Error
+ ? connect.error.message
+ : "That account could not be connected."
+ : null,
+ };
+}
diff --git a/app/src/lib/app-access/queries.ts b/app/src/lib/app-access/queries.ts
index 4cda503b06..d8366366ce 100644
--- a/app/src/lib/app-access/queries.ts
+++ b/app/src/lib/app-access/queries.ts
@@ -46,6 +46,11 @@ export type AppAccess = {
state: AppAccessState;
/** Whether this Bot holds any of the app's tools. */
granted: boolean;
+ /**
+ * When the account this app runs in for this person was connected, or null. Read by the chat card
+ * to tell an account lost at the vendor from one connected again since. See `AppAccessCard`.
+ */
+ connectedAt: string | null;
/** The request Request access files for this state; null where the next step is the person's. */
requestKind: AppAccessRequestKind | null;
/** This person's pending request of exactly that kind — drawn as "Requested". */
diff --git a/app/tests/app-access-action.test.tsx b/app/tests/app-access-action.test.tsx
new file mode 100644
index 0000000000..155b8d1e6e
--- /dev/null
+++ b/app/tests/app-access-action.test.tsx
@@ -0,0 +1,242 @@
+import { afterAll, afterEach, beforeAll, expect, test } from "bun:test";
+import { GlobalRegistrator } from "@happy-dom/global-registrator";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import {
+ createMemoryHistory,
+ createRootRoute,
+ createRoute,
+ createRouter,
+ Outlet,
+ RouterProvider,
+} from "@tanstack/react-router";
+import { cleanup, fireEvent, render, waitFor } from "@testing-library/react";
+import {
+ AppAccessAction,
+ appAccessSentence,
+} from "@/components/plugins/app-access-action";
+import type { AppAccess, AppAccessState } from "@/lib/app-access/queries";
+import { appAccess } from "./app-access-fixture";
+import { settleReactWork } from "./settle-react-work";
+
+/**
+ * The one piece the Bot page and the chat card both draw an app's state with.
+ *
+ * Each of the six states must say whose move it is and offer exactly that move — and nothing for a
+ * state that needs none. The two moves that write are asserted against the request they send: the
+ * browser names a Bot and an app and never a kind, which is the server's to decide.
+ */
+
+let originalOpen: typeof window.open;
+beforeAll(() => {
+ GlobalRegistrator.register();
+ originalOpen = window.open;
+});
+afterEach(cleanup);
+afterAll(async () => {
+ await settleReactWork();
+ GlobalRegistrator.unregister();
+});
+
+const originalFetch = global.fetch;
+afterEach(() => {
+ global.fetch = originalFetch;
+ window.open = originalOpen;
+});
+
+type Sent = { method: string; url: string; body: unknown };
+
+function mounted(
+ access: AppAccess,
+ answer?: { status: number; body: unknown },
+) {
+ const sent: Sent[] = [];
+ const opened: string[] = [];
+ window.open = (() => {
+ const tab = {
+ opener: {} as unknown,
+ location: { replace: (next: string) => opened.push(next) },
+ close: () => {},
+ };
+ return tab as unknown as Window;
+ }) as typeof window.open;
+ global.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
+ const url = String(input);
+ const method = init?.method ?? "GET";
+ sent.push({
+ method,
+ url,
+ body: init?.body ? JSON.parse(String(init.body)) : undefined,
+ });
+ if (answer && method !== "GET")
+ return Response.json(answer.body, { status: answer.status });
+ if (url.endsWith("/connect?returnTo=settings"))
+ return Response.json({ authorizationUrl: "https://gmail.test/consent" });
+ return Response.json({ request: { id: "req_1" }, created: true });
+ }) as unknown as typeof fetch;
+
+ const client = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ });
+ const root = createRootRoute({ component: Outlet });
+ const page = createRoute({
+ getParentRoute: () => root,
+ path: "/",
+ component: () => (
+
+ ),
+ });
+ const settings = createRoute({
+ getParentRoute: () => root,
+ path: "/settings/connected-accounts/$key",
+ component: () => null,
+ });
+ const router = createRouter({
+ routeTree: root.addChildren([page, settings]),
+ history: createMemoryHistory({ initialEntries: ["/"] }),
+ });
+ const view = render(
+
+
+ ,
+ );
+ return { view, sent, opened };
+}
+
+const COPY: Record = {
+ ready: /^Ready to use\.$/,
+ not_connected: /You haven't connected your Gmail account yet\./,
+ reconnect: /Your Gmail connection needs reconnecting\./,
+ shared_not_connected:
+ /Your organisation's Gmail account isn't connected yet\. Only an administrator can connect it\./,
+ disabled: /Turned off by an administrator\. Only they can turn Gmail on\./,
+ not_granted:
+ /This Bot hasn't been given Gmail\. Only an administrator can give it\./,
+};
+
+test.each(Object.entries(COPY))(
+ "says whose move it is: %s",
+ (state, sentence) => {
+ expect(appAccessSentence(state as AppAccessState, "Gmail")).toMatch(
+ sentence,
+ );
+ },
+);
+
+test("ready offers nothing to press", async () => {
+ const { view } = mounted(appAccess());
+ await view.findByText("Ready to use.");
+ expect(view.queryByRole("button")).toBeNull();
+ expect(view.queryByRole("link")).toBeNull();
+});
+
+test("not connected offers Connect, which starts the person's own consent in a new tab", async () => {
+ const { view, sent, opened } = mounted(appAccess({ state: "not_connected" }));
+ fireEvent.click(await view.findByRole("button", { name: "Connect" }));
+ await waitFor(() => expect(opened).toEqual(["https://gmail.test/consent"]));
+ expect(sent.map((call) => `${call.method} ${call.url}`)).toContain(
+ "POST /api/plugins/servers/composio-gmail/connect?returnTo=settings",
+ );
+ await view.findByText("Finish in the tab that opened.");
+});
+
+test("an app connected with a typed key goes to its page in Settings, where the form is", async () => {
+ const { view } = mounted(
+ appAccess({ state: "not_connected", connect: "key" }),
+ );
+ // Drawn as a button (`Button` over a `Link`), and an anchor underneath with the address.
+ const link = (await view.findByText("Connect")).closest("a");
+ expect(link?.getAttribute("href")).toBe(
+ "/settings/connected-accounts/composio-gmail",
+ );
+});
+
+test("a Composio app to reconnect goes to Settings, because the connect route refuses a row that stands", async () => {
+ const { view } = mounted(appAccess({ state: "reconnect" }));
+ const link = (await view.findByText("Reconnect")).closest("a");
+ expect(link?.getAttribute("href")).toBe(
+ "/settings/connected-accounts/composio-gmail",
+ );
+});
+
+test("this deployment's own OAuth app reconnects in place, with a fresh consent", async () => {
+ const { view, opened } = mounted(
+ appAccess({
+ key: "notion",
+ displayName: "Notion",
+ provider: "catalogue",
+ connect: "oauth",
+ state: "reconnect",
+ }),
+ );
+ fireEvent.click(await view.findByRole("button", { name: "Reconnect" }));
+ await waitFor(() => expect(opened).toHaveLength(1));
+});
+
+test.each([
+ ["disabled", "enable"],
+ ["not_granted", "grant"],
+ ["shared_not_connected", "connect_shared"],
+] as const)(
+ "%s offers Request access, naming the Bot and the app and never the kind",
+ async (state, kind) => {
+ const { view, sent } = mounted(appAccess({ state, requestKind: kind }));
+ fireEvent.click(
+ await view.findByRole("button", { name: "Request access" }),
+ );
+ await waitFor(() =>
+ expect(sent.find((call) => call.method === "POST")).toEqual({
+ method: "POST",
+ url: "/api/plugins/access-requests",
+ body: { agentId: "expenses", app: "composio-gmail" },
+ }),
+ );
+ },
+);
+
+test("a pending request reads Requested, and Withdraw cancels it", async () => {
+ const { view, sent } = mounted(
+ appAccess({
+ state: "disabled",
+ requestKind: "enable",
+ request: {
+ id: "req_9",
+ kind: "enable",
+ createdAt: "2026-10-20T10:00:00.000Z",
+ },
+ }),
+ );
+ await view.findByText("Requested");
+ expect(view.queryByRole("button", { name: "Request access" })).toBeNull();
+ fireEvent.click(view.getByRole("button", { name: "Withdraw" }));
+ await waitFor(() =>
+ expect(
+ sent.some(
+ (call) =>
+ call.method === "DELETE" &&
+ call.url === "/api/plugins/access-requests/req_9",
+ ),
+ ).toBe(true),
+ );
+});
+
+test("the server's refusal of a request is drawn beside the button", async () => {
+ const { view } = mounted(
+ appAccess({ state: "not_granted", requestKind: "grant" }),
+ { status: 409, body: { error: "Gmail is ready to use." } },
+ );
+ fireEvent.click(await view.findByRole("button", { name: "Request access" }));
+ expect((await view.findByRole("alert")).textContent).toBe(
+ "Gmail is ready to use.",
+ );
+});
+
+test("a state with no request to file offers none, rather than a button the server would refuse", async () => {
+ const { view } = mounted(
+ appAccess({ state: "shared_not_connected", requestKind: null }),
+ );
+ await view.findByText(/organisation's Gmail account/);
+ expect(view.queryByRole("button")).toBeNull();
+});
diff --git a/app/tests/app-access-fixture.ts b/app/tests/app-access-fixture.ts
new file mode 100644
index 0000000000..10930a6732
--- /dev/null
+++ b/app/tests/app-access-fixture.ts
@@ -0,0 +1,24 @@
+import type { AppAccess } from "@/lib/app-access/queries";
+
+/**
+ * One app as `GET /api/plugins/for/:agentId/apps` answers it: Gmail, through Composio, under each
+ * person's own account, ready. Each test overrides only the fields its case is about.
+ */
+export function appAccess(overrides: Partial = {}): AppAccess {
+ return {
+ key: "composio-gmail",
+ displayName: "Gmail",
+ logo: null,
+ provider: "composio",
+ account: "personal",
+ connect: "consent",
+ authScheme: "OAUTH2",
+ accountMode: "personal",
+ state: "ready",
+ granted: true,
+ connectedAt: null,
+ requestKind: null,
+ request: null,
+ ...overrides,
+ };
+}
diff --git a/app/tests/bot-access-apps.test.tsx b/app/tests/bot-access-apps.test.tsx
new file mode 100644
index 0000000000..0efee53595
--- /dev/null
+++ b/app/tests/bot-access-apps.test.tsx
@@ -0,0 +1,235 @@
+import { afterAll, afterEach, beforeAll, expect, test } from "bun:test";
+import { GlobalRegistrator } from "@happy-dom/global-registrator";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import {
+ createMemoryHistory,
+ createRootRoute,
+ createRoute,
+ createRouter,
+ Outlet,
+ RouterProvider,
+} from "@tanstack/react-router";
+import { cleanup, render, within } from "@testing-library/react";
+import { AccessSections } from "@/components/bot-profile/access";
+import type { AgentProfile } from "@/lib/agents/queries";
+import type { AppAccess } from "@/lib/app-access/queries";
+import { appAccess } from "./app-access-fixture";
+import { settleReactWork } from "./settle-react-work";
+
+/**
+ * The Granted section of a Bot's page, which used to list what a Bot holds and say nothing about
+ * whether the person reading could use any of it.
+ *
+ * Each app is named by its own name — never "Composio Gmail" — and says what it is for the person
+ * looking, with the one move they have. An administrator keeps the way to the Bot's grants for each
+ * app; nobody gets anybody else's connection state, because the read answers for the asker alone.
+ */
+
+beforeAll(() => GlobalRegistrator.register());
+afterEach(cleanup);
+afterAll(async () => {
+ await settleReactWork();
+ GlobalRegistrator.unregister();
+});
+
+const originalFetch = global.fetch;
+afterEach(() => {
+ global.fetch = originalFetch;
+});
+
+const BOT: AgentProfile = {
+ id: "expenses",
+ name: "Expenses",
+ title: "Finance",
+ roleDescription: "Receipts.",
+ avatarSeed: "e",
+ visibility: "public",
+ endpoint: null,
+ builtIn: true,
+ hasAuth: false,
+ hasCallbackToken: false,
+ hidden: false,
+ pinned: false,
+ systemOwned: false,
+ canManage: false,
+ mine: false,
+};
+
+const tool = (ref: string, toolName: string) => ({
+ ref,
+ toolName,
+ description: "",
+ inputSchema: {},
+});
+
+function serving(options: {
+ role?: "admin" | "user";
+ apps?: AppAccess[] | "fail";
+ tools?: ReturnType[];
+}) {
+ const { role = "user", apps = [], tools = [] } = options;
+ global.fetch = (async (input: RequestInfo | URL) => {
+ const url = String(input);
+ if (url.endsWith("/api/plugins/for/expenses/apps"))
+ return apps === "fail"
+ ? Response.json({ error: "nope" }, { status: 500 })
+ : Response.json({ apps });
+ if (url.endsWith("/api/plugins/for/expenses"))
+ return Response.json({ tools, skills: [] });
+ if (url.includes("/handoff"))
+ return Response.json({
+ handoff: {
+ enabled: false,
+ canGrant: false,
+ reachable: [],
+ grantable: false,
+ },
+ });
+ if (url.includes("/api/agents")) return Response.json({ agents: [] });
+ return Response.json({
+ user: { id: "me", role, email: "me@example.test", name: "Me" },
+ });
+ }) as unknown as typeof fetch;
+}
+
+function draw() {
+ const client = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ });
+ const root = createRootRoute({ component: Outlet });
+ const page = createRoute({
+ getParentRoute: () => root,
+ path: "/",
+ component: () => ,
+ });
+ const grants = createRoute({
+ getParentRoute: () => root,
+ path: "/admin/plugins/$key/bots/$agentId",
+ component: () => null,
+ });
+ const settings = createRoute({
+ getParentRoute: () => root,
+ path: "/settings/connected-accounts/$key",
+ component: () => null,
+ });
+ const router = createRouter({
+ routeTree: root.addChildren([page, grants, settings]),
+ history: createMemoryHistory({ initialEntries: ["/"] }),
+ });
+ return render(
+
+
+ ,
+ );
+}
+
+const row = async (view: ReturnType, key: string) => {
+ await view.findByText("Granted");
+ const found = await view.findByText((_, element) =>
+ Boolean(element?.getAttribute("data-app") === key),
+ );
+ return within(found);
+};
+
+test("each app is named by its own name and says it is ready, with its tools", async () => {
+ serving({
+ apps: [appAccess()],
+ tools: [
+ tool(
+ "composio-gmail/GMAIL_SEND_EMAIL",
+ "mcp__composio-gmail__GMAIL_SEND_EMAIL",
+ ),
+ tool(
+ "composio-gmail/GMAIL_FETCH_EMAILS",
+ "mcp__composio-gmail__GMAIL_FETCH_EMAILS",
+ ),
+ ],
+ });
+ const view = draw();
+ const gmail = await row(view, "composio-gmail");
+ expect(gmail.getByText("Gmail")).toBeTruthy();
+ expect(gmail.getByText(/^Ready to use\./)).toBeTruthy();
+ expect(gmail.getByText("2 tools")).toBeTruthy();
+ expect(view.queryByText(/Composio Gmail/)).toBeNull();
+ expect(gmail.queryByRole("button")).toBeNull();
+});
+
+test("an app this person has not connected says so and offers Connect", async () => {
+ serving({ apps: [appAccess({ state: "not_connected" })] });
+ const gmail = await row(draw(), "composio-gmail");
+ expect(
+ gmail.getByText("You haven't connected your Gmail account yet."),
+ ).toBeTruthy();
+ expect(gmail.getByRole("button", { name: "Connect" })).toBeTruthy();
+});
+
+test("an app only an administrator can fix offers Request access, and a pending one reads Requested", async () => {
+ serving({
+ apps: [
+ appAccess({
+ key: "composio-slack",
+ displayName: "Slack",
+ state: "shared_not_connected",
+ account: "shared",
+ accountMode: "shared",
+ requestKind: "connect_shared",
+ }),
+ appAccess({
+ key: "notion",
+ displayName: "Notion",
+ provider: "catalogue",
+ state: "disabled",
+ requestKind: "enable",
+ request: {
+ id: "req_1",
+ kind: "enable",
+ createdAt: "2026-10-20T10:00:00.000Z",
+ },
+ }),
+ ],
+ });
+ const view = draw();
+ const slack = await row(view, "composio-slack");
+ expect(
+ slack.getByText(
+ "Your organisation's Slack account isn't connected yet. Only an administrator can connect it.",
+ ),
+ ).toBeTruthy();
+ expect(slack.getByRole("button", { name: "Request access" })).toBeTruthy();
+ const notion = await row(view, "notion");
+ expect(notion.getByText(/Turned off by an administrator/)).toBeTruthy();
+ expect(notion.getByText("Requested")).toBeTruthy();
+ expect(notion.getByRole("button", { name: "Withdraw" })).toBeTruthy();
+});
+
+test("an administrator keeps the way to this Bot's grants on each app, beside the action", async () => {
+ serving({ role: "admin", apps: [appAccess({ state: "not_connected" })] });
+ const gmail = await row(draw(), "composio-gmail");
+ const manage = gmail.getByRole("button", {
+ name: "Manage Gmail on this Bot",
+ });
+ expect(manage.getAttribute("href")).toBe(
+ "/admin/plugins/composio-gmail/bots/expenses",
+ );
+ expect(gmail.getByRole("button", { name: "Connect" })).toBeTruthy();
+});
+
+test("a person who is not an administrator gets no way to the grants", async () => {
+ serving({ apps: [appAccess()] });
+ const gmail = await row(draw(), "composio-gmail");
+ expect(gmail.queryByRole("button", { name: /Manage/ })).toBeNull();
+});
+
+test("when the states cannot be read, the apps are still listed and the failure is said", async () => {
+ serving({
+ apps: "fail",
+ tools: [
+ tool("google-drive/search_files", "mcp__google-drive__search_files"),
+ ],
+ });
+ const view = draw();
+ expect((await view.findByRole("alert")).textContent).toContain(
+ "Whether each app is ready for you could not be loaded.",
+ );
+ expect(await view.findByText("Google Drive")).toBeTruthy();
+});
diff --git a/app/tests/bot-access-skills.test.tsx b/app/tests/bot-access-skills.test.tsx
index f8bab06846..fc7d4eee84 100644
--- a/app/tests/bot-access-skills.test.tsx
+++ b/app/tests/bot-access-skills.test.tsx
@@ -73,6 +73,9 @@ function serving() {
grants.push(`${init?.method ?? "GET"} ${url} ${init?.body ?? ""}`);
return Response.json({ ok: true });
}
+ if (url.includes("/api/plugins/for/") && url.endsWith("/apps")) {
+ return Response.json({ apps: [] });
+ }
if (url.includes("/api/plugins/for/")) {
return Response.json({ tools: [], skills: [] });
}
diff --git a/server/src/plugins/app-access.ts b/server/src/plugins/app-access.ts
index cf8d6f7948..caa50ac949 100644
--- a/server/src/plugins/app-access.ts
+++ b/server/src/plugins/app-access.ts
@@ -108,6 +108,16 @@ export type AppAccess = {
state: AppAccessState;
/** Whether this Bot holds any of the app's tools. */
granted: boolean;
+ /**
+ * When the account this app runs in for this person was connected — their own, or the team's for
+ * a Shared app — and null where none is, or where no account is involved.
+ *
+ * FOR THE ONE FACT THE STATE CANNOT CARRY. An account the vendor has lost still has its row here,
+ * so the state reads `ready`; a card drawn over the refusal that found out says Reconnect anyway.
+ * This is how that card tells "lost since the refusal" from "connected again since the refusal":
+ * a connection newer than the refusal is the reconnection, and the live state wins.
+ */
+ connectedAt: string | null;
/** The request a press of Request access files for this state, and null where there is none. */
requestKind: AppAccessRequestKind | null;
/**
diff --git a/server/src/plugins/plugin-store/app-access.ts b/server/src/plugins/plugin-store/app-access.ts
index 18beb5334b..615dddfc72 100644
--- a/server/src/plugins/plugin-store/app-access.ts
+++ b/server/src/plugins/plugin-store/app-access.ts
@@ -348,6 +348,11 @@ export function appAccessMethods(context: StoreContext) {
.flatMap((app) => app.rowIds);
const usable = (row: { verified: boolean; probeAction: string | null }) =>
!(row.probeAction !== null && !row.verified);
+ const fact = (row: {
+ verified: boolean;
+ probeAction: string | null;
+ connectedAt: Date | null;
+ }) => ({ usable: usable(row), connectedAt: iso(row.connectedAt) });
const [personal, deployment, held] = await Promise.all([
userId && toolkits.length > 0
@@ -356,6 +361,7 @@ export function appAccessMethods(context: StoreContext) {
app: brokeredConnections.app,
verified: brokeredConnections.verified,
probeAction: brokeredConnections.probeAction,
+ connectedAt: brokeredConnections.connectedAt,
})
.from(brokeredConnections)
.where(
@@ -373,6 +379,7 @@ export function appAccessMethods(context: StoreContext) {
app: brokeredConnections.app,
verified: brokeredConnections.verified,
probeAction: brokeredConnections.probeAction,
+ connectedAt: brokeredConnections.connectedAt,
})
.from(brokeredConnections)
.where(
@@ -388,6 +395,7 @@ export function appAccessMethods(context: StoreContext) {
.select({
serverId: mcpUserCredentials.serverId,
revokedAt: credentialRows.revokedAt,
+ connectedAt: mcpUserCredentials.connectedAt,
})
.from(mcpUserCredentials)
.innerJoin(
@@ -403,12 +411,18 @@ export function appAccessMethods(context: StoreContext) {
: [],
]);
return {
- personalBrokered: new Map(personal.map((row) => [row.app, usable(row)])),
+ personalBrokered: new Map(personal.map((row) => [row.app, fact(row)])),
deploymentBrokered: new Map(
- deployment.map((row) => [row.app, usable(row)]),
+ deployment.map((row) => [row.app, fact(row)]),
),
personalHeld: new Map(
- held.map((row) => [row.serverId, row.revokedAt === null]),
+ held.map((row) => [
+ row.serverId,
+ {
+ usable: row.revokedAt === null,
+ connectedAt: iso(row.connectedAt),
+ },
+ ]),
),
};
}
@@ -463,9 +477,10 @@ export function appAccessMethods(context: StoreContext) {
: [],
]);
return apps.map((app) => {
- const sharedUsable = app.toolkit
- ? connections.deploymentBrokered.get(app.toolkit) === true
- : false;
+ const teamAccount = app.toolkit
+ ? connections.deploymentBrokered.get(app.toolkit)
+ : undefined;
+ const sharedUsable = teamAccount?.usable === true;
const personal =
app.provider === "composio" && app.toolkit
? connections.personalBrokered.get(app.toolkit)
@@ -483,13 +498,10 @@ export function appAccessMethods(context: StoreContext) {
catalogueAuth: app.entry?.auth ?? null,
hasCredential: app.hasCredential,
granted: [app.key, ...app.rowIds].some((id) => granted.has(id)),
- personal: personal === undefined ? null : { usable: personal },
- shared: app.toolkit
- ? connections.deploymentBrokered.has(app.toolkit)
- ? { usable: sharedUsable }
- : null
- : null,
+ personal: personal === undefined ? null : { usable: personal.usable },
+ shared: teamAccount ? { usable: sharedUsable } : null,
};
+ const account = accountOf(facts);
const state = appAccessState(facts);
const requestKind = REQUEST_KIND_FOR[state];
const request =
@@ -504,12 +516,18 @@ export function appAccessMethods(context: StoreContext) {
displayName: app.title,
logo: app.logo,
provider: app.provider,
- account: accountOf(facts),
+ account,
connect: connectOf(facts),
authScheme: app.provider === "composio" ? app.authScheme : null,
accountMode: app.accountMode,
state,
granted: facts.granted,
+ connectedAt:
+ account === "shared"
+ ? (teamAccount?.connectedAt ?? null)
+ : account === "personal"
+ ? (personal?.connectedAt ?? null)
+ : null,
requestKind,
request: request
? {
diff --git a/server/tests/app-access-routes.integration.test.ts b/server/tests/app-access-routes.integration.test.ts
index 1ff23e2aa3..eeb0e17a4b 100644
--- a/server/tests/app-access-routes.integration.test.ts
+++ b/server/tests/app-access-routes.integration.test.ts
@@ -369,15 +369,20 @@ describe("what each app a Bot holds is, for the person asking", () => {
userId: other,
vendorUserId: other,
verified: true,
+ connectedAt: new Date("2026-10-20T12:00:00.000Z"),
});
const mine = await json<{ apps: AppAccess[] }>(
await appAs(person).request(`/api/plugins/for/${bot}/apps`),
);
expect(mine.apps[0]?.state).toBe("not_connected");
+ // Nothing of theirs leaks into mine, not even when they connected.
+ expect(mine.apps[0]?.connectedAt).toBeNull();
const theirs = await json<{ apps: AppAccess[] }>(
await appAs(other).request(`/api/plugins/for/${bot}/apps`),
);
expect(theirs.apps[0]?.state).toBe("ready");
+ // When, so a chat card over a lost account can tell a reconnection from the loss.
+ expect(theirs.apps[0]?.connectedAt).toBe("2026-10-20T12:00:00.000Z");
});
test("a Shared app with its team account connected is ready for everybody", async () => {
@@ -388,11 +393,14 @@ describe("what each app a Bot holds is, for the person asking", () => {
holder: "deployment",
vendorUserId: `openbot-deployment:${suite}`,
verified: true,
+ connectedAt: new Date("2026-10-20T09:30:00.000Z"),
});
const { apps } = await json<{ apps: AppAccess[] }>(
await appAs(person).request(`/api/plugins/for/${bot}/apps`),
);
expect(apps[0]?.state).toBe("ready");
+ // The team account's, since that is the account the app runs in for everybody.
+ expect(apps[0]?.connectedAt).toBe("2026-10-20T09:30:00.000Z");
});
test("with no Composio key every Composio app is switched off", async () => {
From 444fb182007785964ad2628c1fc9340a9d61fb71 Mon Sep 17 00:00:00 2001
From: Guido Vizoso
Date: Tue, 20 Oct 2026 19:39:39 -0300
Subject: [PATCH 06/33] List access requests for administrators to approve or
dismiss
An app's admin page has a Requests section, and the Plugins list shows every pending request at the top with a count on each app's row, so a request for an app nobody has switched on is still seen. Approve does what was asked; when the server cannot finish (a token to add, a team account to connect) its reason is shown on the request, which stays pending. Requests name who asked; nobody's connections are shown.
---
.../components/plugins/access-requests.tsx | 162 ++++++++++++
app/src/routes/_authed/admin/plugins/$key.tsx | 8 +
.../routes/_authed/admin/plugins/index.tsx | 34 +++
app/tests/access-requests.test.tsx | 250 ++++++++++++++++++
4 files changed, 454 insertions(+)
create mode 100644 app/src/components/plugins/access-requests.tsx
create mode 100644 app/tests/access-requests.test.tsx
diff --git a/app/src/components/plugins/access-requests.tsx b/app/src/components/plugins/access-requests.tsx
new file mode 100644
index 0000000000..f1df877710
--- /dev/null
+++ b/app/src/components/plugins/access-requests.tsx
@@ -0,0 +1,162 @@
+import { IconInbox } from "@tabler/icons-react";
+import { useMutation, useQuery } from "@tanstack/react-query";
+import { Fragment } from "react";
+import { PageRows, PageSection } from "@/components/layout/page-shell";
+import { Button } from "@/components/ui/button";
+import {
+ Item,
+ ItemActions,
+ ItemContent,
+ ItemDescription,
+ ItemFooter,
+ ItemMedia,
+ ItemTitle,
+} from "@/components/ui/item";
+import { Separator } from "@/components/ui/separator";
+import {
+ approveAppAccessRequestMutationOptions,
+ dismissAppAccessRequestMutationOptions,
+} from "@/lib/app-access/mutations";
+import {
+ type AppAccessRequest,
+ appAccessInboxQueryOptions,
+} from "@/lib/app-access/queries";
+import { relativeTime } from "@/lib/relative-time";
+import { queryClient } from "@/query-client";
+
+/**
+ * The requests people have filed for an administrator to answer: switch an app on, give it to a
+ * Bot, or connect its shared account.
+ *
+ * ONE SECTION, ON TWO SCREENS. An app's own admin page lists the requests about that app, because
+ * that is where an administrator is already deciding about it. The Plugins list carries all of them,
+ * because an app nobody has switched on has no page of its own to list them on — a Composio app
+ * enabled by approving the request is exactly that case — and because a request nobody sees is a
+ * person waiting for nothing.
+ *
+ * APPROVE DOES THE THING. The server switches the app on and grants it to the Bot in the same press,
+ * or refuses with the step only an administrator can take first — a token to paste, a team account
+ * to connect — and the request stays pending. That sentence is drawn on the row it is about, because
+ * the screen's own banner is a long way from the button that was pressed.
+ *
+ * REQUESTS, NOT CONNECTIONS. Each row names who asked, because they asked; nothing here says who has
+ * connected what, which stays private to each person (see the note on `admin/plugins/$key.tsx`).
+ *
+ * Nothing at all while the read is open or when nothing is waiting: an empty section on every app's
+ * page would teach an administrator to scroll past the heading.
+ */
+export function AccessRequestsSection({ app }: { app?: string }) {
+ const inbox = useQuery(appAccessInboxQueryOptions(true, app));
+ if (inbox.isPending) return null;
+ if (inbox.error)
+ return (
+
+
+ Access requests could not be loaded.
+
+
+ );
+ const requests = inbox.data?.requests ?? [];
+ if (requests.length === 0) return null;
+ return (
+
+
+ {requests.map((request, index) => (
+
+ {index > 0 ? : null}
+
+
+ ))}
+
+
+ );
+}
+
+/** What the request asks for, as the thing an administrator would do. */
+export function askedFor(request: AppAccessRequest): string {
+ const bot = request.botName ?? "a Bot";
+ switch (request.kind) {
+ case "enable":
+ return `Turn on ${request.appTitle} for ${bot}`;
+ case "grant":
+ return `Give ${request.appTitle} to ${bot}`;
+ case "connect_shared":
+ return `Connect the shared ${request.appTitle} account`;
+ }
+}
+
+function AccessRequestRow({ request }: { request: AppAccessRequest }) {
+ // One pair per row, so a refusal is drawn under the request it is about and nowhere else.
+ const approve = useMutation(
+ approveAppAccessRequestMutationOptions(queryClient),
+ );
+ const dismiss = useMutation(
+ dismissAppAccessRequestMutationOptions(queryClient),
+ );
+ const busy = approve.isPending || dismiss.isPending;
+ const error = approve.error ?? dismiss.error;
+ const who = request.requesterName ?? request.requesterEmail ?? "Somebody";
+ return (
+
+
+
+
+
+ {askedFor(request)}
+
+ {who} asked {relativeTime(request.createdAt)}
+ {request.kind === "connect_shared" && request.botName
+ ? `, for ${request.botName}`
+ : ""}
+ .
+
+
+
+ {
+ approve.reset();
+ dismiss.mutate(request.id);
+ }}
+ size="sm"
+ variant="ghost"
+ >
+ {dismiss.isPending ? "Dismissing…" : "Dismiss"}
+
+ {
+ dismiss.reset();
+ approve.mutate({ id: request.id });
+ }}
+ size="sm"
+ >
+ {approve.isPending ? "Approving…" : "Approve"}
+
+
+ {error ? (
+
+
+ {error.message}
+
+
+ ) : null}
+
+ );
+}
+
+/**
+ * How many requests are waiting, for a list row that wants to say so: one app's, or all of them.
+ * Null while nothing is waiting or nothing has been read, so a row draws nothing rather than "0".
+ */
+export function usePendingRequestCounts() {
+ const inbox = useQuery(appAccessInboxQueryOptions(true));
+ return inbox.data?.counts ?? null;
+}
diff --git a/app/src/routes/_authed/admin/plugins/$key.tsx b/app/src/routes/_authed/admin/plugins/$key.tsx
index 69cab47f2a..c46fa8020d 100644
--- a/app/src/routes/_authed/admin/plugins/$key.tsx
+++ b/app/src/routes/_authed/admin/plugins/$key.tsx
@@ -13,6 +13,7 @@ import {
PageSection,
PageShell,
} from "@/components/layout/page-shell";
+import { AccessRequestsSection } from "@/components/plugins/access-requests";
import { AccountModeDialog } from "@/components/plugins/account-mode-dialog";
import {
BrokeredAccountRow,
@@ -612,6 +613,13 @@ function RouteComponent() {
+ {/*
+ * People asking for this app: to switch it on, give it to a Bot, or connect its shared
+ * account. Only when somebody is; the section draws nothing otherwise. Requests name who
+ * asked — connections still name nobody.
+ */}
+
+
!open && setModeTarget(null)}
diff --git a/app/src/routes/_authed/admin/plugins/index.tsx b/app/src/routes/_authed/admin/plugins/index.tsx
index b90b6994ba..1681d62c75 100644
--- a/app/src/routes/_authed/admin/plugins/index.tsx
+++ b/app/src/routes/_authed/admin/plugins/index.tsx
@@ -14,6 +14,10 @@ import {
PageShell,
} from "@/components/layout/page-shell";
import { RowMark } from "@/components/layout/row-mark";
+import {
+ AccessRequestsSection,
+ usePendingRequestCounts,
+} from "@/components/plugins/access-requests";
import {
Item,
ItemActions,
@@ -96,6 +100,11 @@ function summaryFor(
function RouteComponent() {
const plugins = useQuery(pluginsPageQueryOptions());
const connections = useQuery(connectionsQueryOptions());
+ /*
+ * Requests waiting per app, so an administrator sees which app somebody is asking about from the
+ * list, without opening each one. Every request is also listed in full above the lists.
+ */
+ const requestCounts = usePendingRequestCounts();
/*
* PERSONAL ONLY: a Shared app's connection is the deployment's standing account, not something
@@ -128,6 +137,12 @@ function RouteComponent() {
) : (
<>
+ {/*
+ * First, because each one is somebody waiting. All of them, not only those about an app
+ * that is connected: an app nobody has switched on has no page of its own to list them on.
+ */}
+
+
+
{summaryFor(
server,
@@ -226,6 +244,9 @@ function RouteComponent() {
{entry.summary}
+
Not added
@@ -295,3 +316,16 @@ function RouteComponent() {
);
}
+
+/**
+ * "2 requests", beside a row's summary, in the foreground colour so it is the first thing on the
+ * row that reads as something to do. Nothing at zero: a count of nothing on every row is noise.
+ */
+function PendingRequests({ count }: { count: number }) {
+ if (count === 0) return null;
+ return (
+
+ {count} {count === 1 ? "request" : "requests"}
+
+ );
+}
diff --git a/app/tests/access-requests.test.tsx b/app/tests/access-requests.test.tsx
new file mode 100644
index 0000000000..133ae74da8
--- /dev/null
+++ b/app/tests/access-requests.test.tsx
@@ -0,0 +1,250 @@
+import { afterAll, afterEach, beforeAll, expect, test } from "bun:test";
+import { GlobalRegistrator } from "@happy-dom/global-registrator";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import {
+ createMemoryHistory,
+ createRootRoute,
+ createRoute,
+ createRouter,
+ Outlet,
+ RouterProvider,
+} from "@tanstack/react-router";
+import {
+ cleanup,
+ fireEvent,
+ render,
+ waitFor,
+ within,
+} from "@testing-library/react";
+import type { ReactNode } from "react";
+import { AccessRequestsSection } from "@/components/plugins/access-requests";
+import type { AppAccessRequest } from "@/lib/app-access/queries";
+import { Route as PluginsIndexRoute } from "@/routes/_authed/admin/plugins/index";
+import { settleReactWork } from "./settle-react-work";
+
+/**
+ * The administrator's side of an access request: the Requests section on an app's admin page and
+ * at the top of the Plugins list, and the count on each row of that list.
+ *
+ * Approve and Dismiss go to the routes that do the work; a refusal from Approve — the step only an
+ * administrator can take first, with the request left pending — is drawn under the request it is
+ * about. Who asked is shown, because they asked; nobody's connection is.
+ */
+
+beforeAll(() => GlobalRegistrator.register());
+afterEach(cleanup);
+afterAll(async () => {
+ await settleReactWork();
+ GlobalRegistrator.unregister();
+});
+
+const originalFetch = global.fetch;
+afterEach(() => {
+ global.fetch = originalFetch;
+});
+
+const request = (
+ overrides: Partial = {},
+): AppAccessRequest => ({
+ id: "req_1",
+ appKey: "composio-gmail",
+ appTitle: "Gmail",
+ kind: "grant",
+ status: "pending",
+ agentId: "expenses",
+ botName: "Expenses",
+ requestedBy: "u_ada",
+ requesterEmail: "ada@example.test",
+ requesterName: "Ada",
+ createdAt: new Date(Date.now() - 5 * 60_000).toISOString(),
+ resolvedAt: null,
+ resolvedBy: null,
+ ...overrides,
+});
+
+function serving(options: {
+ requests: AppAccessRequest[];
+ approve?: { status: number; body: unknown };
+}) {
+ const sent: string[] = [];
+ global.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
+ const url = String(input);
+ const method = init?.method ?? "GET";
+ sent.push(`${method} ${url}`);
+ if (method === "POST" && url.endsWith("/approve"))
+ return Response.json(options.approve?.body ?? { request: {} }, {
+ status: options.approve?.status ?? 200,
+ });
+ if (method === "POST" && url.endsWith("/dismiss"))
+ return Response.json({ request: {} });
+ if (url.startsWith("/api/plugins/access-requests")) {
+ const app = new URL(url, "http://x").searchParams.get("app");
+ const byApp: Record = {};
+ for (const each of options.requests)
+ byApp[each.appKey] = (byApp[each.appKey] ?? 0) + 1;
+ return Response.json({
+ requests: app
+ ? options.requests.filter((each) => each.appKey === app)
+ : options.requests,
+ counts: { total: options.requests.length, byApp },
+ });
+ }
+ if (url === "/api/plugins/connections")
+ return Response.json({ connections: [], redirectUri: null });
+ if (url === "/api/plugins")
+ return Response.json({
+ catalogue: [],
+ servers: [
+ {
+ id: "composio-gmail",
+ title: "Gmail",
+ summary: "Mail.",
+ tools: [],
+ lastError: null,
+ },
+ {
+ id: "notion",
+ title: "Notion",
+ summary: "Docs.",
+ tools: [],
+ lastError: null,
+ },
+ ],
+ composioConfigured: true,
+ botsMayCallBack: true,
+ redirectUri: null,
+ skills: [],
+ });
+ return Response.json({});
+ }) as unknown as typeof fetch;
+ return sent;
+}
+
+function draw(Page: () => ReactNode) {
+ const client = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ });
+ const root = createRootRoute({ component: Outlet });
+ const page = createRoute({
+ getParentRoute: () => root,
+ path: "/",
+ component: Page,
+ });
+ const app = createRoute({
+ getParentRoute: () => root,
+ path: "/admin/plugins/$key",
+ component: () => null,
+ });
+ const composio = createRoute({
+ getParentRoute: () => root,
+ path: "/admin/plugins/composio",
+ component: () => null,
+ });
+ const router = createRouter({
+ routeTree: root.addChildren([page, app, composio]),
+ history: createMemoryHistory({ initialEntries: ["/"] }),
+ });
+ return render(
+
+
+ ,
+ );
+}
+
+test("lists who asked for what, for which Bot, and when — and nothing about anybody's connection", async () => {
+ serving({
+ requests: [
+ request(),
+ request({ id: "req_2", kind: "enable", appTitle: "Gmail" }),
+ request({
+ id: "req_3",
+ kind: "connect_shared",
+ requesterName: null,
+ }),
+ ],
+ });
+ const view = draw(() => );
+ expect(await view.findByText("Give Gmail to Expenses")).toBeTruthy();
+ expect(view.getByText("Turn on Gmail for Expenses")).toBeTruthy();
+ expect(view.getByText("Connect the shared Gmail account")).toBeTruthy();
+ expect(view.getAllByText(/Ada asked 5 minutes ago/)).toHaveLength(2);
+ // No name on the request falls back to the address they asked from.
+ expect(view.getByText(/ada@example\.test asked/)).toBeTruthy();
+ expect(view.queryByText(/connected/i)).toBeNull();
+});
+
+test("approve does what was asked through the approve route", async () => {
+ const sent = serving({ requests: [request()] });
+ const view = draw(() => );
+ fireEvent.click(await view.findByRole("button", { name: "Approve" }));
+ await waitFor(() =>
+ expect(sent).toContain("POST /api/plugins/access-requests/req_1/approve"),
+ );
+});
+
+test("an approval the server cannot complete says why, on the request, and leaves it there", async () => {
+ serving({
+ requests: [request({ kind: "connect_shared" })],
+ approve: {
+ status: 409,
+ body: {
+ error:
+ "The shared Gmail account is not connected yet. Connect it on Gmail's page under Plugins, then approve this request.",
+ },
+ },
+ });
+ const view = draw(() => );
+ fireEvent.click(await view.findByRole("button", { name: "Approve" }));
+ const row = within(
+ (await view.findByRole("alert")).closest("[data-request]") as HTMLElement,
+ );
+ expect(row.getByRole("alert").textContent).toMatch(
+ /^The shared Gmail account is not connected yet\./,
+ );
+ expect(row.getByRole("button", { name: "Approve" })).toBeTruthy();
+});
+
+test("dismiss goes to the dismiss route", async () => {
+ const sent = serving({ requests: [request()] });
+ const view = draw(() => );
+ fireEvent.click(await view.findByRole("button", { name: "Dismiss" }));
+ await waitFor(() =>
+ expect(sent).toContain("POST /api/plugins/access-requests/req_1/dismiss"),
+ );
+});
+
+test("nothing waiting draws no section at all", async () => {
+ const sent = serving({ requests: [] });
+ const view = draw(() => );
+ await waitFor(() =>
+ expect(sent).toContain(
+ "GET /api/plugins/access-requests?app=composio-gmail",
+ ),
+ );
+ await settleReactWork();
+ expect(view.queryByText("Requests")).toBeNull();
+});
+
+test("the Plugins list puts every request first and counts them on each app's row", async () => {
+ serving({
+ requests: [
+ request(),
+ request({ id: "req_2", kind: "enable" }),
+ // An app with no row of its own: its request is still listed, at the top.
+ request({
+ id: "req_3",
+ appKey: "composio-slack",
+ appTitle: "Slack",
+ kind: "enable",
+ }),
+ ],
+ });
+ const Page = PluginsIndexRoute.options.component as () => ReactNode;
+ const view = draw(Page);
+ expect(await view.findByText("Requests")).toBeTruthy();
+ expect(view.getByText("Turn on Slack for Expenses")).toBeTruthy();
+ const gmail = within(await view.findByTestId("plugin-composio-gmail"));
+ expect(gmail.getByText("2 requests")).toBeTruthy();
+ const notion = within(view.getByTestId("plugin-notion"));
+ expect(notion.queryByText(/request/)).toBeNull();
+});
From 322dce2b7ddfb816f6981bf4837a3ae4539a6f1a Mon Sep 17 00:00:00 2001
From: Guido Vizoso
Date: Tue, 20 Oct 2026 19:39:45 -0300
Subject: [PATCH 07/33] Draw an app card in chat for every way a Bot cannot use
an app
Refusals for a Shared app with no team account and for an account Composio no longer holds now carry the same structured prompt as the connect refusal, with a reason, the refusing Bot and (for a lost account) when. A new show_app_access tool, held by every Bot, answers with that prompt for an app that is switched off or not granted, and the guidance tells the Bot to call it instead of only naming an administrator. The transcript draws all of them as one card that reads the app's live state for this person on that Bot, using the shared state piece; a lost account is shown as Reconnect until a connection newer than the refusal appears. The card is never folded into the tool group.
---
.../components/channels/app-access-card.tsx | 141 +++++++++
.../components/channels/chat-transcript.tsx | 32 +-
app/src/components/channels/tool-activity.tsx | 13 +-
app/tests/app-access-card.test.tsx | 289 ++++++++++++++++++
app/tests/tool-activity.test.tsx | 32 ++
server/src/index.ts | 12 +
server/src/plugins/app-access-tool.ts | 105 +++++++
server/src/plugins/broker.ts | 20 ++
server/src/plugins/composio-vendor.ts | 8 +-
server/src/plugins/composio.ts | 14 +-
server/src/plugins/mcp.ts | 6 +
server/src/plugins/plugin-store/calls.ts | 30 +-
server/src/plugins/plugin-store/context.ts | 17 +-
server/src/plugins/plugin-store/model.ts | 3 +-
server/src/plugins/tools.ts | 63 +++-
server/tests/app-access-tool.test.ts | 156 ++++++++++
server/tests/composio-adapter.test.ts | 15 +
server/tests/composio-transport.test.ts | 49 ++-
server/tests/copilot.test.ts | 47 +++
.../shared-brokered-store.integration.test.ts | 103 ++++++-
shared/connect-prompt.test.ts | 63 ++++
shared/connect-prompt.ts | 103 ++++++-
22 files changed, 1286 insertions(+), 35 deletions(-)
create mode 100644 app/src/components/channels/app-access-card.tsx
create mode 100644 app/tests/app-access-card.test.tsx
create mode 100644 server/src/plugins/app-access-tool.ts
create mode 100644 server/tests/app-access-tool.test.ts
diff --git a/app/src/components/channels/app-access-card.tsx b/app/src/components/channels/app-access-card.tsx
new file mode 100644
index 0000000000..5f851dd903
--- /dev/null
+++ b/app/src/components/channels/app-access-card.tsx
@@ -0,0 +1,141 @@
+import { useQuery } from "@tanstack/react-query";
+import {
+ AppAccessAction,
+ appAccessSentence,
+} from "@/components/plugins/app-access-action";
+import { PluginLogo } from "@/components/plugins/plugin-logo";
+import { type AppAccess, botAppQueryOptions } from "@/lib/app-access/queries";
+import { useDeclaredBotId } from "@/lib/copilot/active-bot";
+import type { ConnectPrompt } from "@openbot/shared/connect-prompt";
+import { ConnectAppCard } from "./connect-app-card";
+
+/**
+ * A work app a Bot could not use, in front of the person, with the one thing that would let it.
+ *
+ * WHAT DRAWS ONE. Every way an app is out of reach for a call now reaches the transcript carrying
+ * the same marker (`@openbot/shared/connect-prompt`): a person who has not connected, a Shared app
+ * with no team account, an account the vendor has lost, and — with no call refused at all — the
+ * `show_app_access` tool a Bot calls for an app it holds nothing for, switched off or not granted.
+ * They used to end in three different places: a card with Connect, a sentence telling the person to
+ * ask an administrator, and a sentence naming a settings page.
+ *
+ * LIVE, NEVER WHAT THE REFUSAL SAID. The marker names an app and a Bot; the state is asked of the
+ * server for this person on that Bot when the card is drawn, and the shared app-access piece draws
+ * that with the button that goes with it. A card scrolled back to an hour later says what is true
+ * now — connected since, granted since, the request approved — and never offers a step that has
+ * already been taken. Every write those buttons make refreshes the same key, so the card follows.
+ *
+ * THE ONE PLACE THE REFUSAL WINS. An account the vendor no longer holds is not recorded anywhere on
+ * this deployment: its row still stands, so the server reads `ready` until a call fails — and this
+ * card is drawn over exactly the call that failed. So a `reconnect` or `shared` refusal over a live
+ * `ready` is drawn as the refusal says — unless the account was connected after the refusal, which
+ * is the reconnection. Anything else live is newer than the refusal and is drawn as it is.
+ */
+export function AppAccessCard({ prompt }: { prompt: ConnectPrompt }) {
+ const declared = useDeclaredBotId();
+ const agentId = prompt.agentId ?? declared;
+ /*
+ * A refusal from before refusals named their Bot, on a surface that declared none: there is no Bot
+ * to ask about, so the card it was drawn as then is the card it is drawn as now.
+ */
+ if (!agentId) return ;
+ return ;
+}
+
+function LiveAppAccessCard({
+ agentId,
+ prompt,
+}: {
+ agentId: string;
+ prompt: ConnectPrompt;
+}) {
+ const live = useQuery(botAppQueryOptions(agentId, prompt.serverId));
+ /*
+ * A read that failed falls back to what this card was before it read anything, where that was a
+ * card at all: a person's own missing connection still gets its Connect button. The other reasons
+ * had only ever been a sentence, and the sentence is what is left.
+ */
+ if (live.error && (prompt.reason ?? "connect") === "connect")
+ return ;
+ const access = live.data ? asRefused(live.data, prompt) : null;
+ const name = access?.displayName ?? prompt.title;
+ return (
+
+
+
+
+
+
+
{name}
+ {/*
+ * Nothing while the read is open — no placeholder, per the data-access rule — and the
+ * Bot's own sentence if it failed. Otherwise the state, which is the server's.
+ */}
+ {live.isPending ? null : access ? (
+
+ {access.state === "ready" && prompt.reason !== "access"
+ ? `${name} is ready now. Ask again and the Bot can use it.`
+ : refusedSentence(access, prompt)}
+
+ ) : (
+
+ {prompt.message}
+
+ )}
+
+
+ {access && access.state !== "ready" ? (
+
+
+
+ ) : null}
+
+ );
+}
+
+/**
+ * The live state, or the refusal's where only the refusal can know: an account the vendor lost,
+ * which the server still reads as `ready`. See the card's own comment.
+ *
+ * A lost team account is drawn as not connected with NO request to file — the server would answer
+ * any request 409, since as far as it knows the account is fine — so the sentence says whose move it
+ * is and the button is left out rather than offered and refused.
+ */
+function asRefused(live: AppAccess, prompt: ConnectPrompt): AppAccess {
+ if (live.state !== "ready") return live;
+ /*
+ * Connected again since the refusal: that is the reconnection, and the live `ready` is true.
+ * Compared as instants rather than as strings, because the two come from different clocks'
+ * formatters and only the instant is the same.
+ */
+ if (
+ prompt.at &&
+ live.connectedAt &&
+ Date.parse(live.connectedAt) > Date.parse(prompt.at)
+ )
+ return live;
+ if (prompt.reason === "reconnect") return { ...live, state: "reconnect" };
+ if (prompt.reason === "shared")
+ return {
+ ...live,
+ state: "shared_not_connected",
+ requestKind: null,
+ request: null,
+ };
+ return live;
+}
+
+/** As the shared sentence, except a lost team account, which needs reconnecting rather than connecting. */
+function refusedSentence(access: AppAccess, prompt: ConnectPrompt): string {
+ if (
+ access.state === "shared_not_connected" &&
+ prompt.reason === "shared" &&
+ !access.requestKind
+ )
+ return `Your organisation's ${access.displayName} account needs reconnecting. Only an administrator can reconnect it.`;
+ return appAccessSentence(access.state, access.displayName);
+}
diff --git a/app/src/components/channels/chat-transcript.tsx b/app/src/components/channels/chat-transcript.tsx
index 94a8972d20..1fb109f7b2 100644
--- a/app/src/components/channels/chat-transcript.tsx
+++ b/app/src/components/channels/chat-transcript.tsx
@@ -59,7 +59,10 @@ import {
toolResultFailed,
} from "@/lib/plugins/tool-result";
import { readTeamBotConsent } from "@/lib/team-bots";
-import { readConnectPrompt } from "@openbot/shared/connect-prompt";
+import {
+ readConnectPrompt,
+ SHOW_APP_ACCESS_TOOL,
+} from "@openbot/shared/connect-prompt";
import { cn } from "@/lib/utils";
import { VOICE_CHAT_ACTIVITY, type VoiceChatEntry } from "@/lib/voice/archive";
import {
@@ -74,7 +77,7 @@ import {
toVisibleChatItems,
} from "./chat-messages";
import type { QueuedMessage } from "./composer";
-import { ConnectAppCard } from "./connect-app-card";
+import { AppAccessCard } from "./app-access-card";
import { TeamBotConsentCard } from "./team-bot-consent";
import { ToolRenderBoundary } from "./tool-boundary";
import { ToolLine } from "./tool-line";
@@ -1351,8 +1354,16 @@ const TranscriptToolCall = memo(function TranscriptToolCall({
*
* Named from the reader's side: what was done, against which server, with the server's own words
* behind a disclosure. The identifier the model was offered never reaches the screen.
+ *
+ * Exported for its tests, which draw it without the CopilotKit provider `TranscriptToolCall` needs.
*/
-function ServerToolLine({ name, result }: { name: string; result?: string }) {
+export function ServerToolLine({
+ name,
+ result,
+}: {
+ name: string;
+ result?: string;
+}) {
const { label, detail } = readToolName(name);
/*
* A refusal is not a result, and must not read like one.
@@ -1374,9 +1385,18 @@ function ServerToolLine({ name, result }: { name: string; result?: string }) {
// A Team Bot asking to use this person's own account draws its consent card, not the sentence.
const consent = refused ? readTeamBotConsent(body) : null;
if (consent) return ;
- // An app this person has not connected draws the connect button, not the name of a screen.
- const connect = refused ? readConnectPrompt(body) : null;
- if (connect) return ;
+ /*
+ * An app this call could not use draws a card with the step that fixes it, not the name of a
+ * screen: a refusal carrying the marker, or the answer of the one tool whose whole job is to put
+ * that card on screen. Only that tool's ordinary answer is read for a marker — any other result
+ * is somebody else's text, and a card must not be conjured out of it.
+ */
+ const card = refused
+ ? readConnectPrompt(body)
+ : name === SHOW_APP_ACCESS_TOOL
+ ? readConnectPrompt(answer)
+ : null;
+ if (card) return ;
return (
boolean): boolean {
if (isBrowserStep(item)) return true;
const name = item.toolCall.function.name;
if (!name.startsWith("mcp__") && isDrawn(name)) return false;
+ /*
+ * The app-access card is drawn from a server tool's answer, so no renderer registers it and the
+ * registry cannot say so. Never folded — not even while its answer is still on the way, or the
+ * card would land inside a group and then jump out of it.
+ */
+ if (name === SHOW_APP_ACCESS_TOOL) return false;
return !asksThePerson(item.result);
}
@@ -116,7 +125,7 @@ export function useDrawnTool(): (name: string) => boolean {
* Whether this refusal is a question for the person rather than a line about what a Bot did.
*
* Both of these draw a card with something to press — a Team Bot asking to act as them, and an app
- * they have not connected — and neither may sit behind a disclosure for the same reason: a question
+ * they cannot use yet (not connected, lost at the vendor, or a team account nobody connected) — and neither may sit behind a disclosure for the same reason: a question
* nobody can see is a conversation that has stopped without saying so.
*/
function asksThePerson(result: string | undefined): boolean {
diff --git a/app/tests/app-access-card.test.tsx b/app/tests/app-access-card.test.tsx
new file mode 100644
index 0000000000..b4ead7add4
--- /dev/null
+++ b/app/tests/app-access-card.test.tsx
@@ -0,0 +1,289 @@
+import { afterAll, afterEach, beforeAll, expect, test } from "bun:test";
+import { GlobalRegistrator } from "@happy-dom/global-registrator";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import {
+ createMemoryHistory,
+ createRootRoute,
+ createRoute,
+ createRouter,
+ Outlet,
+ RouterProvider,
+} from "@tanstack/react-router";
+import { cleanup, render, waitFor } from "@testing-library/react";
+import type { ReactNode } from "react";
+import { AppAccessCard } from "@/components/channels/app-access-card";
+import { ServerToolLine } from "@/components/channels/chat-transcript";
+import type { AppAccess } from "@/lib/app-access/queries";
+import { REFUSAL_MARKER } from "@/lib/plugins/tool-result";
+import {
+ type ConnectPrompt,
+ connectAppRefusal,
+ SHOW_APP_ACCESS_TOOL,
+} from "@openbot/shared/connect-prompt";
+import { appAccess } from "./app-access-fixture";
+import { settleReactWork } from "./settle-react-work";
+
+/**
+ * The card a conversation draws when a Bot could not use an app, for every way that happens: the
+ * person has not connected, a Shared app has no team account, the vendor lost the account, and the
+ * `show_app_access` tool a Bot calls for an app it holds nothing for (switched off, not granted).
+ *
+ * The card never draws what the refusal said: it asks the server what the app is for this person
+ * on the Bot that refused, and draws that. The one exception is an account the vendor lost, which
+ * the server cannot know about — drawn as Reconnect over a live `ready`, until a connection newer
+ * than the refusal says it has been put back.
+ */
+
+beforeAll(() => GlobalRegistrator.register());
+afterEach(cleanup);
+afterAll(async () => {
+ await settleReactWork();
+ GlobalRegistrator.unregister();
+});
+
+const originalFetch = global.fetch;
+afterEach(() => {
+ global.fetch = originalFetch;
+});
+
+/** Answers the live read with `live`, and records which Bot and app it was asked about. */
+function serving(live: AppAccess | null) {
+ const asked: string[] = [];
+ global.fetch = (async (input: RequestInfo | URL) => {
+ const url = String(input);
+ asked.push(url);
+ if (url.includes("/apps/"))
+ return live
+ ? Response.json({ app: live })
+ : Response.json({ error: "nope" }, { status: 500 });
+ if (url.includes("/api/plugins/connections"))
+ return Response.json({ connections: [], redirectUri: null });
+ return Response.json({});
+ }) as unknown as typeof fetch;
+ return asked;
+}
+
+function mount(children: ReactNode) {
+ const client = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ });
+ const root = createRootRoute({ component: Outlet });
+ const page = createRoute({
+ getParentRoute: () => root,
+ path: "/",
+ component: () => <>{children}>,
+ });
+ const settings = createRoute({
+ getParentRoute: () => root,
+ path: "/settings/connected-accounts/$key",
+ component: () => null,
+ });
+ const router = createRouter({
+ routeTree: root.addChildren([page, settings]),
+ history: createMemoryHistory({ initialEntries: ["/"] }),
+ });
+ return render(
+
+
+ ,
+ );
+}
+
+const prompt = (overrides: Partial = {}): ConnectPrompt => ({
+ serverId: "composio-gmail",
+ title: "Gmail",
+ message:
+ "You have not connected your Gmail account. Connect it and ask again.",
+ agentId: "expenses",
+ ...overrides,
+});
+
+const card = (view: ReturnType) =>
+ waitFor(() => {
+ const found = view.container.querySelector("[data-app-access]");
+ if (!found?.getAttribute("data-state")) throw new Error("not drawn yet");
+ return found as HTMLElement;
+ });
+
+test("asks about the app on the Bot that refused, not on another", async () => {
+ const asked = serving(appAccess({ state: "not_connected" }));
+ mount();
+ await waitFor(() =>
+ expect(asked).toContain("/api/plugins/for/expenses/apps/composio-gmail"),
+ );
+});
+
+test("not connected: names the app and offers Connect", async () => {
+ serving(appAccess({ state: "not_connected" }));
+ const view = mount();
+ const drawn = await card(view);
+ expect(drawn.textContent).toContain("Gmail");
+ expect(drawn.textContent).toContain(
+ "You haven't connected your Gmail account yet.",
+ );
+ expect(view.getByRole("button", { name: "Connect" })).toBeTruthy();
+});
+
+test("connected since the refusal: says so and offers nothing stale", async () => {
+ serving(appAccess({ state: "ready" }));
+ const view = mount();
+ const drawn = await card(view);
+ expect(drawn.textContent).toContain(
+ "Gmail is ready now. Ask again and the Bot can use it.",
+ );
+ expect(view.queryByRole("button")).toBeNull();
+});
+
+test("a Shared app with no team account: Request access, not a sentence", async () => {
+ serving(
+ appAccess({
+ state: "shared_not_connected",
+ account: "shared",
+ accountMode: "shared",
+ requestKind: "connect_shared",
+ }),
+ );
+ const view = mount(
+ ,
+ );
+ const drawn = await card(view);
+ expect(drawn.textContent).toContain(
+ "Your organisation's Gmail account isn't connected yet.",
+ );
+ expect(view.getByRole("button", { name: "Request access" })).toBeTruthy();
+});
+
+test("an account the vendor lost: Reconnect, though the server still reads ready", async () => {
+ serving(
+ appAccess({ state: "ready", connectedAt: "2026-10-01T00:00:00.000Z" }),
+ );
+ const view = mount(
+ ,
+ );
+ const drawn = await card(view);
+ expect(drawn.getAttribute("data-state")).toBe("reconnect");
+ expect(drawn.textContent).toContain(
+ "Your Gmail connection needs reconnecting.",
+ );
+ expect(
+ (await view.findByText("Reconnect")).closest("a")?.getAttribute("href"),
+ ).toBe("/settings/connected-accounts/composio-gmail");
+});
+
+test("an account lost and then connected again: the newer connection wins", async () => {
+ serving(
+ appAccess({ state: "ready", connectedAt: "2026-10-20T12:05:00.000Z" }),
+ );
+ const view = mount(
+ ,
+ );
+ const drawn = await card(view);
+ expect(drawn.getAttribute("data-state")).toBe("ready");
+ expect(view.queryByText("Reconnect")).toBeNull();
+});
+
+test("a team account the vendor lost: says an administrator has to reconnect it, with nothing to press", async () => {
+ serving(appAccess({ state: "ready", account: "shared" }));
+ const view = mount();
+ const drawn = await card(view);
+ expect(drawn.textContent).toContain(
+ "Your organisation's Gmail account needs reconnecting. Only an administrator can reconnect it.",
+ );
+ expect(view.queryByRole("button")).toBeNull();
+});
+
+test.each([
+ ["disabled", "enable", /Turned off by an administrator/],
+ ["not_granted", "grant", /This Bot hasn't been given Gmail/],
+] as const)(
+ "the Bot asked about an app that is %s: Request access",
+ async (state, kind, sentence) => {
+ serving(appAccess({ state, requestKind: kind, granted: false }));
+ const view = mount(
+ ,
+ );
+ const drawn = await card(view);
+ expect(drawn.textContent).toMatch(sentence);
+ expect(view.getByRole("button", { name: "Request access" })).toBeTruthy();
+ },
+);
+
+test("asked about an app that is ready, it says so without claiming it was just connected", async () => {
+ serving(appAccess());
+ const view = mount();
+ const drawn = await card(view);
+ expect(drawn.textContent).toContain("Ready to use.");
+});
+
+/*
+ * The fallback to the Bot the surface declared is not asserted here: `bot-route-default-agent`'s
+ * fixture replaces `useActiveBot` with a no-op for the whole process, so a declaration made in this
+ * file is a no-op whenever bun has loaded that one first.
+ */
+test("with no Bot to ask about at all, it is the Connect card it always was", async () => {
+ const asked = serving(null);
+ const view = mount();
+ expect(await view.findByText("Connect Gmail")).toBeTruthy();
+ expect(asked.some((url) => url.includes("/apps/"))).toBe(false);
+});
+
+test("a live read that fails falls back to the Connect card for a connect refusal", async () => {
+ serving(null);
+ const view = mount();
+ expect(await view.findByText("Connect Gmail")).toBeTruthy();
+});
+
+/*
+ * THROUGH THE TRANSCRIPT'S OWN LINE. The card is drawn from a server tool's answer — a refusal, or
+ * the `show_app_access` result — by `ServerToolLine`, and only those two are read for a marker.
+ * That it is drawn rather than folded into "Used N tools" is `tool-activity.test.tsx`'s.
+ */
+test("the transcript draws a refusal's card", async () => {
+ serving(appAccess({ state: "not_connected" }));
+ const view = mount(
+ ,
+ );
+ const drawn = await card(view);
+ expect(drawn.getAttribute("data-state")).toBe("not_connected");
+});
+
+test("the transcript draws show_app_access's card from its ordinary answer", async () => {
+ serving(
+ appAccess({ state: "disabled", requestKind: "enable", granted: false }),
+ );
+ const view = mount(
+ ,
+ );
+ const drawn = await card(view);
+ expect(drawn.getAttribute("data-state")).toBe("disabled");
+});
+
+test("an ordinary tool's answer is never read for a card, whatever it starts with", async () => {
+ serving(appAccess({ state: "not_connected" }));
+ const view = mount(
+ ,
+ );
+ await settleReactWork();
+ expect(view.container.querySelector("[data-app-access]")).toBeNull();
+});
diff --git a/app/tests/tool-activity.test.tsx b/app/tests/tool-activity.test.tsx
index f395a81126..1f27221068 100644
--- a/app/tests/tool-activity.test.tsx
+++ b/app/tests/tool-activity.test.tsx
@@ -169,6 +169,38 @@ test("a request to connect an app at all is never folded away either", () => {
expect(grouped.map((item) => item.id)).toContain("connect");
});
+test("a refusal that needs an administrator, or a lost account, is never folded away either", () => {
+ for (const reason of ["shared", "reconnect"]) {
+ const prompt = call(
+ reason,
+ "mcp__composio-gmail__GMAIL_FETCH_EMAILS",
+ {},
+ JSON.stringify(
+ `Refused. [connect-app]{"serverId":"composio-gmail","title":"Gmail","agentId":"expenses","reason":"${reason}"} Gmail needs an account.`,
+ ),
+ );
+ const grouped = groupToolSteps([search("a"), prompt, search("b")], drawn);
+ expect(grouped.map((item) => item.id)).toContain(reason);
+ }
+});
+
+test("the app-access card stays out of a group, even before its answer arrives", () => {
+ /*
+ * A server tool, so no renderer registers it and the registry cannot say it is drawn. Folded
+ * while it ran, the card would land inside "Used N tools" and then jump out once answered.
+ */
+ for (const result of [
+ undefined,
+ JSON.stringify(
+ '[connect-app]{"serverId":"composio-gmail","title":"Gmail","agentId":"expenses","reason":"access"} Gmail is switched off on this deployment.',
+ ),
+ ]) {
+ const card = call("card", "show_app_access", { app: "gmail" }, result);
+ const grouped = groupToolSteps([search("a"), card, search("b")], drawn);
+ expect(grouped.map((item) => item.id)).toEqual(["a", "card", "b"]);
+ }
+});
+
test("the summary says what was done, never which provider searched", () => {
const steps = [
search(),
diff --git a/server/src/index.ts b/server/src/index.ts
index 1a519b93c4..20ba5853a6 100644
--- a/server/src/index.ts
+++ b/server/src/index.ts
@@ -179,6 +179,7 @@ import { createIntelligenceMemory } from "./memory/intelligence";
import { createMemoryStore } from "./memory/store";
import { reportModelAvailability } from "./model-availability-check";
import { memoryTools } from "./memory/tools";
+import { appAccessTools } from "./plugins/app-access-tool";
import { createSignInNotifier, createSignInResumer } from "./passwords/resume";
import { createSignInService } from "./passwords/service";
import { createPasswordStore } from "./passwords/store";
@@ -1283,6 +1284,17 @@ const loadToolsForActor =
ownerUserId: actorId,
agentId: botId,
}),
+ /*
+ * Held by every Bot, granted or not: how a Bot puts an app it cannot reach in front of the
+ * person with the step that fixes it, instead of naming an administrator. A read; see the file.
+ */
+ ...appAccessTools({
+ store: pluginStore,
+ botId,
+ actorId,
+ composioConfigured: Boolean(composio),
+ ...(composio ? { directory: () => composio.broker.listApps() } : {}),
+ }),
...(await grantedTools({
store: pluginStore,
botId,
diff --git a/server/src/plugins/app-access-tool.ts b/server/src/plugins/app-access-tool.ts
new file mode 100644
index 0000000000..be48070488
--- /dev/null
+++ b/server/src/plugins/app-access-tool.ts
@@ -0,0 +1,105 @@
+import {
+ connectAppRefusal,
+ SHOW_APP_ACCESS_TOOL,
+} from "@openbot/shared/connect-prompt";
+import type { GrantedTool } from "@openbot/toolkit/granted-tool";
+import { z } from "zod";
+import type { AppAccess, AppAccessState } from "./app-access";
+import type { AppDirectory } from "./plugin-store/app-access";
+import type { PluginStore } from "./store";
+
+/**
+ * The tool every Bot holds for putting a work app's state in front of the person asking.
+ *
+ * WHY A TOOL AND NOT A SENTENCE. A Bot that met an app it does not hold used to be told to "say an
+ * administrator can grant it", and did — correctly, and uselessly: the person was handed the name of
+ * a role and no way to reach it, about an app the Bot could have had all along. The other ways an
+ * app is out of reach already drew a card, because each of them is a refused call and the refusal
+ * carries one (`@openbot/shared/connect-prompt`). An app the Bot holds nothing for is never called,
+ * so nothing was refused and there was nothing to draw. This is the call.
+ *
+ * THE ANSWER IS THE SAME MARKER A REFUSAL CARRIES, with `reason: "access"`, so the transcript draws
+ * it with the same card, and the card asks the server for the app's state for this person and this
+ * Bot when it is drawn — never trusting what this answer said, which is only what was true when the
+ * model asked. The sentence after the marker is what the model reads: the state in words, and what
+ * the card offers, so it does not go on to repeat the card at length.
+ *
+ * A READ, AND ONLY EVER ONE. Nothing here enables, grants or files anything — pressing Request
+ * access on the card does that, as the person, through the same route the Bot page uses, where the
+ * server decides what is asked for. So the tool is safe to hand every Bot: the worst a model can do
+ * with it is show somebody an accurate card about an app they did not ask about.
+ */
+export function appAccessTools(options: {
+ store: Pick;
+ botId: string;
+ actorId: string;
+ composioConfigured: boolean;
+ /** Names an app this deployment has no row for. Absent without a Composio key. */
+ directory?: AppDirectory;
+}): GrantedTool[] {
+ const parameters = z
+ .object({
+ app: z
+ .string()
+ .trim()
+ .min(1)
+ .max(200)
+ .describe(
+ "The app's name (Gmail, Slack, Google Drive) or its id as your instructions list it.",
+ ),
+ })
+ .strict();
+ return [
+ {
+ name: SHOW_APP_ACCESS_TOOL,
+ ref: `app-access/${SHOW_APP_ACCESS_TOOL}`,
+ description:
+ "Show the person a card saying whether they can use one work app through you, and the one thing that would let them: connecting their own account, or asking an administrator to switch the app on, grant it to you, or connect its shared account. Call it whenever a question needs an app you hold no tools for, instead of only saying it is missing.",
+ parameters,
+ async execute(args) {
+ const parsed = parameters.safeParse(args);
+ if (!parsed.success) return "Name the app to show.";
+ const { app } = parsed.data;
+ const inspected = await options.store.inspectApp({
+ app,
+ agentId: options.botId,
+ userId: options.actorId || null,
+ composioConfigured: options.composioConfigured,
+ ...(options.directory ? { directory: options.directory } : {}),
+ });
+ /*
+ * No card for an app nobody can name. A card about it would have nothing to ask the server
+ * and nothing to offer, and the model is better told plainly so it can say so.
+ */
+ if (!inspected)
+ return `This deployment has no app called ${app}, so there is nothing to show. Say so plainly; do not browse to it instead.`;
+ const { access } = inspected;
+ return connectAppRefusal({
+ serverId: access.key,
+ title: access.displayName,
+ agentId: options.botId,
+ reason: "access",
+ message: toldTheModel(access),
+ });
+ },
+ },
+ ];
+}
+
+/**
+ * What the model reads about the card it just put on screen: the state in words, and that the card
+ * already offers the next step, so the reply around it is a sentence rather than a set of
+ * instructions the person can already see.
+ */
+function toldTheModel(access: AppAccess): string {
+ const name = access.displayName;
+ const said: Record = {
+ ready: `${name} is ready for this person to use through you. Use its tools rather than the card.`,
+ not_connected: `${name} is granted to you, and this person has not connected their own account. The card in front of them offers Connect; ask them to try again once they have.`,
+ reconnect: `${name} is granted to you, and this person's connection needs reconnecting. The card in front of them offers Reconnect; ask them to try again once they have.`,
+ shared_not_connected: `${name} uses one shared account for this deployment, and an administrator has not connected it yet. The card in front of the person lets them ask an administrator for it.`,
+ disabled: `${name} is switched off on this deployment. The card in front of the person lets them ask an administrator to switch it on.`,
+ not_granted: `${name} is switched on, and you have not been granted it. The card in front of the person lets them ask an administrator to grant it to you.`,
+ };
+ return `${said[access.state]} Do not browse to its website or ask them to sign in there.`;
+}
diff --git a/server/src/plugins/broker.ts b/server/src/plugins/broker.ts
index 8595a10805..b151287545 100644
--- a/server/src/plugins/broker.ts
+++ b/server/src/plugins/broker.ts
@@ -625,6 +625,26 @@ export class BrokerUnconfiguredError extends BrokerRefusalError {
}
}
+/**
+ * An account this deployment still has a row for and the vendor no longer holds.
+ *
+ * ITS OWN CLASS BECAUSE IT IS THE ONE VENDOR CONDITION A PERSON CAN FIX FROM THE CONVERSATION. A
+ * grant withdrawn at the provider and an account removed in Composio's dashboard both arrive like
+ * this, nothing on this deployment records either — the `brokered_connections` row still stands and
+ * the app still reads as ready — and connecting again is the whole remedy. Telling it apart by type
+ * is what lets the tool call that met it hand the transcript a card with Reconnect on it instead of
+ * a sentence naming a settings page, without anything matching on the sentence.
+ *
+ * A {@link BrokerRefusalError} because it keeps that class's promise about the message, and because
+ * every reader that already prefers an authored sentence goes on preferring this one.
+ */
+export class BrokerAccountGoneError extends BrokerRefusalError {
+ constructor(message: string, options?: ErrorOptions) {
+ super(message, options);
+ this.name = "BrokerAccountGoneError";
+ }
+}
+
/**
* A return address that could not bring anybody back, refused before a consent is spent on it.
*
diff --git a/server/src/plugins/composio-vendor.ts b/server/src/plugins/composio-vendor.ts
index cc8f4e9d41..bbbf8964cb 100644
--- a/server/src/plugins/composio-vendor.ts
+++ b/server/src/plugins/composio-vendor.ts
@@ -1,4 +1,5 @@
import {
+ BrokerAccountGoneError,
type BrokerApp,
type BrokerConnection,
BrokerRefusalError,
@@ -1676,8 +1677,13 @@ function vendorRefusal(
* neither; connecting again is what puts an account back under the row.
*/
case "ComposioConnectedAccountNotFoundError":
- return refusal(
+ /*
+ * Raised as its own class so the tool call that met it can put Reconnect in front of the
+ * person rather than this sentence alone. See {@link BrokerAccountGoneError}.
+ */
+ return new BrokerAccountGoneError(
`Composio holds no connected account for this person and ${app}, so ${outcome}. A grant withdrawn at the provider and an account removed in Composio's own dashboard both read exactly like this, and a retry reaches neither: connecting ${app} again on this deployment's Connected accounts page is what restores it.`,
+ { cause: error },
);
/*
diff --git a/server/src/plugins/composio.ts b/server/src/plugins/composio.ts
index 240a926ffd..7a8dc07271 100644
--- a/server/src/plugins/composio.ts
+++ b/server/src/plugins/composio.ts
@@ -1,5 +1,5 @@
import { cutAtCodeUnits } from "../channels/text";
-import { brokerSentence, flagOf } from "./broker";
+import { BrokerAccountGoneError, brokerSentence, flagOf } from "./broker";
import { type ListedTool, MAX_RESULT_CHARS, type McpCallResult } from "./mcp";
/**
@@ -1767,6 +1767,18 @@ export async function askAction(
* vendor's own words below, exactly as before.
*/
const authored = brokerSentence(error);
+ /*
+ * THE ONE AUTHORED CONDITION THAT TRAVELS AS A FACT AS WELL AS A SENTENCE. An account the
+ * vendor no longer holds is something the person can fix themselves, from the conversation,
+ * and nothing on this deployment's rows knows it happened — so the result says so in a field
+ * the store reads, rather than leaving it to be recognised from the wording. See
+ * {@link BrokerAccountGoneError} and `McpCallResult.accountGone`.
+ */
+ if (authored !== null && error instanceof BrokerAccountGoneError)
+ return {
+ result: { ...failure(authored), accountGone: true },
+ answered: false,
+ };
if (authored !== null) return unreached(authored);
/*
diff --git a/server/src/plugins/mcp.ts b/server/src/plugins/mcp.ts
index d2d310def2..055352a973 100644
--- a/server/src/plugins/mcp.ts
+++ b/server/src/plugins/mcp.ts
@@ -432,6 +432,12 @@ export type McpCallResult = {
/** True when the server itself reported the call as an error rather than failing to answer. */
isError: boolean;
truncated: boolean;
+ /**
+ * The vendor no longer holds the account this call was made in, though this deployment still has
+ * a row for it. Only the Composio transport sets it, and only on an `isError` result; the store
+ * turns it into a refusal that offers Reconnect. Absent everywhere else.
+ */
+ accountGone?: true;
};
/**
diff --git a/server/src/plugins/plugin-store/calls.ts b/server/src/plugins/plugin-store/calls.ts
index 70f9e9482e..12f76655cc 100644
--- a/server/src/plugins/plugin-store/calls.ts
+++ b/server/src/plugins/plugin-store/calls.ts
@@ -1,3 +1,4 @@
+import { connectAppRefusal } from "@openbot/shared/connect-prompt";
import { and, eq } from "drizzle-orm";
import { currentApprovalContext } from "../../approvals/types";
import { type AuditInitiator, recordAuditEvent } from "../../audit";
@@ -510,7 +511,11 @@ export function callMethods(context: StoreContext) {
});
const answer = { text: result.text, isError: result.isError };
await approval?.complete(answer);
- return answer;
+ /*
+ * The one answer that leaves this block without returning: see below the catch. Thrown
+ * from in here, the catch would write a second `mcp.call_failed` row about the same call.
+ */
+ if (!result.accountGone) return answer;
} catch (error) {
/*
* Recorded, then rethrown unchanged. The caller's behaviour is unaffected — what changes is
@@ -546,6 +551,29 @@ export function callMethods(context: StoreContext) {
});
throw error;
}
+ /*
+ * THE ACCOUNT IS GONE AT THE VENDOR, AND THE PERSON CAN PUT IT BACK FROM WHERE THEY ARE. The
+ * only way here: every other answer returned inside the block and every throw left the catch.
+ *
+ * Recorded above as the failure it is, with the vendor's condition in the trail. Handed on as
+ * a refusal carrying an app card rather than as the vendor's error, because nothing on this
+ * deployment's rows knows the account is gone — the app reads as ready on every screen — and
+ * the card is the one place the person is told, with Reconnect beside it. A Shared app's
+ * account is the deployment's, so there the card says an administrator has to reconnect it.
+ */
+ throw new PluginRefusedError(
+ connectAppRefusal({
+ serverId: row.id,
+ title: row.title,
+ message:
+ gatedMode === "shared"
+ ? `The shared ${row.title} account is no longer connected at the vendor, so this was not done. An administrator has to connect it again.`
+ : `Your ${row.title} account is no longer connected at the vendor, so this was not done. Reconnect it and ask again.`,
+ reason: gatedMode === "shared" ? "shared" : "reconnect",
+ at: new Date().toISOString(),
+ }),
+ null,
+ );
},
};
}
diff --git a/server/src/plugins/plugin-store/context.ts b/server/src/plugins/plugin-store/context.ts
index b877d5bf4f..e6c38481a7 100644
--- a/server/src/plugins/plugin-store/context.ts
+++ b/server/src/plugins/plugin-store/context.ts
@@ -528,13 +528,22 @@ export function createStoreContext(options: PluginStoreOptions) {
.limit(1);
if (!connected) {
/*
- * Only the personal half carries a connect prompt. A Shared app's account belongs to the
- * deployment, so the person reading this cannot connect it however clearly they are asked —
- * a card offering them the button would be an invitation to a refusal.
+ * BOTH HALVES CARRY A PROMPT, AND ONLY ONE OF THEM OFFERS A CONNECT BUTTON. A Shared app's
+ * account belongs to the deployment, so the person reading this cannot connect it however
+ * clearly they are asked — a card offering them that button would be an invitation to a
+ * refusal. What the card draws is decided by the app's state for this person, read when it
+ * is drawn: for a missing team account that is Request access, which files the request an
+ * administrator answers by connecting it. The sentence stays for the model and for a reader
+ * with no card. `reason: "shared"` is only a hint; see `ConnectReason`.
*/
throw new PluginRefusedError(
resolved.ref.holder === "deployment"
- ? `${row.title} is shared across this deployment and no account is connected to it yet. Ask an administrator to connect it.`
+ ? connectAppRefusal({
+ serverId: row.id,
+ title: row.title,
+ message: `${row.title} is shared across this deployment and no account is connected to it yet. Ask an administrator to connect it.`,
+ reason: "shared",
+ })
: connectAppRefusal({
serverId: row.id,
title: row.title,
diff --git a/server/src/plugins/plugin-store/model.ts b/server/src/plugins/plugin-store/model.ts
index 69ef78b109..0eec70dafb 100644
--- a/server/src/plugins/plugin-store/model.ts
+++ b/server/src/plugins/plugin-store/model.ts
@@ -1104,7 +1104,8 @@ export type PluginStoreOptions = {
},
toolName: string,
args: Record,
- ) => Promise<{ text: string; isError: boolean }>;
+ /** `accountGone` as `McpCallResult` carries it: the vendor lost the account this call ran in. */
+ ) => Promise<{ text: string; isError: boolean; accountGone?: true }>;
/** Trading a refresh token for a short-lived access token. Defaults to a real HTTP exchange. */
exchangeRefreshToken?: (input: {
tokenUrl: string;
diff --git a/server/src/plugins/tools.ts b/server/src/plugins/tools.ts
index baa73f39af..9496f2f6a9 100644
--- a/server/src/plugins/tools.ts
+++ b/server/src/plugins/tools.ts
@@ -8,6 +8,10 @@ import {
catalogueEntry,
researchRoleFor,
} from "./catalogue";
+import {
+ addressedTo,
+ SHOW_APP_ACCESS_TOOL,
+} from "@openbot/shared/connect-prompt";
import { type ResearchRole, RESEARCH_TOOLS } from "@openbot/shared/research";
import type { SelectableSkill } from "./selection";
import {
@@ -100,10 +104,19 @@ export function grantedToolGuidance(
*/
connectedButNotHeld: readonly string[] = [],
): string {
- if (tools.length === 0 && connectedButNotHeld.length === 0) return "";
+ /*
+ * THE CARD TOOL IS NOT A SYSTEM. It is offered to every Bot so a missing app can be put in front of
+ * the person with the step that fixes it, and it reaches nothing — listed beside the vendors it
+ * would read as one more system this Bot "can reach directly, as the person asking". It is named
+ * below instead, at exactly the sentences that used to end on "an administrator can grant it".
+ */
+ const showsCards = tools.some((tool) => tool.name === SHOW_APP_ACCESS_TOOL);
+ const reaching = tools.filter((tool) => tool.name !== SHOW_APP_ACCESS_TOOL);
+ if (reaching.length === 0 && connectedButNotHeld.length === 0 && !showsCards)
+ return "";
const bySystem = new Map();
- for (const tool of tools) {
+ for (const tool of reaching) {
// `mcp__server__tool`, which is the shape the model is offered.
const parts = tool.name.replace(/^mcp__/, "").split("__");
const system = parts.length > 1 ? (parts[0] as string) : "this deployment";
@@ -116,10 +129,10 @@ export function grantedToolGuidance(
(system) => !held.includes(system),
);
- const research = researchProviders(tools);
+ const research = researchProviders(reaching);
return [
- ...(tools.length > 0
+ ...(reaching.length > 0
? [
"You can reach these systems directly, as the person asking, with their own access:",
]
@@ -127,7 +140,7 @@ export function grantedToolGuidance(
...[...bySystem.entries()].map(
([system, names]) => `- ${system}: ${names.join(", ")}`,
),
- ...(tools.length > 0
+ ...(reaching.length > 0
? [
"Use them for anything about those systems. Do NOT browse to one of their websites instead: your",
"browser is signed in as nobody, so it sees less than these tools do and will meet a sign-in wall",
@@ -151,12 +164,35 @@ export function grantedToolGuidance(
*/
...(missing.length > 0
? [
- ...(tools.length > 0 ? [""] : []),
+ ...(reaching.length > 0 ? [""] : []),
`This deployment also connects to: ${missing.join(", ")}. You hold none of their tools.`,
- "If a question needs one of them, say plainly that you have not been granted it and that an",
- "administrator can grant it on that connector. Do NOT browse to its website: that is not the",
- "same thing, your browser is signed in as nobody, and it will meet a sign-in wall that the",
- "connector exists to avoid. Do not ask the person to sign in there either.",
+ ...(showsCards
+ ? [
+ `If a question needs one of them, call ${SHOW_APP_ACCESS_TOOL} with its name: that puts a card in`,
+ "front of the person saying you have not been granted it, with a button to ask an administrator",
+ "for it. Then say so in a sentence; the card already carries the rest.",
+ ]
+ : [
+ "If a question needs one of them, say plainly that you have not been granted it and that an",
+ "administrator can grant it on that connector.",
+ ]),
+ "Do NOT browse to its website: that is not the same thing, your browser is signed in as nobody,",
+ "and it will meet a sign-in wall that the connector exists to avoid. Do not ask the person to",
+ "sign in there either.",
+ ]
+ : []),
+ /*
+ * AN APP THIS DEPLOYMENT DOES NOT CONNECT AT ALL is the one case nothing above names, and the
+ * one a Bot met most bluntly: asked about Gmail on a deployment that has not switched it on, it
+ * had no word for Gmail anywhere in its instructions and either browsed or said it could not.
+ * The card says whether it can be switched on and lets the person ask for it.
+ */
+ ...(showsCards
+ ? [
+ ...(reaching.length > 0 || missing.length > 0 ? [""] : []),
+ `If the person needs a work app that is not named here at all (Gmail, Slack, Salesforce), call`,
+ `${SHOW_APP_ACCESS_TOOL} with its name before saying you cannot reach it: the card tells them whether it`,
+ "can be switched on and lets them ask an administrator. Do not browse to its website instead.",
]
: []),
].join("\n");
@@ -281,7 +317,12 @@ export async function grantedTools(options: {
error instanceof PluginRefusedError ||
error instanceof ApprovalRefusedError
) {
- return `${REFUSAL_MARKER} ${error.message}`;
+ /*
+ * A refusal that carries an app card names the Bot here, the one place both are known:
+ * in a channel several Bots answer in, the card has to ask what the app is for this
+ * person on THIS Bot, not on whichever one the surface happens to have declared.
+ */
+ return `${REFUSAL_MARKER} ${addressedTo(error.message, botId)}`;
}
/*
* A contradiction in this deployment's own tables says nothing to a model.
diff --git a/server/tests/app-access-tool.test.ts b/server/tests/app-access-tool.test.ts
new file mode 100644
index 0000000000..1496510f2a
--- /dev/null
+++ b/server/tests/app-access-tool.test.ts
@@ -0,0 +1,156 @@
+import { describe, expect, test } from "bun:test";
+import {
+ readConnectPrompt,
+ SHOW_APP_ACCESS_TOOL,
+} from "@openbot/shared/connect-prompt";
+import type { AppAccess, AppAccessState } from "../src/plugins/app-access";
+import { appAccessTools } from "../src/plugins/app-access-tool";
+import type { AppInspection } from "../src/plugins/plugin-store/app-access";
+import { PluginRefusedError, type PluginStore } from "../src/plugins/store";
+import { grantedTools, REFUSAL_MARKER } from "../src/plugins/tools";
+
+/**
+ * The tool every Bot holds for putting an app it cannot use in front of the person, and the Bot's
+ * name on the refusals that already carried a card.
+ *
+ * The tool is a read: it asks the store what the app is for this person on this Bot and answers
+ * with the card marker — the app, the Bot, `reason: "access"` — and a sentence for the model. It
+ * never files a request; pressing the card does that, as the person.
+ */
+
+function access(state: AppAccessState): AppAccess {
+ return {
+ key: "composio-gmail",
+ displayName: "Gmail",
+ logo: null,
+ provider: "composio",
+ account: "personal",
+ connect: "consent",
+ authScheme: "OAUTH2",
+ accountMode: "personal",
+ state,
+ granted: state !== "not_granted" && state !== "disabled",
+ connectedAt: null,
+ requestKind: null,
+ request: null,
+ };
+}
+
+function toolOver(answer: AppAccess | null) {
+ const asked: unknown[] = [];
+ const [tool] = appAccessTools({
+ store: {
+ inspectApp: async (input) => {
+ asked.push(input);
+ return answer ? ({ access: answer } as unknown as AppInspection) : null;
+ },
+ },
+ botId: "expenses",
+ actorId: "usr_7",
+ composioConfigured: true,
+ });
+ if (!tool) throw new Error("no tool");
+ return { tool, asked };
+}
+
+describe("show_app_access", () => {
+ test("is offered under its shared name, as a read anybody may hold", () => {
+ const { tool } = toolOver(null);
+ expect(tool.name).toBe(SHOW_APP_ACCESS_TOOL);
+ expect(tool.description).toContain("instead of only saying it is missing");
+ });
+
+ test("asks about the app for this person on this Bot", async () => {
+ const { tool, asked } = toolOver(access("disabled"));
+ await tool.execute({ app: "Gmail" });
+ expect(asked).toEqual([
+ {
+ app: "Gmail",
+ agentId: "expenses",
+ userId: "usr_7",
+ composioConfigured: true,
+ },
+ ]);
+ });
+
+ test.each([
+ ["ready", /ready for this person to use through you/],
+ ["not_connected", /offers Connect/],
+ ["reconnect", /offers Reconnect/],
+ ["shared_not_connected", /ask an administrator for it/],
+ ["disabled", /switched off on this deployment/],
+ ["not_granted", /you have not been granted it/],
+ ] as const)(
+ "answers %s with the card marker and the state in words",
+ async (state, said) => {
+ const { tool } = toolOver(access(state));
+ const answer = await tool.execute({ app: "gmail" });
+ // Not a refusal: the transcript reads this tool's ordinary answer for its card.
+ expect(answer.startsWith(REFUSAL_MARKER)).toBe(false);
+ const prompt = readConnectPrompt(answer);
+ expect(prompt).toMatchObject({
+ serverId: "composio-gmail",
+ title: "Gmail",
+ agentId: "expenses",
+ reason: "access",
+ });
+ expect(prompt?.message).toMatch(said);
+ expect(prompt?.message).toContain("Do not browse to its website");
+ },
+ );
+
+ test("an app nobody can name gets a plain sentence and no card", async () => {
+ const { tool } = toolOver(null);
+ const answer = await tool.execute({ app: "Frobnicator" });
+ expect(readConnectPrompt(answer)).toBeNull();
+ expect(answer).toContain("no app called Frobnicator");
+ });
+
+ test("arguments that name no app are refused before the store is asked", async () => {
+ const { tool, asked } = toolOver(access("ready"));
+ expect(await tool.execute({})).toBe("Name the app to show.");
+ expect(await tool.execute({ app: " " })).toBe("Name the app to show.");
+ expect(asked).toEqual([]);
+ });
+});
+
+describe("a refusal carrying a card, on its way to the model", () => {
+ async function refusedWith(message: string) {
+ const [tool] = await grantedTools({
+ store: {
+ callTool: async () => {
+ throw new PluginRefusedError(message, null);
+ },
+ listForAgent: async () => ({
+ tools: [
+ {
+ toolName: "mcp__composio-gmail__GMAIL_FETCH_EMAILS",
+ ref: "composio-gmail/GMAIL_FETCH_EMAILS",
+ description: "Fetch mail.",
+ inputSchema: { type: "object" },
+ },
+ ],
+ }),
+ } as unknown as PluginStore,
+ botId: "expenses",
+ actorId: "usr_7",
+ });
+ return (await tool?.execute({})) ?? "";
+ }
+
+ test("names the Bot that refused, so the card asks about the right one", async () => {
+ const answer = await refusedWith(
+ '[connect-app]{"serverId":"composio-gmail","title":"Gmail","reason":"shared"} Gmail is shared across this deployment and no account is connected to it yet. Ask an administrator to connect it.',
+ );
+ expect(answer.startsWith(`${REFUSAL_MARKER} `)).toBe(true);
+ expect(
+ readConnectPrompt(answer.slice(REFUSAL_MARKER.length).trim()),
+ ).toMatchObject({ agentId: "expenses", reason: "shared" });
+ });
+
+ test("leaves an ordinary refusal exactly as it was", async () => {
+ expect(await refusedWith("That tool is not granted.")).toBe(
+ `${REFUSAL_MARKER} That tool is not granted.`,
+ );
+ });
+});
diff --git a/server/tests/composio-adapter.test.ts b/server/tests/composio-adapter.test.ts
index e80987a9c7..fd95336d87 100644
--- a/server/tests/composio-adapter.test.ts
+++ b/server/tests/composio-adapter.test.ts
@@ -1,6 +1,7 @@
import { describe, expect, test } from "bun:test";
import { Composio, telemetry } from "@composio/core";
import {
+ BrokerAccountGoneError,
type BrokerConnection,
BrokerRefusalError,
BrokerUnconfiguredError,
@@ -3502,6 +3503,20 @@ describe("each vendor condition reaches the reader as its own remedy", () => {
});
}
+ test("an account Composio no longer holds is raised as its own class, so a call can offer Reconnect", async () => {
+ const failure = await failureOf(
+ whileRunning(raising("ComposioConnectedAccountNotFoundError")),
+ );
+ expect(failure).toBeInstanceOf(BrokerAccountGoneError);
+ // Still an authored refusal, so every reader that prefers this deployment's sentence still does.
+ expect(failure).toBeInstanceOf(BrokerRefusalError);
+ // And nothing else is: the class is what the store acts on.
+ const other = await failureOf(
+ whileRunning(raising("ComposioRequestCancelledError")),
+ );
+ expect(other).not.toBeInstanceOf(BrokerAccountGoneError);
+ });
+
/**
* ONE VENDOR CLASS OVER EVERY WAY A FETCH CAN FAIL, which is a fact about the SDK rather than a
* reading of its name. `getRawComposioToolBySlug` wraps its retrieve in a try whose catch
diff --git a/server/tests/composio-transport.test.ts b/server/tests/composio-transport.test.ts
index 9c1086a55e..022d542fa9 100644
--- a/server/tests/composio-transport.test.ts
+++ b/server/tests/composio-transport.test.ts
@@ -1,5 +1,8 @@
import { afterEach, describe, expect, test } from "bun:test";
-import { BrokerRefusalError } from "../src/plugins/broker";
+import {
+ BrokerAccountGoneError,
+ BrokerRefusalError,
+} from "../src/plugins/broker";
import {
askAction,
type ComposioAction,
@@ -3223,6 +3226,50 @@ describe("calling one action", () => {
expect(result.text).not.toContain("Invalid request");
});
+ test("an account the vendor lost travels as a fact on the result, beside its sentence", async () => {
+ /*
+ * The one authored condition the store acts on: it turns this result into a refusal that
+ * offers Reconnect. Read off a field rather than off the wording, so rephrasing the sentence
+ * cannot quietly stop the card being drawn — and set on nothing else.
+ */
+ const authored =
+ "Composio holds no connected account for this person and gmail, so GMAIL_FETCH_EMAILS was not run.";
+ useComposioClient(
+ recording({
+ execute: async () => {
+ throw new BrokerAccountGoneError(authored);
+ },
+ }).client,
+ );
+
+ const gone = await askAction(
+ { url: "composio://gmail", actorId: "user_asker" },
+ "GMAIL_FETCH_EMAILS",
+ { __version: "20260903_00" },
+ );
+ expect(gone.result).toMatchObject({
+ isError: true,
+ text: authored,
+ accountGone: true,
+ });
+ // Not a verdict on the key: a probe meeting this keeps its "could not find out".
+ expect(gone.answered).toBe(false);
+
+ useComposioClient(
+ recording({
+ execute: async () => {
+ throw new BrokerRefusalError("Some other authored refusal.");
+ },
+ }).client,
+ );
+ const other = await callTool(
+ { url: "composio://gmail", actorId: "user_asker" },
+ "GMAIL_FETCH_EMAILS",
+ { __version: "20260903_00" },
+ );
+ expect(other.accountGone).toBeUndefined();
+ });
+
test("a failure this deployment cannot explain still reaches for the vendor's words", async () => {
// The other half of the precedence, and the reason it is `brokerSentence` rather than
// `error.message`. `./broker` raises its class only where the sentence names the step that
diff --git a/server/tests/copilot.test.ts b/server/tests/copilot.test.ts
index 48b43149e6..978a0811d9 100644
--- a/server/tests/copilot.test.ts
+++ b/server/tests/copilot.test.ts
@@ -1069,6 +1069,53 @@ describe("what a Bot is told it holds", () => {
);
});
+ /*
+ * THE CARD TOOL. Every Bot holds `show_app_access`, and the sentences that used to end on "an
+ * administrator can grant it" now tell the Bot to call it — which puts a card with Request access
+ * in front of the person, where the sentence put the name of a role.
+ */
+ const card = { name: "show_app_access" } as never;
+
+ test("tells a Bot to show the card for a connected vendor it holds nothing for", () => {
+ const guidance = grantedToolGuidance([card], ["composio-gmail"])
+ .toLowerCase()
+ .replace(/\s+/g, " ");
+ expect(guidance).toContain("composio-gmail");
+ expect(guidance).toContain("you hold none of their tools");
+ expect(guidance).toContain("call show_app_access with its name");
+ expect(guidance).not.toContain(
+ "administrator can grant it on that connector",
+ );
+ expect(guidance).toContain("do not browse to its website");
+ });
+
+ test("tells a Bot to show the card for an app this deployment does not connect at all", () => {
+ const guidance = grantedToolGuidance([card])
+ .toLowerCase()
+ .replace(/\s+/g, " ");
+ expect(guidance).toContain("not named here at all");
+ expect(guidance).toContain("show_app_access");
+ expect(guidance).toContain("ask an administrator");
+ });
+
+ test("never lists the card tool as a system the Bot can reach", () => {
+ // Listed beside the vendors, it would read as one more system reached "as the person asking".
+ const guidance = grantedToolGuidance([...drive, card]);
+ expect(guidance).not.toMatch(/^- .*show_app_access/m);
+ expect(guidance).toContain("search_files");
+ expect(grantedToolGuidance([card])).not.toContain(
+ "You can reach these systems directly",
+ );
+ });
+
+ test("a Bot not offered the card is still told to name the administrator, as before", () => {
+ const guidance = grantedToolGuidance([], ["google-drive"])
+ .toLowerCase()
+ .replace(/\s+/g, " ");
+ expect(guidance).toContain("administrator can grant it on that connector");
+ expect(guidance).not.toContain("show_app_access");
+ });
+
test("says nothing at all when the Bot holds nothing", () => {
// A deployment with no connectors must not be told about connectors it does not have.
expect(grantedToolGuidance([])).toBe("");
diff --git a/server/tests/shared-brokered-store.integration.test.ts b/server/tests/shared-brokered-store.integration.test.ts
index 59d2e912ee..1abeb1fc92 100644
--- a/server/tests/shared-brokered-store.integration.test.ts
+++ b/server/tests/shared-brokered-store.integration.test.ts
@@ -10,7 +10,12 @@ import {
mcpServers,
mcpTools,
} from "../src/db/schema";
-import type { ConnectedAppBroker } from "../src/plugins/broker";
+import { readConnectPrompt } from "@openbot/shared/connect-prompt";
+import { auditEvents } from "../src/db/schema";
+import {
+ BrokerAccountGoneError,
+ type ConnectedAppBroker,
+} from "../src/plugins/broker";
import { useComposioClient } from "../src/plugins/composio";
import { createPluginStore } from "../src/plugins/store";
import { expectOnlyRefusal } from "./helpers/refusals";
@@ -181,6 +186,12 @@ describe("a shared app", () => {
(error: Error) => error.message,
);
expectOnlyRefusal(said, "sharedNotConnected", "Team GitHub");
+ // And it carries the card, which asks an administrator rather than offering Connect.
+ expect(readConnectPrompt(said)).toMatchObject({
+ serverId: app,
+ title: "Team GitHub",
+ reason: "shared",
+ });
});
test("offboarding the admin who connected it leaves it in place", async () => {
@@ -539,3 +550,93 @@ describe("a duplicate row at an app's url", () => {
]);
});
});
+
+/*
+ * THE ACCOUNT GONE AT THE VENDOR. Nothing on this deployment's rows knows it happened, so the call
+ * that meets it is the only place the person can be told: refused with the app card, `reconnect`
+ * for their own account and `shared` for the team's, stamped with when — and audited once, as the
+ * failure it is.
+ */
+describe("an account the vendor no longer holds", () => {
+ const gone = () =>
+ useComposioClient({
+ listActions: async () => [],
+ execute: async () => {
+ throw new BrokerAccountGoneError(
+ "Composio holds no connected account for this person and team-gh, so GITHUB_LIST_ISSUES was not run.",
+ );
+ },
+ });
+
+ /* Counted before and after: the trail is not cleaned between tests, which share the app id. */
+ const failures = async () =>
+ (
+ await database
+ .select({ eventType: auditEvents.eventType })
+ .from(auditEvents)
+ .where(eq(auditEvents.targetId, `${app}/GITHUB_LIST_ISSUES`))
+ ).filter((row) => row.eventType === "mcp.call_failed").length;
+
+ test("on a Shared app: refused with a card saying an administrator has to connect it again", async () => {
+ const store = freshStore();
+ await seedShared(store, true);
+ gone();
+ const already = await failures();
+ const before = Date.now();
+ const said = await store
+ .callTool({
+ ref: `${app}/GITHUB_LIST_ISSUES`,
+ args: {},
+ botId: bot,
+ actorId: asker,
+ })
+ .then(
+ () => "called",
+ (error: Error) => error.message,
+ );
+ const prompt = readConnectPrompt(said);
+ expect(prompt).toMatchObject({
+ serverId: app,
+ title: "Team GitHub",
+ reason: "shared",
+ });
+ expect(prompt?.message).toContain(
+ "An administrator has to connect it again",
+ );
+ expect(Date.parse(prompt?.at ?? "")).toBeGreaterThanOrEqual(before - 1000);
+ expect(await failures()).toBe(already + 1);
+ });
+
+ test("on a personal app: refused with a card offering Reconnect", async () => {
+ const store = freshStore();
+ await seedShared(store, false);
+ await database
+ .update(mcpServers)
+ .set({ accountMode: "personal", sharedVendorUserId: null })
+ .where(eq(mcpServers.id, app));
+ await database.insert(brokeredConnections).values({
+ provider: "composio",
+ app,
+ holder: "person",
+ userId: asker,
+ vendorUserId: asker,
+ });
+ gone();
+ const said = await store
+ .callTool({
+ ref: `${app}/GITHUB_LIST_ISSUES`,
+ args: {},
+ botId: bot,
+ actorId: asker,
+ })
+ .then(
+ () => "called",
+ (error: Error) => error.message,
+ );
+ expect(readConnectPrompt(said)).toMatchObject({
+ serverId: app,
+ reason: "reconnect",
+ });
+ expect(said).toContain("Reconnect it and ask again");
+ });
+});
diff --git a/shared/connect-prompt.test.ts b/shared/connect-prompt.test.ts
index 630b76437c..336c53058a 100644
--- a/shared/connect-prompt.test.ts
+++ b/shared/connect-prompt.test.ts
@@ -1,5 +1,6 @@
import { describe, expect, test } from "bun:test";
import {
+ addressedTo,
CONNECT_APP_MARKER,
connectAppRefusal,
readConnectPrompt,
@@ -58,3 +59,65 @@ describe("text that is not one", () => {
expect(readConnectPrompt(undefined)).toBeNull();
});
});
+
+describe("the Bot, the reason and the moment it carries", () => {
+ test("survive the round trip, and are left out when absent", () => {
+ const text = connectAppRefusal({
+ serverId: "composio-gmail",
+ title: "Gmail",
+ message: "Your Gmail account is no longer connected at the vendor.",
+ agentId: "expenses",
+ reason: "reconnect",
+ at: "2026-10-20T12:00:00.000Z",
+ });
+ expect(readConnectPrompt(text)).toEqual({
+ serverId: "composio-gmail",
+ title: "Gmail",
+ message: "Your Gmail account is no longer connected at the vendor.",
+ agentId: "expenses",
+ reason: "reconnect",
+ at: "2026-10-20T12:00:00.000Z",
+ });
+ expect(connectAppRefusal({ serverId: "s", title: "T", message: "m" })).toBe(
+ `${CONNECT_APP_MARKER}{"serverId":"s","title":"T"} m`,
+ );
+ });
+
+ test("a field of the wrong shape is dropped, and the prompt still names its app", () => {
+ const prompt = readConnectPrompt(
+ `${CONNECT_APP_MARKER}{"serverId":"composio-gmail","title":"Gmail","agentId":7,"reason":"grant me","at":"yesterday"} hello`,
+ );
+ expect(prompt).toEqual({
+ serverId: "composio-gmail",
+ title: "Gmail",
+ message: "hello",
+ });
+ });
+
+ test("addressedTo names the Bot on a prompt that names none, and leaves everything else alone", () => {
+ const bare = connectAppRefusal({
+ serverId: "notion",
+ title: "Notion",
+ message: "You have not connected your Notion account.",
+ reason: "shared",
+ });
+ expect(readConnectPrompt(addressedTo(bare, "expenses"))).toEqual({
+ serverId: "notion",
+ title: "Notion",
+ message: "You have not connected your Notion account.",
+ reason: "shared",
+ agentId: "expenses",
+ });
+
+ const named = connectAppRefusal({
+ serverId: "notion",
+ title: "Notion",
+ message: "m",
+ agentId: "travel",
+ });
+ expect(addressedTo(named, "expenses")).toBe(named);
+ expect(addressedTo("That tool is not granted.", "expenses")).toBe(
+ "That tool is not granted.",
+ );
+ });
+});
diff --git a/shared/connect-prompt.ts b/shared/connect-prompt.ts
index 499ea32787..e1042b5936 100644
--- a/shared/connect-prompt.ts
+++ b/shared/connect-prompt.ts
@@ -1,5 +1,7 @@
/**
- * The refusal a person can act on: an app this call needed, that they have not connected.
+ * The refusal a person can act on: an app this call needed, that they have not connected — or
+ * whose account the vendor has lost, or whose team account nobody has connected — and the answer of
+ * the one tool that says what an app is for them without a call having been refused at all.
*
* ONE DECLARATION, READ FROM BOTH SIDES, for the reason `./handoff-markers.ts` sets out at length.
* A tool that runs on the server reaches the transcript as text meant for a model, so a renderer
@@ -16,15 +18,65 @@
/** The first characters of the refusal, immediately followed by its JSON. */
export const CONNECT_APP_MARKER = "[connect-app]";
+/**
+ * Why the card was drawn, as the server saw it at the moment of the call.
+ *
+ * `connect` — this person has no account at the app. `reconnect` — they had one, and the vendor no
+ * longer holds it: a grant withdrawn at the provider, an account removed in Composio's dashboard.
+ * `shared` — a Shared app whose one team account is missing, which only an administrator connects.
+ * `access` — the Bot asked what the app is for this person (`show_app_access`), with no refusal
+ * behind it: switched off, not granted, or any of the above.
+ *
+ * A HINT, NOT THE STATE. The card asks the server for the app's state for this person and this Bot
+ * when it is drawn, and draws that. The one place the hint wins is an account the vendor has lost:
+ * nothing on this deployment records that, so the live state still reads `ready` there, and only the
+ * refusal knows otherwise. See `AppAccessCard`.
+ */
+export type ConnectReason = "connect" | "reconnect" | "shared" | "access";
+
+const REASONS: readonly ConnectReason[] = [
+ "connect",
+ "reconnect",
+ "shared",
+ "access",
+];
+
export type ConnectPrompt = {
- /** The plugin server to connect, as `POST /api/plugins/servers/:serverId/connect` names it. */
+ /**
+ * The app, as the server names it: the plugin server to connect, as
+ * `POST /api/plugins/servers/:serverId/connect` names it, or the key an app nobody enabled would
+ * have. What the card asks `GET /api/plugins/for/:agentId/apps/:app` about.
+ */
serverId: string;
/** The app's own name, for a card that says what it is asking for. */
title: string;
/** What the Bot would have said, kept intact for a reader with no card to draw. */
message: string;
+ /**
+ * The Bot whose call this was, so the card asks about the right one in a channel several Bots
+ * answer in. Absent on a refusal written before it carried one, where the card falls back to the
+ * Bot the surface declared.
+ */
+ agentId?: string;
+ /** See {@link ConnectReason}. Absent reads as `connect`, which is all a refusal used to mean. */
+ reason?: ConnectReason;
+ /**
+ * When the server refused, as an ISO instant — on a `reconnect` or `shared` refusal, the only
+ * ones whose hint can outrank the live state. A connection made after this is the reconnection,
+ * and the card stops saying Reconnect. See `AppAccess.connectedAt`.
+ */
+ at?: string;
};
+/**
+ * The name of the tool every Bot holds for saying what an app is for the person asking.
+ *
+ * Here rather than beside the tool, because the transcript reads it too: that tool's answer is the
+ * one ordinary result (not a refusal) whose marker is drawn as a card, and a renderer may only trust
+ * a marker on a result it knows this deployment wrote.
+ */
+export const SHOW_APP_ACCESS_TOOL = "show_app_access";
+
/**
* The refusal text a `PluginRefusedError` carries for an app the asker has not connected.
*
@@ -32,8 +84,28 @@ export type ConnectPrompt = {
* the MODEL is told, and because a transcript that cannot parse the marker still reads correctly.
*/
export function connectAppRefusal(prompt: ConnectPrompt): string {
- const { serverId, title, message } = prompt;
- return `${CONNECT_APP_MARKER}${JSON.stringify({ serverId, title })} ${message}`;
+ const { serverId, title, message, agentId, reason, at } = prompt;
+ return `${CONNECT_APP_MARKER}${JSON.stringify({
+ serverId,
+ title,
+ ...(agentId ? { agentId } : {}),
+ ...(reason ? { reason } : {}),
+ ...(at ? { at } : {}),
+ })} ${message}`;
+}
+
+/**
+ * The same text with the Bot named on it, for the one caller that knows which Bot asked.
+ *
+ * WHY AFTERWARDS RATHER THAN AT THE SITE THAT WROTE IT. The refusals are raised deep in resolving a
+ * credential, which is a question about a person and an app and never about a Bot — threading a Bot
+ * id down there would be a parameter nothing else reads. The tool's own wrapper knows the Bot, so it
+ * adds it on the way out. Text that is not a prompt, or already names a Bot, is returned unchanged.
+ */
+export function addressedTo(text: string, agentId: string): string {
+ const prompt = readConnectPrompt(text);
+ if (!prompt || prompt.agentId || !agentId) return text;
+ return connectAppRefusal({ ...prompt, agentId });
}
/**
@@ -53,13 +125,32 @@ export function readConnectPrompt(
try {
const parsed: unknown = JSON.parse(rest.slice(0, end + 1));
if (!parsed || typeof parsed !== "object") return null;
- const { serverId, title } = parsed as {
+ const { serverId, title, agentId, reason, at } = parsed as {
serverId?: unknown;
title?: unknown;
+ agentId?: unknown;
+ reason?: unknown;
+ at?: unknown;
};
if (typeof serverId !== "string" || typeof title !== "string") return null;
if (!serverId || !title) return null;
- return { serverId, title, message: rest.slice(end + 1).trim() };
+ /*
+ * The optional fields are dropped rather than failing the whole prompt when they are the
+ * wrong shape: without them the card still names the right app and asks about the declared Bot,
+ * which is what every refusal did before they existed.
+ */
+ return {
+ serverId,
+ title,
+ message: rest.slice(end + 1).trim(),
+ ...(typeof agentId === "string" && agentId ? { agentId } : {}),
+ ...(REASONS.includes(reason as ConnectReason)
+ ? { reason: reason as ConnectReason }
+ : {}),
+ ...(typeof at === "string" && !Number.isNaN(Date.parse(at))
+ ? { at }
+ : {}),
+ };
} catch {
return null;
}
From 6cbe79a9762ffe93a898e84229265bbe06e14ed4 Mon Sep 17 00:00:00 2001
From: Guido Vizoso
Date: Tue, 20 Oct 2026 19:39:45 -0300
Subject: [PATCH 08/33] Describe app states on the Bot page, the chat card and
access requests
---
CHANGELOG.md | 16 ++++++++++++++++
docs/plugins/composio.md | 29 +++++++++++++++++++++++++++--
2 files changed, 43 insertions(+), 2 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 515f00da48..78e4f0d5e3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -35,6 +35,22 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged.
request for an administrator to switch on a work app, grant it to a Bot, or connect a Shared app's
team account is kept, with who answered it and when. It starts empty and nothing is backfilled.
+### Each app on a Bot says whether you can use it, and you can ask an administrator for the rest
+
+A Bot's page now says, for every work app the Bot holds, what that app is for the person looking:
+ready, not connected yet, needing reconnecting, waiting on your organisation's shared account, turned
+off by an administrator, or not given to this Bot. Each comes with the one thing you can do about it —
+Connect or Reconnect your own account, or Request access where only an administrator can help — and
+apps are named by their own name ("Gmail", not "Composio Gmail"). In a conversation, a Bot that needs
+an app it cannot use now puts the same card in front of you instead of a sentence: an account you have
+not connected, a Shared app with no team account, an account the vendor no longer holds, and — through
+a new `show_app_access` tool every Bot holds — an app that is switched off or not granted to it. The
+card reads the app's current state when it is drawn, so it never offers a step already taken.
+Administrators see waiting requests at the top of Plugins, a count on each app's row, and a Requests
+section on the app's own page, where Approve switches the app on and grants it to the Bot in one press
+(or says what has to be done first) and Dismiss closes the request. Requests name who asked; nobody's
+connection status is shown to administrators.
+
### A new Bot is made from a sheet that asks where it runs first
New Bot opens a sheet instead of the four-screen wizard. It asks where the Bot will run, with Run it
diff --git a/docs/plugins/composio.md b/docs/plugins/composio.md
index a6ac79ca5b..0e5afe5144 100644
--- a/docs/plugins/composio.md
+++ b/docs/plugins/composio.md
@@ -514,7 +514,31 @@ page and a card in a conversation say the same thing a call would find:
"Connected" is what this deployment last recorded — the same `verified` and probe columns the
Connected accounts page reads. Nothing asks the vendor per page load, so an account ended in
-Composio's dashboard reads as `ready` until the next call or re-check finds out.
+Composio's dashboard reads as `ready` until the next call or re-check finds out — and the call that
+finds out is refused with a card offering **Reconnect** (below).
+
+On the Bot's page, Connect opens the app's consent screen in a new tab through the same connect route
+Settings uses, and the row turns ready once the connection is recorded. An app connected with a typed
+key, and Reconnect on a Composio app (whose row has to be disconnected before a new account can be
+attached), go to the app's own page under `/settings/connected-accounts` instead.
+
+### In a conversation
+
+A Bot that needs an app it cannot use puts a card in the transcript naming the app, its state and the
+next step, drawn from the same state as the Bot's page:
+
+- a call refused because the person has not connected the app;
+- a call refused because a Shared app's team account is not connected — **Request access** rather
+ than the sentence "Ask an administrator to connect it.";
+- a call Composio answered with "no connected account" — **Reconnect**, or for a Shared app a line
+ saying an administrator has to connect it again;
+- the `show_app_access` tool, which every Bot holds and is told to call for an app it holds nothing
+ for, switched off or not granted — **Request access**.
+
+The card asks `GET /api/plugins/for/:agentId/apps/:app` for the Bot that made the call when it is
+drawn, so a card scrolled back to later shows what is true now. The one case it takes the refusal's
+word over the live state is the lost account, which the server cannot see; a connection made after
+the refusal ends that. The card is drawn in the transcript, never folded into the "Used N tools" line.
`GET /api/plugins/for/:agentId/apps` lists every app the Bot holds, including a grant whose app is
gone; `GET /api/plugins/for/:agentId/apps/:app` answers for one app by key or by name, held or not.
@@ -531,7 +555,8 @@ the app does not need. Asking twice answers with the request already waiting: on
per person, app, Bot and kind, held by a unique index rather than a check, so two replicas cannot
both file one. A person can withdraw their own.
-Requests wait on the app's own page under `/admin/plugins`, with a count on the Plugins list. They
+Requests wait on the app's own page under `/admin/plugins`, and all of them at the top of the
+Plugins list, with a count on each app's row — an app nobody has switched on has no page of its own. They
show who asked and for which Bot — they asked — and nothing about anybody's connections.
**Approve** does what was asked in the same press:
From 969d2795703c8fb17c9521f24c43772797ca6c0e Mon Sep 17 00:00:00 2001
From: Guido Vizoso
Date: Wed, 21 Oct 2026 09:30:35 -0300
Subject: [PATCH 09/33] Leave Composio apps off Connected accounts when the
deployment has no Composio key
---
.../settings/connected-accounts/index.tsx | 24 ++++++++++++++++---
1 file changed, 21 insertions(+), 3 deletions(-)
diff --git a/app/src/routes/_authed/settings/connected-accounts/index.tsx b/app/src/routes/_authed/settings/connected-accounts/index.tsx
index 9eaeea4d2a..b55508bdc8 100644
--- a/app/src/routes/_authed/settings/connected-accounts/index.tsx
+++ b/app/src/routes/_authed/settings/connected-accounts/index.tsx
@@ -100,6 +100,23 @@ export function brokeredAccountsListedOn(
);
}
+/**
+ * The servers this page may list at all: none reached through Composio when the deployment has no
+ * Composio key.
+ *
+ * An app an administrator enabled while a key was set keeps its row after the key is removed, and
+ * listing it offered a person an account they cannot connect, under a sentence about an environment
+ * variable only an administrator can set. Without the broker there is nothing for them to decide.
+ */
+export function serversListedFor(page: {
+ servers: PluginServer[];
+ composioConfigured: boolean;
+}): PluginServer[] {
+ return page.composioConfigured
+ ? page.servers
+ : page.servers.filter((server) => server.provenance !== "composio");
+}
+
/** Apps whose account is the organisation's, shown so a person knows a Bot does not act as them there. */
export function sharedAccountsListedOn(
servers: PluginServer[],
@@ -198,16 +215,17 @@ function RouteComponent() {
* unrecorded scheme included: a row whose column was never written is far likelier to be a key or
* consent app, and dropping it here would hide a connection somebody does have.
*/
- const brokered = brokeredAccountsListedOn(plugins.data?.servers ?? []);
+ const listed = plugins.data ? serversListedFor(plugins.data) : [];
+ const brokered = brokeredAccountsListedOn(listed);
/*
* Listed, never connected here. A Shared app's account is the organisation's, not this person's to
* make or to break, so this list draws no Connect action and links nowhere — it exists only so a
* person can see that a Bot calling this app acts as the team, not as them.
*/
- const shared = sharedAccountsListedOn(plugins.data?.servers ?? []);
+ const shared = sharedAccountsListedOn(listed);
const sectionsToShow = connectedAccountSections(
yours.length + brokered.length,
- plugins.data?.servers ?? [],
+ listed,
);
const accounts = [
...yours.map((entry) => {
From b95bbde74acb2efb821ab1719dd1251bee0ecb4b Mon Sep 17 00:00:00 2001
From: Guido Vizoso
Date: Wed, 21 Oct 2026 09:30:35 -0300
Subject: [PATCH 10/33] Say a Composio app is not available yet, rather than
naming the key, on its account page
---
.../settings/connected-accounts/$key.tsx | 18 ++++++++++++++++++
1 file changed, 18 insertions(+)
diff --git a/app/src/routes/_authed/settings/connected-accounts/$key.tsx b/app/src/routes/_authed/settings/connected-accounts/$key.tsx
index b507c54cc0..8b7f37aa9a 100644
--- a/app/src/routes/_authed/settings/connected-accounts/$key.tsx
+++ b/app/src/routes/_authed/settings/connected-accounts/$key.tsx
@@ -206,6 +206,24 @@ function RouteComponent() {
);
}
+ /*
+ * A COMPOSIO APP ON A DEPLOYMENT WITH NO COMPOSIO KEY, BEFORE EITHER BRANCH BELOW DRAWS A ROW.
+ *
+ * The list on the way in no longer offers one, but a link from elsewhere still lands here, and the
+ * brokered row's own sentence for this case names an environment variable — an instruction for
+ * whoever runs the deployment, read by somebody who cannot act on it.
+ */
+ if (server?.provenance === "composio" && !plugins.data.composioConfigured) {
+ return (
+
+
+ {server.title} is not available on this OpenBot yet. An administrator
+ has to finish setting it up before anybody can connect it.
+
+
+ );
+ }
+
/*
* A SHARED APP, BEFORE THE BROKERED BRANCH BELOW GETS A LOOK AT IT.
*
From 1df882a9c17a8f7fda2fd17832f7a28db257c298 Mon Sep 17 00:00:00 2001
From: Guido Vizoso
Date: Wed, 21 Oct 2026 09:30:35 -0300
Subject: [PATCH 11/33] Test Connected accounts on a deployment with no
Composio key
---
app/tests/brokered-account-row.test.tsx | 12 +-
.../composio-unconfigured-account.test.tsx | 181 ++++++++++++++++++
2 files changed, 190 insertions(+), 3 deletions(-)
create mode 100644 app/tests/composio-unconfigured-account.test.tsx
diff --git a/app/tests/brokered-account-row.test.tsx b/app/tests/brokered-account-row.test.tsx
index 1c1811fd89..6ac33d89a9 100644
--- a/app/tests/brokered-account-row.test.tsx
+++ b/app/tests/brokered-account-row.test.tsx
@@ -808,7 +808,11 @@ test("a disconnect that lands stops reading Connected and offers Connect again",
expect(server.deletes).toBe(1);
});
-test("a deployment with no Composio key names the setting and offers neither action", async () => {
+/*
+ * The setting is named on the admin page below, to somebody who can set it. Here the reader is the
+ * person who would connect the account, so the page says only that the app is not available yet.
+ */
+test("a deployment with no Composio key says the app is not available yet and offers neither action", async () => {
installDeployment({
composioConfigured: false,
// A row left over from when a key WAS set: the app keeps its row and its grants, which is the
@@ -819,8 +823,10 @@ test("a deployment with no Composio key names the setting and offers neither act
const view = renderAccountScreen(queryClient());
- expect(await view.findByText(NAMES_THE_SETTING)).toBeTruthy();
- expect(view.queryByText("Key missing")).toBeTruthy();
+ expect(
+ await view.findByText(/is not available on this OpenBot yet/),
+ ).toBeTruthy();
+ expect(view.queryByText(NAMES_THE_SETTING)).toBeNull();
expect(view.queryByText("Connected")).toBeNull();
expect(view.queryByRole("button", { name: "Disconnect" })).toBeNull();
expect(view.queryByRole("button", { name: "Connect" })).toBeNull();
diff --git a/app/tests/composio-unconfigured-account.test.tsx b/app/tests/composio-unconfigured-account.test.tsx
new file mode 100644
index 0000000000..cb5920dbd8
--- /dev/null
+++ b/app/tests/composio-unconfigured-account.test.tsx
@@ -0,0 +1,181 @@
+import {
+ afterAll,
+ afterEach,
+ beforeAll,
+ beforeEach,
+ expect,
+ test,
+} from "bun:test";
+import { GlobalRegistrator } from "@happy-dom/global-registrator";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import {
+ createMemoryHistory,
+ createRootRoute,
+ createRoute,
+ createRouter,
+ Outlet,
+ RouterProvider,
+} from "@tanstack/react-router";
+import { cleanup, render } from "@testing-library/react";
+import {
+ type PluginConnections,
+ type PluginServer,
+ type PluginsPage,
+ pluginKeys,
+} from "@/lib/plugins/queries";
+import { Route as ConnectedAccountRoute } from "@/routes/_authed/settings/connected-accounts/$key";
+import { serversListedFor } from "@/routes/_authed/settings/connected-accounts/index";
+
+/**
+ * A Composio app on a deployment with no Composio key, as the person who would connect it sees it.
+ *
+ * An app an administrator enabled while a key was set keeps its row after the key goes. The
+ * Connected accounts list offered it, and its page drew "Set COMPOSIO_API_KEY on this deployment" —
+ * an instruction for whoever runs the deployment, shown to somebody who cannot act on it. The list
+ * now leaves it off, and the page says plainly that it is not available yet.
+ *
+ * The harness is `plugin-read-failure.test.tsx`'s: the exported `Route` singleton is captured and
+ * restored around its `.update()`.
+ */
+
+beforeAll(() => GlobalRegistrator.register());
+afterEach(cleanup);
+afterAll(() => GlobalRegistrator.unregister());
+
+const originalFetch = global.fetch;
+
+beforeEach(() => {
+ // Every read this page makes is seeded below; anything else is a request the test did not expect.
+ global.fetch = Object.assign(
+ async () => new Response(null, { status: 404 }),
+ { preconnect: originalFetch.preconnect },
+ );
+});
+
+afterEach(() => {
+ global.fetch = originalFetch;
+});
+
+function server(overrides: Partial & { id: string }) {
+ return {
+ title: "App",
+ summary: "",
+ vendor: "Composio",
+ url: `composio://${overrides.id}`,
+ provenance: "composio",
+ authScheme: "OAUTH2",
+ tools: [],
+ ...overrides,
+ } as PluginServer;
+}
+
+const gmail = server({ id: "composio-gmail", title: "Gmail" });
+const drive = server({
+ id: "google-drive",
+ title: "Google Drive",
+ provenance: "catalogue",
+ vendor: "Google",
+} as Partial & { id: string });
+
+test("lists no Composio app when the deployment has no Composio key", () => {
+ expect(
+ serversListedFor({
+ servers: [gmail, drive],
+ composioConfigured: false,
+ }).map((row) => row.id),
+ ).toEqual(["google-drive"]);
+});
+
+test("lists Composio apps as before once the key is set", () => {
+ expect(
+ serversListedFor({
+ servers: [gmail, drive],
+ composioConfigured: true,
+ }).map((row) => row.id),
+ ).toEqual(["composio-gmail", "google-drive"]);
+});
+
+type Updatable = { update: (options: unknown) => unknown };
+
+const pristine = {
+ ...ConnectedAccountRoute,
+ options: { ...ConnectedAccountRoute.options },
+} as Record;
+
+afterEach(() => {
+ for (const key of Object.keys(ConnectedAccountRoute)) {
+ if (!(key in pristine)) {
+ delete (ConnectedAccountRoute as unknown as Record)[key];
+ }
+ }
+ Object.assign(ConnectedAccountRoute, pristine);
+});
+
+function renderAccount(composioConfigured: boolean) {
+ const client = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ });
+ const page: PluginsPage = {
+ catalogue: [],
+ servers: [gmail],
+ skills: [],
+ botsMayCallBack: true,
+ redirectUri: null,
+ composioConfigured,
+ };
+ const connections: PluginConnections = { connections: [], redirectUri: null };
+ client.setQueryData(pluginKeys.page(), page);
+ client.setQueryData(pluginKeys.connections(), connections);
+
+ const rootRoute = createRootRoute({ component: Outlet });
+ const authedRoute = createRoute({
+ id: "/_authed",
+ getParentRoute: () => rootRoute,
+ component: Outlet,
+ });
+ const settingsRoute = createRoute({
+ path: "/settings",
+ getParentRoute: () => authedRoute,
+ component: Outlet,
+ });
+ const indexRoute = createRoute({
+ path: "/connected-accounts/",
+ getParentRoute: () => settingsRoute,
+ component: () => null,
+ });
+ const account = (ConnectedAccountRoute as unknown as Updatable).update({
+ id: "/connected-accounts/$key",
+ path: "/connected-accounts/$key",
+ getParentRoute: () => settingsRoute,
+ });
+ const router = createRouter({
+ routeTree: rootRoute.addChildren([
+ authedRoute.addChildren([
+ settingsRoute.addChildren([indexRoute, account as never]),
+ ]),
+ ]),
+ history: createMemoryHistory({
+ initialEntries: ["/settings/connected-accounts/composio-gmail"],
+ }),
+ });
+ return render(
+
+
+ ,
+ );
+}
+
+test("the app's page says it is not available yet, and names no setting", async () => {
+ const view = renderAccount(false);
+ expect(
+ await view.findByText(/Gmail is not available on this OpenBot yet/),
+ ).toBeTruthy();
+ expect(view.queryByText(/COMPOSIO_API_KEY/)).toBeNull();
+ expect(view.queryByRole("button", { name: "Connect" })).toBeNull();
+});
+
+test("with the key set, the app's page offers the account as before", async () => {
+ const view = renderAccount(true);
+ expect(await view.findByText("Your account")).toBeTruthy();
+ expect(view.queryByText(/not available on this OpenBot yet/)).toBeNull();
+});
From fd09ac986019041fc0ca43c5d239dbd4cfe38a5c Mon Sep 17 00:00:00 2001
From: Guido Vizoso
Date: Wed, 21 Oct 2026 09:57:57 -0300
Subject: [PATCH 12/33] Note Connected accounts on a deployment with no
Composio key in the changelog
---
CHANGELOG.md | 5 +++++
1 file changed, 5 insertions(+)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 78e4f0d5e3..f7fa72c2dd 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -51,6 +51,11 @@ section on the app's own page, where Approve switches the app on and grants it t
(or says what has to be done first) and Dismiss closes the request. Requests name who asked; nobody's
connection status is shown to administrators.
+On a deployment with no `COMPOSIO_API_KEY`, Settings → Connected accounts no longer lists Composio
+apps, which nobody could connect there. An app's own page, reached from an older link, says it is not
+available yet and that an administrator has to finish setting it up, instead of naming the setting.
+The administrator's page for the app still names `COMPOSIO_API_KEY`.
+
### A new Bot is made from a sheet that asks where it runs first
New Bot opens a sheet instead of the four-screen wizard. It asks where the Bot will run, with Run it
From fc368b13d5be79d14aab29bb519b057f83bd611f Mon Sep 17 00:00:00 2001
From: Guido Vizoso
Date: Wed, 21 Oct 2026 14:07:40 -0300
Subject: [PATCH 13/33] Let React finish before tearing down the Composio
account test's browser
---
app/tests/composio-unconfigured-account.test.tsx | 8 +++++++-
1 file changed, 7 insertions(+), 1 deletion(-)
diff --git a/app/tests/composio-unconfigured-account.test.tsx b/app/tests/composio-unconfigured-account.test.tsx
index cb5920dbd8..bcdc7046e8 100644
--- a/app/tests/composio-unconfigured-account.test.tsx
+++ b/app/tests/composio-unconfigured-account.test.tsx
@@ -25,6 +25,7 @@ import {
} from "@/lib/plugins/queries";
import { Route as ConnectedAccountRoute } from "@/routes/_authed/settings/connected-accounts/$key";
import { serversListedFor } from "@/routes/_authed/settings/connected-accounts/index";
+import { settleReactWork } from "./settle-react-work";
/**
* A Composio app on a deployment with no Composio key, as the person who would connect it sees it.
@@ -40,7 +41,12 @@ import { serversListedFor } from "@/routes/_authed/settings/connected-accounts/i
beforeAll(() => GlobalRegistrator.register());
afterEach(cleanup);
-afterAll(() => GlobalRegistrator.unregister());
+// React may still have a passive effect queued when the last test ends; unregistering under it
+// throws "window is not defined" between tests, which fails the run though every test passed.
+afterAll(async () => {
+ await settleReactWork();
+ GlobalRegistrator.unregister();
+});
const originalFetch = global.fetch;
From dc6f40a51401fabb06594b5a23b6836c1536347d Mon Sep 17 00:00:00 2001
From: Guido Vizoso
Date: Wed, 21 Oct 2026 14:21:50 -0300
Subject: [PATCH 14/33] Keep the Pydantic AI harness on anthropic 1.x
---
agent-pydantic-ai/requirements.txt | 5 +++++
1 file changed, 5 insertions(+)
diff --git a/agent-pydantic-ai/requirements.txt b/agent-pydantic-ai/requirements.txt
index 1ef30a6424..bed8980de1 100644
--- a/agent-pydantic-ai/requirements.txt
+++ b/agent-pydantic-ai/requirements.txt
@@ -1,4 +1,9 @@
pydantic-ai-slim[openai,anthropic,google,ag-ui]
+# anthropic 2.0.0 fails every Anthropic run in this harness: the stream ends in RUN_ERROR
+# `Cannot instantiate typing.Union` before a request leaves the process. pydantic-ai-slim
+# 2.59.0 asks only for anthropic>=1.12.1, so a fresh install picks up 2.0.0. 1.17.0 works.
+# Lift this when pydantic-ai supports anthropic 2.
+anthropic<2
fastapi
python-multipart
uvicorn[standard]
From 4c62cbc3227cc98991279c0db9ecf517aab2eedf Mon Sep 17 00:00:00 2001
From: Guido Vizoso
Date: Wed, 21 Oct 2026 15:59:02 -0300
Subject: [PATCH 15/33] Hand the active broker to enableComposioApp from both
of its callers
The merge with main left enableComposioApp needing the active provider and
vendor. The Plugins route and the access-request approve route now both pass
them, and the app-access route test builds its store and shared-use store for
Composio explicitly.
---
server/src/plugins/route-groups/app-access.ts | 4 +++-
server/src/plugins/route-groups/composio-apps.ts | 12 ++++++++++--
server/tests/app-access-routes.integration.test.ts | 7 +++++--
3 files changed, 18 insertions(+), 5 deletions(-)
diff --git a/server/src/plugins/route-groups/app-access.ts b/server/src/plugins/route-groups/app-access.ts
index f1f98c19ac..bbdb5b19b2 100644
--- a/server/src/plugins/route-groups/app-access.ts
+++ b/server/src/plugins/route-groups/app-access.ts
@@ -77,6 +77,8 @@ export function mountAppAccessRoutes({
composio,
shared,
actorEmail,
+ activeProvider,
+ activeVendor,
}: PluginRouteContext) {
const composioConfigured = Boolean(composio);
/*
@@ -375,7 +377,7 @@ export function mountAppAccessRoutes({
503,
);
const outcome = await enableComposioApp(
- { store, composio },
+ { store, composio, activeProvider, activeVendor },
inspected.toolkit,
actorEmail(context),
);
diff --git a/server/src/plugins/route-groups/composio-apps.ts b/server/src/plugins/route-groups/composio-apps.ts
index a11a2805f8..7750ada3e0 100644
--- a/server/src/plugins/route-groups/composio-apps.ts
+++ b/server/src/plugins/route-groups/composio-apps.ts
@@ -175,7 +175,7 @@ export function mountComposioAppRoutes({
*/
const slug = typeof body?.slug === "string" ? body.slug.trim() : undefined;
const outcome = await enableComposioApp(
- { store, composio },
+ { store, composio, activeProvider, activeVendor },
slug,
actorEmail(context),
);
@@ -200,7 +200,15 @@ export type EnableOutcome =
* are administrator-only.
*/
export async function enableComposioApp(
- { store, composio }: Pick,
+ {
+ store,
+ composio,
+ activeProvider,
+ activeVendor,
+ }: Pick<
+ PluginRouteContext,
+ "store" | "composio" | "activeProvider" | "activeVendor"
+ >,
slug: string | undefined,
by: string,
): Promise {
diff --git a/server/tests/app-access-routes.integration.test.ts b/server/tests/app-access-routes.integration.test.ts
index eeb0e17a4b..5f7e420f9b 100644
--- a/server/tests/app-access-routes.integration.test.ts
+++ b/server/tests/app-access-routes.integration.test.ts
@@ -117,6 +117,7 @@ const enabled: string[] = [];
function storeFor(): PluginStore {
const real = createPluginStore({
+ brokerProvider: "composio",
database,
auditStore: audit,
credentials: {} as never,
@@ -166,7 +167,7 @@ function appAs(
} as never);
await next();
};
- const sharedUse = createSharedUseStore(database);
+ const sharedUse = createSharedUseStore(database, "composio");
return new Hono().route(
"/api/plugins",
createPluginRoutes(
@@ -175,7 +176,9 @@ function appAs(
// Everybody may use the suite's Bot; nobody but an administrator may use the hidden one.
async (actor, agentId) => agentId !== hidden || actor.role === "admin",
undefined,
- options.composio === false ? undefined : { broker },
+ options.composio === false
+ ? undefined
+ : { broker, provider: "composio" },
{ modes: {} as never, use: sharedUse },
),
);
From dc55298fc0f87dc64afc53b5c58a091cb350315d Mon Sep 17 00:00:00 2001
From: Guido Vizoso
Date: Wed, 21 Oct 2026 16:00:33 -0300
Subject: [PATCH 16/33] Build the app-access route test's broker with
stubBroker
The hand-written broker answered revokeAccount with a silent no-op, which the
broker-stub ratchet refuses. It is now built by stubBroker with Composio's own
declarations, revokeAccount is left unprovided, and every test checks that no
unprovided member was reached. The personal connections it seeds also carry a
vendor id distinct from the local one, as the fixture-identity guard asks.
---
.../app-access-routes.integration.test.ts | 30 +++++++++++++------
1 file changed, 21 insertions(+), 9 deletions(-)
diff --git a/server/tests/app-access-routes.integration.test.ts b/server/tests/app-access-routes.integration.test.ts
index 5f7e420f9b..4c66f823b8 100644
--- a/server/tests/app-access-routes.integration.test.ts
+++ b/server/tests/app-access-routes.integration.test.ts
@@ -1,6 +1,7 @@
// server/tests/app-access-routes.integration.test.ts
import {
afterAll,
+ afterEach,
beforeAll,
beforeEach,
describe,
@@ -27,7 +28,7 @@ import {
users,
} from "../src/db/schema";
import type { AppAccess } from "../src/plugins/app-access";
-import type { BrokerApp, ConnectedAppBroker } from "../src/plugins/broker";
+import type { BrokerApp } from "../src/plugins/broker";
import type { AppAccessRequestRecord } from "../src/plugins/plugin-store/app-access";
import { createPluginRoutes } from "../src/plugins/routes";
import { createSharedUseStore } from "../src/plugins/shared-use-store";
@@ -37,6 +38,7 @@ import {
type ServerRecord,
} from "../src/plugins/store";
import { TEST_POOL, testDatabaseUrl } from "./support/database";
+import { stubBroker } from "./support/stub-broker";
/**
* What a work app is for one person on one Bot, the requests they file when only an administrator
@@ -100,7 +102,16 @@ const directory: BrokerApp[] = [
},
];
-const broker: ConnectedAppBroker = {
+/**
+ * The broker, built by `stubBroker`: every member this file does not provide THROWS AND RECORDS,
+ * and the check after each test reads the record, so a path that reaches past what is provided is
+ * a red even where production catches the throw. Nothing here undoes one account, so
+ * `revokeAccount` is not provided rather than stubbed to answer "done" for a revoke that never ran.
+ */
+const stub = stubBroker({
+ // Composio's own declarations: the grant is held here, and there is no authorization flow.
+ grantIsLocal: true,
+ authorizations: null,
listApps: async () => directory,
ensureAuthConfig: async () => "created",
deleteAuthConfig: async () => {},
@@ -109,9 +120,12 @@ const broker: ConnectedAppBroker = {
revoke: async () => true,
connectionFields: async () => [],
connectWithFields: async () => ({ accountId: "ca_1" }),
- revokeAccount: async () => {},
accountName: async () => "team",
-};
+});
+const broker = stub.broker;
+afterEach(() => {
+ stub.expectNothingUnprovidedReached();
+});
const enabled: string[] = [];
@@ -176,9 +190,7 @@ function appAs(
// Everybody may use the suite's Bot; nobody but an administrator may use the hidden one.
async (actor, agentId) => agentId !== hidden || actor.role === "admin",
undefined,
- options.composio === false
- ? undefined
- : { broker, provider: "composio" },
+ options.composio === false ? undefined : { broker, provider: "composio" },
{ modes: {} as never, use: sharedUse },
),
);
@@ -315,7 +327,7 @@ describe("what each app a Bot holds is, for the person asking", () => {
app: slug("linear"),
holder: "person",
userId: person,
- vendorUserId: person,
+ vendorUserId: `vendor-${person}`,
verified: false,
probeAction: "LINEAR_LIST",
});
@@ -370,7 +382,7 @@ describe("what each app a Bot holds is, for the person asking", () => {
app: slug("gmail"),
holder: "person",
userId: other,
- vendorUserId: other,
+ vendorUserId: `vendor-${other}`,
verified: true,
connectedAt: new Date("2026-10-20T12:00:00.000Z"),
});
From e406e7b0528e33f5f3a38b06a4b9434aba693bef Mon Sep 17 00:00:00 2001
From: Guido Vizoso
Date: Wed, 21 Oct 2026 16:00:33 -0300
Subject: [PATCH 17/33] Give the vendor-gone personal fixture its own vendor id
The account-gone test OB-05 added seeded a personal connection with the same
value as local and vendor id, one more collapse than the fixture-identity
guard allows for this file.
---
server/tests/shared-brokered-store.integration.test.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/server/tests/shared-brokered-store.integration.test.ts b/server/tests/shared-brokered-store.integration.test.ts
index 386cdb6ffd..01716fa486 100644
--- a/server/tests/shared-brokered-store.integration.test.ts
+++ b/server/tests/shared-brokered-store.integration.test.ts
@@ -734,7 +734,7 @@ describe("an account the vendor no longer holds", () => {
app,
holder: "person",
userId: asker,
- vendorUserId: asker,
+ vendorUserId: `vendor-${asker}`,
});
gone();
const said = await store
From 94769aa5ca42b6c5a941fecce98b64d85b49fa09 Mon Sep 17 00:00:00 2001
From: Guido Vizoso
Date: Wed, 21 Oct 2026 16:01:53 -0300
Subject: [PATCH 18/33] Assert the missing approve body on its own, not as an
undefined key
toEqual passes over an expected key whose value is undefined, so body:
undefined asserted nothing. The fetch recorder now leaves body out when none
was sent, and the test checks that the first approval has no body property.
---
app/tests/app-access-mutations.test.ts | 14 +++++++-------
1 file changed, 7 insertions(+), 7 deletions(-)
diff --git a/app/tests/app-access-mutations.test.ts b/app/tests/app-access-mutations.test.ts
index 771366abd4..0e9fa3a32f 100644
--- a/app/tests/app-access-mutations.test.ts
+++ b/app/tests/app-access-mutations.test.ts
@@ -20,12 +20,13 @@ afterEach(() => {
});
function answering(status: number, body: unknown) {
- const sent: { url: string; method: string; body: unknown }[] = [];
+ const sent: { url: string; method: string; body?: unknown }[] = [];
globalThis.fetch = (async (url: string, init?: RequestInit) => {
+ // A request with no body is recorded with no `body` key, so its absence can be asserted.
sent.push({
url,
method: init?.method ?? "GET",
- body: init?.body ? JSON.parse(String(init.body)) : undefined,
+ ...(init?.body ? { body: JSON.parse(String(init.body)) } : {}),
});
return new Response(JSON.stringify(body), {
status,
@@ -94,17 +95,16 @@ test("approving sends refs only when given", async () => {
refs: ["composio-gmail/SEND"],
});
expect(sent).toEqual([
- {
- url: "/api/plugins/access-requests/r1/approve",
- method: "POST",
- body: undefined,
- },
+ { url: "/api/plugins/access-requests/r1/approve", method: "POST" },
{
url: "/api/plugins/access-requests/r2/approve",
method: "POST",
body: { refs: ["composio-gmail/SEND"] },
},
]);
+ // `toEqual` passes over a key it is not given, so the first approval's missing body is its own
+ // assertion: no refs means no body at all, not an empty one.
+ expect(sent[0]).not.toHaveProperty("body");
});
test("a refused write still refreshes every plugin screen", async () => {
From 08a7da10d504818ea6e501b8e4ba35587c974b3f Mon Sep 17 00:00:00 2001
From: Guido Vizoso
Date: Wed, 21 Oct 2026 16:01:54 -0300
Subject: [PATCH 19/33] Re-key two allow-listed composio-adapter assertions
after the merge
The two listActions assertions main allow-lists at composio-adapter lines 5412
and 5458 now sit at 5427 and 5473, because OB-05 added fifteen lines of tests
above them. They are the same offences, still owned by the fix cycle.
---
server/tests/assertion-shape.test.ts | 8 ++++++--
1 file changed, 6 insertions(+), 2 deletions(-)
diff --git a/server/tests/assertion-shape.test.ts b/server/tests/assertion-shape.test.ts
index 2c1f398017..d8e055952c 100644
--- a/server/tests/assertion-shape.test.ts
+++ b/server/tests/assertion-shape.test.ts
@@ -835,14 +835,18 @@ const ALLOW: Allowed[] = [
},
{
file: "server/tests/composio-adapter.test.ts",
- line: 5412,
+ // Moved from 5412 by OB-05, which added fifteen lines of tests above it; the offence is the same
+ // one and is re-keyed rather than re-found.
+ line: 5427,
shape: "c",
finding: "NEW (round 2 did not find this)",
why: 'expect( await actions.listActions("gmail", { limit: WHOLE_LISTING }), ).toEqual([ { slug: "GMAIL_FETCH_EMAILS"',
},
{
file: "server/tests/composio-adapter.test.ts",
- line: 5458,
+ // Moved from 5458 by OB-05, which added fifteen lines of tests above it; the offence is the same
+ // one and is re-keyed rather than re-found.
+ line: 5473,
shape: "c",
finding: "NEW (round 2 did not find this)",
why: 'expect( await actions.listActions("gmail", { limit: WHOLE_LISTING }), ).toEqual([ { slug: "GMAIL_FETCH_EMAILS"',
From fb84008d1b787940e324d5b5bcc03760a3ec2bd8 Mon Sep 17 00:00:00 2001
From: Guido Vizoso
Date: Wed, 21 Oct 2026 16:04:40 -0300
Subject: [PATCH 20/33] Format the enableComposioApp refusal the merge
resolution left long
---
server/src/plugins/route-groups/composio-apps.ts | 5 ++++-
1 file changed, 4 insertions(+), 1 deletion(-)
diff --git a/server/src/plugins/route-groups/composio-apps.ts b/server/src/plugins/route-groups/composio-apps.ts
index 7750ada3e0..2636afa8b5 100644
--- a/server/src/plugins/route-groups/composio-apps.ts
+++ b/server/src/plugins/route-groups/composio-apps.ts
@@ -230,7 +230,10 @@ export async function enableComposioApp(
* deployment whose key is wrong was told nothing at all, on the one screen where the key
* had just been set.
*/
- return { ok: false, ...brokerRefusal(error, directoryUnavailable(activeProvider)) };
+ return {
+ ok: false,
+ ...brokerRefusal(error, directoryUnavailable(activeProvider)),
+ };
}
}
const app = directory.find((candidate) => candidate.slug === slug);
From dbef2f57ad05c0bfff69ec2bc32fa8fb0d9c116c Mon Sep 17 00:00:00 2001
From: Guido Vizoso
Date: Wed, 21 Oct 2026 16:04:51 -0300
Subject: [PATCH 21/33] Read app access through the store's broker, not
Composio by name
The app-access store compared provenance and the connection provider to
"composio", built composio:// urls and composio- ids by hand, and called
Composio's own url parser, so on an Arcade deployment every Arcade app read as
a custom server that was always ready. It now asks the store's broker
provider, BROKERED_PROFILE for the scheme and id prefix, and
toolkitOfFor(provider, url), and the state rule asks brokeredProvenance(). A
personal Arcade app is therefore not_connected until that person connects it,
switched off without an Arcade key, and an app only the directory knows gets
the arcade- id enabling would mint. Slugs are matched as typed before being
lowercased, so Arcade's case-sensitive toolkit names can be found. An app's
provider is now composio or arcade on the wire, and the approve refusal names
the active vendor and its key. Composio deployments read exactly as before.
---
app/src/lib/app-access/queries.ts | 5 +-
server/src/plugins/app-access-tool.ts | 2 +-
server/src/plugins/app-access.ts | 26 +--
server/src/plugins/plugin-store/app-access.ts | 105 +++++++-----
server/src/plugins/route-groups/app-access.ts | 31 +++-
.../app-access-routes.integration.test.ts | 156 ++++++++++++++++++
6 files changed, 265 insertions(+), 60 deletions(-)
diff --git a/app/src/lib/app-access/queries.ts b/app/src/lib/app-access/queries.ts
index d8366366ce..ef6430230a 100644
--- a/app/src/lib/app-access/queries.ts
+++ b/app/src/lib/app-access/queries.ts
@@ -36,10 +36,11 @@ export type AppAccess = {
/** The app's own name — "Gmail", never a humanised server id. */
displayName: string;
logo: string | null;
- provider: "composio" | "catalogue" | "custom";
+ /** A brokered app names its broker; a catalogue or custom app is reached directly. */
+ provider: "composio" | "arcade" | "catalogue" | "custom";
/** Whose account calls land in: each person's own, one team account, or nobody's. */
account: "personal" | "shared" | "deployment";
- /** How the account is connected: this deployment's OAuth flow, a Composio consent, or a key form. */
+ /** How the account is connected: this deployment's OAuth flow, a broker's consent, or a key form. */
connect: "oauth" | "consent" | "key" | null;
authScheme: string | null;
accountMode: "personal" | "shared" | null;
diff --git a/server/src/plugins/app-access-tool.ts b/server/src/plugins/app-access-tool.ts
index be48070488..70d0f55b7d 100644
--- a/server/src/plugins/app-access-tool.ts
+++ b/server/src/plugins/app-access-tool.ts
@@ -34,7 +34,7 @@ export function appAccessTools(options: {
botId: string;
actorId: string;
composioConfigured: boolean;
- /** Names an app this deployment has no row for. Absent without a Composio key. */
+ /** Names an app this deployment has no row for. Absent without the broker's key. */
directory?: AppDirectory;
}): GrantedTool[] {
const parameters = z
diff --git a/server/src/plugins/app-access.ts b/server/src/plugins/app-access.ts
index caa50ac949..82594a3e31 100644
--- a/server/src/plugins/app-access.ts
+++ b/server/src/plugins/app-access.ts
@@ -1,3 +1,4 @@
+import { type BrokerProvider, brokeredProvenance } from "./access";
import { schemeKind } from "./broker";
import type { CatalogueEntry } from "./catalogue";
@@ -6,7 +7,7 @@ import type { CatalogueEntry } from "./catalogue";
* whose it is.
*
* WHY THIS EXISTS AS ONE ANSWER. Every fact it reads already had a home: whether the app is
- * enabled is a row in `mcp_servers`, whether Composio is configured is a key in the environment,
+ * enabled is a row in `mcp_servers`, whether the broker is configured is a key in the environment,
* whether the Bot holds the app is `plugin_grants`, whether the person connected is
* `brokered_connections` or `mcp_user_credentials`, and whether a Shared app has its team account
* is a deployment row in `brokered_connections`. Each screen that wanted to say "you can use this"
@@ -33,7 +34,7 @@ import type { CatalogueEntry } from "./catalogue";
* to fix.
* `shared_not_connected` — a Shared app whose one team account is not connected, or whose key was
* refused. Only an administrator can connect it.
- * `disabled` — switched off at the deployment: no row, Composio not configured, or an app that
+ * `disabled` — switched off at the deployment: no row, the broker not configured, or an app that
* still needs something only an administrator can supply.
* `not_granted` — enabled, and this Bot holds none of its tools. Only an administrator can grant.
*/
@@ -81,12 +82,17 @@ export type AppAccount =
/**
* How the account is connected, for the screen that starts it: the deployment's own OAuth flow, a
- * Composio consent screen, a Composio key form, or nothing to connect.
+ * broker's consent screen, a broker's key form, or nothing to connect.
*/
export type AppConnect = "oauth" | "consent" | "key" | null;
-/** Where the app comes from, which decides what enabling it takes. */
-export type AppProvider = "composio" | "catalogue" | "custom";
+/**
+ * Where the app comes from, which decides what enabling it takes.
+ *
+ * A BROKERED APP IS NAMED BY ITS BROKER, `composio` or `arcade`, and every rule below asks
+ * {@link brokeredProvenance} rather than comparing to one of them, so the two are read alike.
+ */
+export type AppProvider = BrokerProvider | "catalogue" | "custom";
/** One app as the Bot page and the chat card draw it. */
export type AppAccess = {
@@ -94,7 +100,7 @@ export type AppAccess = {
key: string;
/**
* The app's own name — "Gmail", never "Composio Gmail" and never a humanised `composio-gmail`.
- * A Composio row's title is the directory's name for the app; a catalogue row's is the entry's.
+ * A brokered row's title is the directory's name for the app; a catalogue row's is the entry's.
*/
displayName: string;
logo: string | null;
@@ -135,7 +141,7 @@ export type AppAccessFacts = {
provider: AppProvider;
/** Whether a row stands for the app at all. */
present: boolean;
- /** Whether this deployment has a Composio key, for a brokered app. */
+ /** Whether this deployment has its broker's key, for a brokered app. */
composioConfigured: boolean;
/** The scheme the app's answering row records, for a brokered app. */
authScheme: string | null;
@@ -153,7 +159,7 @@ export type AppAccessFacts = {
/** Whose account, from the same facts `accessFor` resolves a call with. */
export function accountOf(facts: AppAccessFacts): AppAccount {
- if (facts.provider === "composio") {
+ if (brokeredProvenance(facts.provider)) {
if (schemeKind(facts.authScheme) === "none") return "deployment";
return facts.accountMode === "shared" ? "shared" : "personal";
}
@@ -165,7 +171,7 @@ export function accountOf(facts: AppAccessFacts): AppAccount {
}
export function connectOf(facts: AppAccessFacts): AppConnect {
- if (facts.provider === "composio") {
+ if (brokeredProvenance(facts.provider)) {
const kind = schemeKind(facts.authScheme);
return kind === "consent" || kind === "key" ? kind : null;
}
@@ -189,7 +195,7 @@ export type Unavailable =
export function unavailableReason(facts: AppAccessFacts): Unavailable | null {
if (!facts.present) return "absent";
- if (facts.provider === "composio") {
+ if (brokeredProvenance(facts.provider)) {
if (!facts.composioConfigured) return "composio_unconfigured";
if (schemeKind(facts.authScheme) === "unreadable")
return "scheme_unreadable";
diff --git a/server/src/plugins/plugin-store/app-access.ts b/server/src/plugins/plugin-store/app-access.ts
index 615dddfc72..14b11ea675 100644
--- a/server/src/plugins/plugin-store/app-access.ts
+++ b/server/src/plugins/plugin-store/app-access.ts
@@ -12,6 +12,7 @@ import {
pluginGrants,
users,
} from "../../db/schema";
+import { BROKERED_PROFILE, brokeredProvenance, toolkitOfFor } from "../access";
import {
type AppAccess,
type AppAccessFacts,
@@ -26,7 +27,6 @@ import {
} from "../app-access";
import type { BrokerApp } from "../broker";
import { CATALOGUE, type CatalogueEntry, catalogueEntry } from "../catalogue";
-import { toolkitOf } from "../composio";
import type { StoreContext } from "./context";
import { iso } from "./model";
@@ -34,7 +34,7 @@ import { iso } from "./model";
* One app as this module resolves it before anybody's connection is asked about: which rows are
* it, which of them answers for it, and what the deployment holds for it.
*
- * `rowIds` IS EVERY ROW THAT IS THIS APP, so a grant made through a duplicate row at a Composio
+ * `rowIds` IS EVERY ROW THAT IS THIS APP, so a grant made through a duplicate row at a brokered
* app's url counts as holding the app — the same "the apps, not the rows" rule
* `sharedAppsHeldBy` follows. `answeringId` is the one row whose columns decide, and the one a
* grant made by approving a request is written against.
@@ -75,7 +75,7 @@ export type AppInspection = {
unavailable: Unavailable | null;
/** The row a grant is written against, and null for an app with no row. */
answeringId: string | null;
- /** The Composio app, for a brokered one. */
+ /** The app at the broker, for a brokered one. */
toolkit: string | null;
/** The catalogue entry, for an app this deployment ships. */
entry: CatalogueEntry | null;
@@ -108,14 +108,24 @@ export type AppAccessRequestRecord = {
resolvedBy: string | null;
};
-const SLUG_OF_ID = /^composio-/;
-
/**
* Per-person, per-Bot status of the work apps a Bot holds, and the requests a person files when
* only an administrator can change it. See `../app-access.ts` for the rule itself.
+ *
+ * ONE BROKER, THE STORE'S. Which rows are brokered apps, which connections are theirs, the scheme
+ * an app's url is minted at and the prefix its row id takes all come from `brokerProvider` and
+ * {@link BROKERED_PROFILE}, so a personal Arcade app is read exactly as a personal Composio app is
+ * — and a row left by the broker this deployment does not run is no brokered app here, as it is
+ * no brokered app to any other reader in the store.
*/
export function appAccessMethods(context: StoreContext) {
- const { database, auditStore, brokeredAppRowsAt } = context;
+ const { database, auditStore, brokeredAppRowsAt, brokerProvider } = context;
+ const { scheme, idPrefix } = BROKERED_PROFILE[brokerProvider];
+ /** Whether an id is in the namespace `addBrokeredApp` mints this broker's rows into. */
+ const isBrokeredId = (id: string) => id.startsWith(idPrefix);
+ /** An id with that namespace taken off, and any other word as it came. */
+ const slugOfId = (id: string) =>
+ isBrokeredId(id) ? id.slice(idPrefix.length) : id;
/*
* EVERY ROW, ONCE. `mcp_servers` is the deployment's list of apps — tens of rows, not thousands —
@@ -151,11 +161,14 @@ export function appAccessMethods(context: StoreContext) {
async function appsByRow(rows: ServerRow[]) {
const toolkitOfRow = new Map();
for (const row of rows) {
- const toolkit = row.provenance === "composio" ? toolkitOf(row.url) : null;
+ const toolkit =
+ row.provenance === brokerProvider
+ ? toolkitOfFor(brokerProvider, row.url)
+ : null;
if (toolkit && !catalogueEntry(row.id)) toolkitOfRow.set(row.id, toolkit);
}
const answering = await brokeredAppRowsAt([
- ...new Set([...toolkitOfRow.values()].map((t) => `composio://${t}`)),
+ ...new Set([...toolkitOfRow.values()].map((t) => `${scheme}${t}`)),
]);
const byId = new Map(rows.map((row) => [row.id, row]));
const apps = new Map();
@@ -163,7 +176,7 @@ export function appAccessMethods(context: StoreContext) {
for (const row of rows) {
const toolkit = toolkitOfRow.get(row.id) ?? null;
- const head = toolkit ? answering.get(`composio://${toolkit}`) : undefined;
+ const head = toolkit ? answering.get(`${scheme}${toolkit}`) : undefined;
const key = head?.id ?? row.id;
let app = apps.get(key);
if (!app) {
@@ -173,7 +186,7 @@ export function appAccessMethods(context: StoreContext) {
key,
title: entry?.title ?? source.title,
logo: source.logo,
- provider: toolkit ? "composio" : entry ? "catalogue" : "custom",
+ provider: toolkit ? brokerProvider : entry ? "catalogue" : "custom",
present: true,
answeringId: key,
rowIds: [],
@@ -192,10 +205,11 @@ export function appAccessMethods(context: StoreContext) {
}
/**
- * An app with no row: a catalogue entry nobody added, or a Composio app nobody enabled.
+ * An app with no row: a catalogue entry nobody added, or a brokered app nobody enabled.
*
- * `key` is the id enabling it would mint — the entry's key, or `composio-` as
- * `addBrokeredApp` names a row — so a request filed now names the row the approval creates.
+ * `key` is the id enabling it would mint — the entry's key, or the broker's prefix and the slug
+ * (`composio-`, `arcade-`) as `addBrokeredApp` names a row — so a request filed
+ * now names the row the approval creates.
*/
function absent(input: {
key: string;
@@ -250,37 +264,41 @@ export function appAccessMethods(context: StoreContext) {
provider: "catalogue",
entry,
});
- const slug = key.replace(SLUG_OF_ID, "");
- if (toolkitOf(`composio://${slug}`) !== slug) return null;
+ const slug = slugOfId(key);
+ if (toolkitOfFor(brokerProvider, `${scheme}${slug}`) !== slug) return null;
const apps = listed === undefined ? await readDirectory(directory) : listed;
const app = apps?.find((candidate) => candidate.slug === slug);
if (app)
return absent({
- key: `composio-${app.slug}`,
+ key: `${idPrefix}${app.slug}`,
title: app.name,
logo: app.logo,
- provider: "composio",
+ provider: brokerProvider,
toolkit: app.slug,
});
/*
- * A `composio-` id the directory cannot vouch for is still an app somebody once enabled — a
- * grant can outlive its row — so it is named by its slug rather than dropped. A bare word that
- * matches nothing is not an app at all.
+ * An id in the broker's namespace that the directory cannot vouch for is still an app somebody
+ * once enabled — a grant can outlive its row — so it is named by its slug rather than dropped.
+ * A bare word that matches nothing is not an app at all.
*/
- if (SLUG_OF_ID.test(key))
+ if (isBrokeredId(key))
return absent({
key,
title: slug,
- provider: "composio",
+ provider: brokerProvider,
toolkit: slug,
});
return null;
}
/**
- * One app by whatever a caller called it: a server id, a catalogue key, a Composio slug with or
- * without its `composio-` prefix, or the app's name. Null for a word that names no app here or
- * in the directory.
+ * One app by whatever a caller called it: a server id, a catalogue key, a brokered app's slug
+ * with or without its id prefix (`composio-`, `arcade-`), or the app's name. Null for a word that
+ * names no app here or in the directory.
+ *
+ * A SLUG IS MATCHED AS TYPED FIRST, THEN LOWERCASED. Arcade's toolkit names are case-sensitive
+ * (`Gmail`), so the word as the caller typed it is the one that can reach them; Composio's slugs
+ * are lowercase, so the lowercased word is the one that always reached those, and still does.
*/
async function candidateFor(
app: string,
@@ -295,9 +313,12 @@ export function appAccessMethods(context: StoreContext) {
const byId = byRow.get(asked);
if (byId) return byId;
- const slug = lower.replace(SLUG_OF_ID, "");
- for (const candidate of new Set(byRow.values()))
- if (candidate.toolkit === slug) return candidate;
+ const typed = slugOfId(asked);
+ const slug = slugOfId(lower);
+ const apps = new Set(byRow.values());
+ for (const wanted of new Set([typed, slug]))
+ for (const candidate of apps)
+ if (candidate.toolkit === wanted) return candidate;
const entry =
catalogueEntry(lower) ??
@@ -306,16 +327,20 @@ export function appAccessMethods(context: StoreContext) {
if (entry)
return byRow.get(entry.key) ?? absentFor(entry.key, undefined, null);
- for (const candidate of new Set(byRow.values()))
+ for (const candidate of apps)
if (candidate.title.toLowerCase() === lower) return candidate;
const listed = await readDirectory(directory);
- const named = listed?.find(
- (candidate) =>
- candidate.slug === slug || candidate.name.toLowerCase() === lower,
- );
- if (named) return absentFor(`composio-${named.slug}`, undefined, listed);
- return absentFor(SLUG_OF_ID.test(lower) ? lower : asked, undefined, listed);
+ const named =
+ (typed !== slug
+ ? listed?.find((candidate) => candidate.slug === typed)
+ : undefined) ??
+ listed?.find(
+ (candidate) =>
+ candidate.slug === slug || candidate.name.toLowerCase() === lower,
+ );
+ if (named) return absentFor(`${idPrefix}${named.slug}`, undefined, listed);
+ return absentFor(isBrokeredId(lower) ? lower : asked, undefined, listed);
}
/** The server halves of a Bot's MCP grants. */
@@ -366,7 +391,7 @@ export function appAccessMethods(context: StoreContext) {
.from(brokeredConnections)
.where(
and(
- eq(brokeredConnections.provider, "composio"),
+ eq(brokeredConnections.provider, brokerProvider),
eq(brokeredConnections.holder, "person"),
eq(brokeredConnections.userId, userId),
inArray(brokeredConnections.app, toolkits),
@@ -384,7 +409,7 @@ export function appAccessMethods(context: StoreContext) {
.from(brokeredConnections)
.where(
and(
- eq(brokeredConnections.provider, "composio"),
+ eq(brokeredConnections.provider, brokerProvider),
eq(brokeredConnections.holder, "deployment"),
inArray(brokeredConnections.app, toolkits),
),
@@ -482,7 +507,7 @@ export function appAccessMethods(context: StoreContext) {
: undefined;
const sharedUsable = teamAccount?.usable === true;
const personal =
- app.provider === "composio" && app.toolkit
+ brokeredProvenance(app.provider) && app.toolkit
? connections.personalBrokered.get(app.toolkit)
: app.provider === "catalogue"
? app.rowIds
@@ -518,7 +543,7 @@ export function appAccessMethods(context: StoreContext) {
provider: app.provider,
account,
connect: connectOf(facts),
- authScheme: app.provider === "composio" ? app.authScheme : null,
+ authScheme: brokeredProvenance(app.provider) ? app.authScheme : null,
accountMode: app.accountMode,
state,
granted: facts.granted,
@@ -615,7 +640,7 @@ export function appAccessMethods(context: StoreContext) {
if (app) apps.set(app.key, app);
else missing.push(serverId);
}
- const listed = missing.some((id) => SLUG_OF_ID.test(id))
+ const listed = missing.some(isBrokeredId)
? await readDirectory(input.directory)
: null;
for (const serverId of missing) {
diff --git a/server/src/plugins/route-groups/app-access.ts b/server/src/plugins/route-groups/app-access.ts
index bbdb5b19b2..1ba6af4443 100644
--- a/server/src/plugins/route-groups/app-access.ts
+++ b/server/src/plugins/route-groups/app-access.ts
@@ -1,5 +1,6 @@
import type { Context } from "hono";
import { type AppVariables, requireAdmin } from "../../auth/guards";
+import { BROKERED_PROFILE, type BrokerProvider } from "../access";
import type { Unavailable } from "../app-access";
import type { CatalogueEntry } from "../catalogue";
import type {
@@ -38,13 +39,25 @@ function adminInputFor(entry: CatalogueEntry): string | null {
return null;
}
-/** The sentence an approval is refused with when the app is there and still cannot be used. */
-function unavailableSentence(reason: Unavailable, title: string): string {
+/**
+ * The sentence an approval is refused with when the app is there and still cannot be used.
+ *
+ * The broker's name and key come from {@link BROKERED_PROFILE}, so an Arcade deployment is told
+ * to set ARCADE_API_KEY in the words a Composio one is told to set COMPOSIO_API_KEY.
+ */
+function unavailableSentence(
+ reason: Unavailable,
+ title: string,
+ provider: BrokerProvider,
+): string {
switch (reason) {
case "absent":
return `${title} is not an app this deployment can switch on from here.`;
- case "composio_unconfigured":
- return `${title} is a Composio app, and this deployment has no Composio key. Set COMPOSIO_API_KEY, then approve this request again.`;
+ case "composio_unconfigured": {
+ const { vendor, envVar } = BROKERED_PROFILE[provider];
+ const article = /^[aeiou]/i.test(vendor) ? "an" : "a";
+ return `${title} is ${article} ${vendor} app, and this deployment has no ${vendor} key. Set ${envVar}, then approve this request again.`;
+ }
case "scheme_unreadable":
return `${title} is enabled, but how it connects is not recorded in a way this deployment can read. Remove it and enable it again on its page under Plugins, then approve this request again.`;
case "oauth_client_missing":
@@ -366,14 +379,14 @@ export function mountAppAccessRoutes({
if (inspected.unavailable !== "absent")
return deny(
inspected.unavailable,
- unavailableSentence(inspected.unavailable, title),
+ unavailableSentence(inspected.unavailable, title, activeProvider),
inspected.unavailable === "composio_unconfigured" ? 503 : 409,
);
if (inspected.toolkit) {
if (!composio)
return deny(
"composio_unconfigured",
- unavailableSentence("composio_unconfigured", title),
+ unavailableSentence("composio_unconfigured", title, activeProvider),
503,
);
const outcome = await enableComposioApp(
@@ -407,7 +420,11 @@ export function mountAppAccessRoutes({
throw error;
}
} else {
- return deny("absent", unavailableSentence("absent", title), 409);
+ return deny(
+ "absent",
+ unavailableSentence("absent", title, activeProvider),
+ 409,
+ );
}
enabled = true;
inspected = await inspect();
diff --git a/server/tests/app-access-routes.integration.test.ts b/server/tests/app-access-routes.integration.test.ts
index 4c66f823b8..182f21939d 100644
--- a/server/tests/app-access-routes.integration.test.ts
+++ b/server/tests/app-access-routes.integration.test.ts
@@ -936,3 +936,159 @@ describe("answering a request", () => {
expect(again.status).toBe(409);
});
});
+
+/*
+ * THE SAME RULE ON AN ARCADE DEPLOYMENT. Which rows are brokered apps, which connections count and
+ * which id an app nobody enabled would get all come from the store's broker, so a personal Arcade
+ * app reads exactly as a personal Composio app does — under Arcade's case-sensitive toolkit name.
+ */
+describe("on an Arcade deployment", () => {
+ const toolkit = `ArcMail_${suite}`;
+ const ARC_MAIL = `arcade-${toolkit}`;
+ const docs = `ArcDocs_${suite}`;
+
+ function arcadeStore(): PluginStore {
+ return createPluginStore({
+ brokerProvider: "arcade",
+ database,
+ auditStore: audit,
+ credentials: {} as never,
+ encryptionKey: "x".repeat(44),
+ policy: () => ({ mode: "enforce", deny: [], allow: ["true"] }),
+ broker,
+ deploymentId: `acme-${suite}`,
+ });
+ }
+
+ async function seedArcadeApp() {
+ await database.insert(mcpServers).values({
+ id: ARC_MAIL,
+ title: "Arc Mail",
+ vendor: "Arcade",
+ url: `arcade://${toolkit}`,
+ provenance: "arcade",
+ authScheme: "OAUTH2",
+ accountMode: "personal",
+ });
+ await database.insert(mcpTools).values({
+ serverId: ARC_MAIL,
+ name: "READ",
+ description: "READ",
+ inputSchema: {},
+ });
+ await database
+ .insert(pluginGrants)
+ .values({ kind: "mcp", ref: `${ARC_MAIL}/READ`, agentId: bot });
+ }
+
+ const forBot = (composioConfigured = true) =>
+ arcadeStore().appAccessForBot({
+ userId: person,
+ agentId: bot,
+ composioConfigured,
+ });
+
+ test("a personal app is not_connected until the person connects it, then ready", async () => {
+ await seedArcadeApp();
+ expect(await forBot()).toMatchObject([
+ {
+ key: ARC_MAIL,
+ provider: "arcade",
+ account: "personal",
+ connect: "consent",
+ authScheme: "OAUTH2",
+ state: "not_connected",
+ },
+ ]);
+ // Somebody else's connection, and a Composio one at the same app name, are not this person's.
+ await database.insert(brokeredConnections).values([
+ {
+ provider: "arcade",
+ app: toolkit,
+ holder: "person",
+ userId: other,
+ vendorUserId: `vendor-${other}`,
+ verified: true,
+ },
+ {
+ provider: "composio",
+ app: toolkit,
+ holder: "person",
+ userId: person,
+ vendorUserId: `vendor-${person}`,
+ verified: true,
+ },
+ ]);
+ expect((await forBot())[0]?.state).toBe("not_connected");
+ await database.insert(brokeredConnections).values({
+ provider: "arcade",
+ app: toolkit,
+ holder: "person",
+ userId: person,
+ vendorUserId: `vendor-${person}`,
+ verified: true,
+ connectedAt: new Date("2026-10-21T08:00:00.000Z"),
+ });
+ expect(await forBot()).toMatchObject([
+ {
+ key: ARC_MAIL,
+ state: "ready",
+ connectedAt: "2026-10-21T08:00:00.000Z",
+ },
+ ]);
+ });
+
+ test("with no Arcade key the app is switched off at the deployment", async () => {
+ await seedArcadeApp();
+ const inspected = await arcadeStore().inspectApp({
+ app: ARC_MAIL,
+ agentId: bot,
+ userId: person,
+ composioConfigured: false,
+ });
+ expect(inspected?.unavailable).toBe("composio_unconfigured");
+ expect(inspected?.access.state).toBe("disabled");
+ });
+
+ test("an app is found by its id or its toolkit name as typed", async () => {
+ await seedArcadeApp();
+ for (const asked of [ARC_MAIL, toolkit, "arc mail"]) {
+ const inspected = await arcadeStore().inspectApp({
+ app: asked,
+ agentId: bot,
+ userId: person,
+ composioConfigured: true,
+ });
+ expect(inspected?.access.key).toBe(ARC_MAIL);
+ expect(inspected?.toolkit).toBe(toolkit);
+ }
+ });
+
+ test("an app only the directory knows is disabled, under the arcade- id enabling would mint", async () => {
+ const inspected = await arcadeStore().inspectApp({
+ app: "Arc Docs",
+ agentId: bot,
+ userId: person,
+ composioConfigured: true,
+ directory: async () => [
+ {
+ slug: docs,
+ name: "Arc Docs",
+ description: "",
+ logo: null,
+ categories: [],
+ actionCount: 1,
+ connection: { kind: "consent" },
+ },
+ ],
+ });
+ expect(inspected?.access).toMatchObject({
+ key: `arcade-${docs}`,
+ displayName: "Arc Docs",
+ provider: "arcade",
+ state: "disabled",
+ requestKind: "enable",
+ });
+ expect(inspected?.toolkit).toBe(docs);
+ });
+});
From c1e5acee27717a4f5c955fcff634f68caf1cd2fd Mon Sep 17 00:00:00 2001
From: Guido Vizoso
Date: Wed, 21 Oct 2026 16:06:23 -0300
Subject: [PATCH 22/33] Declare authorizesPerAction in the
unconfigured-Composio page fixture
Main made authorizesPerAction a required field of PluginsPage. The fixture
OB-05 added gives Composio's answer, false.
---
app/tests/composio-unconfigured-account.test.tsx | 2 ++
1 file changed, 2 insertions(+)
diff --git a/app/tests/composio-unconfigured-account.test.tsx b/app/tests/composio-unconfigured-account.test.tsx
index bcdc7046e8..f9252f7e8e 100644
--- a/app/tests/composio-unconfigured-account.test.tsx
+++ b/app/tests/composio-unconfigured-account.test.tsx
@@ -128,6 +128,8 @@ function renderAccount(composioConfigured: boolean) {
botsMayCallBack: true,
redirectUri: null,
composioConfigured,
+ // Composio's answer: its auth config fixes one state for a whole toolkit.
+ authorizesPerAction: false,
};
const connections: PluginConnections = { connections: [], redirectUri: null };
client.setQueryData(pluginKeys.page(), page);
From 87da73685951956e29cf6b3a4a25c33bcb4943d7 Mon Sep 17 00:00:00 2001
From: Guido Vizoso
Date: Wed, 21 Oct 2026 16:07:44 -0300
Subject: [PATCH 23/33] Approve with read-only actions, claim a request before
acting, and work an app's state out for the account its calls use
Approving with no refs now grants only the app's read-only actions, and refuses an app that has none. The request is claimed by the approving administrator before anything changes, so a dismissal or withdrawal meanwhile loses rather than leaving a grant under a dismissed request; an approval that cannot be carried out puts it back. On somebody else's published Team Bot the state follows the owner's account where the owner has one, and says when the first call will ask the person to allow their own. A Shared app whose audience the gate would refuse reads shared_not_approved. A name matches enabled apps before catalogue entries. Requests cap their names at 200 characters, refuse to enable an id nothing vouches for, and stop at 20 waiting per person.
---
server/src/index.ts | 1 +
server/src/plugins/app-access-tool.ts | 20 +-
server/src/plugins/app-access.ts | 46 +-
server/src/plugins/plugin-store/app-access.ts | 500 +++++++++++++-----
server/src/plugins/route-groups/app-access.ts | 442 +++++++++-------
server/src/plugins/shared-use-store.ts | 25 +
.../app-access-routes.integration.test.ts | 466 +++++++++++++++-
server/tests/app-access-state.test.ts | 19 +
8 files changed, 1199 insertions(+), 320 deletions(-)
diff --git a/server/src/index.ts b/server/src/index.ts
index 20ba5853a6..78b23719a3 100644
--- a/server/src/index.ts
+++ b/server/src/index.ts
@@ -1294,6 +1294,7 @@ const loadToolsForActor =
actorId,
composioConfigured: Boolean(composio),
...(composio ? { directory: () => composio.broker.listApps() } : {}),
+ sharedAudience: sharedUseStore.admitsPerson,
}),
...(await grantedTools({
store: pluginStore,
diff --git a/server/src/plugins/app-access-tool.ts b/server/src/plugins/app-access-tool.ts
index be48070488..47bafbe89a 100644
--- a/server/src/plugins/app-access-tool.ts
+++ b/server/src/plugins/app-access-tool.ts
@@ -5,7 +5,10 @@ import {
import type { GrantedTool } from "@openbot/toolkit/granted-tool";
import { z } from "zod";
import type { AppAccess, AppAccessState } from "./app-access";
-import type { AppDirectory } from "./plugin-store/app-access";
+import type {
+ AppDirectory,
+ SharedAudienceCheck,
+} from "./plugin-store/app-access";
import type { PluginStore } from "./store";
/**
@@ -36,6 +39,8 @@ export function appAccessTools(options: {
composioConfigured: boolean;
/** Names an app this deployment has no row for. Absent without a Composio key. */
directory?: AppDirectory;
+ /** The shared-use gate's answer for this person, so a Shared app it would refuse says so. */
+ sharedAudience?: SharedAudienceCheck;
}): GrantedTool[] {
const parameters = z
.object({
@@ -66,6 +71,9 @@ export function appAccessTools(options: {
userId: options.actorId || null,
composioConfigured: options.composioConfigured,
...(options.directory ? { directory: options.directory } : {}),
+ ...(options.sharedAudience
+ ? { sharedAudience: options.sharedAudience }
+ : {}),
});
/*
* No card for an app nobody can name. A card about it would have nothing to ask the server
@@ -93,11 +101,17 @@ export function appAccessTools(options: {
*/
function toldTheModel(access: AppAccess): string {
const name = access.displayName;
+ const owners = access.accountHolder === "owner";
const said: Record = {
- ready: `${name} is ready for this person to use through you. Use its tools rather than the card.`,
+ ready: access.asksFirst
+ ? `${name} is ready for this person to use through you, on their own account; the first call will ask them to allow that. Use its tools rather than the card.`
+ : `${name} is ready for this person to use through you. Use its tools rather than the card.`,
not_connected: `${name} is granted to you, and this person has not connected their own account. The card in front of them offers Connect; ask them to try again once they have.`,
- reconnect: `${name} is granted to you, and this person's connection needs reconnecting. The card in front of them offers Reconnect; ask them to try again once they have.`,
+ reconnect: owners
+ ? `${name} is granted to you and runs on your owner's account, whose connection needs reconnecting. Only your owner can reconnect it; the card in front of the person says so.`
+ : `${name} is granted to you, and this person's connection needs reconnecting. The card in front of them offers Reconnect; ask them to try again once they have.`,
shared_not_connected: `${name} uses one shared account for this deployment, and an administrator has not connected it yet. The card in front of the person lets them ask an administrator for it.`,
+ shared_not_approved: `${name} uses one shared account for this deployment, and you reach more people than an administrator approved for it, so its calls are refused. Only an administrator can approve that; the card in front of the person says so.`,
disabled: `${name} is switched off on this deployment. The card in front of the person lets them ask an administrator to switch it on.`,
not_granted: `${name} is switched on, and you have not been granted it. The card in front of the person lets them ask an administrator to grant it to you.`,
};
diff --git a/server/src/plugins/app-access.ts b/server/src/plugins/app-access.ts
index caa50ac949..d174c081ee 100644
--- a/server/src/plugins/app-access.ts
+++ b/server/src/plugins/app-access.ts
@@ -24,7 +24,7 @@ import type { CatalogueEntry } from "./catalogue";
*/
/**
- * The six things a person can be told about one app on one Bot.
+ * The seven things a person can be told about one app on one Bot.
*
* `ready` — usable now, as far as this deployment knows.
* `not_connected` — enabled and held, and this app is used under each person's own account, which
@@ -33,6 +33,9 @@ import type { CatalogueEntry } from "./catalogue";
* to fix.
* `shared_not_connected` — a Shared app whose one team account is not connected, or whose key was
* refused. Only an administrator can connect it.
+ * `shared_not_approved` — a Shared app whose team account is connected, on a Bot that reaches more
+ * people than an administrator approved for that account. Every call is refused by the shared-use
+ * gate, which files the administrator's request itself; nothing is left for a press to ask.
* `disabled` — switched off at the deployment: no row, Composio not configured, or an app that
* still needs something only an administrator can supply.
* `not_granted` — enabled, and this Bot holds none of its tools. Only an administrator can grant.
@@ -42,6 +45,7 @@ export const APP_ACCESS_STATES = [
"not_connected",
"reconnect",
"shared_not_connected",
+ "shared_not_approved",
"disabled",
"not_granted",
] as const;
@@ -66,6 +70,7 @@ export const REQUEST_KIND_FOR: Record<
not_connected: null,
reconnect: null,
shared_not_connected: "connect_shared",
+ shared_not_approved: null,
disabled: "enable",
not_granted: "grant",
};
@@ -106,16 +111,34 @@ export type AppAccess = {
/** The app's mode, off its answering row; null for an app that is not brokered. */
accountMode: "personal" | "shared" | null;
state: AppAccessState;
+ /** Why nobody can use the app, when `state` is `disabled`; null otherwise. See {@link Unavailable}. */
+ unavailable: Unavailable | null;
+ /**
+ * Whose own account a personal app's calls go out on for this person: theirs, or — on somebody
+ * else's published Team Bot whose owner connected the app — the owner's, which only the owner can
+ * reconnect. Null where the app is not used under a person's own account.
+ *
+ * THE SAME ANSWER `credentialActorFor` GIVES A CALL, read without its side effects: the owner's
+ * account wherever the owner has one, and otherwise the person's own.
+ */
+ accountHolder: "you" | "owner" | null;
+ /**
+ * Whether the first call on the person's own account through somebody else's Team Bot will stop
+ * to ask them to allow it, because they have given that Bot no standing consent. The state is
+ * still `ready`: the question is asked in the conversation, and answering it is the next step.
+ */
+ asksFirst: boolean;
/** Whether this Bot holds any of the app's tools. */
granted: boolean;
/**
* When the account this app runs in for this person was connected — their own, or the team's for
* a Shared app — and null where none is, or where no account is involved.
*
- * FOR THE ONE FACT THE STATE CANNOT CARRY. An account the vendor has lost still has its row here,
- * so the state reads `ready`; a card drawn over the refusal that found out says Reconnect anyway.
- * This is how that card tells "lost since the refusal" from "connected again since the refusal":
- * a connection newer than the refusal is the reconnection, and the live state wins.
+ * FOR THE ONE FACT THE STATE CANNOT ALWAYS CARRY. The call that finds an account lost at the
+ * vendor now deletes its row, so the state says so itself from then on; a refusal written before
+ * that, over a row that still stands, reads `ready` here and its card says Reconnect anyway. This
+ * is how that card tells "lost since the refusal" from "connected again since the refusal": a
+ * connection newer than the refusal is the reconnection, and the live state wins.
*/
connectedAt: string | null;
/** The request a press of Request access files for this state, and null where there is none. */
@@ -149,6 +172,11 @@ export type AppAccessFacts = {
personal: { usable: boolean } | null;
/** The deployment's account at a Shared app, or null where none is connected. */
shared: { usable: boolean } | null;
+ /**
+ * Whether the shared-use gate would let this person's calls through this Bot reach the team
+ * account, or null where nobody asked — no person named, or not a Shared app.
+ */
+ sharedAdmitted: boolean | null;
};
/** Whose account, from the same facts `accessFor` resolves a call with. */
@@ -213,7 +241,7 @@ export function unavailableReason(facts: AppAccessFacts): Unavailable | null {
/**
* The rule, in the order a person meets the obstacles: the deployment first, then the Bot, then
- * their own account.
+ * the account — and, for a team account, whether this Bot's audience was approved for it.
*
* A KEY THE VENDOR REFUSED READS AS `reconnect`, AND AN UNCHECKED ONE AS `ready`. The store
* reduces each connection to `usable` the way `brokeredConnectionsFor` sets out its four states: a
@@ -225,8 +253,10 @@ export function appAccessState(facts: AppAccessFacts): AppAccessState {
if (!facts.granted) return "not_granted";
const account = accountOf(facts);
if (account === "deployment") return "ready";
- if (account === "shared")
- return facts.shared?.usable ? "ready" : "shared_not_connected";
+ if (account === "shared") {
+ if (!facts.shared?.usable) return "shared_not_connected";
+ return facts.sharedAdmitted === false ? "shared_not_approved" : "ready";
+ }
if (!facts.personal) return "not_connected";
return facts.personal.usable ? "ready" : "reconnect";
}
diff --git a/server/src/plugins/plugin-store/app-access.ts b/server/src/plugins/plugin-store/app-access.ts
index 615dddfc72..4863914611 100644
--- a/server/src/plugins/plugin-store/app-access.ts
+++ b/server/src/plugins/plugin-store/app-access.ts
@@ -1,7 +1,18 @@
import { randomUUID } from "node:crypto";
-import { and, asc, desc, eq, inArray, isNull, or, type SQL } from "drizzle-orm";
+import {
+ and,
+ asc,
+ count,
+ desc,
+ eq,
+ inArray,
+ isNull,
+ or,
+ type SQL,
+} from "drizzle-orm";
import { type AuditEventInput, recordAuditEvent } from "../../audit";
import {
+ agentProfiles,
agents,
appAccessRequests,
brokeredConnections,
@@ -10,6 +21,9 @@ import {
mcpTools,
mcpUserCredentials,
pluginGrants,
+ teamBotConsentDefaults,
+ teamBotConsents,
+ teamBotPublications,
users,
} from "../../db/schema";
import {
@@ -52,6 +66,12 @@ type AppCandidate = {
authScheme: string | null;
accountMode: "personal" | "shared" | null;
hasCredential: boolean;
+ /**
+ * Whether anything but the caller's own word says this app exists: a row, a catalogue entry, or
+ * the vendor's directory. False only for a `composio-` id the directory did not list — named so a
+ * grant that outlived its row still reads, and never something a request may be filed to enable.
+ */
+ vouched: boolean;
};
type ServerRow = {
@@ -81,8 +101,20 @@ export type AppInspection = {
entry: CatalogueEntry | null;
/** Whether the Shared team account is connected and its key was not refused. */
sharedUsable: boolean;
+ /** See {@link AppCandidate.vouched}. */
+ vouched: boolean;
};
+/**
+ * Whether the shared-use gate would admit this person's calls through this Bot to a Shared app's
+ * team account — the gate's own rule, without the request it files when it refuses.
+ */
+export type SharedAudienceCheck = (input: {
+ botId: string;
+ serverId: string;
+ actorId: string;
+}) => Promise;
+
export type AppAccessRequestStatus =
| "pending"
| "approved"
@@ -110,6 +142,18 @@ export type AppAccessRequestRecord = {
const SLUG_OF_ID = /^composio-/;
+/**
+ * Whether `error` is a Postgres unique_violation (SQLSTATE 23505). Bun's driver puts the SQLSTATE in
+ * `errno`, and drizzle wraps the driver error as `cause` — the same reading `identity/store.ts` makes.
+ */
+function isUniqueViolation(error: unknown): boolean {
+ type Driver = { errno?: unknown };
+ return [
+ error as Driver | null | undefined,
+ (error as { cause?: Driver } | null | undefined)?.cause,
+ ].some((candidate) => candidate?.errno === "23505");
+}
+
/**
* Per-person, per-Bot status of the work apps a Bot holds, and the requests a person files when
* only an administrator can change it. See `../app-access.ts` for the rule itself.
@@ -182,6 +226,7 @@ export function appAccessMethods(context: StoreContext) {
authScheme: toolkit ? (head?.authScheme ?? null) : source.authScheme,
accountMode: toolkit ? (head?.accountMode ?? null) : null,
hasCredential: source.credentialId !== null,
+ vouched: true,
};
apps.set(key, app);
}
@@ -204,6 +249,7 @@ export function appAccessMethods(context: StoreContext) {
provider: AppProvider;
toolkit?: string | null;
entry?: CatalogueEntry | null;
+ vouched?: boolean;
}): AppCandidate {
return {
key: input.key,
@@ -218,6 +264,7 @@ export function appAccessMethods(context: StoreContext) {
authScheme: null,
accountMode: null,
hasCredential: false,
+ vouched: input.vouched ?? true,
};
}
@@ -273,6 +320,7 @@ export function appAccessMethods(context: StoreContext) {
title: slug,
provider: "composio",
toolkit: slug,
+ vouched: false,
});
return null;
}
@@ -281,33 +329,47 @@ export function appAccessMethods(context: StoreContext) {
* One app by whatever a caller called it: a server id, a catalogue key, a Composio slug with or
* without its `composio-` prefix, or the app's name. Null for a word that names no app here or
* in the directory.
+ *
+ * A NAME FINDS WHAT IS ENABLED BEFORE WHAT COULD BE. "Google Drive" is the title of the built-in
+ * `google-drive` entry and of a Composio row alike; on a deployment that enabled the Composio one
+ * and never added the built-in, the name is about the app that is there, and answering with the
+ * entry instead would call it switched off and file a request to enable something nobody needs.
+ * So an exact id or key is taken as it was given, and a bare name is matched against the rows
+ * first — the one this Bot holds, where two rows share it — and only then against the catalogue.
*/
async function candidateFor(
app: string,
directory: AppDirectory | undefined,
+ granted: Set,
): Promise {
const asked = app.trim();
if (!asked) return null;
const lower = asked.toLowerCase();
const rows = await allRows();
const byRow = await appsByRow(rows);
+ const enabled = [...new Set(byRow.values())];
const byId = byRow.get(asked);
if (byId) return byId;
const slug = lower.replace(SLUG_OF_ID, "");
- for (const candidate of new Set(byRow.values()))
+ for (const candidate of enabled)
if (candidate.toolkit === slug) return candidate;
- const entry =
- catalogueEntry(lower) ??
- CATALOGUE.find((item) => item.title.toLowerCase() === lower) ??
- null;
- if (entry)
- return byRow.get(entry.key) ?? absentFor(entry.key, undefined, null);
+ const keyed = catalogueEntry(lower);
+ if (keyed)
+ return byRow.get(keyed.key) ?? absentFor(keyed.key, undefined, null);
- for (const candidate of new Set(byRow.values()))
- if (candidate.title.toLowerCase() === lower) return candidate;
+ const titled = enabled.filter(
+ (candidate) => candidate.title.toLowerCase() === lower,
+ );
+ const held = titled.find((candidate) =>
+ [candidate.key, ...candidate.rowIds].some((id) => granted.has(id)),
+ );
+ if (held ?? titled[0]) return held ?? titled[0] ?? null;
+
+ const entry = CATALOGUE.find((item) => item.title.toLowerCase() === lower);
+ if (entry) return absentFor(entry.key, undefined, null);
const listed = await readDirectory(directory);
const named = listed?.find(
@@ -456,94 +518,207 @@ export function appAccessMethods(context: StoreContext) {
.orderBy(asc(appAccessRequests.createdAt));
}
- /** Every candidate described for one person and one Bot. */
+ /**
+ * The owner of a published Team Bot, when the person looking is somebody else; null for any other
+ * Bot, or for its owner. The same publication `credentialActorFor` reads before it decides whose
+ * account a call goes out on.
+ */
+ async function teamBotOwnerFor(
+ agentId: string,
+ userId: string,
+ ): Promise {
+ const [row] = await database
+ .select({ ownerUserId: agentProfiles.ownerUserId })
+ .from(teamBotPublications)
+ .innerJoin(
+ agentProfiles,
+ eq(agentProfiles.agentId, teamBotPublications.agentId),
+ )
+ .where(
+ and(
+ eq(teamBotPublications.agentId, agentId),
+ isNull(agentProfiles.deletedAt),
+ ),
+ )
+ .limit(1);
+ const owner = row?.ownerUserId ?? null;
+ return owner && owner !== userId ? owner : null;
+ }
+
+ /**
+ * The servers this person lets one Team Bot use their own account on without asking: an
+ * "always" or a not-yet-spent "once" for this Bot, or a default for every Bot.
+ */
+ async function standingConsents(
+ userId: string,
+ agentId: string,
+ ): Promise> {
+ const [forBot, forEvery] = await Promise.all([
+ database
+ .select({ serverId: teamBotConsents.serverId })
+ .from(teamBotConsents)
+ .where(
+ and(
+ eq(teamBotConsents.userId, userId),
+ eq(teamBotConsents.agentId, agentId),
+ ),
+ ),
+ database
+ .select({ serverId: teamBotConsentDefaults.serverId })
+ .from(teamBotConsentDefaults)
+ .where(eq(teamBotConsentDefaults.userId, userId)),
+ ]);
+ return new Set([...forBot, ...forEvery].map((row) => row.serverId));
+ }
+
+ /**
+ * Every candidate described for one person and one Bot.
+ *
+ * THE ACCOUNT IS THE ONE THE CALL WILL USE. On somebody else's published Team Bot, a personal
+ * app's calls go out on the owner's account wherever the owner has one — connected, withdrawn or
+ * refused alike, exactly as `credentialActorFor` decides from `connectedServers` — and only
+ * otherwise on the person's own, after they allow it. So the owner's connection decides the state
+ * there, and a teammate is never offered a Connect that changes nothing, nor told `ready` over a
+ * call that will stop to ask them first without saying so.
+ */
async function describe(
apps: AppCandidate[],
input: {
userId: string | null;
agentId: string;
composioConfigured: boolean;
+ sharedAudience?: SharedAudienceCheck;
},
): Promise {
- const [granted, connections, pending] = await Promise.all([
- grantedServerIds(input.agentId),
- connectionFacts(apps, input.userId),
- input.userId
- ? pendingRequestsFor(
- input.userId,
- input.agentId,
- apps.map((app) => app.key),
- )
- : [],
- ]);
- return apps.map((app) => {
- const teamAccount = app.toolkit
- ? connections.deploymentBrokered.get(app.toolkit)
- : undefined;
- const sharedUsable = teamAccount?.usable === true;
- const personal =
- app.provider === "composio" && app.toolkit
- ? connections.personalBrokered.get(app.toolkit)
- : app.provider === "catalogue"
- ? app.rowIds
- .map((id) => connections.personalHeld.get(id))
- .find((value) => value !== undefined)
- : undefined;
- const facts: AppAccessFacts = {
- provider: app.provider,
- present: app.present,
- composioConfigured: input.composioConfigured,
- authScheme: app.authScheme,
- accountMode: app.accountMode,
- catalogueAuth: app.entry?.auth ?? null,
- hasCredential: app.hasCredential,
- granted: [app.key, ...app.rowIds].some((id) => granted.has(id)),
- personal: personal === undefined ? null : { usable: personal.usable },
- shared: teamAccount ? { usable: sharedUsable } : null,
- };
- const account = accountOf(facts);
- const state = appAccessState(facts);
- const requestKind = REQUEST_KIND_FOR[state];
- const request =
- requestKind === null
- ? undefined
- : pending.find(
- (row) => row.appKey === app.key && row.kind === requestKind,
- );
- return {
- access: {
- key: app.key,
- displayName: app.title,
- logo: app.logo,
+ const { userId } = input;
+ const owner = userId ? await teamBotOwnerFor(input.agentId, userId) : null;
+ const [granted, connections, ownerConnections, consented, pending] =
+ await Promise.all([
+ grantedServerIds(input.agentId),
+ connectionFacts(apps, userId),
+ owner ? connectionFacts(apps, owner) : null,
+ owner && userId
+ ? standingConsents(userId, input.agentId)
+ : new Set(),
+ userId
+ ? pendingRequestsFor(
+ userId,
+ input.agentId,
+ apps.map((app) => app.key),
+ )
+ : [],
+ ]);
+ const personalIn = (
+ found: Awaited>,
+ app: AppCandidate,
+ ) =>
+ app.provider === "composio" && app.toolkit
+ ? found.personalBrokered.get(app.toolkit)
+ : app.provider === "catalogue"
+ ? app.rowIds
+ .map((id) => found.personalHeld.get(id))
+ .find((value) => value !== undefined)
+ : undefined;
+ return Promise.all(
+ apps.map(async (app) => {
+ const ids = [app.key, ...app.rowIds];
+ const teamAccount = app.toolkit
+ ? connections.deploymentBrokered.get(app.toolkit)
+ : undefined;
+ const sharedUsable = teamAccount?.usable === true;
+ const own = personalIn(connections, app);
+ const owners = ownerConnections
+ ? personalIn(ownerConnections, app)
+ : undefined;
+ const personal = owners ?? own;
+ const known: AppAccessFacts = {
provider: app.provider,
- account,
- connect: connectOf(facts),
- authScheme: app.provider === "composio" ? app.authScheme : null,
+ present: app.present,
+ composioConfigured: input.composioConfigured,
+ authScheme: app.authScheme,
accountMode: app.accountMode,
- state,
- granted: facts.granted,
- connectedAt:
- account === "shared"
- ? (teamAccount?.connectedAt ?? null)
- : account === "personal"
- ? (personal?.connectedAt ?? null)
- : null,
- requestKind,
- request: request
+ catalogueAuth: app.entry?.auth ?? null,
+ hasCredential: app.hasCredential,
+ granted: ids.some((id) => granted.has(id)),
+ personal: personal === undefined ? null : { usable: personal.usable },
+ shared: teamAccount ? { usable: sharedUsable } : null,
+ sharedAdmitted: null,
+ };
+ const account = accountOf(known);
+ /*
+ * Asked only where it can change the answer: a Shared app this Bot holds, whose team account
+ * is there, for a named person. The gate is asked again on every call; this is what it would
+ * say now.
+ */
+ const facts: AppAccessFacts =
+ account === "shared" &&
+ sharedUsable &&
+ known.granted &&
+ userId &&
+ input.sharedAudience &&
+ app.answeringId
? {
- id: request.id,
- kind: request.kind,
- createdAt: iso(request.createdAt) ?? "",
+ ...known,
+ sharedAdmitted: await input.sharedAudience({
+ botId: input.agentId,
+ serverId: app.answeringId,
+ actorId: userId,
+ }),
}
- : null,
- },
- unavailable: unavailableReason(facts),
- answeringId: app.answeringId,
- toolkit: app.toolkit,
- entry: app.entry,
- sharedUsable,
- };
- });
+ : known;
+ const state = appAccessState(facts);
+ const holder = owners !== undefined ? "owner" : "you";
+ const requestKind = REQUEST_KIND_FOR[state];
+ const request =
+ requestKind === null
+ ? undefined
+ : pending.find(
+ (row) => row.appKey === app.key && row.kind === requestKind,
+ );
+ return {
+ access: {
+ key: app.key,
+ displayName: app.title,
+ logo: app.logo,
+ provider: app.provider,
+ account,
+ connect: connectOf(facts),
+ authScheme: app.provider === "composio" ? app.authScheme : null,
+ accountMode: app.accountMode,
+ state,
+ unavailable: unavailableReason(facts),
+ accountHolder: account === "personal" ? holder : null,
+ asksFirst:
+ state === "ready" &&
+ account === "personal" &&
+ holder === "you" &&
+ owner !== null &&
+ !ids.some((id) => consented.has(id)),
+ granted: facts.granted,
+ connectedAt:
+ account === "shared"
+ ? (teamAccount?.connectedAt ?? null)
+ : account === "personal"
+ ? (personal?.connectedAt ?? null)
+ : null,
+ requestKind,
+ request: request
+ ? {
+ id: request.id,
+ kind: request.kind,
+ createdAt: iso(request.createdAt) ?? "",
+ }
+ : null,
+ },
+ unavailable: unavailableReason(facts),
+ answeringId: app.answeringId,
+ toolkit: app.toolkit,
+ entry: app.entry,
+ sharedUsable,
+ vouched: app.vouched,
+ };
+ }),
+ );
}
const requestColumns = {
@@ -587,6 +762,35 @@ export function appAccessMethods(context: StoreContext) {
return rows.map(toRecord);
}
+ /** See `closeAppAccessRequest` below. */
+ async function closeRequest(input: {
+ id: string;
+ status: Exclude;
+ by: string;
+ requestedBy?: string;
+ }): Promise {
+ const [closed] = await database
+ .update(appAccessRequests)
+ .set({
+ status: input.status,
+ resolvedAt: new Date(),
+ resolvedBy: input.by,
+ })
+ .where(
+ and(
+ eq(appAccessRequests.id, input.id),
+ eq(appAccessRequests.status, "pending"),
+ input.requestedBy
+ ? eq(appAccessRequests.requestedBy, input.requestedBy)
+ : undefined,
+ ),
+ )
+ .returning({ id: appAccessRequests.id });
+ if (!closed) return null;
+ const [record] = await requestRows(eq(appAccessRequests.id, closed.id));
+ return record ?? null;
+ }
+
return {
/**
* Every work app one Bot holds, each with what it is for this person.
@@ -604,6 +808,7 @@ export function appAccessMethods(context: StoreContext) {
agentId: string;
composioConfigured: boolean;
directory?: AppDirectory;
+ sharedAudience?: SharedAudienceCheck;
}): Promise {
const granted = await grantedServerIds(input.agentId);
if (granted.size === 0) return [];
@@ -645,21 +850,36 @@ export function appAccessMethods(context: StoreContext) {
userId: string | null;
composioConfigured: boolean;
directory?: AppDirectory;
+ sharedAudience?: SharedAudienceCheck;
}): Promise {
- const candidate = await candidateFor(input.app, input.directory);
+ const candidate = await candidateFor(
+ input.app,
+ input.directory,
+ await grantedServerIds(input.agentId),
+ );
if (!candidate) return null;
const [described] = await describe([candidate], input);
return described ?? null;
},
- /** Every tool the app's answering row advertises, as grant refs. */
- async appToolRefs(serverId: string): Promise {
+ /**
+ * Every tool the app's answering row advertises, as grant refs, and whether each is a read.
+ *
+ * `read` IS THE EXACT WORD AND NOTHING ELSE, as `classifyTool` reads `effect`: a null or an
+ * unrecognised value is a write, so a tool nobody classified is never handed out as a read.
+ */
+ async appTools(
+ serverId: string,
+ ): Promise<{ ref: string; read: boolean }[]> {
const rows = await database
- .select({ name: mcpTools.name })
+ .select({ name: mcpTools.name, effect: mcpTools.effect })
.from(mcpTools)
.where(eq(mcpTools.serverId, serverId))
.orderBy(asc(mcpTools.name));
- return rows.map((row) => `${serverId}/${row.name}`);
+ return rows.map((row) => ({
+ ref: `${serverId}/${row.name}`,
+ read: row.effect === "read",
+ }));
},
/**
@@ -718,6 +938,76 @@ export function appAccessMethods(context: StoreContext) {
);
},
+ /** How many requests this person has waiting, across every app and Bot. */
+ async countPendingAppAccessRequests(requestedBy: string): Promise {
+ const [row] = await database
+ .select({ count: count() })
+ .from(appAccessRequests)
+ .where(
+ and(
+ eq(appAccessRequests.requestedBy, requestedBy),
+ eq(appAccessRequests.status, "pending"),
+ ),
+ );
+ return row?.count ?? 0;
+ },
+
+ /**
+ * Claim a pending request for an approval about to be carried out: closed as approved, by this
+ * administrator, before anything is changed — or null when somebody else answered it first.
+ *
+ * THE CLAIM COMES FIRST SO NOTHING CAN ANSWER IT UNDERNEATH THE CHANGE. Approving switches an
+ * app on and grants a Bot its tools, and used to close the request only afterwards; a dismiss or
+ * a withdrawal landing in between closed it first, the approve's close found nothing, and the
+ * Bot kept a grant under a request that read dismissed, with no `app_access.approved` in the
+ * trail. Claimed here by the same conditional update every close is, the approval is the answer
+ * the moment it starts, and anybody else closing meanwhile finds it answered and loses with a
+ * 409 — before the change, not after it.
+ *
+ * NOT A ROW LOCK HELD ACROSS THE CHANGE, which is the other way to get this and the wrong one
+ * here. The change is made through the store on connections of its own, and a transaction kept
+ * open around it is the shape `db/client.ts` warns about: every pooled connection inside such a
+ * transaction, each waiting for a second, is a hang under load and an immediate one on a pool
+ * of one.
+ *
+ * An approval that cannot be carried out puts the request back with
+ * {@link releaseAppAccessRequest}, so it waits again with the step that is missing.
+ */
+ async claimAppAccessRequest(input: {
+ id: string;
+ by: string;
+ }): Promise {
+ return closeRequest({ id: input.id, status: "approved", by: input.by });
+ },
+
+ /**
+ * Put back a request {@link claimAppAccessRequest} claimed and the approval then could not carry
+ * out. Only this administrator's claim, and only while it still reads approved.
+ *
+ * A REQUEST FILED AGAIN MEANWHILE IS THE ONE THAT WAITS. The person saw theirs answered, and the
+ * partial unique index lets them file the same request again; two cannot both be pending, so
+ * this one is closed as cancelled instead — the same request, already waiting under the other id.
+ */
+ async releaseAppAccessRequest(input: { id: string; by: string }) {
+ const claimed = and(
+ eq(appAccessRequests.id, input.id),
+ eq(appAccessRequests.status, "approved"),
+ eq(appAccessRequests.resolvedBy, input.by),
+ );
+ try {
+ await database
+ .update(appAccessRequests)
+ .set({ status: "pending", resolvedAt: null, resolvedBy: null })
+ .where(claimed);
+ } catch (error) {
+ if (!isUniqueViolation(error)) throw error;
+ await database
+ .update(appAccessRequests)
+ .set({ status: "cancelled", resolvedAt: new Date() })
+ .where(claimed);
+ }
+ },
+
async appAccessRequest(id: string): Promise {
const [request] = await requestRows(eq(appAccessRequests.id, id));
return request ?? null;
@@ -750,33 +1040,7 @@ export function appAccessMethods(context: StoreContext) {
* lost. `requestedBy`, when given, confines a cancel to the person's own requests in the same
* statement.
*/
- async closeAppAccessRequest(input: {
- id: string;
- status: Exclude;
- by: string;
- requestedBy?: string;
- }): Promise {
- const [closed] = await database
- .update(appAccessRequests)
- .set({
- status: input.status,
- resolvedAt: new Date(),
- resolvedBy: input.by,
- })
- .where(
- and(
- eq(appAccessRequests.id, input.id),
- eq(appAccessRequests.status, "pending"),
- input.requestedBy
- ? eq(appAccessRequests.requestedBy, input.requestedBy)
- : undefined,
- ),
- )
- .returning({ id: appAccessRequests.id });
- if (!closed) return null;
- const [record] = await requestRows(eq(appAccessRequests.id, closed.id));
- return record ?? null;
- },
+ closeAppAccessRequest: closeRequest,
/**
* A row in the trail about a request, written here so the routes write through the same
diff --git a/server/src/plugins/route-groups/app-access.ts b/server/src/plugins/route-groups/app-access.ts
index f1f98c19ac..7ce1ddccad 100644
--- a/server/src/plugins/route-groups/app-access.ts
+++ b/server/src/plugins/route-groups/app-access.ts
@@ -18,6 +18,21 @@ import { sharedGrantApproval } from "./grants";
type RouteContext = Context<{ Variables: AppVariables }>;
+/**
+ * The longest app name or Bot id a request may carry — the same cap the `show_app_access` tool puts
+ * on what a model may ask about. Both are written into the trail when a filing is refused, and an
+ * app's name becomes the title an administrator reads in the inbox, so neither is a field a caller
+ * may fill with whatever they like.
+ */
+const MAX_NAME = 200;
+
+/**
+ * How many requests one person may have waiting at once. A real person asks about the handful of
+ * apps a question needed; past this the inbox is being filled rather than asked, and each extra row
+ * is one an administrator has to answer by hand.
+ */
+export const MAX_PENDING_REQUESTS = 20;
+
/**
* What enabling a catalogue entry would need that only an administrator can type, or null when
* adding its row is the whole of it.
@@ -87,6 +102,13 @@ export function mountAppAccessRoutes({
const directory: AppDirectory | undefined = composio
? () => composio.broker.listApps()
: undefined;
+ /*
+ * What the shared-use gate would answer this person's chat through this Bot, so a Shared app
+ * whose audience was never approved reads as refused rather than ready. Without the store there
+ * is no gate to ask, and the call path refuses every Shared call anyway; the state says `ready`
+ * there exactly as it did before this was asked.
+ */
+ const sharedAudience = shared?.use.admitsPerson;
const refused = async (
context: RouteContext,
@@ -94,7 +116,7 @@ export function mountAppAccessRoutes({
app: string;
reason: string;
error: string;
- status: 400 | 403 | 404 | 409 | 502 | 503;
+ status: 400 | 403 | 404 | 409 | 429 | 502 | 503;
request?: string;
bot?: string | null;
kind?: string;
@@ -131,6 +153,7 @@ export function mountAppAccessRoutes({
agentId,
composioConfigured,
directory,
+ sharedAudience,
}),
});
});
@@ -144,13 +167,17 @@ export function mountAppAccessRoutes({
if (!(await canUseBot(context.var.actor, agentId)))
return context.json({ error: "There is no such Bot." }, 404);
const app = context.req.param("app");
- const inspected = await store.inspectApp({
- app,
- agentId,
- userId: context.var.actor.id,
- composioConfigured,
- directory,
- });
+ const inspected =
+ app.length > MAX_NAME
+ ? null
+ : await store.inspectApp({
+ app,
+ agentId,
+ userId: context.var.actor.id,
+ composioConfigured,
+ directory,
+ sharedAudience,
+ });
if (!inspected)
return context.json(
{ error: `This deployment has no app called ${app}.` },
@@ -165,6 +192,12 @@ export function mountAppAccessRoutes({
* A SECOND PRESS IS THE FIRST REQUEST. One pending request per person, app, Bot and kind, so
* asking again answers with the one already waiting and `created: false`, and writes no second
* trail row.
+ *
+ * ONLY FOR AN APP SOMETHING VOUCHES FOR, AND ONLY SO MANY. Asking to enable a `composio-` id the
+ * directory does not list would put whatever the caller typed in front of an administrator as an
+ * app's name, for an approval that can only fail; it is answered as no such app, unless the Bot
+ * already holds a grant for it and the request is about putting back something it was given. And
+ * a person with {@link MAX_PENDING_REQUESTS} waiting is told to wait for those to be answered.
*/
routes.post("/access-requests", requireUser, async (context) => {
const body = (await context.req.json().catch(() => null)) as {
@@ -176,6 +209,13 @@ export function mountAppAccessRoutes({
const app = typeof body?.app === "string" ? body.app.trim() : "";
if (!agentId || !app)
return context.json({ error: "Name the Bot and the app." }, 400);
+ if (agentId.length > MAX_NAME || app.length > MAX_NAME)
+ return context.json(
+ {
+ error: `A Bot or an app is named in at most ${MAX_NAME} characters.`,
+ },
+ 400,
+ );
if (!(await canUseBot(context.var.actor, agentId)))
return refused(context, {
app,
@@ -184,13 +224,16 @@ export function mountAppAccessRoutes({
error: "There is no such Bot.",
status: 404,
});
- const inspected = await store.inspectApp({
+ const found = await store.inspectApp({
app,
agentId,
userId: context.var.actor.id,
composioConfigured,
directory,
+ sharedAudience,
});
+ const inspected =
+ found && (found.vouched || found.access.granted) ? found : null;
if (!inspected)
return refused(context, {
app,
@@ -211,6 +254,23 @@ export function mountAppAccessRoutes({
: `${access.displayName} is waiting on your own account, not on an administrator. Connect it yourself.`,
status: 409,
});
+ /*
+ * The one already waiting is answered as itself before the cap is counted, so a person at the
+ * cap pressing again on a request they filed is shown it, not refused.
+ */
+ if (
+ !access.request &&
+ (await store.countPendingAppAccessRequests(context.var.actor.id)) >=
+ MAX_PENDING_REQUESTS
+ )
+ return refused(context, {
+ app: access.key,
+ bot: agentId,
+ reason: "too_many_pending",
+ kind: access.requestKind,
+ error: `You already have ${MAX_PENDING_REQUESTS} requests waiting for an administrator. Withdraw one, or wait for them to be answered, then ask again.`,
+ status: 429,
+ });
const filed = await store.fileAppAccessRequest({
requestedBy: context.var.actor.id,
agentId,
@@ -291,30 +351,24 @@ export function mountAppAccessRoutes({
* Approve a request by doing what it asks: switch the app on, grant its tools to the Bot, or —
* for a team account, which only a consent screen can connect — confirm it has been connected.
*
- * THE CHANGE FIRST, THEN THE CONDITIONAL CLOSE. Enabling and granting are both idempotent, so
- * two administrators approving at once both arrive at the same deployment and one of them is
- * told the request was already answered. Closing first would leave an approved request over a
- * change that then failed.
+ * CLAIMED, THEN CHANGED. The request is closed as approved by this administrator before anything
+ * is changed, so a dismiss, a withdrawal or a second approval arriving meanwhile finds it answered
+ * and loses, and the change can never land under a request somebody else answered. A change that
+ * cannot be carried out puts the request back, and it waits again with the step that is missing;
+ * so does one that throws. See `claimAppAccessRequest` in the store, which says why this is not a
+ * row lock held across the change.
*
- * `refs` NARROWS THE GRANT. Absent, the Bot is granted every tool the app advertises — which is
- * what "let this Bot use Gmail" means — and every call is still policy-checked and audited. A
- * ref outside the app is refused rather than dropped.
+ * `refs` NARROWS THE GRANT, AND WITHOUT IT ONLY THE READS ARE GRANTED. One press in the inbox
+ * gives the Bot every action the app has marked read-only — what the Bot page's bulk switch
+ * offers, and nothing that sends, writes or deletes. Anything more is chosen action by action, on
+ * the Bot's page for the app or by naming them here. An app with no read-only action is refused
+ * with that sentence rather than granted everything. A ref outside the app is refused rather than
+ * dropped.
*/
routes.post("/access-requests/:id/approve", requireUser, async (context) => {
const forbidden = requireAdmin(context);
if (forbidden) return forbidden;
const id = context.req.param("id");
- const request = await store.appAccessRequest(id);
- if (request?.status !== "pending")
- return refused(context, {
- app: request?.appKey ?? "",
- request: id,
- reason: request ? "already_resolved" : "no_such_request",
- error: request
- ? "That request has already been answered."
- : "There is no such request.",
- status: request ? 409 : 404,
- });
const body = (await context.req.json().catch(() => null)) as {
refs?: unknown;
} | null;
@@ -326,177 +380,207 @@ export function mountAppAccessRoutes({
return context.json({ error: "Tools must be a list of refs." }, 400);
const chosen = body?.refs as string[] | undefined;
- const deny = (
- reason: string,
- error: string,
- status: 400 | 404 | 409 | 502 | 503,
- ) =>
- refused(context, {
- app: request.appKey,
- request: request.id,
- bot: request.agentId,
- kind: request.kind,
- reason,
- error,
- status,
- });
-
- const inspect = () =>
- store.inspectApp({
- app: request.appKey,
- agentId: request.agentId ?? "",
- userId: null,
- composioConfigured,
- directory,
- });
+ let carriedOut = false;
+ const approve = async (request: AppAccessRequestRecord) => {
+ const deny = (
+ reason: string,
+ error: string,
+ status: 400 | 404 | 409 | 502 | 503,
+ ) =>
+ refused(context, {
+ app: request.appKey,
+ request: request.id,
+ bot: request.agentId,
+ kind: request.kind,
+ reason,
+ error,
+ status,
+ });
- let inspected = await inspect();
- if (!inspected)
- return deny(
- "no_such_app",
- `${request.appTitle} is no longer an app this deployment can find.`,
- 409,
- );
+ const inspect = () =>
+ store.inspectApp({
+ app: request.appKey,
+ agentId: request.agentId ?? "",
+ userId: null,
+ composioConfigured,
+ directory,
+ });
- let enabled = false;
- if (request.kind !== "connect_shared" && inspected.unavailable) {
- const title = inspected.access.displayName;
- if (inspected.unavailable !== "absent")
+ let inspected = await inspect();
+ if (!inspected)
return deny(
- inspected.unavailable,
- unavailableSentence(inspected.unavailable, title),
- inspected.unavailable === "composio_unconfigured" ? 503 : 409,
+ "no_such_app",
+ `${request.appTitle} is no longer an app this deployment can find.`,
+ 409,
);
- if (inspected.toolkit) {
- if (!composio)
+
+ let enabled = false;
+ if (request.kind !== "connect_shared" && inspected.unavailable) {
+ const title = inspected.access.displayName;
+ if (inspected.unavailable !== "absent")
return deny(
- "composio_unconfigured",
- unavailableSentence("composio_unconfigured", title),
- 503,
+ inspected.unavailable,
+ unavailableSentence(inspected.unavailable, title),
+ inspected.unavailable === "composio_unconfigured" ? 503 : 409,
);
- const outcome = await enableComposioApp(
- { store, composio },
- inspected.toolkit,
- actorEmail(context),
- );
- if (!outcome.ok)
- return deny("enable_failed", outcome.error, outcome.status);
- } else if (inspected.entry) {
- const needed = adminInputFor(inspected.entry);
- if (needed)
+ if (inspected.toolkit) {
+ if (!composio)
+ return deny(
+ "composio_unconfigured",
+ unavailableSentence("composio_unconfigured", title),
+ 503,
+ );
+ const outcome = await enableComposioApp(
+ { store, composio },
+ inspected.toolkit,
+ actorEmail(context),
+ );
+ if (!outcome.ok)
+ return deny("enable_failed", outcome.error, outcome.status);
+ } else if (inspected.entry) {
+ const needed = adminInputFor(inspected.entry);
+ if (needed)
+ return deny(
+ "needs_admin_input",
+ `${title} needs ${needed}, which only an administrator can supply. Add it on its page under Plugins, then approve this request again.`,
+ 409,
+ );
+ try {
+ await store.addServer({
+ key: inspected.entry.key,
+ by: actorEmail(context),
+ });
+ } catch (error) {
+ if (
+ error instanceof CatalogueEntryUnknownError ||
+ error instanceof CustomServerRefusedError
+ )
+ return deny("enable_failed", error.message, 400);
+ if (isDeploymentFault(error))
+ return deny("enable_failed", deploymentFaultSentence(error), 409);
+ throw error;
+ }
+ } else {
+ return deny("absent", unavailableSentence("absent", title), 409);
+ }
+ enabled = true;
+ inspected = await inspect();
+ if (!inspected || inspected.unavailable)
return deny(
- "needs_admin_input",
- `${title} needs ${needed}, which only an administrator can supply. Add it on its page under Plugins, then approve this request again.`,
+ "enable_failed",
+ `${title} was switched on and still cannot be used. Open its page under Plugins to see why.`,
409,
);
- try {
- await store.addServer({
- key: inspected.entry.key,
- by: actorEmail(context),
- });
- } catch (error) {
- if (
- error instanceof CatalogueEntryUnknownError ||
- error instanceof CustomServerRefusedError
- )
- return deny("enable_failed", error.message, 400);
- if (isDeploymentFault(error))
- return deny("enable_failed", deploymentFaultSentence(error), 409);
- throw error;
- }
- } else {
- return deny("absent", unavailableSentence("absent", title), 409);
}
- enabled = true;
- inspected = await inspect();
- if (!inspected || inspected.unavailable)
- return deny(
- "enable_failed",
- `${title} was switched on and still cannot be used. Open its page under Plugins to see why.`,
- 409,
- );
- }
- let granted: string[] = [];
- if (
- request.kind !== "connect_shared" &&
- request.agentId &&
- !inspected.access.granted
- ) {
- const agentId = request.agentId;
- const serverId = inspected.answeringId;
- if (!(await store.agentIsRegistered(agentId)) || !serverId)
- return deny("no_such_bot", "That Bot is no longer here.", 409);
- const advertised = await store.appToolRefs(serverId);
- const refs = chosen ?? advertised;
- const outside = refs.filter((ref) => !advertised.includes(ref));
- if (outside.length > 0)
- return deny(
- "refs_outside_app",
- `${outside.join(", ")} ${outside.length === 1 ? "is" : "are"} not among ${inspected.access.displayName}'s tools.`,
- 400,
+ let granted: string[] = [];
+ if (
+ request.kind !== "connect_shared" &&
+ request.agentId &&
+ !inspected.access.granted
+ ) {
+ const agentId = request.agentId;
+ const serverId = inspected.answeringId;
+ const name = inspected.access.displayName;
+ if (!(await store.agentIsRegistered(agentId)) || !serverId)
+ return deny("no_such_bot", "That Bot is no longer here.", 409);
+ const tools = await store.appTools(serverId);
+ const advertised = tools.map((tool) => tool.ref);
+ const refs =
+ chosen ?? tools.filter((tool) => tool.read).map((tool) => tool.ref);
+ const outside = refs.filter((ref) => !advertised.includes(ref));
+ if (outside.length > 0)
+ return deny(
+ "refs_outside_app",
+ `${outside.join(", ")} ${outside.length === 1 ? "is" : "are"} not among ${name}'s tools.`,
+ 400,
+ );
+ if (advertised.length === 0)
+ return deny(
+ "no_tools",
+ `${name} advertises no tools to grant yet. Refresh its tools on its page under Plugins, then approve this request again.`,
+ 409,
+ );
+ if (refs.length === 0)
+ return deny(
+ "no_read_only_tools",
+ `None of ${name}'s actions is marked read-only, so approving here would grant nothing. Choose which actions this Bot may use on ${name}'s page for it under Plugins, then approve this request again.`,
+ 409,
+ );
+ for (const ref of refs) {
+ await store.grant("mcp", ref, agentId, actorEmail(context));
+ }
+ const sharedDecision = await sharedGrantApproval(
+ { store, shared },
+ refs[0] ?? "",
+ agentId,
+ undefined,
);
- if (refs.length === 0)
+ if (
+ sharedDecision !== "unreadable" &&
+ sharedDecision.approval &&
+ shared
+ )
+ await shared.use.setApproval({
+ botId: agentId,
+ serverId: sharedDecision.appId,
+ approval: sharedDecision.approval,
+ by: context.var.actor.id,
+ });
+ granted = refs;
+ }
+
+ if (
+ request.kind === "connect_shared" &&
+ inspected.access.account === "shared" &&
+ !inspected.sharedUsable
+ )
return deny(
- "no_tools",
- `${inspected.access.displayName} advertises no tools to grant yet. Refresh its tools on its page under Plugins, then approve this request again.`,
+ "shared_not_connected",
+ `The shared ${inspected.access.displayName} account is not connected yet. Connect it on ${inspected.access.displayName}'s page under Plugins, then approve this request.`,
409,
);
- for (const ref of refs) {
- await store.grant("mcp", ref, agentId, actorEmail(context));
- }
- const decided = await sharedGrantApproval(
- { store, shared },
- refs[0] ?? "",
- agentId,
- undefined,
- );
- if (decided !== "unreadable" && decided.approval && shared)
- await shared.use.setApproval({
- botId: agentId,
- serverId: decided.appId,
- approval: decided.approval,
- by: context.var.actor.id,
- });
- granted = refs;
- }
- if (
- request.kind === "connect_shared" &&
- inspected.access.account === "shared" &&
- !inspected.sharedUsable
- )
- return deny(
- "shared_not_connected",
- `The shared ${inspected.access.displayName} account is not connected yet. Connect it on ${inspected.access.displayName}'s page under Plugins, then approve this request.`,
- 409,
- );
+ carriedOut = true;
+ const closed = request;
+ await store.recordAppAccessEvent({
+ eventType: "app_access.approved",
+ targetId: closed.appKey,
+ actorUserId: context.var.actor.id,
+ payload: {
+ request: closed.id,
+ app: closed.appKey,
+ bot: closed.agentId,
+ kind: closed.kind,
+ enabled,
+ granted: granted.length,
+ },
+ });
+ return context.json({ request: closed, enabled, granted });
+ };
- const closed = await store.closeAppAccessRequest({
- id: request.id,
- status: "approved",
+ const claimed = await store.claimAppAccessRequest({
+ id,
by: context.var.actor.id,
});
- if (!closed)
- return deny(
- "already_resolved",
- "That request has already been answered.",
- 409,
- );
- await store.recordAppAccessEvent({
- eventType: "app_access.approved",
- targetId: closed.appKey,
- actorUserId: context.var.actor.id,
- payload: {
- request: closed.id,
- app: closed.appKey,
- bot: closed.agentId,
- kind: closed.kind,
- enabled,
- granted: granted.length,
- },
- });
- return context.json({ request: closed, enabled, granted });
+ if (!claimed) {
+ const existing = await store.appAccessRequest(id);
+ return refused(context, {
+ app: existing?.appKey ?? "",
+ request: id,
+ reason: existing ? "already_resolved" : "no_such_request",
+ error: existing
+ ? "That request has already been answered."
+ : "There is no such request.",
+ status: existing ? 409 : 404,
+ });
+ }
+ try {
+ return await approve(claimed);
+ } finally {
+ if (!carriedOut)
+ await store.releaseAppAccessRequest({ id, by: context.var.actor.id });
+ }
});
/** Dismiss a request: nothing changes but the request, which stops waiting. */
diff --git a/server/src/plugins/shared-use-store.ts b/server/src/plugins/shared-use-store.ts
index c63c345ad3..1472423b34 100644
--- a/server/src/plugins/shared-use-store.ts
+++ b/server/src/plugins/shared-use-store.ts
@@ -650,9 +650,34 @@ export function createSharedUseStore(database: Database) {
return row;
}
+ /**
+ * Whether the gate would let this person's own chat with this Bot reach the app's team account —
+ * the same approval, owner and audience test the gate makes for a person-started run, and nothing
+ * it does after refusing. For a screen saying what a call would meet; the gate still decides
+ * every call.
+ */
+ async function admitsPerson(input: {
+ botId: string;
+ serverId: string;
+ actorId: string;
+ }): Promise {
+ const approval = await approvalFor(input.botId, input.serverId);
+ if (!approval) return false;
+ const facts = await botFacts(input.botId);
+ return admits(
+ approval,
+ facts.ownerUserId,
+ await actorFacts(input.actorId),
+ {
+ outside: false,
+ },
+ );
+ }
+
return {
appIdOf,
approvalFor,
+ admitsPerson,
setApproval,
deleteApprovalsFor,
botFacts,
diff --git a/server/tests/app-access-routes.integration.test.ts b/server/tests/app-access-routes.integration.test.ts
index eeb0e17a4b..9fbc9f313c 100644
--- a/server/tests/app-access-routes.integration.test.ts
+++ b/server/tests/app-access-routes.integration.test.ts
@@ -24,12 +24,16 @@ import {
mcpUserCredentials,
pluginGrants,
sharedUseApprovals,
+ teamBotConsents,
+ teamBotPublications,
users,
} from "../src/db/schema";
import type { AppAccess } from "../src/plugins/app-access";
import type { BrokerApp, ConnectedAppBroker } from "../src/plugins/broker";
import type { AppAccessRequestRecord } from "../src/plugins/plugin-store/app-access";
+import { MAX_PENDING_REQUESTS } from "../src/plugins/route-groups/app-access";
import { createPluginRoutes } from "../src/plugins/routes";
+import { exposureOf } from "../src/plugins/shared-use";
import { createSharedUseStore } from "../src/plugins/shared-use-store";
import {
createPluginStore,
@@ -115,9 +119,15 @@ const broker: ConnectedAppBroker = {
const enabled: string[] = [];
-function storeFor(): PluginStore {
+/**
+ * Run before every grant the routes make, when set — how a test holds an approval partway through
+ * its change while something else answers the same request.
+ */
+let beforeGrant: (() => Promise) | null = null;
+
+function storeFor(db = database): PluginStore {
const real = createPluginStore({
- database,
+ database: db,
auditStore: audit,
credentials: {} as never,
encryptionKey: "x".repeat(44),
@@ -127,6 +137,10 @@ function storeFor(): PluginStore {
});
return {
...real,
+ async grant(...args) {
+ await beforeGrant?.();
+ return real.grant(...args);
+ },
async addBrokeredApp(input) {
enabled.push(input.slug);
await database.insert(mcpServers).values({
@@ -143,6 +157,7 @@ function storeFor(): PluginStore {
name: "LIST_FILES",
description: "List files",
inputSchema: {},
+ effect: "read",
});
return { id: `composio-${input.slug}` } as ServerRecord;
},
@@ -152,9 +167,9 @@ function storeFor(): PluginStore {
function appAs(
actorId: string,
role: "admin" | "user" = "user",
- options: { composio?: boolean } = {},
+ options: { composio?: boolean; database?: typeof database } = {},
) {
- const store = storeFor();
+ const store = storeFor(options.database);
const signedIn: MiddlewareHandler<{ Variables: AppVariables }> = async (
context,
next,
@@ -166,7 +181,7 @@ function appAs(
} as never);
await next();
};
- const sharedUse = createSharedUseStore(database);
+ const sharedUse = createSharedUseStore(options.database ?? database);
return new Hono().route(
"/api/plugins",
createPluginRoutes(
@@ -198,6 +213,9 @@ async function seedApp(input: {
authScheme?: string | null;
accountMode?: "personal" | "shared" | null;
tools?: string[];
+ /** Each tool's `effect`, by name; a tool left out is unclassified, which reads as a write. */
+ effects?: Record;
+ destructive?: string[];
grant?: boolean;
}) {
await database.insert(mcpServers).values({
@@ -218,6 +236,8 @@ async function seedApp(input: {
name: tool,
description: tool,
inputSchema: {},
+ effect: input.effects?.[tool] ?? null,
+ destructive: input.destructive?.includes(tool) ?? false,
});
if (input.grant !== false)
await database.insert(pluginGrants).values({
@@ -229,7 +249,34 @@ async function seedApp(input: {
const ourServers = like(mcpServers.id, `%${suite}%`);
+/** An administrator's approval of the suite Bot's audience for a Shared app, as granting writes. */
+async function approveAudience(serverId: string) {
+ const sharedUse = createSharedUseStore(database);
+ await sharedUse.setApproval({
+ botId: bot,
+ serverId,
+ approval: exposureOf(await sharedUse.botFacts(bot)),
+ by: admin,
+ });
+}
+
+async function connectTeamAccount(name: string) {
+ await database.insert(brokeredConnections).values({
+ provider: "composio",
+ app: slug(name),
+ holder: "deployment",
+ vendorUserId: `openbot-deployment:${suite}`,
+ verified: true,
+ });
+}
+
async function clearApps() {
+ await database
+ .delete(teamBotPublications)
+ .where(inArray(teamBotPublications.agentId, [bot, hidden]));
+ await database
+ .delete(teamBotConsents)
+ .where(inArray(teamBotConsents.userId, [person, other, admin]));
await database
.delete(appAccessRequests)
.where(inArray(appAccessRequests.requestedBy, [person, other, admin]));
@@ -281,6 +328,7 @@ beforeAll(async () => {
beforeEach(async () => {
events.length = 0;
enabled.length = 0;
+ beforeGrant = null;
await clearApps();
});
@@ -385,8 +433,9 @@ describe("what each app a Bot holds is, for the person asking", () => {
expect(theirs.apps[0]?.connectedAt).toBe("2026-10-20T12:00:00.000Z");
});
- test("a Shared app with its team account connected is ready for everybody", async () => {
+ test("a Shared app with its team account connected and its audience approved is ready", async () => {
await seedApp({ key: SLACK, title: "Slack", accountMode: "shared" });
+ await approveAudience(SLACK);
await database.insert(brokeredConnections).values({
provider: "composio",
app: slug("slack"),
@@ -725,6 +774,7 @@ describe("answering a request", () => {
title: "Slack",
accountMode: "shared",
tools: ["SEND", "LIST"],
+ effects: { LIST: "read", SEND: "write" },
grant: false,
});
const request = await file(SLACK);
@@ -744,19 +794,17 @@ describe("answering a request", () => {
{ method: "POST" },
);
expect(response.status).toBe(200);
+ // The read only: sending is chosen on the Bot's page, never handed out by one press.
expect(await response.json()).toMatchObject({
request: { status: "approved", resolvedBy: admin },
enabled: false,
- granted: [`${SLACK}/LIST`, `${SLACK}/SEND`],
+ granted: [`${SLACK}/LIST`],
});
const grants = await database
.select({ ref: pluginGrants.ref })
.from(pluginGrants)
.where(eq(pluginGrants.agentId, bot));
- expect(grants.map((grant) => grant.ref).sort()).toEqual([
- `${SLACK}/LIST`,
- `${SLACK}/SEND`,
- ]);
+ expect(grants.map((grant) => grant.ref).sort()).toEqual([`${SLACK}/LIST`]);
const [approval] = await database
.select()
.from(sharedUseApprovals)
@@ -765,7 +813,7 @@ describe("answering a request", () => {
expect(eventsOf("app_access.approved")[0]?.payload).toMatchObject({
request: request.id,
kind: "grant",
- granted: 2,
+ granted: 1,
});
// The person now sees the next step, which is not theirs to ask for.
@@ -873,6 +921,7 @@ describe("answering a request", () => {
test("a team-account request is approved only once the account is connected", async () => {
await seedApp({ key: SLACK, title: "Slack", accountMode: "shared" });
+ await approveAudience(SLACK);
const request = await file(SLACK);
expect(request.kind).toBe("connect_shared");
const early = await appAs(admin, "admin").request(
@@ -921,3 +970,396 @@ describe("answering a request", () => {
expect(again.status).toBe(409);
});
});
+
+describe("what approving grants, and when it can be answered", () => {
+ async function file(app: string, as = person) {
+ const response = await appAs(as).request(
+ "/api/plugins/access-requests",
+ post({ agentId: bot, app }),
+ );
+ return (await json<{ request: AppAccessRequestRecord }>(response)).request;
+ }
+ const statusOf = async (id: string) =>
+ (
+ await database
+ .select({ status: appAccessRequests.status })
+ .from(appAccessRequests)
+ .where(eq(appAccessRequests.id, id))
+ )[0]?.status;
+
+ test("without refs only the read-only actions are granted, never a send or a delete", async () => {
+ await seedApp({
+ key: GMAIL,
+ title: "Gmail",
+ tools: ["READ_MAIL", "SEND_MAIL", "DELETE_ALL", "UNLABELLED"],
+ effects: { READ_MAIL: "read", SEND_MAIL: "write", DELETE_ALL: "write" },
+ destructive: ["DELETE_ALL"],
+ grant: false,
+ });
+ const request = await file("Gmail");
+ expect(request.kind).toBe("grant");
+ const response = await appAs(admin, "admin").request(
+ `/api/plugins/access-requests/${request.id}/approve`,
+ { method: "POST" },
+ );
+ expect(response.status).toBe(200);
+ expect((await json<{ granted: string[] }>(response)).granted).toEqual([
+ `${GMAIL}/READ_MAIL`,
+ ]);
+ const grants = await database
+ .select({ ref: pluginGrants.ref })
+ .from(pluginGrants)
+ .where(eq(pluginGrants.agentId, bot));
+ expect(grants.map((grant) => grant.ref)).toEqual([`${GMAIL}/READ_MAIL`]);
+ });
+
+ test("an app with no read-only action is refused with where to choose, and stays pending", async () => {
+ await seedApp({
+ key: GMAIL,
+ title: "Gmail",
+ tools: ["SEND_MAIL"],
+ effects: { SEND_MAIL: "write" },
+ grant: false,
+ });
+ const request = await file("Gmail");
+ const response = await appAs(admin, "admin").request(
+ `/api/plugins/access-requests/${request.id}/approve`,
+ { method: "POST" },
+ );
+ expect(response.status).toBe(409);
+ expect((await json<{ error: string }>(response)).error).toContain(
+ "read-only",
+ );
+ expect(await statusOf(request.id)).toBe("pending");
+ expect(
+ await database
+ .select()
+ .from(pluginGrants)
+ .where(eq(pluginGrants.agentId, bot)),
+ ).toEqual([]);
+ // Naming the actions is how an administrator grants more than the reads.
+ const chosen = await appAs(admin, "admin").request(
+ `/api/plugins/access-requests/${request.id}/approve`,
+ post({ refs: [`${GMAIL}/SEND_MAIL`] }),
+ );
+ expect(chosen.status).toBe(200);
+ });
+
+ for (const [loser, answer] of [
+ [
+ "a dismissal",
+ (id: string) =>
+ appAs(admin, "admin").request(
+ `/api/plugins/access-requests/${id}/dismiss`,
+ { method: "POST" },
+ ),
+ ],
+ [
+ "a withdrawal",
+ (id: string) =>
+ appAs(person).request(`/api/plugins/access-requests/${id}`, {
+ method: "DELETE",
+ }),
+ ],
+ ] as const) {
+ test(`${loser} arriving while an approval is granting loses to it`, async () => {
+ await seedApp({
+ key: CALENDAR,
+ title: "Google Calendar",
+ tools: ["READ"],
+ effects: { READ: "read" },
+ grant: false,
+ });
+ const request = await file(CALENDAR);
+
+ let release = () => {};
+ let granting = () => {};
+ const held = new Promise((resolve) => {
+ release = resolve;
+ });
+ const reached = new Promise((resolve) => {
+ granting = resolve;
+ });
+ beforeGrant = async () => {
+ granting();
+ await held;
+ };
+ try {
+ const approving = appAs(admin, "admin").request(
+ `/api/plugins/access-requests/${request.id}/approve`,
+ { method: "POST" },
+ );
+ await reached;
+ // Answered while the grant is still being written: the approval already claimed it.
+ const lost = await answer(request.id);
+ expect(lost.status).toBe(409);
+ release();
+ const approved = await approving;
+ expect(approved.status).toBe(200);
+ expect(await statusOf(request.id)).toBe("approved");
+ expect(eventsOf("app_access.approved")).toHaveLength(1);
+ expect(eventsOf("app_access.dismissed")).toHaveLength(0);
+ expect(eventsOf("app_access.cancelled")).toHaveLength(0);
+ } finally {
+ release();
+ beforeGrant = null;
+ }
+ });
+ }
+
+ test("an approval that throws partway puts the request back", async () => {
+ await seedApp({
+ key: CALENDAR,
+ title: "Google Calendar",
+ tools: ["READ"],
+ effects: { READ: "read" },
+ grant: false,
+ });
+ const request = await file(CALENDAR);
+ beforeGrant = async () => {
+ throw new Error("the grant failed");
+ };
+ const response = await appAs(admin, "admin").request(
+ `/api/plugins/access-requests/${request.id}/approve`,
+ { method: "POST" },
+ );
+ expect(response.status).toBe(500);
+ expect(await statusOf(request.id)).toBe("pending");
+ expect(eventsOf("app_access.approved")).toHaveLength(0);
+ });
+
+ test("a request filed again while the first was claimed is the one left waiting", async () => {
+ await seedApp({
+ key: GMAIL,
+ title: "Gmail",
+ tools: ["SEND_MAIL"],
+ effects: { SEND_MAIL: "write" },
+ grant: false,
+ });
+ const first = await file("Gmail");
+ const store = storeFor();
+ expect(
+ await store.claimAppAccessRequest({ id: first.id, by: admin }),
+ ).not.toBeNull();
+ const second = await file("Gmail");
+ expect(second.id).not.toBe(first.id);
+ await store.releaseAppAccessRequest({ id: first.id, by: admin });
+ expect(await statusOf(first.id)).toBe("cancelled");
+ expect(await statusOf(second.id)).toBe("pending");
+ });
+});
+
+describe("what a request may carry", () => {
+ const ask = (app: string, agentId = bot) =>
+ appAs(person).request(
+ "/api/plugins/access-requests",
+ post({ agentId, app }),
+ );
+
+ test("an app or Bot name past the cap is refused before anything is read or written", async () => {
+ const long = "x".repeat(201);
+ expect((await ask(`composio-${long}`)).status).toBe(400);
+ expect((await ask(GMAIL, long)).status).toBe(400);
+ expect(eventsOf("app_access.refused")).toEqual([]);
+ expect(
+ (await appAs(person).request(`/api/plugins/for/${bot}/apps/${long}`))
+ .status,
+ ).toBe(404);
+ });
+
+ test("enabling a composio- id the directory does not list is no such app", async () => {
+ const response = await ask(row("invented"));
+ expect(response.status).toBe(404);
+ const filed = await database
+ .select()
+ .from(appAccessRequests)
+ .where(eq(appAccessRequests.requestedBy, person));
+ expect(filed).toEqual([]);
+ });
+
+ test("a grant that outlived its app can still be asked about", async () => {
+ await database
+ .insert(pluginGrants)
+ .values({ kind: "mcp", ref: `${GONE}/SEND`, agentId: bot });
+ const response = await appAs(person, "user", { composio: false }).request(
+ "/api/plugins/access-requests",
+ post({ agentId: bot, app: GONE }),
+ );
+ expect(response.status).toBe(201);
+ });
+
+ test(`at ${MAX_PENDING_REQUESTS} waiting a new one is refused, and one already waiting is still shown`, async () => {
+ await database.insert(appAccessRequests).values(
+ Array.from({ length: MAX_PENDING_REQUESTS - 1 }, (_, index) => ({
+ id: randomUUID(),
+ requestedBy: person,
+ agentId: bot,
+ appKey: `composio-filler${index}_${suite}`,
+ appTitle: `Filler ${index}`,
+ kind: "enable" as const,
+ })),
+ );
+ await seedApp({ key: CALENDAR, title: "Google Calendar", grant: false });
+ const last = await ask(CALENDAR);
+ expect(last.status).toBe(201);
+ const over = await ask("Dropbox");
+ expect(over.status).toBe(429);
+ expect(eventsOf("app_access.refused").at(-1)?.payload).toMatchObject({
+ reason: "too_many_pending",
+ });
+ const again = await ask(CALENDAR);
+ expect(again.status).toBe(200);
+ expect((await json<{ created: boolean }>(again)).created).toBe(false);
+ });
+});
+
+describe("finding an app by its name", () => {
+ test("a name finds the enabled app before a catalogue entry nobody added", async () => {
+ const DRIVE = row("googledrive");
+ await seedApp({ key: DRIVE, title: "Google Drive", grant: false });
+ const { app } = await json<{ app: AppAccess }>(
+ await appAs(person).request(
+ `/api/plugins/for/${bot}/apps/Google%20Drive`,
+ ),
+ );
+ expect(app).toMatchObject({
+ key: DRIVE,
+ state: "not_granted",
+ requestKind: "grant",
+ });
+ // The entry is still what its own key names.
+ const { app: entry } = await json<{ app: AppAccess }>(
+ await appAs(person).request(`/api/plugins/for/${bot}/apps/google-drive`),
+ );
+ expect(entry).toMatchObject({ key: "google-drive", state: "disabled" });
+ });
+
+ test("of two enabled apps with one name, the one this Bot holds", async () => {
+ await seedApp({ key: row("drive_a"), title: "Drive", grant: false });
+ await seedApp({ key: row("drive_b"), title: "Drive" });
+ const { app } = await json<{ app: AppAccess }>(
+ await appAs(person).request(`/api/plugins/for/${bot}/apps/Drive`),
+ );
+ expect(app.key).toBe(row("drive_b"));
+ });
+});
+
+describe("why an app is off, and a Shared app's audience", () => {
+ test("a switched-off app says why", async () => {
+ await seedApp({ key: GMAIL, title: "Gmail" });
+ const { app } = await json<{ app: AppAccess }>(
+ await appAs(person, "user", { composio: false }).request(
+ `/api/plugins/for/${bot}/apps/${GMAIL}`,
+ ),
+ );
+ expect(app).toMatchObject({
+ state: "disabled",
+ unavailable: "composio_unconfigured",
+ });
+ const { app: ready } = await json<{ app: AppAccess }>(
+ await appAs(person).request(`/api/plugins/for/${bot}/apps/${GMAIL}`),
+ );
+ expect(ready.unavailable).toBeNull();
+ });
+
+ test("a Shared app whose audience nobody approved is refused, with nothing to file", async () => {
+ await seedApp({ key: SLACK, title: "Slack", accountMode: "shared" });
+ await connectTeamAccount("slack");
+ const { app } = await json<{ app: AppAccess }>(
+ await appAs(person).request(`/api/plugins/for/${bot}/apps/${SLACK}`),
+ );
+ expect(app).toMatchObject({
+ state: "shared_not_approved",
+ requestKind: null,
+ });
+ const filed = await appAs(person).request(
+ "/api/plugins/access-requests",
+ post({ agentId: bot, app: SLACK }),
+ );
+ expect(filed.status).toBe(409);
+ await approveAudience(SLACK);
+ const { app: approved } = await json<{ app: AppAccess }>(
+ await appAs(person).request(`/api/plugins/for/${bot}/apps/${SLACK}`),
+ );
+ expect(approved.state).toBe("ready");
+ });
+});
+
+describe("a teammate on somebody else's Team Bot", () => {
+ beforeEach(async () => {
+ await database
+ .insert(teamBotPublications)
+ .values({ agentId: bot, publishedBy: person, audience: "team" });
+ await seedApp({ key: GMAIL, title: "Gmail" });
+ });
+ const connect = (
+ userId: string,
+ over: { verified?: boolean; probeAction?: string } = {},
+ ) =>
+ database.insert(brokeredConnections).values({
+ provider: "composio",
+ app: slug("gmail"),
+ holder: "person",
+ userId,
+ vendorUserId: userId,
+ verified: over.verified ?? true,
+ ...(over.probeAction ? { probeAction: over.probeAction } : {}),
+ });
+ const asTeammate = async () =>
+ (
+ await json<{ app: AppAccess }>(
+ await appAs(other).request(`/api/plugins/for/${bot}/apps/${GMAIL}`),
+ )
+ ).app;
+
+ test("the owner's connection is the one their calls use", async () => {
+ await connect(person);
+ expect(await asTeammate()).toMatchObject({
+ state: "ready",
+ accountHolder: "owner",
+ asksFirst: false,
+ });
+ });
+
+ test("the owner's refused connection is theirs to reconnect, not the teammate's", async () => {
+ await connect(person, { verified: false, probeAction: "GMAIL_LIST" });
+ await connect(other);
+ expect(await asTeammate()).toMatchObject({
+ state: "reconnect",
+ accountHolder: "owner",
+ requestKind: null,
+ });
+ });
+
+ test("with no owner account, the teammate's own, asked first until they always allow it", async () => {
+ expect(await asTeammate()).toMatchObject({
+ state: "not_connected",
+ accountHolder: "you",
+ });
+ await connect(other);
+ expect(await asTeammate()).toMatchObject({
+ state: "ready",
+ accountHolder: "you",
+ asksFirst: true,
+ });
+ await database.insert(teamBotConsents).values({
+ userId: other,
+ agentId: bot,
+ serverId: GMAIL,
+ decision: "always",
+ });
+ expect((await asTeammate()).asksFirst).toBe(false);
+ });
+
+ test("the owner reads their own account, and is never asked", async () => {
+ await connect(person);
+ const { app } = await json<{ app: AppAccess }>(
+ await appAs(person).request(`/api/plugins/for/${bot}/apps/${GMAIL}`),
+ );
+ expect(app).toMatchObject({
+ state: "ready",
+ accountHolder: "you",
+ asksFirst: false,
+ });
+ });
+});
diff --git a/server/tests/app-access-state.test.ts b/server/tests/app-access-state.test.ts
index e71adfacd0..e2e4428b7a 100644
--- a/server/tests/app-access-state.test.ts
+++ b/server/tests/app-access-state.test.ts
@@ -29,6 +29,7 @@ const composio = (overrides: Partial = {}): AppAccessFacts => ({
granted: true,
personal: null,
shared: null,
+ sharedAdmitted: null,
...overrides,
});
@@ -52,6 +53,7 @@ const catalogue = (
granted: true,
personal: null,
shared: null,
+ sharedAdmitted: null,
...overrides,
});
@@ -107,6 +109,21 @@ describe("a Shared Composio app", () => {
"shared_not_connected",
);
expect(appAccessState(shared({ shared: { usable: true } }))).toBe("ready");
+ // Connected, and the gate would refuse this person's calls through this Bot.
+ expect(
+ appAccessState(
+ shared({ shared: { usable: true }, sharedAdmitted: false }),
+ ),
+ ).toBe("shared_not_approved");
+ expect(
+ appAccessState(
+ shared({ shared: { usable: true }, sharedAdmitted: true }),
+ ),
+ ).toBe("ready");
+ // The audience matters only once there is an account to reach.
+ expect(appAccessState(shared({ sharedAdmitted: false }))).toBe(
+ "shared_not_connected",
+ );
expect(accountOf(shared({}))).toBe("shared");
});
});
@@ -171,6 +188,8 @@ test("only the three states an administrator can fix file a request", () => {
not_connected: null,
reconnect: null,
shared_not_connected: "connect_shared",
+ // The refused call files the administrator's request itself.
+ shared_not_approved: null,
disabled: "enable",
not_granted: "grant",
});
From 505551e86e40724a947c769e6a68dcfb029e1f0b Mon Sep 17 00:00:00 2001
From: Guido Vizoso
Date: Wed, 21 Oct 2026 16:07:44 -0300
Subject: [PATCH 24/33] Forget a Composio account once a call finds the vendor
no longer holds it
---
server/src/plugins/plugin-store/calls.ts | 30 ++++++++++++-
.../shared-brokered-store.integration.test.ts | 42 +++++++++++++++----
shared/connect-prompt.ts | 6 +--
3 files changed, 67 insertions(+), 11 deletions(-)
diff --git a/server/src/plugins/plugin-store/calls.ts b/server/src/plugins/plugin-store/calls.ts
index 12f76655cc..19f7442795 100644
--- a/server/src/plugins/plugin-store/calls.ts
+++ b/server/src/plugins/plugin-store/calls.ts
@@ -8,7 +8,7 @@ import {
policyInitiator,
} from "../../computer/policy";
import { withoutStatement } from "../../db/query-failure";
-import { mcpTools, pluginGrants } from "../../db/schema";
+import { brokeredConnections, mcpTools, pluginGrants } from "../../db/schema";
import { classifyTool, tokenHeaderFor } from "../catalogue";
import { VERSION_ARG } from "../composio";
import { inspectToolArguments } from "../content-governance";
@@ -560,7 +560,35 @@ export function callMethods(context: StoreContext) {
* deployment's rows knows the account is gone — the app reads as ready on every screen — and
* the card is the one place the person is told, with Reconnect beside it. A Shared app's
* account is the deployment's, so there the card says an administrator has to reconnect it.
+ *
+ * AND THE ROW GOES, as it does when a confirm hears the same no from the vendor: the row is
+ * only the vendor's last answer, and one that outlived the account would go on reading
+ * `ready` on every screen — `show_app_access` telling the model to use the app straight after
+ * this card — and would make the connect route refuse a fresh connection until somebody
+ * found Disconnect. Deleted, the app reads not connected for the person, or the team account
+ * not connected for everybody, which is the truth and the state whose button fixes it. Like
+ * the confirm's, the delete files no trail entry: nobody disconnected anything here.
*/
+ if (access.credential === "brokered" && access.toolkit)
+ await database
+ .delete(brokeredConnections)
+ .where(
+ gatedMode === "shared"
+ ? and(
+ eq(brokeredConnections.provider, "composio"),
+ eq(brokeredConnections.app, access.toolkit),
+ eq(brokeredConnections.holder, "deployment"),
+ )
+ : and(
+ eq(brokeredConnections.provider, "composio"),
+ eq(brokeredConnections.app, access.toolkit),
+ eq(brokeredConnections.holder, "person"),
+ eq(
+ brokeredConnections.userId,
+ input.credentialActorId ?? input.actorId,
+ ),
+ ),
+ );
throw new PluginRefusedError(
connectAppRefusal({
serverId: row.id,
diff --git a/server/tests/shared-brokered-store.integration.test.ts b/server/tests/shared-brokered-store.integration.test.ts
index 1abeb1fc92..e2600e8397 100644
--- a/server/tests/shared-brokered-store.integration.test.ts
+++ b/server/tests/shared-brokered-store.integration.test.ts
@@ -568,6 +568,20 @@ describe("an account the vendor no longer holds", () => {
},
});
+ /** Who still holds a row at the app, by holder. */
+ const rowsFor = async (holder: "person" | "deployment") =>
+ (
+ await database
+ .select({ userId: brokeredConnections.userId })
+ .from(brokeredConnections)
+ .where(
+ and(
+ eq(brokeredConnections.app, app),
+ eq(brokeredConnections.holder, holder),
+ ),
+ )
+ ).map((row) => row.userId);
+
/* Counted before and after: the trail is not cleaned between tests, which share the app id. */
const failures = async () =>
(
@@ -605,6 +619,8 @@ describe("an account the vendor no longer holds", () => {
);
expect(Date.parse(prompt?.at ?? "")).toBeGreaterThanOrEqual(before - 1000);
expect(await failures()).toBe(already + 1);
+ // The team account's row is gone with the account, so every screen reads it as not connected.
+ expect(await rowsFor("deployment")).toEqual([]);
});
test("on a personal app: refused with a card offering Reconnect", async () => {
@@ -614,13 +630,23 @@ describe("an account the vendor no longer holds", () => {
.update(mcpServers)
.set({ accountMode: "personal", sharedVendorUserId: null })
.where(eq(mcpServers.id, app));
- await database.insert(brokeredConnections).values({
- provider: "composio",
- app,
- holder: "person",
- userId: asker,
- vendorUserId: asker,
- });
+ await database.insert(brokeredConnections).values([
+ {
+ provider: "composio",
+ app,
+ holder: "person",
+ userId: asker,
+ vendorUserId: asker,
+ },
+ // Somebody else's account at the same app, which the vendor still holds.
+ {
+ provider: "composio",
+ app,
+ holder: "person",
+ userId: admin,
+ vendorUserId: admin,
+ },
+ ]);
gone();
const said = await store
.callTool({
@@ -638,5 +664,7 @@ describe("an account the vendor no longer holds", () => {
reason: "reconnect",
});
expect(said).toContain("Reconnect it and ask again");
+ // Only the asker's row goes, so Connect on their card starts a fresh connection.
+ expect(await rowsFor("person")).toEqual([admin]);
});
});
diff --git a/shared/connect-prompt.ts b/shared/connect-prompt.ts
index e1042b5936..95bafbedb4 100644
--- a/shared/connect-prompt.ts
+++ b/shared/connect-prompt.ts
@@ -28,9 +28,9 @@ export const CONNECT_APP_MARKER = "[connect-app]";
* behind it: switched off, not granted, or any of the above.
*
* A HINT, NOT THE STATE. The card asks the server for the app's state for this person and this Bot
- * when it is drawn, and draws that. The one place the hint wins is an account the vendor has lost:
- * nothing on this deployment records that, so the live state still reads `ready` there, and only the
- * refusal knows otherwise. See `AppAccessCard`.
+ * when it is drawn, and draws that. The one place the hint wins is an account the vendor has lost
+ * whose row still stands — a refusal from before the call that found out started deleting the row —
+ * where the live state reads `ready` and only the refusal knows otherwise. See `AppAccessCard`.
*/
export type ConnectReason = "connect" | "reconnect" | "shared" | "access";
From 5e25a76c7ce352bdccdb2b302a32ac99e46f8004 Mon Sep 17 00:00:00 2001
From: Guido Vizoso
Date: Wed, 21 Oct 2026 16:07:44 -0300
Subject: [PATCH 25/33] Mark a connection withdrawn when the vendor refuses its
refresh token
---
server/src/plugins/plugin-store/context.ts | 29 +++++++++++++++++++
server/src/plugins/plugin-store/model.ts | 8 +++++
server/tests/plugin-store.integration.test.ts | 23 +++++++++++++--
3 files changed, 57 insertions(+), 3 deletions(-)
diff --git a/server/src/plugins/plugin-store/context.ts b/server/src/plugins/plugin-store/context.ts
index e6c38481a7..40b669d5ec 100644
--- a/server/src/plugins/plugin-store/context.ts
+++ b/server/src/plugins/plugin-store/context.ts
@@ -34,6 +34,7 @@ import {
CustomServerRefusedError,
exchangeRefreshTokenOverHttp,
INVALID_CLIENT,
+ INVALID_GRANT,
isUsableClient,
type OAuthClient,
PluginInvariantError,
@@ -804,6 +805,8 @@ export function createStoreContext(options: PluginStoreOptions) {
* token it had read on the way in would present the one the first replica just spent, which is
* the very double-spend this exists to prevent.
*/
+ /** The credential whose refresh token went to the vendor, once one has. */
+ let presented: string | null = null;
try {
return await database.transaction(async (transaction) => {
const [current] = await transaction
@@ -861,6 +864,7 @@ export function createStoreContext(options: PluginStoreOptions) {
locked.encryptedValue,
);
+ presented = current.credentialId;
const minted = await exchangeRefreshToken({
tokenUrl,
client: stored.client,
@@ -894,6 +898,31 @@ export function createStoreContext(options: PluginStoreOptions) {
return { token: minted.accessToken };
});
} catch (error) {
+ /*
+ * THE VENDOR DISOWNED THE GRANT, NOT THE CLIENT. A refresh token answered `invalid_grant`
+ * will never be honoured again, and until now nothing here wrote that down: the connection
+ * read as connected on every screen, and every call came back as the same folded tool
+ * error. Revoked here — after the transaction, for the reason the recovery below gives — the
+ * connection reads as withdrawn, which is Reconnect on the Bot page and on the card this
+ * refusal draws, and the consent that fixes it replaces the credential as any reconnect does.
+ */
+ if (
+ error instanceof TokenRefusedError &&
+ error.code === INVALID_GRANT &&
+ presented
+ ) {
+ await credentials.revoke(presented);
+ throw new PluginRefusedError(
+ connectAppRefusal({
+ serverId: row.id,
+ title,
+ message: `Your ${title} access was withdrawn at the vendor, so this was not done. Reconnect it and ask again.`,
+ reason: "reconnect",
+ at: new Date().toISOString(),
+ }),
+ null,
+ );
+ }
/*
* Outside the transaction, so the row lock is already released and the vault read this does
* is not a second connection held behind this one's.
diff --git a/server/src/plugins/plugin-store/model.ts b/server/src/plugins/plugin-store/model.ts
index 0eec70dafb..686d407555 100644
--- a/server/src/plugins/plugin-store/model.ts
+++ b/server/src/plugins/plugin-store/model.ts
@@ -705,6 +705,14 @@ export class TokenRefusedError extends McpServerError {
*/
export const INVALID_CLIENT = "invalid_client";
+/**
+ * The OAuth error code for a refresh token the vendor will no longer honour (RFC 6749 §5.2): it was
+ * revoked, it expired, or the person withdrew the grant. Nothing this deployment can do brings it
+ * back — only the person consenting again — so the connection is recorded as withdrawn, the state
+ * every screen already reads as Reconnect.
+ */
+export const INVALID_GRANT = "invalid_grant";
+
/**
* A transaction, as the writes in this module hand one to each other.
*
diff --git a/server/tests/plugin-store.integration.test.ts b/server/tests/plugin-store.integration.test.ts
index 4950c8de7c..7d70e72ed7 100644
--- a/server/tests/plugin-store.integration.test.ts
+++ b/server/tests/plugin-store.integration.test.ts
@@ -2795,12 +2795,29 @@ describe("a dynamic client the vendor has evicted", () => {
"invalid_grant",
);
- await expect(call()).rejects.toThrow("invalid_grant");
+ // A withdrawn grant is the person's to fix by connecting again: the refusal draws the card that
+ // says so, rather than folding the vendor's code into a tool error.
+ const refusal = await call().catch((error: unknown) => error);
+ expect(refusal).toBeInstanceOf(PluginRefusedError);
+ expect(readConnectPrompt((refusal as Error).message)).toMatchObject({
+ serverId: dynamicServerId,
+ reason: "reconnect",
+ });
- // A withdrawn grant is the person's to fix by connecting again. Minting a client for it would
- // leave a spare client behind and still refuse.
+ // Minting a client for it would leave a spare client behind and still refuse.
expect(registrations).toEqual([]);
expect(offered).toEqual([EVICTED.clientId]);
+
+ // And the connection now reads as withdrawn, which is Reconnect on every screen.
+ const [connection] = await dynamicStore.connectionsFor(dynamicUserId);
+ expect(connection).toMatchObject({
+ serverId: dynamicServerId,
+ revoked: true,
+ });
+ // The next call is refused before anything is presented to the vendor again.
+ offered.length = 0;
+ await expect(call()).rejects.toBeInstanceOf(PluginRefusedError);
+ expect(offered).toEqual([]);
});
/**
From 44385684d63193d30b57356f6eac2cd372a6f6ca Mon Sep 17 00:00:00 2001
From: Guido Vizoso
Date: Wed, 21 Oct 2026 16:07:44 -0300
Subject: [PATCH 26/33] Say a Memory source's app refusal in words instead of
storing the card's marker
---
server/src/memory/ingestion.ts | 30 ++++++++++++++++--
server/tests/memory-store.integration.test.ts | 31 ++++++++++++++++++-
2 files changed, 57 insertions(+), 4 deletions(-)
diff --git a/server/src/memory/ingestion.ts b/server/src/memory/ingestion.ts
index f6cc574f0a..2ad0cf932d 100644
--- a/server/src/memory/ingestion.ts
+++ b/server/src/memory/ingestion.ts
@@ -1,4 +1,5 @@
import { createHash } from "node:crypto";
+import { readConnectPrompt } from "@openbot/shared/connect-prompt";
import { accessFor } from "../plugins/access";
import type { Decides } from "../plugins/broker";
import { catalogueEntry } from "../plugins/catalogue";
@@ -280,14 +281,37 @@ export function createMemoryIngestion(options: {
};
} catch (error) {
const message =
- error instanceof MemoryRefusedError ||
error instanceof PluginRefusedError
- ? error.message
- : "The source could not sync. Try again or check the connected app.";
+ ? syncRefusal(error.message)
+ : error instanceof MemoryRefusedError
+ ? error.message
+ : "The source could not sync. Try again or check the connected app.";
await store.failSync(ownerUserId, id, message);
throw error;
}
}
+ /**
+ * What the Memory sources page says about a refusal: the refusal's own sentence, except one that
+ * carries an app card.
+ *
+ * A CARD MARKER IS FOR A TRANSCRIPT, AND THIS IS NOT ONE. The marker is a JSON prefix the chat
+ * draws as a card with a Connect button; stored as the sync error it reached the sources page
+ * raw, and the sentence after it is written for a conversation ("ask again"). So a refusal with a
+ * marker is said for this page instead, naming where the account is connected.
+ */
+ function syncRefusal(text: string): string {
+ const prompt = readConnectPrompt(text);
+ if (!prompt) return text;
+ switch (prompt.reason) {
+ case "shared":
+ return `The shared ${prompt.title} account is not connected. An administrator has to connect it before this source can sync.`;
+ case "reconnect":
+ return `Your ${prompt.title} account needs reconnecting. Reconnect it under Settings, Connected accounts, then sync again.`;
+ default:
+ return `Your ${prompt.title} account is not connected. Connect it under Settings, Connected accounts, then sync again.`;
+ }
+ }
+
async function eligibleSources(ownerUserId: string, botId: string) {
const sources = (await store.sources(ownerUserId)).filter(
(source) =>
diff --git a/server/tests/memory-store.integration.test.ts b/server/tests/memory-store.integration.test.ts
index de733595b1..0b22f36b51 100644
--- a/server/tests/memory-store.integration.test.ts
+++ b/server/tests/memory-store.integration.test.ts
@@ -1,5 +1,6 @@
import { afterAll, beforeAll, expect, test } from "bun:test";
import { randomUUID } from "node:crypto";
+import { connectAppRefusal } from "@openbot/shared/connect-prompt";
import { and, eq, inArray } from "drizzle-orm";
import { createAuditStore } from "../src/audit";
import { createCredentialStore } from "../src/credentials";
@@ -11,7 +12,7 @@ import { mcpServers, mcpTools } from "../src/db/schema/plugins";
import { createMemoryIngestion } from "../src/memory/ingestion";
import { createMemoryStore } from "../src/memory/store";
import { MemoryNotFoundError, MemoryRefusedError } from "../src/memory/types";
-import { createPluginStore } from "../src/plugins/store";
+import { createPluginStore, PluginRefusedError } from "../src/plugins/store";
import { TEST_POOL, testDatabaseUrl } from "./support/database";
const database = createDatabase(testDatabaseUrl(), TEST_POOL);
@@ -25,6 +26,8 @@ const ref = `${server}/read_documents`;
let vendorText =
'[{"id":"doc1","text":"Project launch is Tuesday","title":"Roadmap"}]';
let vendorError = false;
+/** Thrown by the vendor instead of answering, when set: how a refused call reaches a sync. */
+let vendorThrows: Error | null = null;
const calls: { actorId?: string; botId?: string }[] = [];
const plugins = createPluginStore({
database,
@@ -34,6 +37,7 @@ const plugins = createPluginStore({
policy: () => ({ mode: "enforce", deny: [], allow: ["true"] }),
callVendor: async (connection) => {
calls.push(connection);
+ if (vendorThrows) throw vendorThrows;
return { text: vendorText, isError: vendorError };
},
});
@@ -249,6 +253,31 @@ test("vendor failure hides stale source facts and records a visible error", asyn
expect((await store.getSource(owner, value.id)).syncStatus).toBe("error");
vendorError = false;
});
+test("a refusal carrying an app card is said for the sources page, not stored raw", async () => {
+ const value = await source();
+ vendorThrows = new PluginRefusedError(
+ connectAppRefusal({
+ serverId: server,
+ title: "Docs",
+ message:
+ "Your Docs account is no longer connected at the vendor. Reconnect it and ask again.",
+ reason: "reconnect",
+ }),
+ null,
+ );
+ try {
+ await expect(ingestion.sync(owner, value.id)).rejects.toBeInstanceOf(
+ PluginRefusedError,
+ );
+ const { syncError } = await store.getSource(owner, value.id);
+ expect(syncError).toBe(
+ "Your Docs account needs reconnecting. Reconnect it under Settings, Connected accounts, then sync again.",
+ );
+ expect(syncError).not.toContain("[connect-app]");
+ } finally {
+ vendorThrows = null;
+ }
+});
test("source leases prevent competing connector calls", async () => {
const value = await source();
const results = await Promise.all([
From 1d2cdbde139dab092c043f160310ab63580cb5e0 Mon Sep 17 00:00:00 2001
From: Guido Vizoso
Date: Wed, 21 Oct 2026 16:07:44 -0300
Subject: [PATCH 27/33] Stop the Bot route fixture replacing the whole
active-bot module for every file
---
app/tests/bot-route-default-agent.fixture.tsx | 11 ++++++++---
1 file changed, 8 insertions(+), 3 deletions(-)
diff --git a/app/tests/bot-route-default-agent.fixture.tsx b/app/tests/bot-route-default-agent.fixture.tsx
index 2754e68b0a..dcab617581 100644
--- a/app/tests/bot-route-default-agent.fixture.tsx
+++ b/app/tests/bot-route-default-agent.fixture.tsx
@@ -19,9 +19,14 @@ mock.module("@copilotkit/react-core/v2", () => ({
),
}));
-mock.module("@/lib/copilot/active-bot", () => ({
- useActiveBot: () => undefined,
-}));
+/*
+ * `@/lib/copilot/active-bot` IS NOT MOCKED, though the route calls `useActiveBot`. Drawn without an
+ * `ActiveBotProvider`, as these tests draw it, the real hook finds no holder and does nothing — which
+ * is all the mock that used to be here did. And a module mock is not this file's to keep: bun cannot
+ * take one back off, so a replacement exporting only `useActiveBot` stood in for the whole module in
+ * every file walked into the same process, and `useDeclaredBotId` — the app-access card's fallback
+ * for a refusal that names no Bot — stopped existing there.
+ */
mock.module("@/lib/copilot/bot-thread", () => ({
useBotThread: (agentId: string) => {
From 08210baaa3b6a2228141cd4865097a422d07c063 Mon Sep 17 00:00:00 2001
From: Guido Vizoso
Date: Wed, 21 Oct 2026 16:07:44 -0300
Subject: [PATCH 28/33] Draw the new app-access states, keep model text off
screen, and end the sign-in wait when its tab closes
---
app/src/components/bot-profile/access.tsx | 10 +-
.../components/channels/app-access-card.tsx | 26 +-
.../components/channels/chat-transcript.tsx | 16 +-
app/src/components/channels/tool-activity.tsx | 16 +-
.../components/plugins/access-requests.tsx | 38 ++-
.../components/plugins/app-access-action.tsx | 116 ++++++--
.../components/plugins/connect-in-new-tab.ts | 61 +++-
app/src/lib/app-access/mutations.ts | 5 +-
app/src/lib/app-access/queries.ts | 50 ++--
app/src/lib/plugins/tool-name.ts | 27 +-
app/tests/access-requests.test.tsx | 67 ++++-
app/tests/app-access-action.test.tsx | 272 +++++++++++++++---
app/tests/app-access-card.test.tsx | 119 +++++++-
app/tests/app-access-fixture.ts | 3 +
app/tests/app-access-mutations.test.ts | 1 -
app/tests/bot-access-apps.test.tsx | 66 ++++-
app/tests/tool-activity.test.tsx | 17 ++
app/tests/tool-name.test.ts | 15 +
18 files changed, 811 insertions(+), 114 deletions(-)
diff --git a/app/src/components/bot-profile/access.tsx b/app/src/components/bot-profile/access.tsx
index dfd3a069cb..ca4a5ed8c6 100644
--- a/app/src/components/bot-profile/access.tsx
+++ b/app/src/components/bot-profile/access.tsx
@@ -226,12 +226,16 @@ function Granted({ agentId }: { agentId: string }) {
{app.displayName}
-
+ {/*
+ * Unclamped: the sentence ends on who can fix it, and two lines at a phone's
+ * width cut exactly that off.
+ */}
+
{app.state === "ready"
- ? [appAccessSentence(app.state, app.displayName)]
+ ? [appAccessSentence(app)]
.concat(labels.length > 0 ? [toolList(labels)] : [])
.join(" ")
- : appAccessSentence(app.state, app.displayName)}
+ : appAccessSentence(app)}
diff --git a/app/src/components/channels/app-access-card.tsx b/app/src/components/channels/app-access-card.tsx
index 5f851dd903..d58164cdf9 100644
--- a/app/src/components/channels/app-access-card.tsx
+++ b/app/src/components/channels/app-access-card.tsx
@@ -25,11 +25,12 @@ import { ConnectAppCard } from "./connect-app-card";
* now — connected since, granted since, the request approved — and never offers a step that has
* already been taken. Every write those buttons make refreshes the same key, so the card follows.
*
- * THE ONE PLACE THE REFUSAL WINS. An account the vendor no longer holds is not recorded anywhere on
- * this deployment: its row still stands, so the server reads `ready` until a call fails — and this
- * card is drawn over exactly the call that failed. So a `reconnect` or `shared` refusal over a live
- * `ready` is drawn as the refusal says — unless the account was connected after the refusal, which
- * is the reconnection. Anything else live is newer than the refusal and is drawn as it is.
+ * THE ONE PLACE THE REFUSAL WINS. The call that finds an account gone at the vendor deletes its row,
+ * so the live state normally says so itself. A refusal from before that, over a row that still
+ * stands, reads `ready` live — and this card is drawn over exactly the call that failed. So a
+ * `reconnect` or `shared` refusal over a live `ready` is drawn as the refusal says — unless the
+ * account was connected after the refusal, which is the reconnection. Anything else live is newer
+ * than the refusal and is drawn as it is.
*/
export function AppAccessCard({ prompt }: { prompt: ConnectPrompt }) {
const declared = useDeclaredBotId();
@@ -72,8 +73,10 @@ function LiveAppAccessCard({
{name}
{/*
- * Nothing while the read is open — no placeholder, per the data-access rule — and the
- * Bot's own sentence if it failed. Otherwise the state, which is the server's.
+ * Nothing while the read is open — no placeholder, per the data-access rule — and a
+ * sentence of the card's own if it failed. NOT the refusal's message: that was written for
+ * the model ("do not browse to its website"), and reads as an instruction to the wrong
+ * reader. Otherwise the state, which is the server's.
*/}
{live.isPending ? null : access ? (
@@ -83,7 +86,7 @@ function LiveAppAccessCard({
) : (
- {prompt.message}
+ {name}'s status couldn't be loaded. Try again in a moment.
)}
@@ -104,6 +107,11 @@ function LiveAppAccessCard({
* A lost team account is drawn as not connected with NO request to file — the server would answer
* any request 409, since as far as it knows the account is fine — so the sentence says whose move it
* is and the button is left out rather than offered and refused.
+ *
+ * A lost account on somebody else's Team Bot keeps the live `accountHolder`. Where that is the
+ * owner's account, the override reads as the owner's reconnection — the sentence says only the owner
+ * can, and `AppAccessAction` offers nothing — because a Reconnect pressed here would connect this
+ * person's own account, which the Bot does not run on.
*/
function asRefused(live: AppAccess, prompt: ConnectPrompt): AppAccess {
if (live.state !== "ready") return live;
@@ -137,5 +145,5 @@ function refusedSentence(access: AppAccess, prompt: ConnectPrompt): string {
!access.requestKind
)
return `Your organisation's ${access.displayName} account needs reconnecting. Only an administrator can reconnect it.`;
- return appAccessSentence(access.state, access.displayName);
+ return appAccessSentence(access);
}
diff --git a/app/src/components/channels/chat-transcript.tsx b/app/src/components/channels/chat-transcript.tsx
index 1fb109f7b2..6cf6cc805d 100644
--- a/app/src/components/channels/chat-transcript.tsx
+++ b/app/src/components/channels/chat-transcript.tsx
@@ -1364,7 +1364,8 @@ export function ServerToolLine({
name: string;
result?: string;
}) {
- const { label, detail } = readToolName(name);
+ const running = result === undefined;
+ const { label, detail } = readToolName(name, running);
/*
* A refusal is not a result, and must not read like one.
*
@@ -1397,17 +1398,24 @@ export function ServerToolLine({
? readConnectPrompt(answer)
: null;
if (card) return ;
+ /*
+ * THAT TOOL'S OTHER ANSWERS ARE FOR THE MODEL. An app nobody can name comes back as plain text
+ * telling the Bot to say so plainly and not to browse to it — an instruction, which read by the
+ * person looks like the Bot being told off in front of them. The line still says the check
+ * happened; the Bot's own reply says what it found.
+ */
+ const shown = name === SHOW_APP_ACCESS_TOOL ? undefined : body;
return (
- {body ? (
+ {shown ? (
- {forDisplay(body)}
+ {forDisplay(shown)}
) : null}
diff --git a/app/src/components/channels/tool-activity.tsx b/app/src/components/channels/tool-activity.tsx
index 793a1708fd..c03e5d2870 100644
--- a/app/src/components/channels/tool-activity.tsx
+++ b/app/src/components/channels/tool-activity.tsx
@@ -10,7 +10,7 @@ import { useCallback, useContext, useMemo, useSyncExternalStore } from "react";
import { Streamdown } from "streamdown";
import { PendingApprovals } from "@/components/approvals/inline-approval";
import { markdownComponents } from "@/lib/markdown";
-import { humanise, readToolName } from "@/lib/plugins/tool-name";
+import { humanise, ownToolLabel, readToolName } from "@/lib/plugins/tool-name";
import {
asText,
forDisplay,
@@ -237,17 +237,25 @@ export function stepDetails(step: ToolItem): StepDetails {
const answer = step.result === undefined ? undefined : asText(step.result);
const refused = answer?.startsWith(REFUSAL_MARKER) ?? false;
const body = refused ? answer?.slice(REFUSAL_MARKER.length).trim() : answer;
+ // This deployment's own tools answer the model, not the person; see `ServerToolLine`.
+ const own = ownToolLabel(name) !== null;
const outcome = {
id: step.id,
pending: step.result === undefined,
failed: !refused && toolResultFailed(step.result),
refused,
inputs,
- ...(body ? { output: body } : {}),
+ ...(body && !own ? { output: body } : {}),
};
- // Not a connector: a tool an external agent runs itself. Named in words, never as an identifier.
+ // Not a connector: one of this deployment's own tools, or one an external agent runs itself.
+ // Named in words, never as an identifier.
if (!name.startsWith("mcp__"))
- return { ...outcome, kind: "tool", label: humanise(name), pages: 0 };
+ return {
+ ...outcome,
+ kind: "tool",
+ label: ownToolLabel(name, outcome.pending) ?? humanise(name),
+ pages: 0,
+ };
const [, server = "", ...rest] = name.split("__");
const role = researchRoleOfTool(server, rest.join("__"));
diff --git a/app/src/components/plugins/access-requests.tsx b/app/src/components/plugins/access-requests.tsx
index f1df877710..973ba8f565 100644
--- a/app/src/components/plugins/access-requests.tsx
+++ b/app/src/components/plugins/access-requests.tsx
@@ -1,5 +1,6 @@
import { IconInbox } from "@tabler/icons-react";
import { useMutation, useQuery } from "@tanstack/react-query";
+import { Link } from "@tanstack/react-router";
import { Fragment } from "react";
import { PageRows, PageSection } from "@/components/layout/page-shell";
import { Button } from "@/components/ui/button";
@@ -39,6 +40,11 @@ import { queryClient } from "@/query-client";
* to connect — and the request stays pending. That sentence is drawn on the row it is about, because
* the screen's own banner is a long way from the button that was pressed.
*
+ * A GRANT IS READ-ONLY UNTIL SOMEBODY CHOOSES MORE. One press is not a decision about every action an
+ * app offers — Gmail's include sending and deleting mail — so approving gives the Bot the app's
+ * read-only actions and nothing else, and an app with none is refused with a sentence pointing at the
+ * Bot's page. The row says so before the press, and links to that page where it can.
+ *
* REQUESTS, NOT CONNECTIONS. Each row names who asked, because they asked; nothing here says who has
* connected what, which stays private to each person (see the note on `admin/plugins/$key.tsx`).
*
@@ -110,12 +116,12 @@ function AccessRequestRow({ request }: { request: AppAccessRequest }) {
{askedFor(request)}
-
+
{who} asked {relativeTime(request.createdAt)}
{request.kind === "connect_shared" && request.botName
? `, for ${request.botName}`
: ""}
- .
+ .
@@ -152,6 +158,34 @@ function AccessRequestRow({ request }: { request: AppAccessRequest }) {
);
}
+/**
+ * What approving gives a Bot, for a request that grants one: the app's read-only actions, with the
+ * rest chosen on the Bot's page under the app. Linked there for a grant, whose app is already on
+ * and so has that page; an app still to be switched on has none until the press that switches it on.
+ */
+function GrantNote({ request }: { request: AppAccessRequest }) {
+ if (request.kind === "connect_shared" || !request.agentId) return null;
+ const bot = request.botName ?? "the Bot";
+ const where = `the Bot's page under ${request.appTitle}`;
+ const page =
+ request.kind === "grant" ? (
+
+ {where}
+
+ ) : (
+ where
+ );
+ return (
+ <>
+ Approving gives {bot} only {request.appTitle}'s read-only actions. Choose
+ any others on {page}.
+ >
+ );
+}
+
/**
* How many requests are waiting, for a list row that wants to say so: one app's, or all of them.
* Null while nothing is waiting or nothing has been read, so a row draws nothing rather than "0".
diff --git a/app/src/components/plugins/app-access-action.tsx b/app/src/components/plugins/app-access-action.tsx
index 25d1fa9eb9..df9e2e9d37 100644
--- a/app/src/components/plugins/app-access-action.tsx
+++ b/app/src/components/plugins/app-access-action.tsx
@@ -7,11 +7,7 @@ import {
cancelAppAccessRequestMutationOptions,
requestAppAccessMutationOptions,
} from "@/lib/app-access/mutations";
-import {
- type AppAccess,
- type AppAccessState,
- appAccessKeys,
-} from "@/lib/app-access/queries";
+import { type AppAccess, appAccessKeys } from "@/lib/app-access/queries";
import { queryClient } from "@/query-client";
/**
@@ -22,10 +18,12 @@ import { queryClient } from "@/query-client";
* whether this person can use this app through this Bot, whose move it is if not, and the button
* for that move — so both draw it from here, and a sentence corrected for one is corrected for both.
*
- * WHO CAN FIX IT IS THE WHOLE SENTENCE. Six states reduce to three answers: nothing to do, a step
- * that is yours (connect, reconnect), and a step only an administrator can take (switch it on,
- * grant it, connect the team account). The copy names which, because "Gmail is unavailable" sends
- * somebody hunting through their own settings for a switch that is not there.
+ * WHO CAN FIX IT IS THE WHOLE SENTENCE. Seven states reduce to four answers: nothing to do, a step
+ * that is yours (connect, reconnect), a step only an administrator can take (switch it on, finish
+ * setting it up, grant it, connect or approve the team account), and on somebody else's Team Bot a
+ * step only its owner can take (reconnect the account the Bot runs on). The copy names which,
+ * because "Gmail is unavailable" sends somebody hunting through their own settings for a switch that
+ * is not there.
*
* THE STATE IS THE SERVER'S. It arrives on `access` and is drawn as it is; nothing here works out
* from `granted` or `accountMode` what the state ought to be. The one exception is the caller's: a
@@ -34,23 +32,50 @@ import { queryClient } from "@/query-client";
*/
/** What the person is told about one app, in words that say whose move it is. */
-export function appAccessSentence(
- state: AppAccessState,
- displayName: string,
-): string {
- switch (state) {
+export function appAccessSentence(access: AppAccess): string {
+ const name = access.displayName;
+ const owners = access.accountHolder === "owner";
+ switch (access.state) {
case "ready":
+ if (owners)
+ return `Ready to use. This Bot uses its owner's ${name} account.`;
+ if (access.asksFirst)
+ return `Ready to use with your own ${name} account. You'll be asked to allow it the first time.`;
return "Ready to use.";
case "not_connected":
- return `You haven't connected your ${displayName} account yet.`;
+ return `You haven't connected your ${name} account yet.`;
case "reconnect":
- return `Your ${displayName} connection needs reconnecting.`;
+ return owners
+ ? `This Bot uses its owner's ${name} account, which needs reconnecting. Only the Bot's owner can reconnect it.`
+ : `Your ${name} connection needs reconnecting.`;
case "shared_not_connected":
- return `Your organisation's ${displayName} account isn't connected yet. Only an administrator can connect it.`;
+ return `Your organisation's ${name} account isn't connected yet. Only an administrator can connect it.`;
+ case "shared_not_approved":
+ return `This Bot reaches more people than an administrator has approved for your organisation's ${name} account. Only an administrator can approve it.`;
case "disabled":
- return `Turned off by an administrator. Only they can turn ${displayName} on.`;
+ return disabledSentence(access);
case "not_granted":
- return `This Bot hasn't been given ${displayName}. Only an administrator can give it.`;
+ return `This Bot hasn't been given ${name}. Only an administrator can give it.`;
+ }
+}
+
+/**
+ * Switched off, in the words for why. "Turned off by an administrator" was true of one reason in
+ * five: an app nobody switched on, and a set-up somebody has not finished, are not a decision
+ * anybody took — and the Composio case says what Settings → Connected accounts says about the same
+ * app, so the two screens never disagree about it.
+ */
+function disabledSentence(access: AppAccess): string {
+ const name = access.displayName;
+ switch (access.unavailable) {
+ case "composio_unconfigured":
+ return `${name} is not available on this OpenBot yet. An administrator has to finish setting it up.`;
+ case "scheme_unreadable":
+ case "oauth_client_missing":
+ case "token_missing":
+ return `${name} isn't fully set up yet. Only an administrator can finish setting it up.`;
+ default:
+ return `${name} isn't switched on for this OpenBot. Only an administrator can switch it on.`;
}
}
@@ -58,6 +83,10 @@ export function appAccessSentence(
* The button for the person's next move, and what it says while it happens.
*
* - `ready` — nothing.
+ * - `shared_not_approved` — nothing either: the call that was refused already filed the
+ * administrator's request, so there is no second one to make.
+ * - `reconnect` on an account the Bot's owner holds — nothing. Only the owner can reconnect their
+ * own account, and nobody else's press could do it for them.
* - `not_connected` / `reconnect` — the person's own connection. Connect starts it in a new tab
* with the connect route Settings presses; an app connected with a typed key, and a Composio app
* that already has a row (which the connect route refuses until it is disconnected), go to the
@@ -72,7 +101,10 @@ export function AppAccessAction({
agentId: string;
access: AppAccess;
}) {
- if (access.state === "ready") return null;
+ if (access.state === "ready" || access.state === "shared_not_approved")
+ return null;
+ if (access.state === "reconnect" && access.accountHolder === "owner")
+ return null;
if (access.state === "not_connected" || access.state === "reconnect")
return ;
return ;
@@ -126,6 +158,7 @@ function ConnectAction({ access }: { access: AppAccess }) {
to="/settings/connected-accounts/$key"
/>
}
+ aria-label={`${label} ${access.displayName}`}
size="sm"
variant="outline"
>
@@ -159,6 +192,8 @@ function ConnectAction({ access }: { access: AppAccess }) {
) : null}
{
stopWaiting();
@@ -187,6 +222,29 @@ function RequestAction({
);
const error = request.error ?? cancel.error;
+ /*
+ * FOCUS FOLLOWS THE PRESS. Request access and Withdraw replace each other, so the button somebody
+ * pressed leaves the page the moment the server's answer is drawn, and the focus with it — onto
+ * the body, where a keyboard user starts again from the top. The press is remembered, and when
+ * the other button arrives in its place it takes the focus. A refused press leaves the same
+ * button standing, which gets it back.
+ */
+ const control = useRef(null);
+ const pressed = useRef(false);
+ const requested = access.request !== null;
+ // biome-ignore lint/correctness/useExhaustiveDependencies: runs on the swap between the two buttons.
+ useEffect(() => {
+ if (!pressed.current) return;
+ pressed.current = false;
+ control.current?.focus();
+ }, [requested]);
+ const refocus = {
+ onError: () => {
+ pressed.current = false;
+ control.current?.focus();
+ },
+ };
+
/*
* Nothing to ask for where the server names no request: a state a caller overrode from a refusal
* the server does not know about (the team account lost at the vendor), which the server would
@@ -207,11 +265,18 @@ function RequestAction({
Requested
{
request.reset();
- if (access.request) cancel.mutate(access.request.id);
+ pressed.current = true;
+ if (access.request) cancel.mutate(access.request.id, refocus);
}}
+ ref={control}
size="sm"
variant="ghost"
>
@@ -220,11 +285,18 @@ function RequestAction({
>
) : (
{
cancel.reset();
- request.mutate({ agentId, app: access.key });
+ pressed.current = true;
+ request.mutate({ agentId, app: access.key }, refocus);
}}
+ ref={control}
size="sm"
variant="outline"
>
diff --git a/app/src/components/plugins/connect-in-new-tab.ts b/app/src/components/plugins/connect-in-new-tab.ts
index a96ac0120a..002f5b3dfd 100644
--- a/app/src/components/plugins/connect-in-new-tab.ts
+++ b/app/src/components/plugins/connect-in-new-tab.ts
@@ -1,6 +1,8 @@
import { useMutation } from "@tanstack/react-query";
-import { useCallback, useState } from "react";
+import { useCallback, useEffect, useRef, useState } from "react";
import { connectAccountMutationOptions } from "@/lib/plugins/mutations";
+import { pluginKeys } from "@/lib/plugins/queries";
+import { queryClient } from "@/query-client";
/**
* Start this person's own connection to one app in a new tab, the way Settings starts it.
@@ -27,6 +29,56 @@ export function useConnectInNewTab() {
const connect = useMutation(connectAccountMutationOptions());
// Stable, so a caller can list it in an effect's dependencies without re-running it every render.
const stopWaiting = useCallback(() => setWaiting(false), []);
+ /** The tab the consent is open in, while there is one to watch. */
+ const handle = useRef(null);
+
+ /*
+ * A CLOSED TAB ENDS THE WAIT ONCE THIS TAB HAS THE FOCUS BACK. Somebody who closes the sign-in
+ * tab without finishing has given up, and a button still saying "Waiting for Gmail…" a minute
+ * later is waiting for nothing — so the handle is checked every second, and once it reads closed
+ * the wait ends and the plugin screens are asked once more, in case the consent did finish before
+ * the tab went.
+ *
+ * CLOSED MAY ALSO MEAN SEVERED, WHICH IS WHY THE FOCUS DECIDES. A vendor's sign-in page that sends
+ * a strict Cross-Origin-Opener-Policy — Google's does — moves the tab into a browsing context of
+ * its own, and the handle this tab holds then reads closed while the person is still on the
+ * consent screen. The handle cannot tell the two apart; the focus can. Closing a tab hands the
+ * focus straight back here, so the wait ends at once; a severed tab keeps it, so the button goes
+ * on waiting until the person comes back to this one, which is when there is an answer to show.
+ */
+ const onReturn = useRef<(() => void) | null>(null);
+ useEffect(() => {
+ if (!waiting) return;
+ const finish = () => {
+ onReturn.current = null;
+ setWaiting(false);
+ void queryClient.invalidateQueries({ queryKey: pluginKeys.all });
+ };
+ const timer = setInterval(() => {
+ if (!handle.current?.closed || onReturn.current) return;
+ handle.current = null;
+ if (document.hasFocus()) {
+ finish();
+ return;
+ }
+ onReturn.current = finish;
+ window.addEventListener("focus", finish, { once: true });
+ }, 1_000);
+ return () => clearInterval(timer);
+ }, [waiting]);
+ // Goes with whoever drew the button, and with a wait that ended some other way first.
+ useEffect(() => {
+ if (waiting || !onReturn.current) return;
+ window.removeEventListener("focus", onReturn.current);
+ onReturn.current = null;
+ }, [waiting]);
+ useEffect(
+ () => () => {
+ if (onReturn.current)
+ window.removeEventListener("focus", onReturn.current);
+ },
+ [],
+ );
/**
* The tab, opened on the click itself rather than on the answer to it.
@@ -49,6 +101,7 @@ export function useConnectInNewTab() {
const open = (serverId: string) => {
const tab = claimTab();
+ handle.current = tab;
connect.mutate(serverId, {
onSuccess: (authorizationUrl) => {
/*
@@ -58,6 +111,7 @@ export function useConnectInNewTab() {
*/
if (!authorizationUrl) {
tab?.close();
+ handle.current = null;
setNeedsKey(true);
return;
}
@@ -66,7 +120,10 @@ export function useConnectInNewTab() {
// The tab was blocked. The url is still good, so offer it as something to click.
else setBlockedUrl(authorizationUrl);
},
- onError: () => tab?.close(),
+ onError: () => {
+ tab?.close();
+ handle.current = null;
+ },
});
};
diff --git a/app/src/lib/app-access/mutations.ts b/app/src/lib/app-access/mutations.ts
index dfe59671f0..31131a5df2 100644
--- a/app/src/lib/app-access/mutations.ts
+++ b/app/src/lib/app-access/mutations.ts
@@ -55,8 +55,9 @@ export function cancelAppAccessRequestMutationOptions(
}
/**
- * Approve a request by doing what it asks. `refs` narrows a grant to those tools; absent, the Bot is
- * granted every tool the app advertises. Answers with what changed.
+ * Approve a request by doing what it asks. `refs` names the tools to grant; absent, the Bot is
+ * granted the app's read-only tools only, and an app with none is answered 409 with a sentence
+ * pointing at the Bot's page, where the others are chosen. Answers with what changed.
*/
export function approveAppAccessRequestMutationOptions(
queryClient: QueryClient,
diff --git a/app/src/lib/app-access/queries.ts b/app/src/lib/app-access/queries.ts
index d8366366ce..9bbf06c9a0 100644
--- a/app/src/lib/app-access/queries.ts
+++ b/app/src/lib/app-access/queries.ts
@@ -12,11 +12,15 @@ import { client } from "@/lib/client";
*/
/**
- * The six things a person can be told about one app on one Bot.
+ * The seven things a person can be told about one app on one Bot.
*
- * `ready` — usable now. `not_connected` / `reconnect` — waiting on this person's own account, which
- * they connect or reconnect themselves. `shared_not_connected` — a Shared app whose team account an
- * administrator has not connected. `disabled` — switched off at the deployment. `not_granted` —
+ * `ready` — usable now. `not_connected` / `reconnect` — waiting on an account: this person's own,
+ * which they connect or reconnect themselves, or on somebody else's Team Bot the owner's, which only
+ * the owner can (see `accountHolder`). `shared_not_connected` — a Shared app whose team account an
+ * administrator has not connected. `shared_not_approved` — a Shared app whose team account IS
+ * connected, but an administrator has not approved this Bot's audience for it, so every call is
+ * refused; the refused call files the administrator's request itself, so there is none to press.
+ * `disabled` — switched off at the deployment, for the reason `unavailable` gives. `not_granted` —
* enabled, and this Bot holds none of it.
*/
export type AppAccessState =
@@ -24,9 +28,22 @@ export type AppAccessState =
| "not_connected"
| "reconnect"
| "shared_not_connected"
+ | "shared_not_approved"
| "disabled"
| "not_granted";
+/**
+ * Why an app is switched off. `absent` — nobody switched it on here. `composio_unconfigured` — a
+ * Composio app on a deployment with no Composio key. The other three — a scheme the server cannot
+ * read, an OAuth client nobody registered, a token nobody pasted — are a set-up left unfinished.
+ */
+export type AppUnavailable =
+ | "absent"
+ | "composio_unconfigured"
+ | "scheme_unreadable"
+ | "oauth_client_missing"
+ | "token_missing";
+
/** What a press of Request access asks an administrator for. */
export type AppAccessRequestKind = "enable" | "grant" | "connect_shared";
@@ -44,6 +61,19 @@ export type AppAccess = {
authScheme: string | null;
accountMode: "personal" | "shared" | null;
state: AppAccessState;
+ /** Why the app is switched off, when state is "disabled"; null otherwise. */
+ unavailable: AppUnavailable | null;
+ /**
+ * Whose account a personal-account app's calls use for this person on this Bot: "you", or "owner"
+ * when this is somebody else's published Team Bot and its owner has connected the app (calls then
+ * run on the owner's account). null when the app is not a personal-account app.
+ */
+ accountHolder: "you" | "owner" | null;
+ /**
+ * True when calls will use this person's OWN account on somebody else's Team Bot and they have not
+ * given standing consent: the first use raises an "Allow once / Always allow" card. State is "ready".
+ */
+ asksFirst: boolean;
/** Whether this Bot holds any of the app's tools. */
granted: boolean;
/**
@@ -90,7 +120,6 @@ export const appAccessKeys = {
bot: (agentId: string) => ["plugins", "app-access", "bot", agentId] as const,
app: (agentId: string, app: string) =>
["plugins", "app-access", "bot", agentId, "app", app] as const,
- mine: () => ["plugins", "app-access", "mine"] as const,
inbox: (app?: string) =>
["plugins", "app-access", "inbox", { app: app ?? null }] as const,
};
@@ -122,17 +151,6 @@ export function botAppQueryOptions(agentId: string, app: string) {
});
}
-/** The person's own pending requests. */
-export function myAppAccessRequestsQueryOptions() {
- return queryOptions({
- queryKey: appAccessKeys.mine(),
- queryFn: (): Promise =>
- client("/api/plugins/access-requests/mine", "requests", {
- fallback: "Your access requests could not be loaded.",
- }),
- });
-}
-
/**
* The pending requests an administrator answers: one app's, or all of them. The counts are always
* the whole deployment's. `enabled` is false for anybody who is not an administrator, who would be
diff --git a/app/src/lib/plugins/tool-name.ts b/app/src/lib/plugins/tool-name.ts
index b7306abbbb..3c23d69ad6 100644
--- a/app/src/lib/plugins/tool-name.ts
+++ b/app/src/lib/plugins/tool-name.ts
@@ -1,3 +1,4 @@
+import { SHOW_APP_ACCESS_TOOL } from "@openbot/shared/connect-prompt";
import {
RESEARCH_ROLE_LABELS,
researchRoleOfTool,
@@ -21,7 +22,9 @@ export type ToolName = {
detail?: string;
};
-export function readToolName(name: string): ToolName {
+export function readToolName(name: string, running = false): ToolName {
+ const own = ownToolLabel(name, running);
+ if (own) return { label: own };
const parts = name.split("__");
if (parts.length < 3 || parts[0] !== "mcp") return { label: name };
@@ -74,6 +77,28 @@ export function readToolName(name: string): ToolName {
return named ? { label } : { label, detail: broker ?? server };
}
+/**
+ * The tools every Bot holds that this deployment wrote itself, named in words somebody chose.
+ *
+ * These are not MCP names, so nothing above rewrites them, and the bare identifier is what a line
+ * would otherwise say: "show_app_access", shimmering, while the Bot works out what an app is for the
+ * person. Named twice, for the moment and for afterwards, because only these lines are drawn while
+ * running AND left on screen once done.
+ */
+const OWN_TOOLS: Record = {
+ [SHOW_APP_ACCESS_TOOL]: {
+ running: "Checking app access",
+ done: "Checked app access",
+ },
+};
+
+/** One of this deployment's own tools in words, or null for any other name. */
+export function ownToolLabel(name: string, running = false): string | null {
+ const own = OWN_TOOLS[name];
+ if (!own) return null;
+ return running ? own.running : own.done;
+}
+
/**
* The app behind a brokered server key, or null for a server this deployment dials itself.
*
diff --git a/app/tests/access-requests.test.tsx b/app/tests/access-requests.test.tsx
index 133ae74da8..e7949f560b 100644
--- a/app/tests/access-requests.test.tsx
+++ b/app/tests/access-requests.test.tsx
@@ -140,8 +140,13 @@ function draw(Page: () => ReactNode) {
path: "/admin/plugins/composio",
component: () => null,
});
+ const bot = createRoute({
+ getParentRoute: () => root,
+ path: "/admin/plugins/$key/bots/$agentId",
+ component: () => null,
+ });
const router = createRouter({
- routeTree: root.addChildren([page, app, composio]),
+ routeTree: root.addChildren([page, app, composio, bot]),
history: createMemoryHistory({ initialEntries: ["/"] }),
});
return render(
@@ -204,6 +209,66 @@ test("an approval the server cannot complete says why, on the request, and leave
expect(row.getByRole("button", { name: "Approve" })).toBeTruthy();
});
+test("a grant says approving gives the Bot read-only actions, and links to where the others are chosen", async () => {
+ serving({ requests: [request()] });
+ const view = draw(() => );
+ const element = (await view.findByText("Give Gmail to Expenses")).closest(
+ "[data-request]",
+ ) as HTMLElement;
+ const row = within(element);
+ expect(element.textContent).toContain(
+ "Approving gives Expenses only Gmail's read-only actions. Choose any others on the Bot's page under Gmail.",
+ );
+ const page = row.getByRole("link", {
+ name: "the Bot's page under Gmail",
+ });
+ expect(page.getAttribute("href")).toBe(
+ "/admin/plugins/composio-gmail/bots/expenses",
+ );
+});
+
+test("switching an app on for a Bot says the same, with no link to a page the app does not have yet", async () => {
+ serving({ requests: [request({ kind: "enable" })] });
+ const view = draw(() => );
+ const row = within(
+ (await view.findByText("Turn on Gmail for Expenses")).closest(
+ "[data-request]",
+ ) as HTMLElement,
+ );
+ expect(
+ row.getByText(
+ /Approving gives Expenses only Gmail's read-only actions\. Choose any others on the Bot's page under Gmail\./,
+ ),
+ ).toBeTruthy();
+ expect(row.queryByRole("link")).toBeNull();
+});
+
+test("connecting the shared account says nothing about actions", async () => {
+ serving({ requests: [request({ kind: "connect_shared" })] });
+ const view = draw(() => );
+ await view.findByText("Connect the shared Gmail account");
+ expect(view.queryByText(/read-only/)).toBeNull();
+});
+
+test("an app with no read-only actions is refused on the row, which stays", async () => {
+ serving({
+ requests: [request()],
+ approve: {
+ status: 409,
+ body: {
+ error:
+ "Gmail has no read-only actions to grant. Choose which actions Expenses may use on its page under Gmail.",
+ },
+ },
+ });
+ const view = draw(() => );
+ fireEvent.click(await view.findByRole("button", { name: "Approve" }));
+ expect((await view.findByRole("alert")).textContent).toMatch(
+ /^Gmail has no read-only actions to grant\./,
+ );
+ expect(view.getByText("Give Gmail to Expenses")).toBeTruthy();
+});
+
test("dismiss goes to the dismiss route", async () => {
const sent = serving({ requests: [request()] });
const view = draw(() => );
diff --git a/app/tests/app-access-action.test.tsx b/app/tests/app-access-action.test.tsx
index 155b8d1e6e..728aac0806 100644
--- a/app/tests/app-access-action.test.tsx
+++ b/app/tests/app-access-action.test.tsx
@@ -9,20 +9,28 @@ import {
Outlet,
RouterProvider,
} from "@tanstack/react-router";
-import { cleanup, fireEvent, render, waitFor } from "@testing-library/react";
+import {
+ act,
+ cleanup,
+ fireEvent,
+ render,
+ waitFor,
+} from "@testing-library/react";
+import { useState } from "react";
import {
AppAccessAction,
appAccessSentence,
} from "@/components/plugins/app-access-action";
-import type { AppAccess, AppAccessState } from "@/lib/app-access/queries";
+import type { AppAccess } from "@/lib/app-access/queries";
import { appAccess } from "./app-access-fixture";
import { settleReactWork } from "./settle-react-work";
/**
* The one piece the Bot page and the chat card both draw an app's state with.
*
- * Each of the six states must say whose move it is and offer exactly that move — and nothing for a
- * state that needs none. The two moves that write are asserted against the request they send: the
+ * Each of the seven states must say whose move it is and offer exactly that move — and nothing for a
+ * state that needs none, or a move that is somebody else's: an administrator's, or on somebody else's
+ * Team Bot its owner's. The two moves that write are asserted against the request they send: the
* browser names a Bot and an app and never a kind, which is the server's to decide.
*/
@@ -51,12 +59,16 @@ function mounted(
) {
const sent: Sent[] = [];
const opened: string[] = [];
+ // The tab the last Connect claimed, so a test can close it the way a person would.
+ const tabs: { closed: boolean }[] = [];
window.open = (() => {
const tab = {
opener: {} as unknown,
+ closed: false,
location: { replace: (next: string) => opened.push(next) },
close: () => {},
};
+ tabs.push(tab);
return tab as unknown as Window;
}) as typeof window.open;
global.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
@@ -77,16 +89,23 @@ function mounted(
const client = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
+ // Stands in for the refetch after a write: the server's next answer, handed to the same row.
+ let swap: (next: AppAccess) => void = () => {};
+ function Row() {
+ const [current, setCurrent] = useState(access);
+ swap = setCurrent;
+ return (
+
- ),
+ component: Row,
});
const settings = createRoute({
getParentRoute: () => root,
@@ -102,28 +121,89 @@ function mounted(
,
);
- return { view, sent, opened };
+ return {
+ view,
+ sent,
+ opened,
+ tabs,
+ swap: (next: AppAccess) => act(() => swap(next)),
+ };
}
-const COPY: Record = {
- ready: /^Ready to use\.$/,
- not_connected: /You haven't connected your Gmail account yet\./,
- reconnect: /Your Gmail connection needs reconnecting\./,
- shared_not_connected:
+const COPY: [string, Partial, RegExp][] = [
+ ["ready", {}, /^Ready to use\.$/],
+ [
+ "ready, on the owner's account",
+ { accountHolder: "owner" },
+ /^Ready to use\. This Bot uses its owner's Gmail account\.$/,
+ ],
+ [
+ "ready, asking first",
+ { asksFirst: true },
+ /^Ready to use with your own Gmail account\. You'll be asked to allow it the first time\.$/,
+ ],
+ [
+ "not_connected",
+ { state: "not_connected" },
+ /You haven't connected your Gmail account yet\./,
+ ],
+ [
+ "reconnect",
+ { state: "reconnect" },
+ /^Your Gmail connection needs reconnecting\.$/,
+ ],
+ [
+ "reconnect, on the owner's account",
+ { state: "reconnect", accountHolder: "owner" },
+ /^This Bot uses its owner's Gmail account, which needs reconnecting\. Only the Bot's owner can reconnect it\.$/,
+ ],
+ [
+ "shared_not_connected",
+ { state: "shared_not_connected" },
/Your organisation's Gmail account isn't connected yet\. Only an administrator can connect it\./,
- disabled: /Turned off by an administrator\. Only they can turn Gmail on\./,
- not_granted:
+ ],
+ [
+ "shared_not_approved",
+ { state: "shared_not_approved" },
+ /^This Bot reaches more people than an administrator has approved for your organisation's Gmail account\. Only an administrator can approve it\.$/,
+ ],
+ [
+ "disabled, never switched on",
+ { state: "disabled", unavailable: "absent" },
+ /^Gmail isn't switched on for this OpenBot\. Only an administrator can switch it on\.$/,
+ ],
+ [
+ "disabled, with no reason given",
+ { state: "disabled", unavailable: null },
+ /^Gmail isn't switched on for this OpenBot\./,
+ ],
+ [
+ "disabled, with no Composio key",
+ { state: "disabled", unavailable: "composio_unconfigured" },
+ /^Gmail is not available on this OpenBot yet\. An administrator has to finish setting it up\.$/,
+ ],
+ ...(
+ ["scheme_unreadable", "oauth_client_missing", "token_missing"] as const
+ ).map((unavailable): [string, Partial, RegExp] => [
+ `disabled, ${unavailable}`,
+ { state: "disabled", unavailable },
+ /^Gmail isn't fully set up yet\. Only an administrator can finish setting it up\.$/,
+ ]),
+ [
+ "not_granted",
+ { state: "not_granted" },
/This Bot hasn't been given Gmail\. Only an administrator can give it\./,
-};
+ ],
+];
-test.each(Object.entries(COPY))(
- "says whose move it is: %s",
- (state, sentence) => {
- expect(appAccessSentence(state as AppAccessState, "Gmail")).toMatch(
- sentence,
- );
- },
-);
+test.each(COPY)("says whose move it is: %s", (_, overrides, sentence) => {
+ expect(appAccessSentence(appAccess(overrides))).toMatch(sentence);
+});
+
+test("no sentence about a switched-off app says an administrator turned it off", () => {
+ for (const [, overrides] of COPY.filter(([, o]) => o.state === "disabled"))
+ expect(appAccessSentence(appAccess(overrides))).not.toMatch(/Turned off/);
+});
test("ready offers nothing to press", async () => {
const { view } = mounted(appAccess());
@@ -134,7 +214,7 @@ test("ready offers nothing to press", async () => {
test("not connected offers Connect, which starts the person's own consent in a new tab", async () => {
const { view, sent, opened } = mounted(appAccess({ state: "not_connected" }));
- fireEvent.click(await view.findByRole("button", { name: "Connect" }));
+ fireEvent.click(await view.findByRole("button", { name: "Connect Gmail" }));
await waitFor(() => expect(opened).toEqual(["https://gmail.test/consent"]));
expect(sent.map((call) => `${call.method} ${call.url}`)).toContain(
"POST /api/plugins/servers/composio-gmail/connect?returnTo=settings",
@@ -171,10 +251,95 @@ test("this deployment's own OAuth app reconnects in place, with a fresh consent"
state: "reconnect",
}),
);
- fireEvent.click(await view.findByRole("button", { name: "Reconnect" }));
+ fireEvent.click(
+ await view.findByRole("button", { name: "Reconnect Notion" }),
+ );
await waitFor(() => expect(opened).toHaveLength(1));
});
+/** Whether this tab has the focus, as `document.hasFocus` answers it, for the length of `run`. */
+async function withFocus(focused: boolean, run: () => Promise) {
+ const real = document.hasFocus;
+ document.hasFocus = () => focused;
+ try {
+ await run();
+ } finally {
+ document.hasFocus = real;
+ }
+}
+
+test("closing the sign-in tab ends the wait, rather than waiting for it forever", async () => {
+ await withFocus(true, async () => {
+ const { view, tabs } = mounted(appAccess({ state: "not_connected" }));
+ fireEvent.click(await view.findByRole("button", { name: "Connect Gmail" }));
+ await view.findByText("Waiting for Gmail…");
+ const [tab] = tabs;
+ if (!tab) throw new Error("no tab was claimed");
+ tab.closed = true;
+ await waitFor(
+ () =>
+ expect(
+ view.getByRole("button", { name: "Connect Gmail" }),
+ ).toBeTruthy(),
+ { timeout: 3_000 },
+ );
+ expect(view.queryByText("Waiting for Gmail…")).toBeNull();
+ expect(view.queryByText("Finish in the tab that opened.")).toBeNull();
+ });
+});
+
+test("a sign-in tab that only reads closed keeps the wait until this tab has the focus back", async () => {
+ await withFocus(false, async () => {
+ const { view, tabs } = mounted(appAccess({ state: "not_connected" }));
+ fireEvent.click(await view.findByRole("button", { name: "Connect Gmail" }));
+ await view.findByText("Waiting for Gmail…");
+ const [tab] = tabs;
+ if (!tab) throw new Error("no tab was claimed");
+ // A vendor's opener policy severs the handle while the person is still on its consent screen.
+ tab.closed = true;
+ await new Promise((resolve) => setTimeout(resolve, 1_500));
+ expect(view.getByText("Waiting for Gmail…")).toBeTruthy();
+ // They come back to this tab: there is an answer to show now.
+ window.dispatchEvent(new Event("focus"));
+ await waitFor(() =>
+ expect(view.queryByText("Waiting for Gmail…")).toBeNull(),
+ );
+ });
+});
+
+test("a Reconnect on the owner's account offers nothing: only the owner can reconnect it", async () => {
+ const { view } = mounted(
+ appAccess({ state: "reconnect", accountHolder: "owner" }),
+ );
+ await view.findByText(/Only the Bot's owner can reconnect it/);
+ expect(view.queryByRole("button")).toBeNull();
+ expect(view.queryByRole("link")).toBeNull();
+});
+
+test("a Shared app waiting on an administrator's approval offers nothing: the refused call already asked", async () => {
+ const { view } = mounted(
+ appAccess({
+ state: "shared_not_approved",
+ account: "shared",
+ accountMode: "shared",
+ }),
+ );
+ await view.findByText(/Only an administrator can approve it/);
+ expect(view.queryByRole("button")).toBeNull();
+});
+
+test.each([
+ [{ accountHolder: "owner" }, /owner's Gmail account/],
+ [{ asksFirst: true }, /asked to allow it the first time/],
+] as const)(
+ "ready on somebody else's Team Bot offers nothing to press",
+ async (overrides, sentence) => {
+ const { view } = mounted(appAccess(overrides));
+ await view.findByText(sentence);
+ expect(view.queryByRole("button")).toBeNull();
+ },
+);
+
test.each([
["disabled", "enable"],
["not_granted", "grant"],
@@ -184,7 +349,7 @@ test.each([
async (state, kind) => {
const { view, sent } = mounted(appAccess({ state, requestKind: kind }));
fireEvent.click(
- await view.findByRole("button", { name: "Request access" }),
+ await view.findByRole("button", { name: "Request access to Gmail" }),
);
await waitFor(() =>
expect(sent.find((call) => call.method === "POST")).toEqual({
@@ -209,8 +374,12 @@ test("a pending request reads Requested, and Withdraw cancels it", async () => {
}),
);
await view.findByText("Requested");
- expect(view.queryByRole("button", { name: "Request access" })).toBeNull();
- fireEvent.click(view.getByRole("button", { name: "Withdraw" }));
+ expect(
+ view.queryByRole("button", { name: "Request access to Gmail" }),
+ ).toBeNull();
+ fireEvent.click(
+ view.getByRole("button", { name: "Withdraw request for Gmail" }),
+ );
await waitFor(() =>
expect(
sent.some(
@@ -227,7 +396,9 @@ test("the server's refusal of a request is drawn beside the button", async () =>
appAccess({ state: "not_granted", requestKind: "grant" }),
{ status: 409, body: { error: "Gmail is ready to use." } },
);
- fireEvent.click(await view.findByRole("button", { name: "Request access" }));
+ fireEvent.click(
+ await view.findByRole("button", { name: "Request access to Gmail" }),
+ );
expect((await view.findByRole("alert")).textContent).toBe(
"Gmail is ready to use.",
);
@@ -240,3 +411,38 @@ test("a state with no request to file offers none, rather than a button the serv
await view.findByText(/organisation's Gmail account/);
expect(view.queryByRole("button")).toBeNull();
});
+
+test("the focus follows the press: Request access hands it to Withdraw, and Withdraw back", async () => {
+ const asked = appAccess({ state: "not_granted", requestKind: "grant" });
+ const { view, sent, swap } = mounted(asked);
+ const ask = await view.findByRole("button", {
+ name: "Request access to Gmail",
+ });
+ ask.focus();
+ fireEvent.click(ask);
+ await waitFor(() =>
+ expect(sent.some((call) => call.method === "POST")).toBe(true),
+ );
+ const pending = {
+ ...asked,
+ request: {
+ id: "req_1",
+ kind: "grant" as const,
+ createdAt: "2026-10-20T10:00:00.000Z",
+ },
+ };
+ await swap(pending);
+ const withdraw = await view.findByRole("button", {
+ name: "Withdraw request for Gmail",
+ });
+ expect(document.activeElement).toBe(withdraw);
+
+ fireEvent.click(withdraw);
+ await waitFor(() =>
+ expect(sent.some((call) => call.method === "DELETE")).toBe(true),
+ );
+ await swap(asked);
+ expect(document.activeElement).toBe(
+ await view.findByRole("button", { name: "Request access to Gmail" }),
+ );
+});
diff --git a/app/tests/app-access-card.test.tsx b/app/tests/app-access-card.test.tsx
index b4ead7add4..d9746cc06d 100644
--- a/app/tests/app-access-card.test.tsx
+++ b/app/tests/app-access-card.test.tsx
@@ -14,6 +14,7 @@ import type { ReactNode } from "react";
import { AppAccessCard } from "@/components/channels/app-access-card";
import { ServerToolLine } from "@/components/channels/chat-transcript";
import type { AppAccess } from "@/lib/app-access/queries";
+import { ActiveBotProvider, useActiveBot } from "@/lib/copilot/active-bot";
import { REFUSAL_MARKER } from "@/lib/plugins/tool-result";
import {
type ConnectPrompt,
@@ -121,7 +122,7 @@ test("not connected: names the app and offers Connect", async () => {
expect(drawn.textContent).toContain(
"You haven't connected your Gmail account yet.",
);
- expect(view.getByRole("button", { name: "Connect" })).toBeTruthy();
+ expect(view.getByRole("button", { name: "Connect Gmail" })).toBeTruthy();
});
test("connected since the refusal: says so and offers nothing stale", async () => {
@@ -156,7 +157,9 @@ test("a Shared app with no team account: Request access, not a sentence", async
expect(drawn.textContent).toContain(
"Your organisation's Gmail account isn't connected yet.",
);
- expect(view.getByRole("button", { name: "Request access" })).toBeTruthy();
+ expect(
+ view.getByRole("button", { name: "Request access to Gmail" }),
+ ).toBeTruthy();
});
test("an account the vendor lost: Reconnect, though the server still reads ready", async () => {
@@ -203,7 +206,7 @@ test("a team account the vendor lost: says an administrator has to reconnect it,
});
test.each([
- ["disabled", "enable", /Turned off by an administrator/],
+ ["disabled", "enable", /Gmail isn't switched on for this OpenBot/],
["not_granted", "grant", /This Bot hasn't been given Gmail/],
] as const)(
"the Bot asked about an app that is %s: Request access",
@@ -214,7 +217,9 @@ test.each([
);
const drawn = await card(view);
expect(drawn.textContent).toMatch(sentence);
- expect(view.getByRole("button", { name: "Request access" })).toBeTruthy();
+ expect(
+ view.getByRole("button", { name: "Request access to Gmail" }),
+ ).toBeTruthy();
},
);
@@ -225,11 +230,68 @@ test("asked about an app that is ready, it says so without claiming it was just
expect(drawn.textContent).toContain("Ready to use.");
});
-/*
- * The fallback to the Bot the surface declared is not asserted here: `bot-route-default-agent`'s
- * fixture replaces `useActiveBot` with a no-op for the whole process, so a declaration made in this
- * file is a no-op whenever bun has loaded that one first.
- */
+test("an account lost on somebody else's Team Bot that runs on its owner's: only the owner can reconnect it", async () => {
+ serving(
+ appAccess({
+ state: "ready",
+ accountHolder: "owner",
+ connectedAt: "2026-10-01T00:00:00.000Z",
+ }),
+ );
+ const view = mount(
+ ,
+ );
+ const drawn = await card(view);
+ expect(drawn.getAttribute("data-state")).toBe("reconnect");
+ expect(drawn.textContent).toContain(
+ "This Bot uses its owner's Gmail account, which needs reconnecting. Only the Bot's owner can reconnect it.",
+ );
+ expect(view.queryByRole("button")).toBeNull();
+ expect(view.queryByRole("link")).toBeNull();
+});
+
+test("a Shared app whose audience is not approved: says so, with nothing to press", async () => {
+ serving(
+ appAccess({
+ state: "shared_not_approved",
+ account: "shared",
+ accountMode: "shared",
+ }),
+ );
+ const view = mount();
+ const drawn = await card(view);
+ expect(drawn.textContent).toContain("Only an administrator can approve it.");
+ expect(view.queryByRole("button")).toBeNull();
+});
+
+/** Declares a Bot the way a channel does, around whatever it is given. */
+function Declaring({
+ botId,
+ children,
+}: {
+ botId: string;
+ children: ReactNode;
+}) {
+ useActiveBot(botId);
+ return <>{children}>;
+}
+
+test("an older refusal that names no Bot asks about the Bot the surface declared", async () => {
+ const asked = serving(appAccess({ state: "not_connected" }));
+ const view = mount(
+
+
+
+
+ ,
+ );
+ const drawn = await card(view);
+ expect(drawn.getAttribute("data-state")).toBe("not_connected");
+ expect(asked).toContain("/api/plugins/for/travel/apps/composio-gmail");
+});
+
test("with no Bot to ask about at all, it is the Connect card it always was", async () => {
const asked = serving(null);
const view = mount();
@@ -243,6 +305,24 @@ test("a live read that fails falls back to the Connect card for a connect refusa
expect(await view.findByText("Connect Gmail")).toBeTruthy();
});
+test("a live read that fails says so in the person's words, never the model's", async () => {
+ serving(null);
+ const view = mount(
+ ,
+ );
+ const alert = await view.findByRole("alert");
+ expect(alert.textContent).toBe(
+ "Gmail's status couldn't be loaded. Try again in a moment.",
+ );
+ expect(view.container.textContent).not.toContain("Do not browse");
+});
+
/*
* THROUGH THE TRANSCRIPT'S OWN LINE. The card is drawn from a server tool's answer — a refusal, or
* the `show_app_access` result — by `ServerToolLine`, and only those two are read for a marker.
@@ -287,3 +367,24 @@ test("an ordinary tool's answer is never read for a card, whatever it starts wit
await settleReactWork();
expect(view.container.querySelector("[data-app-access]")).toBeNull();
});
+
+test("show_app_access is named for the person while it runs, not by its identifier", () => {
+ const view = mount();
+ return waitFor(() => {
+ expect(view.getByText("Checking app access")).toBeTruthy();
+ expect(view.container.textContent).not.toContain(SHOW_APP_ACCESS_TOOL);
+ });
+});
+
+test("show_app_access about an app nobody can name draws the line, never its words to the model", async () => {
+ const view = mount(
+ ,
+ );
+ expect(await view.findByText("Checked app access")).toBeTruthy();
+ expect(view.container.textContent).not.toContain("Say so plainly");
+ // Nothing behind a disclosure either: the line is not expandable.
+ expect(view.container.querySelector("details")).toBeNull();
+});
diff --git a/app/tests/app-access-fixture.ts b/app/tests/app-access-fixture.ts
index 10930a6732..2a6a99e7e8 100644
--- a/app/tests/app-access-fixture.ts
+++ b/app/tests/app-access-fixture.ts
@@ -15,6 +15,9 @@ export function appAccess(overrides: Partial = {}): AppAccess {
authScheme: "OAUTH2",
accountMode: "personal",
state: "ready",
+ unavailable: null,
+ accountHolder: "you",
+ asksFirst: false,
granted: true,
connectedAt: null,
requestKind: null,
diff --git a/app/tests/app-access-mutations.test.ts b/app/tests/app-access-mutations.test.ts
index 771366abd4..4088cc2807 100644
--- a/app/tests/app-access-mutations.test.ts
+++ b/app/tests/app-access-mutations.test.ts
@@ -63,7 +63,6 @@ test("every app-access key sits under the plugin root, so plugin writes refresh
appAccessKeys.all,
appAccessKeys.bot("b"),
appAccessKeys.app("b", "Gmail"),
- appAccessKeys.mine(),
appAccessKeys.inbox("composio-gmail"),
])
expect(key.slice(0, 1)).toEqual([...pluginKeys.all]);
diff --git a/app/tests/bot-access-apps.test.tsx b/app/tests/bot-access-apps.test.tsx
index 0efee53595..c5de93cedd 100644
--- a/app/tests/bot-access-apps.test.tsx
+++ b/app/tests/bot-access-apps.test.tsx
@@ -160,7 +160,7 @@ test("an app this person has not connected says so and offers Connect", async ()
expect(
gmail.getByText("You haven't connected your Gmail account yet."),
).toBeTruthy();
- expect(gmail.getByRole("button", { name: "Connect" })).toBeTruthy();
+ expect(gmail.getByRole("button", { name: "Connect Gmail" })).toBeTruthy();
});
test("an app only an administrator can fix offers Request access, and a pending one reads Requested", async () => {
@@ -195,11 +195,19 @@ test("an app only an administrator can fix offers Request access, and a pending
"Your organisation's Slack account isn't connected yet. Only an administrator can connect it.",
),
).toBeTruthy();
- expect(slack.getByRole("button", { name: "Request access" })).toBeTruthy();
+ expect(
+ slack.getByRole("button", { name: "Request access to Slack" }),
+ ).toBeTruthy();
const notion = await row(view, "notion");
- expect(notion.getByText(/Turned off by an administrator/)).toBeTruthy();
+ expect(
+ notion.getByText(
+ "Notion isn't switched on for this OpenBot. Only an administrator can switch it on.",
+ ),
+ ).toBeTruthy();
expect(notion.getByText("Requested")).toBeTruthy();
- expect(notion.getByRole("button", { name: "Withdraw" })).toBeTruthy();
+ expect(
+ notion.getByRole("button", { name: "Withdraw request for Notion" }),
+ ).toBeTruthy();
});
test("an administrator keeps the way to this Bot's grants on each app, beside the action", async () => {
@@ -211,7 +219,55 @@ test("an administrator keeps the way to this Bot's grants on each app, beside th
expect(manage.getAttribute("href")).toBe(
"/admin/plugins/composio-gmail/bots/expenses",
);
- expect(gmail.getByRole("button", { name: "Connect" })).toBeTruthy();
+ expect(gmail.getByRole("button", { name: "Connect Gmail" })).toBeTruthy();
+});
+
+test("an app's sentence is never cut short, so who can fix it stays on screen at a phone's width", async () => {
+ serving({
+ apps: [
+ appAccess({
+ state: "disabled",
+ unavailable: "composio_unconfigured",
+ requestKind: "enable",
+ }),
+ ],
+ });
+ const gmail = await row(draw(), "composio-gmail");
+ const sentence = gmail.getByText(
+ "Gmail is not available on this OpenBot yet. An administrator has to finish setting it up.",
+ );
+ expect(sentence.className).toContain("line-clamp-none");
+ expect(sentence.className).not.toContain("line-clamp-2");
+});
+
+test("on somebody else's Team Bot, an app on its owner's account says so, and an owner's reconnection offers nothing", async () => {
+ serving({
+ apps: [
+ appAccess({ accountHolder: "owner" }),
+ appAccess({
+ key: "notion",
+ displayName: "Notion",
+ provider: "catalogue",
+ connect: "oauth",
+ state: "reconnect",
+ accountHolder: "owner",
+ }),
+ ],
+ });
+ const view = draw();
+ const gmail = await row(view, "composio-gmail");
+ expect(
+ gmail.getByText(
+ /^Ready to use\. This Bot uses its owner's Gmail account\./,
+ ),
+ ).toBeTruthy();
+ const notion = await row(view, "notion");
+ expect(
+ notion.getByText(
+ "This Bot uses its owner's Notion account, which needs reconnecting. Only the Bot's owner can reconnect it.",
+ ),
+ ).toBeTruthy();
+ expect(notion.queryByRole("button")).toBeNull();
});
test("a person who is not an administrator gets no way to the grants", async () => {
diff --git a/app/tests/tool-activity.test.tsx b/app/tests/tool-activity.test.tsx
index 1f27221068..529e470613 100644
--- a/app/tests/tool-activity.test.tsx
+++ b/app/tests/tool-activity.test.tsx
@@ -201,6 +201,23 @@ test("the app-access card stays out of a group, even before its answer arrives",
}
});
+test("show_app_access is named in words in a step's details too, and keeps its words to the model out", () => {
+ const running = stepDetails(call("card", "show_app_access", { app: "x" }));
+ expect(running.label).toBe("Checking app access");
+ const done = stepDetails(
+ call(
+ "card",
+ "show_app_access",
+ { app: "x" },
+ JSON.stringify(
+ "This deployment has no app called x, so there is nothing to show. Say so plainly; do not browse to it instead.",
+ ),
+ ),
+ );
+ expect(done.label).toBe("Checked app access");
+ expect(done.output).toBeUndefined();
+});
+
test("the summary says what was done, never which provider searched", () => {
const steps = [
search(),
diff --git a/app/tests/tool-name.test.ts b/app/tests/tool-name.test.ts
index 7a9c5bbf4d..2f67894305 100644
--- a/app/tests/tool-name.test.ts
+++ b/app/tests/tool-name.test.ts
@@ -148,3 +148,18 @@ describe("naming a tool call", () => {
});
});
});
+
+describe("this deployment's own tools", () => {
+ test("show_app_access is named for what it is doing, then for what it did", () => {
+ expect(readToolName("show_app_access", true)).toEqual({
+ label: "Checking app access",
+ });
+ expect(readToolName("show_app_access")).toEqual({
+ label: "Checked app access",
+ });
+ });
+
+ test("any other bare name is left exactly as it is", () => {
+ expect(readToolName("run_command", true)).toEqual({ label: "run_command" });
+ });
+});
From 8f22576248927d66785d2439c1f267b3aaec2c1c Mon Sep 17 00:00:00 2001
From: Guido Vizoso
Date: Wed, 21 Oct 2026 16:07:44 -0300
Subject: [PATCH 29/33] Note read-only approvals, Team Bot accounts and lost
connections in the changelog
---
CHANGELOG.md | 19 ++++++++++++++-----
1 file changed, 14 insertions(+), 5 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f7fa72c2dd..52c7e2be81 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -38,8 +38,10 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged.
### Each app on a Bot says whether you can use it, and you can ask an administrator for the rest
A Bot's page now says, for every work app the Bot holds, what that app is for the person looking:
-ready, not connected yet, needing reconnecting, waiting on your organisation's shared account, turned
-off by an administrator, or not given to this Bot. Each comes with the one thing you can do about it —
+ready, not connected yet, needing reconnecting, waiting on your organisation's shared account or on
+an administrator's approval of who the Bot reaches, not switched on or not finished setting up, or not
+given to this Bot. On somebody else's Team Bot it says whose account the app runs on: the owner's,
+which only the owner can reconnect, or your own, which the Bot asks to use the first time. Each comes with the one thing you can do about it —
Connect or Reconnect your own account, or Request access where only an administrator can help — and
apps are named by their own name ("Gmail", not "Composio Gmail"). In a conversation, a Bot that needs
an app it cannot use now puts the same card in front of you instead of a sentence: an account you have
@@ -47,9 +49,16 @@ not connected, a Shared app with no team account, an account the vendor no longe
a new `show_app_access` tool every Bot holds — an app that is switched off or not granted to it. The
card reads the app's current state when it is drawn, so it never offers a step already taken.
Administrators see waiting requests at the top of Plugins, a count on each app's row, and a Requests
-section on the app's own page, where Approve switches the app on and grants it to the Bot in one press
-(or says what has to be done first) and Dismiss closes the request. Requests name who asked; nobody's
-connection status is shown to administrators.
+section on the app's own page, where Approve switches the app on and grants the Bot the app's
+read-only actions in one press (or says what has to be done first; other actions are chosen on the
+Bot's page for the app) and Dismiss closes the request. Requests name who asked; nobody's connection
+status is shown to administrators. A person may have 20 requests waiting at once.
+
+A connection the vendor turns out to have lost now reads as lost everywhere once a call finds out: a
+Composio account the vendor no longer holds is removed so it can be connected again, and a Google
+Drive or Notion connection whose refresh token the vendor refuses is marked withdrawn, with Reconnect
+on the card instead of a tool error. A Memory source that fails for the same reasons says so in words
+rather than showing the card's marker.
On a deployment with no `COMPOSIO_API_KEY`, Settings → Connected accounts no longer lists Composio
apps, which nobody could connect there. An app's own page, reached from an older link, says it is not
From 25541a6a036111f62f16ea820904fac2a64836c6 Mon Sep 17 00:00:00 2001
From: Guido Vizoso
Date: Wed, 21 Oct 2026 16:07:58 -0300
Subject: [PATCH 30/33] Answer authorizationId: null from the app-access test
broker
Once the stub declares authorizations: null it is a broker literal the pairing
guard reads, and a broker with no authorization flow answers a mint with
authorizationId: null.
---
server/tests/app-access-routes.integration.test.ts | 5 ++++-
1 file changed, 4 insertions(+), 1 deletion(-)
diff --git a/server/tests/app-access-routes.integration.test.ts b/server/tests/app-access-routes.integration.test.ts
index 182f21939d..2d5b595958 100644
--- a/server/tests/app-access-routes.integration.test.ts
+++ b/server/tests/app-access-routes.integration.test.ts
@@ -115,7 +115,10 @@ const stub = stubBroker({
listApps: async () => directory,
ensureAuthConfig: async () => "created",
deleteAuthConfig: async () => {},
- authorize: async () => ({ redirectUrl: "https://vendor.example/consent" }),
+ authorize: async () => ({
+ redirectUrl: "https://vendor.example/consent",
+ authorizationId: null,
+ }),
isConnected: async () => true,
revoke: async () => true,
connectionFields: async () => [],
From aa6d0da93ae27046e83382b78782a7b577ac2928 Mon Sep 17 00:00:00 2001
From: Guido Vizoso
Date: Wed, 21 Oct 2026 16:08:34 -0300
Subject: [PATCH 31/33] Give the app-access test's directory apps the counts
main added
BrokerApp gained classifiedCount, permissionCount and scopedActionCount on
main, and typecheck:tests reported the directory fixtures as missing them.
They are null here, as an app whose actions were never classified.
---
server/tests/app-access-routes.integration.test.ts | 9 +++++++++
1 file changed, 9 insertions(+)
diff --git a/server/tests/app-access-routes.integration.test.ts b/server/tests/app-access-routes.integration.test.ts
index 2d5b595958..bd24b34143 100644
--- a/server/tests/app-access-routes.integration.test.ts
+++ b/server/tests/app-access-routes.integration.test.ts
@@ -89,6 +89,9 @@ const directory: BrokerApp[] = [
logo: null,
categories: [],
actionCount: 1,
+ classifiedCount: null,
+ permissionCount: null,
+ scopedActionCount: null,
connection: { kind: "consent" },
},
{
@@ -98,6 +101,9 @@ const directory: BrokerApp[] = [
logo: null,
categories: [],
actionCount: 1,
+ classifiedCount: null,
+ permissionCount: null,
+ scopedActionCount: null,
connection: { kind: "consent" },
},
];
@@ -1081,6 +1087,9 @@ describe("on an Arcade deployment", () => {
logo: null,
categories: [],
actionCount: 1,
+ classifiedCount: null,
+ permissionCount: null,
+ scopedActionCount: null,
connection: { kind: "consent" },
},
],
From b32716c3238364a53ab625aa972c796e0741bc03 Mon Sep 17 00:00:00 2001
From: Guido Vizoso
Date: Wed, 21 Oct 2026 16:21:01 -0300
Subject: [PATCH 32/33] Fill the new AppAccess fields in the app-access tool's
fixture
The review fixes added unavailable, accountHolder and asksFirst to AppAccess,
and typecheck:tests reported the tool test's fixture as missing them.
---
server/tests/app-access-tool.test.ts | 3 +++
1 file changed, 3 insertions(+)
diff --git a/server/tests/app-access-tool.test.ts b/server/tests/app-access-tool.test.ts
index 1496510f2a..74fcbc06b3 100644
--- a/server/tests/app-access-tool.test.ts
+++ b/server/tests/app-access-tool.test.ts
@@ -29,6 +29,9 @@ function access(state: AppAccessState): AppAccess {
authScheme: "OAUTH2",
accountMode: "personal",
state,
+ unavailable: state === "disabled" ? "absent" : null,
+ accountHolder: "you",
+ asksFirst: false,
granted: state !== "not_granted" && state !== "disabled",
connectedAt: null,
requestKind: null,
From 6db9390b8fc6e60ed135e1e484c7c6639bc28f25 Mon Sep 17 00:00:00 2001
From: Guido Vizoso
Date: Wed, 21 Oct 2026 16:21:19 -0300
Subject: [PATCH 33/33] Say composio_unconfigured covers either broker in the
app's type note
---
app/src/lib/app-access/queries.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/app/src/lib/app-access/queries.ts b/app/src/lib/app-access/queries.ts
index 7865ef2e84..2c4313f2b5 100644
--- a/app/src/lib/app-access/queries.ts
+++ b/app/src/lib/app-access/queries.ts
@@ -34,7 +34,7 @@ export type AppAccessState =
/**
* Why an app is switched off. `absent` — nobody switched it on here. `composio_unconfigured` — a
- * Composio app on a deployment with no Composio key. The other three — a scheme the server cannot
+ * brokered app (Composio or Arcade) on a deployment with no key for its broker. The other three — a scheme the server cannot
* read, an OAuth client nobody registered, a token nobody pasted — are a set-up left unfinished.
*/
export type AppUnavailable =