Skip to content

Show each app's state on a Bot for the person looking, and let them ask for access - #814

Merged
guidovizoso merged 35 commits into
mainfrom
guido/ob-05-connector-states
Oct 21, 2026
Merged

guidovizoso merged 35 commits into
mainfrom
guido/ob-05-connector-states

Conversation

@guidovizoso

@guidovizoso guidovizoso commented Oct 20, 2026 •

Copy link
Copy Markdown
Collaborator

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):

  1. 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.
  2. not_granted: this Bot doesn't hold the app.
  3. ready: the app needs no account.
  4. ready or shared_not_connected: the app uses a shared team account.
  5. not_connected, reconnect or ready: a per-person Composio app. reconnect means the probe was refused.
  6. not_connected or reconnect: a native OAuth app (Drive, Notion). reconnect means access was revoked.

Where it shows up:

  • Bot page → Access: granted apps show their state and the next step: Connect or Reconnect (opens in a new tab), Request access, or Requested with Withdraw. Admins open an app's page from a chevron button.
  • Chat: every way a Bot can't use an app now draws an app card with the next step, not a folded tool error. Every Bot also holds a read-only show_app_access tool, and the tool guidance tells it to call that rather than guess. The connect-refusal marker in shared/connect-prompt.ts now carries a reason, the Bot and a timestamp. Shared-not-connected and account-gone refusals now emit it as well.
  • Requests: "Request access" files a row in the new app_access_requests table. 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):

Route What it does
GET /for/:agentId/apps Every app on a Bot, with its state for the caller
GET /for/:agentId/apps/:app One app on a Bot
POST /access-requests File a request: 201 when new, 200 when deduplicated, 409 when the person can fix it themselves
GET /access-requests/mine The caller's pending requests
DELETE /access-requests/:id Withdraw your own request
GET /access-requests?app= Admin: pending requests, optionally for one app
POST /access-requests/:id/approve Admin: approve
POST /access-requests/:id/dismiss Admin: dismiss

Settings → Connected accounts without a Composio key:

  • Composio apps are no longer listed there. An app an admin enabled while a key was set kept its row after the key was removed, so people were offered an account they could not connect.
  • An app's own page, reached from an older link, says "Gmail is not available on this OpenBot yet. An administrator has to finish setting it up before anybody can connect it." It no longer names COMPOSIO_API_KEY to someone who can't set it.
  • The admin page for the app still names COMPOSIO_API_KEY.

Refactors:

  • enableComposioApp was pulled out of the Composio enable route.
  • sharedGrantApproval was pulled out of the grants route.

Approving a request calls exactly the same code as the admin pages.

Where it runs

  • New state that outlives a request? It lives in Postgres: app_access_requests (migration 0057_app_access_requests.sql). There is no in-process state.
  • What happens on the second replica? It sees the same rows. States are computed per request from mcp_servers, plugin_grants, brokered_connections and mcp_user_credentials.
  • Anything serialised?
    • Duplicate requests: a unique partial index on pending requests (requested_by, coalesce(agent_id,''), app_key, kind) plus ON CONFLICT DO NOTHING. A second request is deduplicated, not filed twice.
    • Approve, dismiss and withdraw: each is a conditional UPDATE … WHERE status = 'pending', and the loser gets 409.
    • Approve acts before it closes: enabling and granting are idempotent, so an approval that wins the race has always done its work.
  • Anything fanned out to a browser? No. The UI refetches through TanStack Query invalidation.
  • New listener, port, or schedule? None.

Boundary and audit

  • Every acting call still goes through the gateway. show_app_access is read-only, and approving a request goes through the same enable and grant paths as the admin pages.
  • New refusals and failures each write a row: app_access.requested, .cancelled, .approved, .dismissed, .refused.
  • Nothing new is trusted from the client. The request kind and the app's title are worked out on the server, and a withdrawal is limited to the caller's own requests in the same statement.

Changelog

  • A "Before upgrading" bullet for migration 0057, plus a feature entry under Unreleased.

Known caveats

  • Migration number: 0057 collides with 0057_workflows in the workflows-ui branch. Whichever merges second renumbers.
  • Approve grants every tool: approving with no refs grants all of the app's tools. There is no tool picker in the inbox yet.
  • Shared account: approving a connect_shared request before the team account exists returns 409 and asks the admin to connect it first.
  • "Account gone" isn't stored: the card is driven by the refusal plus connectedAt. A lost shared team account shows the state but no button.
  • Dropped test: I removed one card test that only passed when run in a certain order. It covered the fallback for older refusals that carry no Bot.
  • Composio: covered only by tests and a faked vendor. No live Composio key was available.

Proof

  • Format and lint: bunx biome format . and bunx biome lint --error-on-warnings . are clean.
  • Typecheck: tsc --noEmit is clean for app, server and shared.
  • Tests:
    • app 1271 pass / 0 fail
    • shared 250 pass
    • server 4768 pass / 6 skip / 0 fail
    • migration-journal 8 pass
  • Build: app and server both build.
  • New test files: app-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 in brokered-account-row now expects the new sentence on the person's page instead of the setting name.
  • Manual check, local stack: Bot general-assistant holds Exa (ready) and Gmail (disabled, because there is no Composio key).
    • Access showed "Gmail — Turned off by an administrator. Only they can turn Gmail on." with Request access.
    • Clicking it changed the row to Requested with Withdraw.
    • /admin/plugins listed "Turn on Gmail for General Assistant — dev@openbot.local asked …" with Dismiss and Approve.
    • Approve returned the inline 409 "Gmail is a Composio app, and this deployment has no Composio key. Set COMPOSIO_API_KEY, then approve this request again." The request stayed pending.
    • Dismiss cleared it.

…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.
@guidovizoso
guidovizoso marked this pull request as ready for review October 21, 2026 12:56

@davidmckayv davidmckayv left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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/apps uses the same canUseBot 404 as the rest.
  • kind is decided on the server, and withdraw is scoped in the same UPDATE.
  • 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

  1. 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 no refs are sent, or require refs.

  2. A Team Bot shows the teammate the wrong state. plugin-store/app-access.ts:470 and route-groups/app-access.ts:130 read 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 sees not_connected and a Connect button that changes nothing. The reverse case reads ready, and the call then comes back as a consent card. Work the state out for the account the call will actually use.

  3. Looking up "Google Drive" finds the wrong app. plugin-store/app-access.ts:302-307 checks the built-in catalogue's titles before the enabled rows. Take a deployment with composio-googledrive enabled but not granted to the Bot, and the built-in google-drive never added. show_app_access("Google Drive") returns google-drive, disabled, requestKind: enable, when it should be not_granted / grant. Approving that request returns 409 for want of an OAuth client and stays pending. Match enabled rows first.

  4. 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 at plugin-store/app-access.ts:270-276 and :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 enable for a slug the directory doesn't list.
    • Cap pending requests per person.
  5. An approve that loses a race still grants. route-groups/app-access.ts:445-485 grants 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 UPDATE in the transaction, or have the losing approve report what it changed.

Should fix

  • Three cases read ready while every call is refused. I reasoned these from the code rather than running them:
    • A Composio account gone at the vendor. The new accountGone branch in calls.ts:554-576 already knows; marking the brokered_connections row there would make it reconnect or shared_not_connected. As it stands, show_app_access tells the model "ready, use its tools" right after a Reconnect card.
    • Drive or Notion after the vendor refuses the refresh token (invalid_grant). Nothing sets revokedAt, so the folded tool error comes back.
    • A shared app whose audience approval doesn't cover this Bot. The state ignores shared_use_approvals.
  • Text written for the model reaches the person.
    • When the card can't load the live state, it falls back to prompt.message as 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_access while 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).
  • Closing the sign-in tab leaves the button waiting forever. connect-in-new-tab.ts:44-71 keeps checking every 2 seconds and keeps saying "Waiting for Gmail..." after the tab is closed. Checking tab.closed would end it. The old card has the same problem.
  • "Turned off by an administrator" is often not true. app-access-action.tsx:50-51 shows it for every disabled reason: 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:283 stores error.message, and sources.tsx:149 shows [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 replaces useActiveBot for 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). Use line-clamp-none.
  • myAppAccessRequestsQueryOptions (queries.ts:126) has no consumer.

Merging

  • 0057 is 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 pulls enableComposioApp out of the enable route, and #770 rewrites that route for Arcade. Approving a request calls enableComposioApp, so #770's provider logic has to go into the extracted function, or approvals stay Composio-only.

davidmckayv added a commit that referenced this pull request Oct 21, 2026
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.
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.
@guidovizoso
guidovizoso merged commit e67a76d into main Oct 21, 2026
19 checks passed
@guidovizoso
guidovizoso deleted the guido/ob-05-connector-states branch October 21, 2026 20:45

@davidmckayv davidmckayv left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

  1. 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.)
  2. Team Bot shows the wrong state: fixed. The state follows the owner's account wherever the owner has one, and asksFirst says when the teammate's own account will be asked for.
  3. "Google Drive" finds the wrong app: fixed. Enabled rows are matched before catalogue titles.
  4. 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).
  5. 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 ready while every call is refused: fixed.
    • When a call finds the vendor has lost the account, the row is deleted.
    • A refresh refused with invalid_grant marks 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)

  1. Approving can't grant a built-in app.
    • Cause: appTools (plugin-store/app-access.ts:908) counts an action as read-only only when its effect column says read. For built-in apps that column is null on every read. listTools only ever records write (from destructiveHint). The reads are worked out by classifyTool from 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 through classifyTool(catalogueEntry(serverId), name, true, effect).
    • It also leaves approvals half done. An enable request for Exa switched Exa on, then hit the same 409 and went back to pending as an enable. The person's state is now not_granted with 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 to grant.

Should fix

  1. The 20-request cap doesn't hold when requests are filed in parallel. route-groups/app-access.ts:276-295 counts 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.
  2. A Composio app left on an Arcade deployment reads ready.
    • plugin-store/app-access.ts:210 treats only the active broker's rows as brokered. The other broker's row becomes a custom app on a deployment account (:234), which reads ready.
    • 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 ready and 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.ts documents moving off Composio as a supported path, and grants outlive the move. Read the other broker's rows as disabled.

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 ran serversListedFor with an Arcade row and no key, and the row stayed. Line 199 of index.tsx already 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 no app_access.approved row. An approving status, 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 0060 after its 0057 to 0059. Migrating a database already at main's 0059 up to 0060 is clean. Approvals now pass the active broker to enableComposioApp, 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 as 0061, and whichever lands second as 0062. This PR adds only a CHANGELOG conflict for #788.

@linear-code

linear-code Bot commented Oct 21, 2026

Copy link
Copy Markdown

FOR-344

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants