Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
aef4d4c
Share enabling a Composio app and a Shared grant's approval with othe…
guidovizoso Oct 20, 2026
f536851
Say what each app on a Bot is for the person looking, and let them as…
guidovizoso Oct 20, 2026
d6b03d9
Browser queries and mutations for app access and access requests
guidovizoso Oct 20, 2026
bc89bc8
Document per-person app states and access requests
guidovizoso Oct 20, 2026
a7199e0
Show each app's state and next step for you on a Bot's page
guidovizoso Oct 20, 2026
444fb18
List access requests for administrators to approve or dismiss
guidovizoso Oct 20, 2026
322dce2
Draw an app card in chat for every way a Bot cannot use an app
guidovizoso Oct 20, 2026
6cbe79a
Describe app states on the Bot page, the chat card and access requests
guidovizoso Oct 20, 2026
969d279
Leave Composio apps off Connected accounts when the deployment has no…
guidovizoso Oct 21, 2026
b95bbde
Say a Composio app is not available yet, rather than naming the key, …
guidovizoso Oct 21, 2026
1df882a
Test Connected accounts on a deployment with no Composio key
guidovizoso Oct 21, 2026
fd09ac9
Note Connected accounts on a deployment with no Composio key in the c…
guidovizoso Oct 21, 2026
fc368b1
Let React finish before tearing down the Composio account test's browser
guidovizoso Oct 21, 2026
dc6f40a
Keep the Pydantic AI harness on anthropic 1.x
guidovizoso Oct 21, 2026
c1c102c
Merge remote-tracking branch 'origin/main' into HEAD
guidovizoso Oct 21, 2026
4c62cbc
Hand the active broker to enableComposioApp from both of its callers
guidovizoso Oct 21, 2026
dc55298
Build the app-access route test's broker with stubBroker
guidovizoso Oct 21, 2026
e406e7b
Give the vendor-gone personal fixture its own vendor id
guidovizoso Oct 21, 2026
94769aa
Assert the missing approve body on its own, not as an undefined key
guidovizoso Oct 21, 2026
08a7da1
Re-key two allow-listed composio-adapter assertions after the merge
guidovizoso Oct 21, 2026
fb84008
Format the enableComposioApp refusal the merge resolution left long
guidovizoso Oct 21, 2026
dbef2f5
Read app access through the store's broker, not Composio by name
guidovizoso Oct 21, 2026
c1e5ace
Declare authorizesPerAction in the unconfigured-Composio page fixture
guidovizoso Oct 21, 2026
87da736
Approve with read-only actions, claim a request before acting, and wo…
guidovizoso Oct 21, 2026
505551e
Forget a Composio account once a call finds the vendor no longer hold…
guidovizoso Oct 21, 2026
5e25a76
Mark a connection withdrawn when the vendor refuses its refresh token
guidovizoso Oct 21, 2026
4438568
Say a Memory source's app refusal in words instead of storing the car…
guidovizoso Oct 21, 2026
1d2cdbd
Stop the Bot route fixture replacing the whole active-bot module for …
guidovizoso Oct 21, 2026
08210ba
Draw the new app-access states, keep model text off screen, and end t…
guidovizoso Oct 21, 2026
8f22576
Note read-only approvals, Team Bot accounts and lost connections in t…
guidovizoso Oct 21, 2026
25541a6
Answer authorizationId: null from the app-access test broker
guidovizoso Oct 21, 2026
aa6d0da
Give the app-access test's directory apps the counts main added
guidovizoso Oct 21, 2026
af490b5
Merge the OB-05 review fixes into the branch adapted to main
guidovizoso Oct 21, 2026
b32716c
Fill the new AppAccess fields in the app-access tool's fixture
guidovizoso Oct 21, 2026
6db9390
Say composio_unconfigured covers either broker in the app's type note
guidovizoso Oct 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 34 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged.

## Unreleased

**Before upgrading.** Five things change for an existing deployment:
**Before upgrading.** Six things change for an existing deployment:
- A hosted computer's browser is now headed by default. It uses a little more memory than headless
did; set `COMPUTER_BROWSER_MODE=headless` (Helm: `computers.browserMode: headless`) to keep the old
behaviour.
Expand All @@ -31,6 +31,39 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged.
- Migration `0056_sign_in_code_step` widens the check on `sign_in_requests.status` to allow
`code_required` (below). A server and a computer must be upgraded together: an older computer has
no `/sign-in/code`.
- Migration `0060_app_access_requests` adds one table, `app_access_requests`, where a person's
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 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
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 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
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 desktop install starts its server again

Expand Down
176 changes: 146 additions & 30 deletions app/src/components/bot-profile/access.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,12 @@ import { useMutation, useQuery } from "@tanstack/react-query";
import { Link } from "@tanstack/react-router";
import { Fragment } from "react";
import { HandoffPanel } from "@/components/agents/handoff-panel";
import {
AppAccessAction,
appAccessSentence,
} from "@/components/plugins/app-access-action";
import { PluginLogo } from "@/components/plugins/plugin-logo";
import { Button } from "@/components/ui/button";
import {
PageEmpty,
PageRows,
Expand All @@ -19,6 +25,7 @@ import {
import { Separator } from "@/components/ui/separator";
import { Switch } from "@/components/ui/switch";
import type { AgentProfile } from "@/lib/agents/queries";
import { botAppsQueryOptions } from "@/lib/app-access/queries";
import { currentUserQueryOptions } from "@/lib/auth/queries";
import { setPluginGrantMutationOptions } from "@/lib/plugins/mutations";
import {
Expand All @@ -29,7 +36,12 @@ import {
import { readToolName } from "@/lib/plugins/tool-name";
import { queryClient } from "@/query-client";

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

/**
* Everything this Bot holds, read from the same snapshot a run is offered. Read-only: apps are
* granted by an administrator, who gets a link to where that is decided.
* Everything this Bot holds, read from the same snapshot a run is offered, and what each app is for
* the person looking.
*
* READ-ONLY FOR GRANTS, NOT FOR THE PERSON'S OWN PART. Apps are granted by an administrator, who gets
* a link to where that is decided. But a Bot that holds Gmail is no use to somebody who has not
* connected their Gmail, and this list used to say "Gmail · 4 tools" to everybody alike — so each
* app now says what it is for the person reading (`GET /api/plugins/for/:agentId/apps`) with the one
* thing they can do about it: Connect, Reconnect, Request access, or Requested. The state is the
* server's and is drawn as it comes; see `AppAccessAction`.
*
* ADMINISTRATORS SEE THEIR OWN STATE HERE TOO, AND NOBODY ELSE'S. The app-access read answers for
* whoever is asking, exactly as the connections read does, so this cannot become a list of who has
* connected what.
*/
function Granted({ agentId }: { agentId: string }) {
const me = useQuery(currentUserQueryOptions()).data;
const plugins = useQuery(agentPluginsQueryOptions(agentId));
if (plugins.isPending) return null;
const apps = useQuery(botAppsQueryOptions(agentId));
// Both, so no row is drawn without its state and then redrawn with one a moment later.
if (plugins.isPending || apps.isPending) return null;
if (!plugins.data) {
return (
<p className="mt-6 text-destructive text-sm" role="alert">
What this Bot may reach could not be loaded.
</p>
);
}
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 (
<PageSection
description="What this Bot may reach when it works. Anything not listed is refused when called."
description="What this Bot may reach when it works, and whether each app is ready for you. Anything not listed is refused when called."
title="Granted"
>
{connectors.length === 0 && skills.length === 0 ? (
{apps.error ? (
<p className="mt-4 text-destructive text-sm" role="alert">
Whether each app is ready for you could not be loaded.
</p>
) : null}
{count === 0 ? (
<PageEmpty>
Nothing granted yet. An administrator grants apps from the Plugins
screens; until then this Bot can converse, and nothing more.
</PageEmpty>
) : (
<PageRows>
{connectors.map(([key, labels], index) => (
{states.map((app, index) => {
const labels = tools.get(app.key) ?? [];
return (
<Fragment key={app.key}>
{index > 0 ? <Separator /> : null}
<Item data-app={app.key} data-state={app.state} size="sm">
<ItemMedia variant="icon">
<PluginLogo logo={app.logo} />
</ItemMedia>
<ItemContent>
<ItemTitle>{app.displayName}</ItemTitle>
{/*
* Unclamped: the sentence ends on who can fix it, and two lines at a phone's
* width cut exactly that off.
*/}
<ItemDescription className="line-clamp-none">
{app.state === "ready"
? [appAccessSentence(app)]
.concat(labels.length > 0 ? [toolList(labels)] : [])
.join(" ")
: appAccessSentence(app)}
</ItemDescription>
</ItemContent>
<ItemActions>
{app.state === "ready" ? (
<ToolCount count={labels.length} />
) : (
<AppAccessAction access={app} agentId={agentId} />
)}
{admin ? (
<ManageLink
agentId={agentId}
appKey={app.key}
title={app.displayName}
/>
) : null}
</ItemActions>
</Item>
</Fragment>
);
})}
{rest.map(([key, labels], index) => (
<Fragment key={key}>
{index > 0 ? <Separator /> : null}
<Item
render={
me?.role === "admin" ? (
<Link
params={{ agentId, key }}
to="/admin/plugins/$key/bots/$agentId"
/>
) : undefined
}
size="sm"
>
{states.length + index > 0 ? <Separator /> : null}
<Item size="sm">
<ItemMedia variant="icon">
<IconApps />
</ItemMedia>
<ItemContent>
<ItemTitle>{connectorName(key)}</ItemTitle>
<ItemDescription>
{labels.slice(0, 4).join(", ")}
{labels.length > 4 ? ` and ${labels.length - 4} more` : ""}
</ItemDescription>
<ItemDescription>{toolList(labels)}</ItemDescription>
</ItemContent>
<ItemActions>
<span className="text-muted-foreground text-sm tabular-nums">
{labels.length} {labels.length === 1 ? "tool" : "tools"}
</span>
{me?.role === "admin" ? (
<IconChevronRight className="size-4 text-muted-foreground" />
<ToolCount count={labels.length} />
{admin ? (
<ManageLink
agentId={agentId}
appKey={key}
title={connectorName(key)}
/>
) : null}
</ItemActions>
</Item>
</Fragment>
))}
{skills.map((skill, index) => (
<Fragment key={skill.slug}>
{connectors.length + index > 0 ? <Separator /> : null}
{states.length + rest.length + index > 0 ? <Separator /> : null}
<Item size="sm">
<ItemMedia variant="icon">
<IconBox />
Expand All @@ -232,3 +302,49 @@ function Granted({ agentId }: { agentId: string }) {
</PageSection>
);
}

/** 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 (
<span className="text-muted-foreground text-sm tabular-nums">
{count} {count === 1 ? "tool" : "tools"}
</span>
);
}

/**
* 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 (
<Button
aria-label={`Manage ${title} on this Bot`}
render={
<Link
params={{ agentId, key: appKey }}
to="/admin/plugins/$key/bots/$agentId"
/>
}
size="icon-sm"
variant="ghost"
>
<IconChevronRight className="size-4 text-muted-foreground" />
</Button>
);
}
Loading
Loading