Skip to content

Commit e67a76d

Browse files
authored
Show each app's state on a Bot for the person looking, and let them ask for access (#814)
* Share enabling a Composio app and a Shared grant's approval with other callers Moves the body of POST /composio/apps into enableComposioApp and the Shared-approval rule of POST /grants into sharedGrantApproval, unchanged, so approving an access request can switch an app on and grant it through the same code. * Say what each app on a Bot is for the person looking, and let them ask an administrator for the rest GET /api/plugins/for/:agentId/apps and /apps/:app answer one of six states per app (ready, not_connected, reconnect, shared_not_connected, disabled, not_granted) from the tables the call gates read. Migration 0057 adds app_access_requests: a person files enable, grant or connect_shared as the state dictates, deduplicated by a partial unique index; an administrator approves by doing the change, or dismisses. Every step and refusal is audited as app_access.*. * Browser queries and mutations for app access and access requests * Document per-person app states and access requests * Show each app's state and next step for you on a Bot's page A Bot's Granted list now names each app by its own name and says what it is for the person looking — ready, not connected, needs reconnecting, waiting on the team account, turned off, or not given to this Bot — with Connect, Reconnect, Request access or Requested beside it. One piece draws the state and the action so the chat card can reuse it. Connect starts the same connect route Settings uses, in a new tab, lifted out of ConnectAppCard; key apps and Composio reconnects go to the app's Settings page. AppAccess gains connectedAt, the time the account the app runs in was connected. * 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. * 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. * Describe app states on the Bot page, the chat card and access requests * Leave Composio apps off Connected accounts when the deployment has no Composio key * Say a Composio app is not available yet, rather than naming the key, on its account page * Test Connected accounts on a deployment with no Composio key * Note Connected accounts on a deployment with no Composio key in the changelog * Let React finish before tearing down the Composio account test's browser * Keep the Pydantic AI harness on anthropic 1.x * 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. * 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. * 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. * 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. * 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. * Format the enableComposioApp refusal the merge resolution left long * 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. * 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. * 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. * Forget a Composio account once a call finds the vendor no longer holds it * Mark a connection withdrawn when the vendor refuses its refresh token * Say a Memory source's app refusal in words instead of storing the card's marker * Stop the Bot route fixture replacing the whole active-bot module for every file * Draw the new app-access states, keep model text off screen, and end the sign-in wait when its tab closes * Note read-only approvals, Team Bot accounts and lost connections in the changelog * 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. * 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. * 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. * Say composio_unconfigured covers either broker in the app's type note
1 parent f1961c3 commit e67a76d

66 files changed

Lines changed: 18714 additions & 349 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎CHANGELOG.md‎

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged.
88

99
## Unreleased
1010

11-
**Before upgrading.** Five things change for an existing deployment:
11+
**Before upgrading.** Six things change for an existing deployment:
1212
- A hosted computer's browser is now headed by default. It uses a little more memory than headless
1313
did; set `COMPUTER_BROWSER_MODE=headless` (Helm: `computers.browserMode: headless`) to keep the old
1414
behaviour.
@@ -31,6 +31,39 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged.
3131
- Migration `0056_sign_in_code_step` widens the check on `sign_in_requests.status` to allow
3232
`code_required` (below). A server and a computer must be upgraded together: an older computer has
3333
no `/sign-in/code`.
34+
- Migration `0060_app_access_requests` adds one table, `app_access_requests`, where a person's
35+
request for an administrator to switch on a work app, grant it to a Bot, or connect a Shared app's
36+
team account is kept, with who answered it and when. It starts empty and nothing is backfilled.
37+
38+
### Each app on a Bot says whether you can use it, and you can ask an administrator for the rest
39+
40+
A Bot's page now says, for every work app the Bot holds, what that app is for the person looking:
41+
ready, not connected yet, needing reconnecting, waiting on your organisation's shared account or on
42+
an administrator's approval of who the Bot reaches, not switched on or not finished setting up, or not
43+
given to this Bot. On somebody else's Team Bot it says whose account the app runs on: the owner's,
44+
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 —
45+
Connect or Reconnect your own account, or Request access where only an administrator can help — and
46+
apps are named by their own name ("Gmail", not "Composio Gmail"). In a conversation, a Bot that needs
47+
an app it cannot use now puts the same card in front of you instead of a sentence: an account you have
48+
not connected, a Shared app with no team account, an account the vendor no longer holds, and — through
49+
a new `show_app_access` tool every Bot holds — an app that is switched off or not granted to it. The
50+
card reads the app's current state when it is drawn, so it never offers a step already taken.
51+
Administrators see waiting requests at the top of Plugins, a count on each app's row, and a Requests
52+
section on the app's own page, where Approve switches the app on and grants the Bot the app's
53+
read-only actions in one press (or says what has to be done first; other actions are chosen on the
54+
Bot's page for the app) and Dismiss closes the request. Requests name who asked; nobody's connection
55+
status is shown to administrators. A person may have 20 requests waiting at once.
56+
57+
A connection the vendor turns out to have lost now reads as lost everywhere once a call finds out: a
58+
Composio account the vendor no longer holds is removed so it can be connected again, and a Google
59+
Drive or Notion connection whose refresh token the vendor refuses is marked withdrawn, with Reconnect
60+
on the card instead of a tool error. A Memory source that fails for the same reasons says so in words
61+
rather than showing the card's marker.
62+
63+
On a deployment with no `COMPOSIO_API_KEY`, Settings → Connected accounts no longer lists Composio
64+
apps, which nobody could connect there. An app's own page, reached from an older link, says it is not
65+
available yet and that an administrator has to finish setting it up, instead of naming the setting.
66+
The administrator's page for the app still names `COMPOSIO_API_KEY`.
3467

3568
### A new desktop install starts its server again
3669

‎app/src/components/bot-profile/access.tsx‎

Lines changed: 146 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,12 @@ import { useMutation, useQuery } from "@tanstack/react-query";
33
import { Link } from "@tanstack/react-router";
44
import { Fragment } from "react";
55
import { HandoffPanel } from "@/components/agents/handoff-panel";
6+
import {
7+
AppAccessAction,
8+
appAccessSentence,
9+
} from "@/components/plugins/app-access-action";
10+
import { PluginLogo } from "@/components/plugins/plugin-logo";
11+
import { Button } from "@/components/ui/button";
612
import {
713
PageEmpty,
814
PageRows,
@@ -19,6 +25,7 @@ import {
1925
import { Separator } from "@/components/ui/separator";
2026
import { Switch } from "@/components/ui/switch";
2127
import type { AgentProfile } from "@/lib/agents/queries";
28+
import { botAppsQueryOptions } from "@/lib/app-access/queries";
2229
import { currentUserQueryOptions } from "@/lib/auth/queries";
2330
import { setPluginGrantMutationOptions } from "@/lib/plugins/mutations";
2431
import {
@@ -29,7 +36,12 @@ import {
2936
import { readToolName } from "@/lib/plugins/tool-name";
3037
import { queryClient } from "@/query-client";
3138

32-
/** "google-drive" as "Google Drive": the connector key, said the way a person would. */
39+
/**
40+
* "google-drive" as "Google Drive": the connector key, said the way a person would.
41+
*
42+
* ONLY FOR A CONNECTOR THE APP-ACCESS READ DID NOT NAME. Every app it names is drawn by its own
43+
* display name — "Gmail", where this would have said "Composio Gmail".
44+
*/
3345
function connectorName(key: string): string {
3446
return key
3547
.split(/[-_]/)
@@ -147,72 +159,130 @@ function YourSkills({ agentId }: { agentId: string }) {
147159
}
148160

149161
/**
150-
* Everything this Bot holds, read from the same snapshot a run is offered. Read-only: apps are
151-
* granted by an administrator, who gets a link to where that is decided.
162+
* Everything this Bot holds, read from the same snapshot a run is offered, and what each app is for
163+
* the person looking.
164+
*
165+
* READ-ONLY FOR GRANTS, NOT FOR THE PERSON'S OWN PART. Apps are granted by an administrator, who gets
166+
* a link to where that is decided. But a Bot that holds Gmail is no use to somebody who has not
167+
* connected their Gmail, and this list used to say "Gmail · 4 tools" to everybody alike — so each
168+
* app now says what it is for the person reading (`GET /api/plugins/for/:agentId/apps`) with the one
169+
* thing they can do about it: Connect, Reconnect, Request access, or Requested. The state is the
170+
* server's and is drawn as it comes; see `AppAccessAction`.
171+
*
172+
* ADMINISTRATORS SEE THEIR OWN STATE HERE TOO, AND NOBODY ELSE'S. The app-access read answers for
173+
* whoever is asking, exactly as the connections read does, so this cannot become a list of who has
174+
* connected what.
152175
*/
153176
function Granted({ agentId }: { agentId: string }) {
154177
const me = useQuery(currentUserQueryOptions()).data;
155178
const plugins = useQuery(agentPluginsQueryOptions(agentId));
156-
if (plugins.isPending) return null;
179+
const apps = useQuery(botAppsQueryOptions(agentId));
180+
// Both, so no row is drawn without its state and then redrawn with one a moment later.
181+
if (plugins.isPending || apps.isPending) return null;
157182
if (!plugins.data) {
158183
return (
159184
<p className="mt-6 text-destructive text-sm" role="alert">
160185
What this Bot may reach could not be loaded.
161186
</p>
162187
);
163188
}
164-
const connectors = [...connectorTools(plugins.data).entries()];
189+
const admin = me?.role === "admin";
190+
const tools = connectorTools(plugins.data);
191+
const states = apps.data ?? [];
192+
/*
193+
* Every app the Bot holds a grant for, by the app's own name, and then any connector the grant
194+
* snapshot names that the app-access read did not — which is none on a healthy deployment, and
195+
* keeps a row from vanishing on one where the second read failed.
196+
*/
197+
const described = new Set(states.map((app) => app.key));
198+
const rest = [...tools.entries()].filter(([key]) => !described.has(key));
165199
const skills = plugins.data.skills;
200+
const count = states.length + rest.length + skills.length;
166201
return (
167202
<PageSection
168-
description="What this Bot may reach when it works. Anything not listed is refused when called."
203+
description="What this Bot may reach when it works, and whether each app is ready for you. Anything not listed is refused when called."
169204
title="Granted"
170205
>
171-
{connectors.length === 0 && skills.length === 0 ? (
206+
{apps.error ? (
207+
<p className="mt-4 text-destructive text-sm" role="alert">
208+
Whether each app is ready for you could not be loaded.
209+
</p>
210+
) : null}
211+
{count === 0 ? (
172212
<PageEmpty>
173213
Nothing granted yet. An administrator grants apps from the Plugins
174214
screens; until then this Bot can converse, and nothing more.
175215
</PageEmpty>
176216
) : (
177217
<PageRows>
178-
{connectors.map(([key, labels], index) => (
218+
{states.map((app, index) => {
219+
const labels = tools.get(app.key) ?? [];
220+
return (
221+
<Fragment key={app.key}>
222+
{index > 0 ? <Separator /> : null}
223+
<Item data-app={app.key} data-state={app.state} size="sm">
224+
<ItemMedia variant="icon">
225+
<PluginLogo logo={app.logo} />
226+
</ItemMedia>
227+
<ItemContent>
228+
<ItemTitle>{app.displayName}</ItemTitle>
229+
{/*
230+
* Unclamped: the sentence ends on who can fix it, and two lines at a phone's
231+
* width cut exactly that off.
232+
*/}
233+
<ItemDescription className="line-clamp-none">
234+
{app.state === "ready"
235+
? [appAccessSentence(app)]
236+
.concat(labels.length > 0 ? [toolList(labels)] : [])
237+
.join(" ")
238+
: appAccessSentence(app)}
239+
</ItemDescription>
240+
</ItemContent>
241+
<ItemActions>
242+
{app.state === "ready" ? (
243+
<ToolCount count={labels.length} />
244+
) : (
245+
<AppAccessAction access={app} agentId={agentId} />
246+
)}
247+
{admin ? (
248+
<ManageLink
249+
agentId={agentId}
250+
appKey={app.key}
251+
title={app.displayName}
252+
/>
253+
) : null}
254+
</ItemActions>
255+
</Item>
256+
</Fragment>
257+
);
258+
})}
259+
{rest.map(([key, labels], index) => (
179260
<Fragment key={key}>
180-
{index > 0 ? <Separator /> : null}
181-
<Item
182-
render={
183-
me?.role === "admin" ? (
184-
<Link
185-
params={{ agentId, key }}
186-
to="/admin/plugins/$key/bots/$agentId"
187-
/>
188-
) : undefined
189-
}
190-
size="sm"
191-
>
261+
{states.length + index > 0 ? <Separator /> : null}
262+
<Item size="sm">
192263
<ItemMedia variant="icon">
193264
<IconApps />
194265
</ItemMedia>
195266
<ItemContent>
196267
<ItemTitle>{connectorName(key)}</ItemTitle>
197-
<ItemDescription>
198-
{labels.slice(0, 4).join(", ")}
199-
{labels.length > 4 ? ` and ${labels.length - 4} more` : ""}
200-
</ItemDescription>
268+
<ItemDescription>{toolList(labels)}</ItemDescription>
201269
</ItemContent>
202270
<ItemActions>
203-
<span className="text-muted-foreground text-sm tabular-nums">
204-
{labels.length} {labels.length === 1 ? "tool" : "tools"}
205-
</span>
206-
{me?.role === "admin" ? (
207-
<IconChevronRight className="size-4 text-muted-foreground" />
271+
<ToolCount count={labels.length} />
272+
{admin ? (
273+
<ManageLink
274+
agentId={agentId}
275+
appKey={key}
276+
title={connectorName(key)}
277+
/>
208278
) : null}
209279
</ItemActions>
210280
</Item>
211281
</Fragment>
212282
))}
213283
{skills.map((skill, index) => (
214284
<Fragment key={skill.slug}>
215-
{connectors.length + index > 0 ? <Separator /> : null}
285+
{states.length + rest.length + index > 0 ? <Separator /> : null}
216286
<Item size="sm">
217287
<ItemMedia variant="icon">
218288
<IconBox />
@@ -232,3 +302,49 @@ function Granted({ agentId }: { agentId: string }) {
232302
</PageSection>
233303
);
234304
}
305+
306+
/** The first few tool labels, and how many more. */
307+
function toolList(labels: string[]): string {
308+
return `${labels.slice(0, 4).join(", ")}${labels.length > 4 ? ` and ${labels.length - 4} more` : ""}`;
309+
}
310+
311+
function ToolCount({ count }: { count: number }) {
312+
return (
313+
<span className="text-muted-foreground text-sm tabular-nums">
314+
{count} {count === 1 ? "tool" : "tools"}
315+
</span>
316+
);
317+
}
318+
319+
/**
320+
* Where an administrator decides which of the app's tools this Bot holds.
321+
*
322+
* A CHEVRON OF ITS OWN RATHER THAN THE WHOLE ROW. The row used to be the link, which was right while
323+
* it held nothing else; now it can hold Connect or Request access, and a button inside a link is two
324+
* controls answering one click. The destination is unchanged.
325+
*/
326+
function ManageLink({
327+
agentId,
328+
appKey,
329+
title,
330+
}: {
331+
agentId: string;
332+
appKey: string;
333+
title: string;
334+
}) {
335+
return (
336+
<Button
337+
aria-label={`Manage ${title} on this Bot`}
338+
render={
339+
<Link
340+
params={{ agentId, key: appKey }}
341+
to="/admin/plugins/$key/bots/$agentId"
342+
/>
343+
}
344+
size="icon-sm"
345+
variant="ghost"
346+
>
347+
<IconChevronRight className="size-4 text-muted-foreground" />
348+
</Button>
349+
);
350+
}

0 commit comments

Comments
 (0)