Skip to content

An app's account can belong to the team - #757

Merged
davidmckayv merged 63 commits into
mainfrom
shared-brokered-accounts
Oct 8, 2026
Merged

davidmckayv merged 63 commits into
mainfrom
shared-brokered-accounts

Conversation

@mxmzb

@mxmzb mxmzb commented Oct 7, 2026 •

Copy link
Copy Markdown
Collaborator

An app's account can belong to the team

An administrator can make a Composio app Shared: one account, connected once, that every Bot granted the app acts as. Until now every brokered app was personal — each person connected their own mailbox or tracker, and a Bot acting for the team had no account of its own to use.

What this adds

  • A Personal or Shared choice per app. An administrator switches an app on its admin page. The switch previews what it would end before it does anything, and nothing changes unless every account on the old side is withdrawn at the vendor first — a half-finished switch would leave people's mailboxes attached to an app that no longer reaches them.
  • Who may use it, approved per Bot. Owner only, named people and groups, or everyone, and separately whether email, Slack or webhook input may steer a run that uses the account. Checked on every call, against the app's answering row rather than whichever duplicate row was dialled.
  • Requests when it is too narrow. Publishing a Bot, assigning it a responsibility or adding a trigger tells the owner what an administrator still has to approve, and files the request. Administrators answer them in the Approvals inbox.
  • Writes ask first. Making an app Shared adds an ask-before-write rule over its tools, and removing the app or switching it back to Personal revokes that rule rather than leaving it standing over an account nobody holds.
  • A new brokered_connections table that records an account's holder — a person or the deployment — rather than assuming a person.

Before upgrading

  • Migration 0052_shared_brokered_accounts copies every Composio connection into brokered_connections and leaves composio_connections in place, unwritten. Rolling back to 0.1.2 works, but accounts connected after the upgrade are invisible to it.
  • A handoff now records what started the run it came from. Older remote Bots' signed runs carry no such record for up to ten minutes after the upgrade, and calls they make to a Shared app in that window are refused.

main is merged in as of ae688f2.

Verification

Check Result
bun run format clean
bun run lint clean
bun run typecheck 0 errors
Tests 6,358 pass, 0 fail

The suite was run in batches rather than as a single bun test, because a whole-suite run exhausts PostgreSQL's default 100-connection limit locally — the suite now has 91 test files that each open a 2-connection pool, up from 62 on main. Every file passes, and CI's tests job passes as a single run.

Tested against a real Composio account

Driven through the browser against the live Composio API, not only in tests:

  • The app switched Personal → Shared and minted openbot-deployment:<id>:<random>.
  • The connected account is recorded as holder=deployment with a null user id — there is no personal row for the app at all.
  • Granting to a private Bot recorded audience: "owner"; granting to a public Bot recorded audience: "team", each computed from the Bot's own exposure in the same request.
  • Widening the private Bot to public re-approved it inline (owner → team), which is the administrator branch; an owner without admin rights would have filed a request instead.
  • A real tool call was audited as "reachedAs": "deployment".
  • The live API check (bun run test:live-composio) passes for the deployment identity.

One pre-existing live test fails, unrelated to this branch: a listing carries a version and a behaviour label for every action. Composio has begun returning actions tagged createHint/updateHint instead of readOnlyHint/destructiveHint — 25 of 63 Gmail actions, including GMAIL_SEND_EMAIL. The classifier's fail-closed branch treats them as writes, so nothing is misclassified as a read, but that branch is now load-bearing and the test's assumption that it never fires no longer holds. Worth a follow-up to teach effectOf the new tags.

🤖 Generated with Claude Code

mxmzb and others added 30 commits October 7, 2026 20:02
Tests for the account holder, the audience check, the account-mode switch,
the shared-use inbox and the app screens. They fail until the code lands.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… Personal or Shared mode

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…anded-over work

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ccount row carry its own heading

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… account-holding app a mode

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… from a signed run

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t it holds

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…request

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ounts

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…y its published display name

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ccount's stored id

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…l on them

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t through each Bot

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nistrators the requests

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…g for owners

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ming who acted

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s for its shared apps

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
mxmzb and others added 20 commits October 7, 2026 21:39
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nd account holders

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…, never a duplicate

A second mcp_servers row at the same composio:// address could mark a Shared
app Personal and let calls through it skip the audience gate, or let a
non-admin connect a personal account to it. Every account decision, the
gate, the audit's reachedAs and the mode write now resolve the app's
answering row, and the gate refuses if the mode changes before the call goes
out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A Bot granted through a duplicate or non-canonical row of an app now gets the
approval the audience gate actually reads, appears once in what it holds, and
is found when the app's mode changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…wering row

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… is ended

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…a duplicate row

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…fault

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e vendor

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…hared beside the empty state

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…orget its rule on removal

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…o the running app

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… 37th positional argument

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…e lint and format run

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two conflicts, both resolved by keeping each side:

- `CHANGELOG.md`: 0.1.1 and 0.1.2 were cut while this branch was open, so the
  Unreleased section this branch writes now sits above them rather than in
  their place. The migration note's rollback target moves from 0.1.0 to 0.1.2,
  which is the release somebody would actually roll back to.
- `server/src/app.ts`: an import-order collision only. Main's
  `createCoworkerRoutingService` import and this branch's shared-account
  imports landed in the same place; both are kept.

Main's AG-UI 1.0 upgrade changes `@ag-ui/core` out from under this branch, so
the merged tree needs `bun install` before it typechecks.
One conflict, in `CHANGELOG.md`, resolved by keeping both: #756's note about a
new deployment starting with two coworkers sits beside this branch's Shared
apps entry, under the same Unreleased heading. The upgrade preamble stays at
the top of the section, where that section's notes belong.
@mxmzb
mxmzb marked this pull request as ready for review October 8, 2026 17:39

@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.

Reviewed against main at aff4981e (this branch already contains it; merged clean). The design holds: every call that uses a shared account goes through the audience gate on the answering row and is refused when anything is missing, steering from email, Slack or webhooks is held to its own approval, unoriginated handoffs are refused, only administrators connect or switch shared accounts, and connectionTokenFor refuses when the resolved account differs from the one decided. Migration 0052 chains to 0051, drizzle-kit check is clean, and the full suite on main plus this branch passes 6,078 against a fresh PostgreSQL with the same 13 local-environment failures as main.

One fix before merging:

Switching to Shared can leave the ask-before-write rule missing for good (server/src/plugins/account-mode.ts:147-160, early return at :119). switchMode sets the mode (setAccountModeColumns), then each Bot's approval (setApproval), then the write rule (createTeamRule), with no transaction. If either of the last two throws, the app is already shared, and a retry takes the accountMode === input.mode early return, reports changed: true, and never creates them. Missing approvals fail closed, but a missing write rule fails open: writes through the team account run without asking. Do the three writes in one transaction, or have the early return re-assert the approvals and the rule.

Hardening, not blocking:

A run with no initiator reads as a person (server/src/plugins/shared-use.ts, steeringOf; also mintRunAssertion defaulting to PERSON_INITIATOR in server/src/agents/callback-token.ts:138). Every background path today sets one (responsibilities, routines, memory, group turns, hops), so nothing gets through now, but a future path that forgets would pass the outside-input check. Refusing a missing initiator for shared accounts would close that.

…t does not say what started it

switchMode set the mode, then each Bot's approval, then the ask-before-write
rule, with no transaction, and a retry on an app already in that mode returned
changed without doing anything. A failure after the mode left a Shared app
writing through the team account without asking, permanently. The approvals
and the rule are now made before the mode says shared, only where missing, and
the already-in-that-mode path makes them exist again (or, for Personal, removes
what a half-finished switch back left). The rule is never duplicated and an
approval an administrator changed is not written over.

A run that carried no initiator was read as a person, so a background path
added later that forgot to pass one would get past the outside-input check on a
shared account. The shared gate now refuses it, without filing a request no
approval could answer, and records it as shared_steering. The direct tool-call
route says it is a person.
@davidmckayv
davidmckayv merged commit 4773ef6 into main Oct 8, 2026
19 checks passed
@davidmckayv
davidmckayv deleted the shared-brokered-accounts branch October 8, 2026 23:18
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