Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
63 commits
Select commit Hold shift + click to select a range
55b941a
Pin shared brokered accounts with failing tests before building them
mxmzb Oct 7, 2026
aed5a01
Document Shared apps, who may use them, and what changes on upgrade
mxmzb Oct 7, 2026
89d353f
Format the shared-account tests
mxmzb Oct 7, 2026
3ec99b4
Record brokered accounts with an explicit holder, and give each app a…
mxmzb Oct 7, 2026
6e15a3d
Decide whose account a brokered call reaches in one place
mxmzb Oct 7, 2026
d45738a
Let a handoff carry where its run started, and accept every root in h…
mxmzb Oct 7, 2026
f3500a9
Audit a Shared app's calls as reached by the deployment
mxmzb Oct 7, 2026
08029d2
Read shared accounts, approvals and requests in the app, and let an a…
mxmzb Oct 7, 2026
16aff2b
Migrate Composio connections into brokered_connections and give every…
mxmzb Oct 7, 2026
4f8488e
Write down who may steer a run that uses a shared account
mxmzb Oct 7, 2026
ed4eb31
Carry where a run started through every handoff, and read it strictly…
mxmzb Oct 7, 2026
aa3b808
Tell the broker whose account it acts for, and let it name the accoun…
mxmzb Oct 7, 2026
f55a305
Declare the audit events for shared accounts and mode switches
mxmzb Oct 7, 2026
5bf2937
Resolve the account a brokered call acts for from the row, never the …
mxmzb Oct 7, 2026
bdc4277
Add the app pieces for approving, requesting and switching shared acc…
mxmzb Oct 7, 2026
269521c
Ask Composio about an account by its holder's vendor id, and read onl…
mxmzb Oct 7, 2026
c31e731
Check a brokered connection by holder and call the vendor under the a…
mxmzb Oct 7, 2026
ffdf426
Store shared-account approvals and requests, and gate each shared cal…
mxmzb Oct 7, 2026
00c8308
Let an administrator share an app's account and approve who may use i…
mxmzb Oct 7, 2026
6260e3d
Show people when a Bot acts as the team account in Settings
mxmzb Oct 7, 2026
029aa49
Tell owners when widening a Bot needs an administrator, and show admi…
mxmzb Oct 7, 2026
c75e9a9
Call the broker by account holder throughout the adapter tests
mxmzb Oct 7, 2026
6544d81
Read and write one brokered account's row by its holder
mxmzb Oct 7, 2026
4f042ed
Give administrators one place to answer requests to use a shared account
mxmzb Oct 7, 2026
62fe91d
Approve a wider Bot inline for administrators, and say what is missin…
mxmzb Oct 7, 2026
b26c5ec
Add the Personal or Shared control to an app's admin page
mxmzb Oct 7, 2026
393f29d
Check against the live API that a deployment identity is accepted
mxmzb Oct 7, 2026
95637dc
Confirm, connect with a key and re-check an account by its holder, na…
mxmzb Oct 7, 2026
e5f832d
Say what publishing, assigning, a trigger or making a Bot public mean…
mxmzb Oct 7, 2026
d50a88b
Show each Bot's shared-account approval on the app's admin page
mxmzb Oct 7, 2026
b76edd5
Count the account-mode calls in the mutation refresh roster
mxmzb Oct 7, 2026
21b4fab
Record the administrator who most recently connected a shared account
mxmzb Oct 7, 2026
b5bec03
End one brokered account by its holder, and name whose it was in the …
mxmzb Oct 7, 2026
bebe6bb
Offboard a person from their own brokered accounts only, never the de…
mxmzb Oct 7, 2026
b0013a5
Let only an administrator confirm, re-check or end a Shared app's acc…
mxmzb Oct 7, 2026
c393f39
Start a newly enabled brokered app as Personal, and never reset a Sha…
mxmzb Oct 7, 2026
17760f3
Connect a Shared app's team account only as an administrator, and ret…
mxmzb Oct 7, 2026
e4dc080
Revoke every account on a removed brokered app, the deployment's incl…
mxmzb Oct 7, 2026
142d55d
Expose each Shared app's team account, its name and who connected it,…
mxmzb Oct 7, 2026
f8d7cae
Move the remaining fake brokers to ConnectedAppBroker
mxmzb Oct 7, 2026
1bcb84b
Refuse a shared call from a run its approval does not cover, and fail…
mxmzb Oct 7, 2026
d483c4f
List Shared apps as the deployment's in GET /connections
mxmzb Oct 7, 2026
b116b44
Let a memory source read a Shared app through the deployment's account
mxmzb Oct 7, 2026
e1d11ff
List and end every account on an app, and write its account mode
mxmzb Oct 7, 2026
21e0d56
Build the shared-use store and audience gate at boot
mxmzb Oct 7, 2026
e79b6bf
Move the connection, scheme and route tests to brokered_connections a…
mxmzb Oct 7, 2026
4b1ac01
Pin that granting a Shared app's action records who may use it
mxmzb Oct 7, 2026
2adcb65
Decide whose account and whether to gate from the app's answering row…
mxmzb Oct 7, 2026
8cdc4b4
Key shared-account approvals and requests by the app's answering row
mxmzb Oct 7, 2026
5131090
Carry the app's account mode on a server's address, read from its ans…
mxmzb Oct 7, 2026
c9fde17
Switch an app between Personal and Shared only once every old account…
mxmzb Oct 7, 2026
f598f5c
Ask the approval gate under the app's id for a write granted through …
mxmzb Oct 7, 2026
6bbdb69
Move the plugin store tests to brokered_connections and a Personal de…
mxmzb Oct 7, 2026
fa45e9c
Pin that a call whose app turned Personal mid-flight never reaches th…
mxmzb Oct 7, 2026
f53e6a1
Decide which Connected accounts sections draw in one place, keeping S…
mxmzb Oct 7, 2026
2be58af
Switch an app's account mode, record a Shared grant's approval, and f…
mxmzb Oct 7, 2026
b981adb
Wire the mode switch, the shared-use inbox and exposure re-checks int…
mxmzb Oct 7, 2026
dcbc8f3
Hand the shared wiring to createApp in the coworker options, not as a…
mxmzb Oct 7, 2026
21917b7
Fail loudly when a decided request cannot be read back, and settle th…
mxmzb Oct 7, 2026
bb29c63
Merge origin/main into shared-brokered-accounts
mxmzb Oct 8, 2026
ae688f2
Merge origin/main into shared-brokered-accounts
mxmzb Oct 8, 2026
b7a6992
Merge remote-tracking branch 'origin/main' into fix757
davidmckayv Oct 8, 2026
f7867a0
Make the switch to Shared safe to retry, and refuse a shared call tha…
davidmckayv Oct 8, 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
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,22 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged.

## Unreleased

**Before upgrading.** Two things change for an existing deployment:
- Migration `0052_shared_brokered_accounts` copies every Composio connection into a new
`brokered_connections` table 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.

### 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. Who may use it through each Bot is approved per Bot — owner only, named people and
groups, or everyone, and separately whether email, Slack or webhook input may — and checked on every
call. Requests to widen it wait in the Approvals inbox. Writes through a Shared app ask the person
first.

### A new deployment starts with two coworkers, not twelve

The example package loaded every coworker in `examples/fintech/agents/`, so a fresh deployment opened
Expand Down
2 changes: 2 additions & 0 deletions app/src/components/approvals/inbox.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { SharedUseRequests } from "@/components/approvals/shared-use-requests";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
Expand Down Expand Up @@ -331,6 +332,7 @@ export function ApprovalInbox() {
))
)}
</div>
<SharedUseRequests />
<div className="space-y-3">
<h2 className="font-medium">Rules</h2>
<p className="text-sm text-muted-foreground">
Expand Down
75 changes: 75 additions & 0 deletions app/src/components/approvals/shared-use-requests.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Button } from "@/components/ui/button";
import { currentUserQueryOptions } from "@/lib/auth/queries";
import {
decideSharedUseMutationOptions,
describeApproval,
type SharedUseRequestReason,
sharedUseRequestsQueryOptions,
} from "@/lib/plugins/shared-use";

const WHY: Record<SharedUseRequestReason, string> = {
refused_call:
"A call was refused because this Bot reaches more people than approved.",
publish: "Its owner widened who can reach it.",
trigger: "Its owner added a trigger that lets outside input steer it.",
grant: "It was granted the app's actions.",
};

export function SharedUseRequests() {
const queryClient = useQueryClient();
const me = useQuery(currentUserQueryOptions()).data;
const isAdmin = me?.role === "admin";
const requests = useQuery(sharedUseRequestsQueryOptions(isAdmin));
const decide = useMutation(decideSharedUseMutationOptions(queryClient));
if (!isAdmin || !requests.data?.length) return null;

return (
<div className="space-y-3">
<h2 className="font-medium">Shared account requests</h2>
{decide.error ? (
<p className="text-destructive text-sm" role="alert">
{decide.error.message}
</p>
) : null}
{requests.data.map((request) => (
<article className="space-y-2 rounded-lg border p-4" key={request.id}>
<h3 className="font-medium text-sm">
{request.botName} wants the shared {request.title} account
</h3>
<p className="text-muted-foreground text-sm">
{WHY[request.reason as SharedUseRequestReason] ?? request.reason}
</p>
<p className="text-sm">
Now:{" "}
{request.current
? describeApproval(request.current)
: "Not approved"}
</p>
<p className="text-sm">Asked: {describeApproval(request.proposed)}</p>
<div className="flex gap-2">
<Button
disabled={decide.isPending}
onClick={() =>
decide.mutate({ id: request.id, decision: "approve" })
}
size="sm"
>
Approve
</Button>
<Button
disabled={decide.isPending}
onClick={() =>
decide.mutate({ id: request.id, decision: "decline" })
}
size="sm"
variant="outline"
>
Decline
</Button>
</div>
</article>
))}
</div>
);
}
4 changes: 4 additions & 0 deletions app/src/components/bot-profile/profile.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { Link } from "@tanstack/react-router";
import { Fragment, useState } from "react";
import { AbstractAvatar } from "@/components/agents/abstract-avatar";
import { PageRows, PageSection } from "@/components/layout/page-shell";
import { SharedAppNotice } from "@/components/plugins/shared-app-notice";
import { Button } from "@/components/ui/button";
import {
Dialog,
Expand Down Expand Up @@ -290,6 +291,9 @@ export function BotProfile({ agent }: { agent: AgentProfile }) {
</div>

<PageSection title="For you">
{agent.canManage ? (
<SharedAppNotice botId={agent.id} reason="publish" />
) : null}
{lifecycle.isPending ? null : lifecycle.error ? (
<p className="mt-4 text-destructive text-sm" role="alert">
Could not load this Bot's state.
Expand Down
125 changes: 125 additions & 0 deletions app/src/components/plugins/account-mode-dialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
// app/src/components/plugins/account-mode-dialog.tsx
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
accountModePreview,
setAccountModeMutationOptions,
} from "@/lib/plugins/mutations";
import { describeApproval } from "@/lib/plugins/shared-use";

/**
* The one moment an administrator decides between people's own accounts and one shared account.
*
* WHAT GOES IS NAMED BEFORE THE BUTTON, from the server's own dry run, so the count on screen is the
* count that will be revoked — and each Bot's approval is listed as it will be written, because that
* is the decision being made on their behalf, not a detail of it.
*/
export function AccountModeDialog({
serverId,
title,
target,
open,
onOpenChange,
names = {},
}: {
serverId: string;
title: string;
target: "personal" | "shared";
open: boolean;
onOpenChange: (open: boolean) => void;
names?: Record<string, string>;
}) {
const queryClient = useQueryClient();
/**
* Kept out of the `["plugins", …]` family on purpose. `invalidatePlugins` — which every plugin
* write, including the switch below, runs `onSettled` — invalidates by that prefix, and this
* dialog stays mounted and enabled through its own confirm. Sharing the prefix would have the
* switch's own settle re-fire this dry run a moment after the real write, so the last request the
* dialog sent would read as a second preview rather than the switch that just happened.
*/
const preview = useQuery({
queryKey: ["account-mode", serverId, target],
queryFn: () => accountModePreview(serverId, target),
enabled: open,
});
const change = useMutation({
...setAccountModeMutationOptions(queryClient),
onSuccess: () => onOpenChange(false),
});
const count = preview.data?.wouldRevoke.count ?? 0;
const goes =
preview.data?.wouldRevoke.holder === "deployment"
? count > 0
? "The shared account will be disconnected."
: "No shared account is connected."
: `${count} personal ${count === 1 ? "account" : "accounts"} will be disconnected.`;
const action =
target === "shared" ? `Make ${title} shared` : `Make ${title} personal`;

return (
<Dialog onOpenChange={onOpenChange} open={open}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>
{target === "shared"
? `Share one ${title} account?`
: `Give everyone their own ${title} account?`}
</DialogTitle>
<DialogDescription>
{preview.data ? goes : "Checking what this would change…"}
</DialogDescription>
</DialogHeader>
{target === "shared" ? (
<div className="mt-4 space-y-3 text-sm">
<p>
Everyone who can use a Bot granted this app will act as this one
account.
</p>
{preview.data?.bots.length ? (
<ul className="space-y-1">
{preview.data.bots.map((bot) => (
<li key={bot.botId}>
<span className="font-medium">
{names[bot.botId] ?? bot.botId}
</span>
: {describeApproval(bot.exposure)}
</li>
))}
</ul>
) : null}
</div>
) : null}
{preview.error || change.error ? (
<p className="mt-4 text-destructive text-sm" role="alert">
{(change.error ?? preview.error)?.message}
</p>
) : null}
<DialogFooter className="mt-4">
<Button
onClick={() => onOpenChange(false)}
size="sm"
variant="outline"
>
Cancel
</Button>
<Button
disabled={!preview.data || change.isPending}
onClick={() => change.mutate({ serverId, mode: target })}
size="sm"
variant="destructive"
>
{action}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
26 changes: 25 additions & 1 deletion app/src/components/plugins/brokered-account-row.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -816,6 +816,8 @@ export function BrokeredAccountRow({
connectedDescription,
disconnectedDescription,
disconnectedReassurance,
heading,
notice,
title,
}: {
account: BrokeredAccount;
Expand All @@ -832,6 +834,21 @@ export function BrokeredAccountRow({
* right for both. A screen with nothing of the second kind to say passes nothing.
*/
disconnectedReassurance?: string;
/**
* The row's own title, where "Your account" is wrong for it.
*
* A shared app is nobody's own account, so the screen that draws one passes its own heading
* rather than live with the row's default sense of ownership.
*/
heading?: string;
/**
* A sentence beneath the row's description, for what the description itself is not about.
*
* Separate from `connectedDescription`/`disconnectedDescription` because those two already carry
* everything the connection state decides; a notice is the screen's own fact about the row —
* that it is shared, say — which holds regardless of which of those two is showing.
*/
notice?: string;
/**
* The app's own name, for the sentences that name it.
*
Expand Down Expand Up @@ -868,7 +885,7 @@ export function BrokeredAccountRow({
<ItemContent>
{/* Not "Connect your account": the row is also the connected state, and a title has to
read for both. */}
<ItemTitle>Your account</ItemTitle>
<ItemTitle>{heading ?? "Your account"}</ItemTitle>
{/* Unclamped where the key is missing: that sentence is the only place the setting is
named, so it is the point rather than a hint. */}
<ItemDescription
Expand All @@ -882,6 +899,13 @@ export function BrokeredAccountRow({
title,
})}
</ItemDescription>
{/* A fact about the row itself rather than about the connection, so it holds under
either of the sentences above and is never clamped away. */}
{notice ? (
<ItemDescription className="line-clamp-none">
{notice}
</ItemDescription>
) : null}
</ItemContent>
{/*
* AN APP THAT NEEDS NO ACCOUNT HAS NOTHING HERE AT ALL — no Connect, and not a disabled one
Expand Down
82 changes: 82 additions & 0 deletions app/src/components/plugins/shared-app-notice.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Button } from "@/components/ui/button";
import { currentUserQueryOptions } from "@/lib/auth/queries";
import {
botSharedAppsQueryOptions,
requestSharedUseMutationOptions,
} from "@/lib/plugins/shared-use";

/**
* What changing who can reach a Bot means for the shared accounts it uses, said BEFORE the change.
*
* An administrator's change is its own approval, so they are told that and nothing is asked of them.
* Anybody else is told plainly that the shared calls will be refused until an administrator agrees,
* and is given the one button that asks — the alternative being that the first person to find out
* is whoever the refused call was for.
*/
export function SharedAppNotice({
botId,
reason,
}: {
botId: string;
reason: "publish" | "trigger";
}) {
const queryClient = useQueryClient();
const me = useQuery(currentUserQueryOptions()).data;
const shared = useQuery(botSharedAppsQueryOptions(botId));
const ask = useMutation(requestSharedUseMutationOptions(queryClient));
const apps = shared.data?.apps ?? [];
if (apps.length === 0) return null;

if (me?.role === "admin") {
return (
<div className="space-y-1 text-sm">
{apps.map((app) => (
<p key={app.serverId}>
Saving also approves the shared {app.title} account for whoever can
reach this Bot.
</p>
))}
</div>
);
}

const waiting = new Set(
(shared.data?.pending ?? []).map((request) => request.serverId),
);
return (
<div className="space-y-2 text-sm">
{apps.map((app) =>
waiting.has(app.serverId) ? (
<p className="text-muted-foreground" key={app.serverId}>
Waiting for an administrator: shared {app.title}
</p>
) : app.covered ? null : (
<div className="flex flex-wrap items-center gap-2" key={app.serverId}>
<p className="text-amber-700 dark:text-amber-400">
{app.title} calls from this Bot are refused until an administrator
approves who can reach it.
</p>
<Button
aria-label={`Request approval for ${app.title}`}
disabled={ask.isPending}
onClick={() =>
ask.mutate({ botId, serverId: app.serverId, reason })
}
size="sm"
type="button"
variant="outline"
>
Request approval
</Button>
</div>
),
)}
{ask.error ? (
<p className="text-destructive" role="alert">
{ask.error.message}
</p>
) : null}
</div>
);
}
Loading
Loading