Repository navigation
Show each app's state on a Bot for the person looking, and let them ask for access - #814
Conversation
…r 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.
…k 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.*.
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.
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.
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.
…on its account page
davidmckayv
left a comment
There was a problem hiding this comment.
This is the right shape. Every app says what state it's in for the person looking and offers one next step, the request goes to an admin, and approving does the work. Several pieces hold up well under testing:
- Admin gating uses
requireAdmin, and a demoted admin is refused on their next call. /for/:agentId/appsuses the samecanUseBot404 as the rest.kindis decided on the server, and withdraw is scoped in the sameUPDATE.- The connect URL always comes from the server, never from the marker, so a forged marker can't produce a bad link.
- The migration is safe while older servers are still running.
- The two refactors keep the existing routes' status codes, audit rows and order.
- Transcripts saved before this PR still render.
The CI failure in composio-unconfigured-account.test.tsx is fixed by fc368b13, which is the change I'd have suggested, and the anthropic<2 pin is fine.
I checked each finding below by running it (scratch tests against a throwaway database, a forced race, headless screenshots) unless it says otherwise.
Must fix
-
Approve grants every tool, writes and deletes included.
route-groups/app-access.ts:431(const refs = chosen ?? advertised). I gave Gmail a read, a send and a delete-all action, then approved a request from the inbox row "Give Gmail to Bot". All three were granted. The admin pages never do that in one click: the Bot page's only bulk action is "Turn on every read-only action", and the app page makes you pick tools. Default to read-only tools when norefsare sent, or requirerefs. -
A Team Bot shows the teammate the wrong state.
plugin-store/app-access.ts:470androute-groups/app-access.ts:130read the viewer's own connection, but a published Team Bot's calls run as the owner (credentialActorFor,index.ts:1294). If the owner has connected Gmail and the teammate hasn't, the teammate seesnot_connectedand a Connect button that changes nothing. The reverse case readsready, and the call then comes back as a consent card. Work the state out for the account the call will actually use. -
Looking up "Google Drive" finds the wrong app.
plugin-store/app-access.ts:302-307checks the built-in catalogue's titles before the enabled rows. Take a deployment withcomposio-googledriveenabled but not granted to the Bot, and the built-ingoogle-drivenever added.show_app_access("Google Drive")returnsgoogle-drive,disabled,requestKind: enable, when it should benot_granted/grant. Approving that request returns 409 for want of an OAuth client and stays pending. Match enabled rows first. -
Requests have no cap. One non-admin filed 50 requests, with and without a Composio key. A 200,000-character slug became the title shown in the admin inbox (
app-access.ts:176, plus the slug-as-title fallback atplugin-store/app-access.ts:270-276and:318). A refused filing also writes caller-chosen text of any length to the audit trail (app-access.ts:103-105). Approving one of those requests returns 400 but leaves it pending, so each has to be dismissed by hand. Three changes:- Cap
app; the tool already caps it at 200. - Refuse an
enablefor a slug the directory doesn't list. - Cap pending requests per person.
- Cap
-
An approve that loses a race still grants.
route-groups/app-access.ts:445-485grants first and closes the request afterwards. With a dismiss forced partway through an approve:- the request ends up
dismissed; - the Bot keeps the grant;
- the approving admin gets 409;
- the trail has no
app_access.approved.
A withdraw at the same moment does the same thing. Claim the row first, with an intermediate status or
SELECT … FOR UPDATEin the transaction, or have the losing approve report what it changed. - the request ends up
Should fix
- Three cases read
readywhile every call is refused. I reasoned these from the code rather than running them:- A Composio account gone at the vendor. The new
accountGonebranch incalls.ts:554-576already knows; marking thebrokered_connectionsrow there would make itreconnectorshared_not_connected. As it stands,show_app_accesstells the model "ready, use its tools" right after a Reconnect card. - Drive or Notion after the vendor refuses the refresh token (
invalid_grant). Nothing setsrevokedAt, so the folded tool error comes back. - A shared app whose audience approval doesn't cover this Bot. The state ignores
shared_use_approvals.
- A Composio account gone at the vendor. The new
- Text written for the model reaches the person.
- When the card can't load the live state, it falls back to
prompt.messageas an alert (app-access-card.tsx:84-87). The person then reads "...Do not browse to its website or ask them to sign in there." - The tool line shows the raw name
show_app_accesswhile it runs. For an unknown app it shows "Say so plainly; do not browse to it instead." (chat-transcript.tsx:1394-1398,tool-activity.tsx:89).
- When the card can't load the live state, it falls back to
- Closing the sign-in tab leaves the button waiting forever.
connect-in-new-tab.ts:44-71keeps checking every 2 seconds and keeps saying "Waiting for Gmail..." after the tab is closed. Checkingtab.closedwould end it. The old card has the same problem. - "Turned off by an administrator" is often not true.
app-access-action.tsx:50-51shows it for everydisabledreason: never added, no Composio key, missing OAuth client, missing token. With no key, it contradicts this PR's own Settings sentence ("not available on this OpenBot yet"). Word it per reason. - The Memory sources page shows raw marker JSON. An account-gone memory sync now throws
PluginRefusedError(calls.ts:564),memory/ingestion.ts:283storeserror.message, andsources.tsx:149shows[connect-app]{"serverId":...}. I reasoned this from the code. - Bring back the dropped old-marker test. The fallback works; I proved both cases with a probe. The test only failed because
bot-route-default-agent's fixture replacesuseActiveBotfor the whole run. Fix that fixture rather than dropping the coverage.
Nits
- After Request access or Withdraw, focus drops to
<body>, and the buttons don't name the app (app-access-action.tsx:204-232). - At 390px the Access row's sentence clamps to two lines and cuts off who can fix it (
access.tsx:229-235). Useline-clamp-none. myAppAccessRequestsQueryOptions(queries.ts:126) has no consumer.
Merging
0057is also claimed by #770 (which adds 0057 to 0059), #788 and #766. The snapshots chain, so whoever lands later regenerates them rather than renaming.- #814 and #770 both change 23 files. Merged one after the other, they conflict in 4 files. The one that needs care is
route-groups/composio-apps.ts: this PR pullsenableComposioAppout of the enable route, and #770 rewrites that route for Arcade. Approving a request callsenableComposioApp, so #770's provider logic has to go into the extracted function, or approvals stay Composio-only.
anthropic 2.0.0, released 2026-10-21, fails every Anthropic run in this harness with Cannot instantiate typing.Union. The same pin is in #814.
# Conflicts: # app/src/routes/_authed/admin/plugins/index.tsx # server/drizzle/meta/0057_snapshot.json # server/drizzle/meta/_journal.json # server/src/plugins/route-groups/composio-apps.ts
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.
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.
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.
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.
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.
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.
Main made authorizesPerAction a required field of PluginsPage. The fixture OB-05 added gives Composio's answer, false.
…rk 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.
…he sign-in wait when its tab closes
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.
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.
Brings in read-only approvals, claiming a request before acting, app state worked out for the account a call uses, forgetting an account the vendor no longer holds, marking a connection withdrawn when its refresh token is refused, and the new app-access states on screen. Conflicts kept both sides: the review fixes' behaviour, read through the store's broker rather than Composio by name. The account-gone delete in calls.ts and the owner's-account lookup in the app-access store filter on the store's broker provider, the approve route passes the active provider to enableComposioApp and to its refusal sentences, and the new test fixtures use distinct vendor ids and a provider-aware shared-use store.
The review fixes added unavailable, accountHolder and asksFirst to AppAccess, and typecheck:tests reported the tool test's fixture as missing them.
davidmckayv
left a comment
There was a problem hiding this comment.
This merged at 20:45 UTC while I was re-reviewing it, and what landed is exactly the tree I tested (6db9390b). Everything from my first review is fixed, and each fix comes with a regression test. One new problem needs a follow-up before this goes out in a release: approving a request can't grant any of the built-in apps.
I checked each finding below by running it: scratch tests against a throwaway database through the real routes, a forced race, a migration upgrade, and component tests for the UI text. To check the regression tests, I reverted each fix in turn (19 of them) and ran its tests. Every one fails without its fix and passes with it. Anything I only read is marked.
What changed since my last review
Must fix
- Approve grants every tool: fixed. With no
refs, only read-only actions are granted. An app with none is refused and stays pending. (See new must-fix 1, though.) - Team Bot shows the wrong state: fixed. The state follows the owner's account wherever the owner has one, and
asksFirstsays when the teammate's own account will be asked for. - "Google Drive" finds the wrong app: fixed. Enabled rows are matched before catalogue titles.
- No caps on requests: fixed for requests filed one at a time. Names are capped at 200 characters, an unlisted
composio-id is refused, and one person can have 20 waiting. The 20 doesn't hold when requests are filed in parallel (new should-fix 1). - An approve that loses a race still grants: fixed. The request is now claimed before anything changes. I held an approval inside the vendor read and sent a dismiss, a withdraw and a second admin's approve. All three got 409, the request ended approved, the grant landed once, and the trail has one
app_access.approved.
Should fix
- Three cases read
readywhile every call is refused: fixed.- When a call finds the vendor has lost the account, the row is deleted.
- A refresh refused with
invalid_grantmarks the connection withdrawn. - A Shared app whose audience isn't approved reads
shared_not_approved.
- Text written for the model reaches the person: fixed. The card's fallback is a sentence of its own, and the tool line reads "Checking app access" and never shows the tool's text.
- Closing the sign-in tab leaves the button waiting: fixed, for both cards. It also handles a strict Cross-Origin-Opener-Policy, which makes the tab read closed while the person is still signing in.
- "Turned off by an administrator" is often untrue: fixed. Each reason has its own wording.
- Raw marker JSON on Memory sources: fixed.
- Dropped old-marker test: fixed. The test is back, and the fixture no longer replaces the whole module. With the old mock put back, the test fails again when both files run in one process.
Nits: all three fixed. Focus follows the press and the buttons name the app, the Access sentence is no longer clamped, and the unused query is gone.
Must fix (in a follow-up)
- Approving can't grant a built-in app.
- Cause:
appTools(plugin-store/app-access.ts:908) counts an action as read-only only when itseffectcolumn saysread. For built-in apps that column is null on every read.listToolsonly ever recordswrite(fromdestructiveHint). The reads are worked out byclassifyToolfrom the catalogue entry, and that's what the admin pages show (servers.ts:2017). So for Exa, Parallel, Firecrawl, Google Drive and Notion, approval finds no read-only actions. - What I saw: I seeded Exa the way a tool refresh stores it. The Bot page's "Turn on every read-only action" counts 3. Approving the person's request returns 409, "None of Exa Search's actions is marked read-only". The inbox row it's refused on says approving gives the Bot its read-only actions.
- Fix: in
appTools, classify throughclassifyTool(catalogueEntry(serverId), name, true, effect). - It also leaves approvals half done. An
enablerequest for Exa switched Exa on, then hit the same 409 and went back to pending as anenable. The person's state is nownot_grantedwith no request attached, so they're offered Request access again. Pressing it filed a second request, and the inbox listed both. When an approval has switched the app on but can't grant, either close the request for the part it did, or move it togrant.
- Cause:
Should fix
- The 20-request cap doesn't hold when requests are filed in parallel.
route-groups/app-access.ts:276-295counts first, then inserts. 40 parallel requests for 40 directory apps all got 201 and left 40 waiting. I ran it three times with the same result. The directory has far more than 20 apps, so a script can still fill the inbox. Count and insert in one transaction under a per-person advisory lock, or insert only where the count is under 20. - A Composio app left on an Arcade deployment reads
ready.plugin-store/app-access.ts:210treats only the active broker's rows as brokered. The other broker's row becomes acustomapp on adeploymentaccount (:234), which readsready.- The call path still treats that row as brokered and refuses with a Connect card. With an Arcade store and a granted Composio Gmail row, the state is
readyand the call throws "You have not connected your Gmail account". - The card over that refusal draws the live
ready, so it tells the person Gmail is ready and to ask again. The next call is refused the same way. index.tsdocuments moving off Composio as a supported path, and grants outlive the move. Read the other broker's rows asdisabled.
Nits
- Connected accounts still filters by the name
composio(connected-accounts/index.tsx:233,$key.tsx:350). An Arcade deployment whose key is removed keeps listing Arcade apps nobody can connect. I ranserversListedForwith an Arcade row and no key, and the row stayed. Line 199 ofindex.tsxalready names both brokers. - A claim is written as
approved(plugin-store/app-access.ts:1003). If the process dies between claim and release, the request reads approved with nothing changed and noapp_access.approvedrow. Anapprovingstatus, or releasing claims older than a minute, would make that recoverable. I reasoned this from the code; I didn't run it. - The PR description still lists the old caveats: approve grants every tool, migration 0057, the dropped test, and "Approve does what was asked, then closes the request". Worth correcting, since it's the record of what shipped.
Merging
- #770 landed first, so this went in as
0060after its0057to0059. Migrating a database already at main's0059up to0060is clean. Approvals now pass the active broker toenableComposioApp, which settles my earlier note about #770. I read that one in the code rather than running it. - #788 and #766 still claim
0057, and both already conflicted with main once #770 landed. Each needs to regenerate as0061, and whichever lands second as0062. This PR adds only a CHANGELOG conflict for #788.
What this changes
OB-05. A Bot's apps used to be either granted or not, and when one failed, the person got an error in the transcript with nothing to do about it. Now each app on a Bot has a state for the person looking, and every state says what to do next.
States, evaluated in order (
server/src/plugins/app-access.ts):disabled: the app is not added, the deployment has no Composio key, how it connects can't be read, a native OAuth app has no client, or a token is missing.not_granted: this Bot doesn't hold the app.ready: the app needs no account.readyorshared_not_connected: the app uses a shared team account.not_connected,reconnectorready: a per-person Composio app.reconnectmeans the probe was refused.not_connectedorreconnect: a native OAuth app (Drive, Notion).reconnectmeans access was revoked.Where it shows up:
show_app_accesstool, and the tool guidance tells it to call that rather than guess. The connect-refusal marker inshared/connect-prompt.tsnow carries areason, the Bot and a timestamp. Shared-not-connected and account-gone refusals now emit it as well.app_access_requeststable. The server works out what is being asked (turn the app on, grant it to the Bot, connect the shared account) and returns 409 if the person can fix it themselves. Admins get a Requests section at the top of Plugins, and a request count on each app's row. Approve does what was asked, then closes the request. Dismiss closes it without acting. An approval that can't be carried out returns a 409 that says what an administrator has to do first, and the request stays pending.Routes (all under
/api/plugins):GET /for/:agentId/appsGET /for/:agentId/apps/:appPOST /access-requestsGET /access-requests/mineDELETE /access-requests/:idGET /access-requests?app=POST /access-requests/:id/approvePOST /access-requests/:id/dismissSettings → Connected accounts without a Composio key:
COMPOSIO_API_KEYto someone who can't set it.COMPOSIO_API_KEY.Refactors:
enableComposioAppwas pulled out of the Composio enable route.sharedGrantApprovalwas pulled out of the grants route.Approving a request calls exactly the same code as the admin pages.
Where it runs
app_access_requests(migration0057_app_access_requests.sql). There is no in-process state.mcp_servers,plugin_grants,brokered_connectionsandmcp_user_credentials.requested_by, coalesce(agent_id,''), app_key, kind) plusON CONFLICT DO NOTHING. A second request is deduplicated, not filed twice.UPDATE … WHERE status = 'pending', and the loser gets 409.Boundary and audit
show_app_accessis read-only, and approving a request goes through the same enable and grant paths as the admin pages.app_access.requested,.cancelled,.approved,.dismissed,.refused.Changelog
Unreleased.Known caveats
0057collides with0057_workflowsin the workflows-ui branch. Whichever merges second renumbers.refsgrants all of the app's tools. There is no tool picker in the inbox yet.connect_sharedrequest before the team account exists returns 409 and asks the admin to connect it first.connectedAt. A lost shared team account shows the state but no button.Proof
bunx biome format .andbunx biome lint --error-on-warnings .are clean.tsc --noEmitis clean for app, server and shared.migration-journal8 passapp-access-state,app-access-routes.integration,app-access-tool,app-access-mutations,app-access-action,bot-access-apps,access-requests,app-access-card,tool-activity,composio-unconfigured-account. One existing test inbrokered-account-rownow expects the new sentence on the person's page instead of the setting name.general-assistantholds Exa (ready) and Gmail (disabled, because there is no Composio key)./admin/pluginslisted "Turn on Gmail for General Assistant — dev@openbot.local asked …" with Dismiss and Approve.