diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b1e6a8afc..7582826b80 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. @@ -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 diff --git a/app/src/components/bot-profile/access.tsx b/app/src/components/bot-profile/access.tsx index 14a8b8bed9..ca4a5ed8c6 100644 --- a/app/src/components/bot-profile/access.tsx +++ b/app/src/components/bot-profile/access.tsx @@ -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, @@ -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 { @@ -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(/[-_]/) @@ -147,13 +159,26 @@ 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 (

@@ -161,50 +186,95 @@ function Granted({ agentId }: { agentId: string }) {

); } - 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 ( - {connectors.length === 0 && skills.length === 0 ? ( + {apps.error ? ( +

+ Whether each app is ready for you could not be loaded. +

+ ) : null} + {count === 0 ? ( Nothing granted yet. An administrator grants apps from the Plugins screens; until then this Bot can converse, and nothing more. ) : ( - {connectors.map(([key, labels], index) => ( + {states.map((app, index) => { + const labels = tools.get(app.key) ?? []; + return ( + + {index > 0 ? : null} + + + + + + {app.displayName} + {/* + * Unclamped: the sentence ends on who can fix it, and two lines at a phone's + * width cut exactly that off. + */} + + {app.state === "ready" + ? [appAccessSentence(app)] + .concat(labels.length > 0 ? [toolList(labels)] : []) + .join(" ") + : appAccessSentence(app)} + + + + {app.state === "ready" ? ( + + ) : ( + + )} + {admin ? ( + + ) : null} + + + + ); + })} + {rest.map(([key, labels], index) => ( - {index > 0 ? : null} - - ) : undefined - } - size="sm" - > + {states.length + index > 0 ? : null} + {connectorName(key)} - - {labels.slice(0, 4).join(", ")} - {labels.length > 4 ? ` and ${labels.length - 4} more` : ""} - + {toolList(labels)} - - {labels.length} {labels.length === 1 ? "tool" : "tools"} - - {me?.role === "admin" ? ( - + + {admin ? ( + ) : null} @@ -212,7 +282,7 @@ function Granted({ agentId }: { agentId: string }) { ))} {skills.map((skill, index) => ( - {connectors.length + index > 0 ? : null} + {states.length + rest.length + index > 0 ? : null} @@ -232,3 +302,49 @@ function Granted({ agentId }: { agentId: string }) {
); } + +/** 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 ( + + {count} {count === 1 ? "tool" : "tools"} + + ); +} + +/** + * 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 ( + + ); +} diff --git a/app/src/components/channels/app-access-card.tsx b/app/src/components/channels/app-access-card.tsx new file mode 100644 index 0000000000..d58164cdf9 --- /dev/null +++ b/app/src/components/channels/app-access-card.tsx @@ -0,0 +1,149 @@ +import { useQuery } from "@tanstack/react-query"; +import { + AppAccessAction, + appAccessSentence, +} from "@/components/plugins/app-access-action"; +import { PluginLogo } from "@/components/plugins/plugin-logo"; +import { type AppAccess, botAppQueryOptions } from "@/lib/app-access/queries"; +import { useDeclaredBotId } from "@/lib/copilot/active-bot"; +import type { ConnectPrompt } from "@openbot/shared/connect-prompt"; +import { ConnectAppCard } from "./connect-app-card"; + +/** + * A work app a Bot could not use, in front of the person, with the one thing that would let it. + * + * WHAT DRAWS ONE. Every way an app is out of reach for a call now reaches the transcript carrying + * the same marker (`@openbot/shared/connect-prompt`): a person who has not connected, a Shared app + * with no team account, an account the vendor has lost, and — with no call refused at all — the + * `show_app_access` tool a Bot calls for an app it holds nothing for, switched off or not granted. + * They used to end in three different places: a card with Connect, a sentence telling the person to + * ask an administrator, and a sentence naming a settings page. + * + * LIVE, NEVER WHAT THE REFUSAL SAID. The marker names an app and a Bot; the state is asked of the + * server for this person on that Bot when the card is drawn, and the shared app-access piece draws + * that with the button that goes with it. A card scrolled back to an hour later says what is true + * now — connected since, granted since, the request approved — and never offers a step that has + * already been taken. Every write those buttons make refreshes the same key, so the card follows. + * + * THE ONE PLACE THE REFUSAL WINS. The call that finds an account gone at the vendor deletes its row, + * so the live state normally says so itself. A refusal from before that, over a row that still + * stands, reads `ready` live — and this card is drawn over exactly the call that failed. So a + * `reconnect` or `shared` refusal over a live `ready` is drawn as the refusal says — unless the + * account was connected after the refusal, which is the reconnection. Anything else live is newer + * than the refusal and is drawn as it is. + */ +export function AppAccessCard({ prompt }: { prompt: ConnectPrompt }) { + const declared = useDeclaredBotId(); + const agentId = prompt.agentId ?? declared; + /* + * A refusal from before refusals named their Bot, on a surface that declared none: there is no Bot + * to ask about, so the card it was drawn as then is the card it is drawn as now. + */ + if (!agentId) return ; + return ; +} + +function LiveAppAccessCard({ + agentId, + prompt, +}: { + agentId: string; + prompt: ConnectPrompt; +}) { + const live = useQuery(botAppQueryOptions(agentId, prompt.serverId)); + /* + * A read that failed falls back to what this card was before it read anything, where that was a + * card at all: a person's own missing connection still gets its Connect button. The other reasons + * had only ever been a sentence, and the sentence is what is left. + */ + if (live.error && (prompt.reason ?? "connect") === "connect") + return ; + const access = live.data ? asRefused(live.data, prompt) : null; + const name = access?.displayName ?? prompt.title; + return ( +
+
+
+ +
+
+

{name}

+ {/* + * Nothing while the read is open — no placeholder, per the data-access rule — and a + * sentence of the card's own if it failed. NOT the refusal's message: that was written for + * the model ("do not browse to its website"), and reads as an instruction to the wrong + * reader. Otherwise the state, which is the server's. + */} + {live.isPending ? null : access ? ( +

+ {access.state === "ready" && prompt.reason !== "access" + ? `${name} is ready now. Ask again and the Bot can use it.` + : refusedSentence(access, prompt)} +

+ ) : ( +

+ {name}'s status couldn't be loaded. Try again in a moment. +

+ )} +
+
+ {access && access.state !== "ready" ? ( +
+ +
+ ) : null} +
+ ); +} + +/** + * The live state, or the refusal's where only the refusal can know: an account the vendor lost, + * which the server still reads as `ready`. See the card's own comment. + * + * A lost team account is drawn as not connected with NO request to file — the server would answer + * any request 409, since as far as it knows the account is fine — so the sentence says whose move it + * is and the button is left out rather than offered and refused. + * + * A lost account on somebody else's Team Bot keeps the live `accountHolder`. Where that is the + * owner's account, the override reads as the owner's reconnection — the sentence says only the owner + * can, and `AppAccessAction` offers nothing — because a Reconnect pressed here would connect this + * person's own account, which the Bot does not run on. + */ +function asRefused(live: AppAccess, prompt: ConnectPrompt): AppAccess { + if (live.state !== "ready") return live; + /* + * Connected again since the refusal: that is the reconnection, and the live `ready` is true. + * Compared as instants rather than as strings, because the two come from different clocks' + * formatters and only the instant is the same. + */ + if ( + prompt.at && + live.connectedAt && + Date.parse(live.connectedAt) > Date.parse(prompt.at) + ) + return live; + if (prompt.reason === "reconnect") return { ...live, state: "reconnect" }; + if (prompt.reason === "shared") + return { + ...live, + state: "shared_not_connected", + requestKind: null, + request: null, + }; + return live; +} + +/** As the shared sentence, except a lost team account, which needs reconnecting rather than connecting. */ +function refusedSentence(access: AppAccess, prompt: ConnectPrompt): string { + if ( + access.state === "shared_not_connected" && + prompt.reason === "shared" && + !access.requestKind + ) + return `Your organisation's ${access.displayName} account needs reconnecting. Only an administrator can reconnect it.`; + return appAccessSentence(access); +} diff --git a/app/src/components/channels/chat-transcript.tsx b/app/src/components/channels/chat-transcript.tsx index 94a8972d20..6cf6cc805d 100644 --- a/app/src/components/channels/chat-transcript.tsx +++ b/app/src/components/channels/chat-transcript.tsx @@ -59,7 +59,10 @@ import { toolResultFailed, } from "@/lib/plugins/tool-result"; import { readTeamBotConsent } from "@/lib/team-bots"; -import { readConnectPrompt } from "@openbot/shared/connect-prompt"; +import { + readConnectPrompt, + SHOW_APP_ACCESS_TOOL, +} from "@openbot/shared/connect-prompt"; import { cn } from "@/lib/utils"; import { VOICE_CHAT_ACTIVITY, type VoiceChatEntry } from "@/lib/voice/archive"; import { @@ -74,7 +77,7 @@ import { toVisibleChatItems, } from "./chat-messages"; import type { QueuedMessage } from "./composer"; -import { ConnectAppCard } from "./connect-app-card"; +import { AppAccessCard } from "./app-access-card"; import { TeamBotConsentCard } from "./team-bot-consent"; import { ToolRenderBoundary } from "./tool-boundary"; import { ToolLine } from "./tool-line"; @@ -1351,9 +1354,18 @@ const TranscriptToolCall = memo(function TranscriptToolCall({ * * Named from the reader's side: what was done, against which server, with the server's own words * behind a disclosure. The identifier the model was offered never reaches the screen. + * + * Exported for its tests, which draw it without the CopilotKit provider `TranscriptToolCall` needs. */ -function ServerToolLine({ name, result }: { name: string; result?: string }) { - const { label, detail } = readToolName(name); +export function ServerToolLine({ + name, + result, +}: { + name: string; + result?: string; +}) { + const running = result === undefined; + const { label, detail } = readToolName(name, running); /* * A refusal is not a result, and must not read like one. * @@ -1374,20 +1386,36 @@ function ServerToolLine({ name, result }: { name: string; result?: string }) { // A Team Bot asking to use this person's own account draws its consent card, not the sentence. const consent = refused ? readTeamBotConsent(body) : null; if (consent) return ; - // An app this person has not connected draws the connect button, not the name of a screen. - const connect = refused ? readConnectPrompt(body) : null; - if (connect) return ; + /* + * An app this call could not use draws a card with the step that fixes it, not the name of a + * screen: a refusal carrying the marker, or the answer of the one tool whose whole job is to put + * that card on screen. Only that tool's ordinary answer is read for a marker — any other result + * is somebody else's text, and a card must not be conjured out of it. + */ + const card = refused + ? readConnectPrompt(body) + : name === SHOW_APP_ACCESS_TOOL + ? readConnectPrompt(answer) + : null; + if (card) return ; + /* + * THAT TOOL'S OTHER ANSWERS ARE FOR THE MODEL. An app nobody can name comes back as plain text + * telling the Bot to say so plainly and not to browse to it — an instruction, which read by the + * person looks like the Bot being told off in front of them. The line still says the check + * happened; the Bot's own reply says what it found. + */ + const shown = name === SHOW_APP_ACCESS_TOOL ? undefined : body; return ( - {body ? ( + {shown ? ( - {forDisplay(body)} + {forDisplay(shown)} ) : null} diff --git a/app/src/components/channels/connect-app-card.tsx b/app/src/components/channels/connect-app-card.tsx index 89f712d54d..56361a9e84 100644 --- a/app/src/components/channels/connect-app-card.tsx +++ b/app/src/components/channels/connect-app-card.tsx @@ -1,8 +1,8 @@ -import { useMutation, useQuery } from "@tanstack/react-query"; +import { useQuery } from "@tanstack/react-query"; import { Link } from "@tanstack/react-router"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useRef } from "react"; +import { useConnectInNewTab } from "@/components/plugins/connect-in-new-tab"; import { Button } from "@/components/ui/button"; -import { connectAccountMutationOptions } from "@/lib/plugins/mutations"; import { connectionsQueryOptions, personalConnections, @@ -21,6 +21,11 @@ import { queryClient } from "@/query-client"; * WATCHING, NOT TRUSTING. The return trip proves nothing — it is an ordinary redirect in a tab this * one cannot read — so nothing here treats "they came back" as "it worked". The card polls this * person's own connections while it waits and only says connected when the server lists one. + * + * NOW THE FALLBACK RATHER THAN THE CARD. A refusal names the Bot it came from and `AppAccessCard` + * draws it from the app's live state for this person on that Bot. This one is drawn only where no + * Bot can be named — a refusal written before refusals carried one, on a surface that declared none + * — and it can only offer Connect, which is all such a refusal ever meant. */ export function ConnectAppCard({ serverId, @@ -31,12 +36,15 @@ export function ConnectAppCard({ title: string; message: string; }) { - const [waiting, setWaiting] = useState(false); - /** This app takes a key somebody types, so there is no page to send them to. */ - const [needsKey, setNeedsKey] = useState(false); - /** The authorization url, when the browser refused to open the tab holding it. */ - const [blockedUrl, setBlockedUrl] = useState(null); - const connect = useMutation(connectAccountMutationOptions()); + const { + open: openTab, + waiting, + stopWaiting, + needsKey, + blockedUrl, + pending, + error, + } = useConnectInNewTab(); const connections = useQuery({ ...connectionsQueryOptions(), // Only while a consent is open in the other tab; otherwise this is an ordinary cached read. @@ -59,53 +67,13 @@ export function ConnectAppCard({ const was = useRef(connected); useEffect(() => { if (connected && !was.current) { - setWaiting(false); + stopWaiting(); void queryClient.invalidateQueries(); } was.current = connected; - }, [connected]); - - /** - * The tab, opened on the click itself rather than on the answer to it. - * - * A BROWSER ONLY LETS A PAGE OPEN A TAB WHILE A CLICK IS STILL RECENT. Chrome's transient - * activation lasts about five seconds, and the request that mints the authorization url has to - * finish first — so on a slow deployment `window.open` was called with the activation already - * expired, the tab was blocked, and the card cheerfully said to finish in a tab that did not - * exist. The blank tab is claimed first and navigated once the url arrives. - * - * `noopener` CANNOT BE PASSED HERE: it makes `window.open` answer null, and null is a handle - * nothing can navigate later. The opener is severed on the handle instead, which is the same - * guarantee by a different route. - */ - const claimTab = (): Window | null => { - const tab = window.open("", "_blank"); - if (tab) tab.opener = null; - return tab; - }; + }, [connected, stopWaiting]); - const open = () => { - const tab = claimTab(); - connect.mutate(serverId, { - onSuccess: (authorizationUrl) => { - /* - * No url means this app is connected by typing a key rather than by visiting a page — the - * route answers `{ fields }` and the mutation unwraps nothing. Saying so, with the page - * that has the form, beats a button that silently does nothing. - */ - if (!authorizationUrl) { - tab?.close(); - setNeedsKey(true); - return; - } - setWaiting(true); - if (tab) tab.location.replace(authorizationUrl); - // The tab was blocked. The url is still good, so offer it as something to click. - else setBlockedUrl(authorizationUrl); - }, - onError: () => tab?.close(), - }); - }; + const open = () => openTab(serverId); return (
{/* The heading above already names the app; the button says what pressing it does. */} - {waiting ? ( @@ -158,11 +126,9 @@ export function ConnectAppCard({ .

) : null} - {connect.isError ? ( + {error ? (

- {connect.error instanceof Error - ? connect.error.message - : `${title} could not be connected.`} + {error}

) : null}
diff --git a/app/src/components/channels/tool-activity.tsx b/app/src/components/channels/tool-activity.tsx index 22431443b4..c03e5d2870 100644 --- a/app/src/components/channels/tool-activity.tsx +++ b/app/src/components/channels/tool-activity.tsx @@ -10,7 +10,7 @@ import { useCallback, useContext, useMemo, useSyncExternalStore } from "react"; import { Streamdown } from "streamdown"; import { PendingApprovals } from "@/components/approvals/inline-approval"; import { markdownComponents } from "@/lib/markdown"; -import { humanise, readToolName } from "@/lib/plugins/tool-name"; +import { humanise, ownToolLabel, readToolName } from "@/lib/plugins/tool-name"; import { asText, forDisplay, @@ -18,7 +18,10 @@ import { toolResultFailed, } from "@/lib/plugins/tool-result"; import { readTeamBotConsent } from "@/lib/team-bots"; -import { readConnectPrompt } from "@openbot/shared/connect-prompt"; +import { + readConnectPrompt, + SHOW_APP_ACCESS_TOOL, +} from "@openbot/shared/connect-prompt"; import { researchRoleOfTool } from "@openbot/shared/research"; import { browserStepDetails, isBrowserStep } from "./browser-activity"; import type { VisibleChatItem } from "./chat-messages"; @@ -78,6 +81,12 @@ function foldable(item: ToolItem, isDrawn: (name: string) => boolean): boolean { if (isBrowserStep(item)) return true; const name = item.toolCall.function.name; if (!name.startsWith("mcp__") && isDrawn(name)) return false; + /* + * The app-access card is drawn from a server tool's answer, so no renderer registers it and the + * registry cannot say so. Never folded — not even while its answer is still on the way, or the + * card would land inside a group and then jump out of it. + */ + if (name === SHOW_APP_ACCESS_TOOL) return false; return !asksThePerson(item.result); } @@ -116,7 +125,7 @@ export function useDrawnTool(): (name: string) => boolean { * Whether this refusal is a question for the person rather than a line about what a Bot did. * * Both of these draw a card with something to press — a Team Bot asking to act as them, and an app - * they have not connected — and neither may sit behind a disclosure for the same reason: a question + * they cannot use yet (not connected, lost at the vendor, or a team account nobody connected) — and neither may sit behind a disclosure for the same reason: a question * nobody can see is a conversation that has stopped without saying so. */ function asksThePerson(result: string | undefined): boolean { @@ -228,17 +237,25 @@ export function stepDetails(step: ToolItem): StepDetails { const answer = step.result === undefined ? undefined : asText(step.result); const refused = answer?.startsWith(REFUSAL_MARKER) ?? false; const body = refused ? answer?.slice(REFUSAL_MARKER.length).trim() : answer; + // This deployment's own tools answer the model, not the person; see `ServerToolLine`. + const own = ownToolLabel(name) !== null; const outcome = { id: step.id, pending: step.result === undefined, failed: !refused && toolResultFailed(step.result), refused, inputs, - ...(body ? { output: body } : {}), + ...(body && !own ? { output: body } : {}), }; - // Not a connector: a tool an external agent runs itself. Named in words, never as an identifier. + // Not a connector: one of this deployment's own tools, or one an external agent runs itself. + // Named in words, never as an identifier. if (!name.startsWith("mcp__")) - return { ...outcome, kind: "tool", label: humanise(name), pages: 0 }; + return { + ...outcome, + kind: "tool", + label: ownToolLabel(name, outcome.pending) ?? humanise(name), + pages: 0, + }; const [, server = "", ...rest] = name.split("__"); const role = researchRoleOfTool(server, rest.join("__")); diff --git a/app/src/components/plugins/access-requests.tsx b/app/src/components/plugins/access-requests.tsx new file mode 100644 index 0000000000..973ba8f565 --- /dev/null +++ b/app/src/components/plugins/access-requests.tsx @@ -0,0 +1,196 @@ +import { IconInbox } from "@tabler/icons-react"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { Link } from "@tanstack/react-router"; +import { Fragment } from "react"; +import { PageRows, PageSection } from "@/components/layout/page-shell"; +import { Button } from "@/components/ui/button"; +import { + Item, + ItemActions, + ItemContent, + ItemDescription, + ItemFooter, + ItemMedia, + ItemTitle, +} from "@/components/ui/item"; +import { Separator } from "@/components/ui/separator"; +import { + approveAppAccessRequestMutationOptions, + dismissAppAccessRequestMutationOptions, +} from "@/lib/app-access/mutations"; +import { + type AppAccessRequest, + appAccessInboxQueryOptions, +} from "@/lib/app-access/queries"; +import { relativeTime } from "@/lib/relative-time"; +import { queryClient } from "@/query-client"; + +/** + * The requests people have filed for an administrator to answer: switch an app on, give it to a + * Bot, or connect its shared account. + * + * ONE SECTION, ON TWO SCREENS. An app's own admin page lists the requests about that app, because + * that is where an administrator is already deciding about it. The Plugins list carries all of them, + * because an app nobody has switched on has no page of its own to list them on — a Composio app + * enabled by approving the request is exactly that case — and because a request nobody sees is a + * person waiting for nothing. + * + * APPROVE DOES THE THING. The server switches the app on and grants it to the Bot in the same press, + * or refuses with the step only an administrator can take first — a token to paste, a team account + * to connect — and the request stays pending. That sentence is drawn on the row it is about, because + * the screen's own banner is a long way from the button that was pressed. + * + * A GRANT IS READ-ONLY UNTIL SOMEBODY CHOOSES MORE. One press is not a decision about every action an + * app offers — Gmail's include sending and deleting mail — so approving gives the Bot the app's + * read-only actions and nothing else, and an app with none is refused with a sentence pointing at the + * Bot's page. The row says so before the press, and links to that page where it can. + * + * REQUESTS, NOT CONNECTIONS. Each row names who asked, because they asked; nothing here says who has + * connected what, which stays private to each person (see the note on `admin/plugins/$key.tsx`). + * + * Nothing at all while the read is open or when nothing is waiting: an empty section on every app's + * page would teach an administrator to scroll past the heading. + */ +export function AccessRequestsSection({ app }: { app?: string }) { + const inbox = useQuery(appAccessInboxQueryOptions(true, app)); + if (inbox.isPending) return null; + if (inbox.error) + return ( + +

+ Access requests could not be loaded. +

+
+ ); + const requests = inbox.data?.requests ?? []; + if (requests.length === 0) return null; + return ( + + + {requests.map((request, index) => ( + + {index > 0 ? : null} + + + ))} + + + ); +} + +/** What the request asks for, as the thing an administrator would do. */ +export function askedFor(request: AppAccessRequest): string { + const bot = request.botName ?? "a Bot"; + switch (request.kind) { + case "enable": + return `Turn on ${request.appTitle} for ${bot}`; + case "grant": + return `Give ${request.appTitle} to ${bot}`; + case "connect_shared": + return `Connect the shared ${request.appTitle} account`; + } +} + +function AccessRequestRow({ request }: { request: AppAccessRequest }) { + // One pair per row, so a refusal is drawn under the request it is about and nowhere else. + const approve = useMutation( + approveAppAccessRequestMutationOptions(queryClient), + ); + const dismiss = useMutation( + dismissAppAccessRequestMutationOptions(queryClient), + ); + const busy = approve.isPending || dismiss.isPending; + const error = approve.error ?? dismiss.error; + const who = request.requesterName ?? request.requesterEmail ?? "Somebody"; + return ( + + + + + + {askedFor(request)} + + {who} asked {relativeTime(request.createdAt)} + {request.kind === "connect_shared" && request.botName + ? `, for ${request.botName}` + : ""} + . + + + + + + + {error ? ( + +

+ {error.message} +

+
+ ) : null} +
+ ); +} + +/** + * What approving gives a Bot, for a request that grants one: the app's read-only actions, with the + * rest chosen on the Bot's page under the app. Linked there for a grant, whose app is already on + * and so has that page; an app still to be switched on has none until the press that switches it on. + */ +function GrantNote({ request }: { request: AppAccessRequest }) { + if (request.kind === "connect_shared" || !request.agentId) return null; + const bot = request.botName ?? "the Bot"; + const where = `the Bot's page under ${request.appTitle}`; + const page = + request.kind === "grant" ? ( + + {where} + + ) : ( + where + ); + return ( + <> + Approving gives {bot} only {request.appTitle}'s read-only actions. Choose + any others on {page}. + + ); +} + +/** + * How many requests are waiting, for a list row that wants to say so: one app's, or all of them. + * Null while nothing is waiting or nothing has been read, so a row draws nothing rather than "0". + */ +export function usePendingRequestCounts() { + const inbox = useQuery(appAccessInboxQueryOptions(true)); + return inbox.data?.counts ?? null; +} diff --git a/app/src/components/plugins/app-access-action.tsx b/app/src/components/plugins/app-access-action.tsx new file mode 100644 index 0000000000..df9e2e9d37 --- /dev/null +++ b/app/src/components/plugins/app-access-action.tsx @@ -0,0 +1,308 @@ +import { useMutation } from "@tanstack/react-query"; +import { Link } from "@tanstack/react-router"; +import { useEffect, useRef } from "react"; +import { useConnectInNewTab } from "@/components/plugins/connect-in-new-tab"; +import { Button } from "@/components/ui/button"; +import { + cancelAppAccessRequestMutationOptions, + requestAppAccessMutationOptions, +} from "@/lib/app-access/mutations"; +import { type AppAccess, appAccessKeys } from "@/lib/app-access/queries"; +import { queryClient } from "@/query-client"; + +/** + * One work app's state for the person looking, and the one thing they can do about it. + * + * ONE PIECE, DRAWN IN TWO PLACES. A Bot's page lists every app the Bot holds with a row each, and a + * conversation draws a card when the Bot needed an app it could not use. Both say the same thing — + * whether this person can use this app through this Bot, whose move it is if not, and the button + * for that move — so both draw it from here, and a sentence corrected for one is corrected for both. + * + * WHO CAN FIX IT IS THE WHOLE SENTENCE. Seven states reduce to four answers: nothing to do, a step + * that is yours (connect, reconnect), a step only an administrator can take (switch it on, finish + * setting it up, grant it, connect or approve the team account), and on somebody else's Team Bot a + * step only its owner can take (reconnect the account the Bot runs on). The copy names which, + * because "Gmail is unavailable" sends somebody hunting through their own settings for a switch that + * is not there. + * + * THE STATE IS THE SERVER'S. It arrives on `access` and is drawn as it is; nothing here works out + * from `granted` or `accountMode` what the state ought to be. The one exception is the caller's: a + * card may hand in an `access` whose state it overrode from the refusal it was drawn for — see + * `AppAccessCard` — and that is the caller's decision, made where the refusal is in hand. + */ + +/** What the person is told about one app, in words that say whose move it is. */ +export function appAccessSentence(access: AppAccess): string { + const name = access.displayName; + const owners = access.accountHolder === "owner"; + switch (access.state) { + case "ready": + if (owners) + return `Ready to use. This Bot uses its owner's ${name} account.`; + if (access.asksFirst) + return `Ready to use with your own ${name} account. You'll be asked to allow it the first time.`; + return "Ready to use."; + case "not_connected": + return `You haven't connected your ${name} account yet.`; + case "reconnect": + return owners + ? `This Bot uses its owner's ${name} account, which needs reconnecting. Only the Bot's owner can reconnect it.` + : `Your ${name} connection needs reconnecting.`; + case "shared_not_connected": + return `Your organisation's ${name} account isn't connected yet. Only an administrator can connect it.`; + case "shared_not_approved": + return `This Bot reaches more people than an administrator has approved for your organisation's ${name} account. Only an administrator can approve it.`; + case "disabled": + return disabledSentence(access); + case "not_granted": + return `This Bot hasn't been given ${name}. Only an administrator can give it.`; + } +} + +/** + * Switched off, in the words for why. "Turned off by an administrator" was true of one reason in + * five: an app nobody switched on, and a set-up somebody has not finished, are not a decision + * anybody took — and the Composio case says what Settings → Connected accounts says about the same + * app, so the two screens never disagree about it. + */ +function disabledSentence(access: AppAccess): string { + const name = access.displayName; + switch (access.unavailable) { + case "composio_unconfigured": + return `${name} is not available on this OpenBot yet. An administrator has to finish setting it up.`; + case "scheme_unreadable": + case "oauth_client_missing": + case "token_missing": + return `${name} isn't fully set up yet. Only an administrator can finish setting it up.`; + default: + return `${name} isn't switched on for this OpenBot. Only an administrator can switch it on.`; + } +} + +/** + * The button for the person's next move, and what it says while it happens. + * + * - `ready` — nothing. + * - `shared_not_approved` — nothing either: the call that was refused already filed the + * administrator's request, so there is no second one to make. + * - `reconnect` on an account the Bot's owner holds — nothing. Only the owner can reconnect their + * own account, and nobody else's press could do it for them. + * - `not_connected` / `reconnect` — the person's own connection. Connect starts it in a new tab + * with the connect route Settings presses; an app connected with a typed key, and a Composio app + * that already has a row (which the connect route refuses until it is disconnected), go to the + * app's own page in Settings instead, where the form and Disconnect are. + * - the three an administrator fixes — Request access, which files the request the server decides + * the kind of, and then Requested with a way to withdraw it. + */ +export function AppAccessAction({ + agentId, + access, +}: { + agentId: string; + access: AppAccess; +}) { + if (access.state === "ready" || access.state === "shared_not_approved") + return null; + if (access.state === "reconnect" && access.accountHolder === "owner") + return null; + if (access.state === "not_connected" || access.state === "reconnect") + return ; + return ; +} + +/** The person's own account: connect it, or reconnect it. */ +function ConnectAction({ access }: { access: AppAccess }) { + const label = access.state === "reconnect" ? "Reconnect" : "Connect"; + const tab = useConnectInNewTab(); + const { waiting, stopWaiting } = tab; + + /* + * WATCHING THE SERVER WHILE A CONSENT IS OPEN in the other tab, because the return trip proves + * nothing. The whole app-access view is refreshed every two seconds until this app stops being + * this person's to connect — which is what unmounts this component — and then everything once, + * because the grants a Bot is offered are read per conversation and the turn after this has to + * be asked against a store that knows about the new account. + */ + useEffect(() => { + if (!waiting) return; + const timer = setInterval(() => { + void queryClient.invalidateQueries({ queryKey: appAccessKeys.all }); + }, 2_000); + return () => clearInterval(timer); + }, [waiting]); + const waited = useRef(false); + waited.current ||= waiting; + useEffect( + () => () => { + if (waited.current) void queryClient.invalidateQueries(); + }, + [], + ); + + /* + * A FORM, OR A CONNECTION THAT ALREADY HAS A ROW, IS SETTINGS' JOB. A typed key has nowhere to be + * typed but the app's page, and a Composio app whose row still stands — a refused key, an account + * the vendor lost — is refused by the connect route until it is disconnected there. Only this + * deployment's own OAuth flow takes a fresh consent over a withdrawn one. + */ + const viaSettings = + access.connect === "key" || + (access.state === "reconnect" && access.connect !== "oauth") || + tab.needsKey; + if (viaSettings) + return ( + + ); + + return ( +
+ {tab.error ? ( +

+ {tab.error} +

+ ) : null} + {tab.blockedUrl ? ( +

+ This browser blocked the tab.{" "} + + Open {access.displayName}'s sign-in + + . +

+ ) : waiting ? ( +

+ Finish in the tab that opened. +

+ ) : null} + +
+ ); +} + +/** A step only an administrator can take: ask for it, and withdraw the ask. */ +function RequestAction({ + agentId, + access, +}: { + agentId: string; + access: AppAccess; +}) { + const request = useMutation(requestAppAccessMutationOptions(queryClient)); + const cancel = useMutation( + cancelAppAccessRequestMutationOptions(queryClient), + ); + const error = request.error ?? cancel.error; + + /* + * FOCUS FOLLOWS THE PRESS. Request access and Withdraw replace each other, so the button somebody + * pressed leaves the page the moment the server's answer is drawn, and the focus with it — onto + * the body, where a keyboard user starts again from the top. The press is remembered, and when + * the other button arrives in its place it takes the focus. A refused press leaves the same + * button standing, which gets it back. + */ + const control = useRef(null); + const pressed = useRef(false); + const requested = access.request !== null; + // biome-ignore lint/correctness/useExhaustiveDependencies: runs on the swap between the two buttons. + useEffect(() => { + if (!pressed.current) return; + pressed.current = false; + control.current?.focus(); + }, [requested]); + const refocus = { + onError: () => { + pressed.current = false; + control.current?.focus(); + }, + }; + + /* + * Nothing to ask for where the server names no request: a state a caller overrode from a refusal + * the server does not know about (the team account lost at the vendor), which the server would + * answer 409 because as far as it knows nothing is wrong. The sentence still says whose move it is. + */ + if (!access.request && !access.requestKind) return null; + + return ( +
+ {error ? ( +

+ {error.message} +

+ ) : null} + {access.request ? ( + <> + + Requested + + + + ) : ( + + )} +
+ ); +} diff --git a/app/src/components/plugins/connect-in-new-tab.ts b/app/src/components/plugins/connect-in-new-tab.ts new file mode 100644 index 0000000000..002f5b3dfd --- /dev/null +++ b/app/src/components/plugins/connect-in-new-tab.ts @@ -0,0 +1,146 @@ +import { useMutation } from "@tanstack/react-query"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { connectAccountMutationOptions } from "@/lib/plugins/mutations"; +import { pluginKeys } from "@/lib/plugins/queries"; +import { queryClient } from "@/query-client"; + +/** + * Start this person's own connection to one app in a new tab, the way Settings starts it. + * + * THE SAME WRITE AS SETTINGS, A DIFFERENT TAB. `connectAccountMutationOptions` is the connect route + * the connected-accounts page presses, returning to that page when the vendor is done, so the + * confirm that writes the connection down is the one that page already runs. What differs is where + * the consent screen opens: Settings navigates its own tab, because the person went there to + * connect; a card in a conversation or a row on a Bot's page opens a new one, because the person is + * in the middle of something and a full navigation several redirects long would take them out of + * it. This tab stays where it was, and whoever drew the button watches the server for the + * connection to appear — the return trip proves nothing, being an ordinary redirect in a tab this + * one cannot read. + * + * Lifted out of `ConnectAppCard`, which had it inline, so the Bot page and the app-access card start + * a connection with the same code rather than a second copy of the popup rules below. + */ +export function useConnectInNewTab() { + const [waiting, setWaiting] = useState(false); + /** This app takes a key somebody types, so there is no page to send them to. */ + const [needsKey, setNeedsKey] = useState(false); + /** The authorization url, when the browser refused to open the tab holding it. */ + const [blockedUrl, setBlockedUrl] = useState(null); + const connect = useMutation(connectAccountMutationOptions()); + // Stable, so a caller can list it in an effect's dependencies without re-running it every render. + const stopWaiting = useCallback(() => setWaiting(false), []); + /** The tab the consent is open in, while there is one to watch. */ + const handle = useRef(null); + + /* + * A CLOSED TAB ENDS THE WAIT ONCE THIS TAB HAS THE FOCUS BACK. Somebody who closes the sign-in + * tab without finishing has given up, and a button still saying "Waiting for Gmail…" a minute + * later is waiting for nothing — so the handle is checked every second, and once it reads closed + * the wait ends and the plugin screens are asked once more, in case the consent did finish before + * the tab went. + * + * CLOSED MAY ALSO MEAN SEVERED, WHICH IS WHY THE FOCUS DECIDES. A vendor's sign-in page that sends + * a strict Cross-Origin-Opener-Policy — Google's does — moves the tab into a browsing context of + * its own, and the handle this tab holds then reads closed while the person is still on the + * consent screen. The handle cannot tell the two apart; the focus can. Closing a tab hands the + * focus straight back here, so the wait ends at once; a severed tab keeps it, so the button goes + * on waiting until the person comes back to this one, which is when there is an answer to show. + */ + const onReturn = useRef<(() => void) | null>(null); + useEffect(() => { + if (!waiting) return; + const finish = () => { + onReturn.current = null; + setWaiting(false); + void queryClient.invalidateQueries({ queryKey: pluginKeys.all }); + }; + const timer = setInterval(() => { + if (!handle.current?.closed || onReturn.current) return; + handle.current = null; + if (document.hasFocus()) { + finish(); + return; + } + onReturn.current = finish; + window.addEventListener("focus", finish, { once: true }); + }, 1_000); + return () => clearInterval(timer); + }, [waiting]); + // Goes with whoever drew the button, and with a wait that ended some other way first. + useEffect(() => { + if (waiting || !onReturn.current) return; + window.removeEventListener("focus", onReturn.current); + onReturn.current = null; + }, [waiting]); + useEffect( + () => () => { + if (onReturn.current) + window.removeEventListener("focus", onReturn.current); + }, + [], + ); + + /** + * The tab, opened on the click itself rather than on the answer to it. + * + * A BROWSER ONLY LETS A PAGE OPEN A TAB WHILE A CLICK IS STILL RECENT. Chrome's transient + * activation lasts about five seconds, and the request that mints the authorization url has to + * finish first — so on a slow deployment `window.open` was called with the activation already + * expired, the tab was blocked, and the card cheerfully said to finish in a tab that did not + * exist. The blank tab is claimed first and navigated once the url arrives. + * + * `noopener` CANNOT BE PASSED HERE: it makes `window.open` answer null, and null is a handle + * nothing can navigate later. The opener is severed on the handle instead, which is the same + * guarantee by a different route. + */ + const claimTab = (): Window | null => { + const tab = window.open("", "_blank"); + if (tab) tab.opener = null; + return tab; + }; + + const open = (serverId: string) => { + const tab = claimTab(); + handle.current = tab; + connect.mutate(serverId, { + onSuccess: (authorizationUrl) => { + /* + * No url means this app is connected by typing a key rather than by visiting a page — the + * route answers `{ fields }` and the mutation unwraps nothing. Saying so, with the page + * that has the form, beats a button that silently does nothing. + */ + if (!authorizationUrl) { + tab?.close(); + handle.current = null; + setNeedsKey(true); + return; + } + setWaiting(true); + if (tab) tab.location.replace(authorizationUrl); + // The tab was blocked. The url is still good, so offer it as something to click. + else setBlockedUrl(authorizationUrl); + }, + onError: () => { + tab?.close(); + handle.current = null; + }, + }); + }; + + return { + open, + /** A consent is open in the other tab; whoever drew the button should be watching. */ + waiting, + /** Called once the server lists the connection, so the button stops saying it is waiting. */ + stopWaiting, + needsKey, + blockedUrl, + pending: connect.isPending, + /** The connect route's own sentence, when it refused. */ + error: connect.isError + ? connect.error instanceof Error + ? connect.error.message + : "That account could not be connected." + : null, + }; +} diff --git a/app/src/lib/app-access/mutations.ts b/app/src/lib/app-access/mutations.ts new file mode 100644 index 0000000000..31131a5df2 --- /dev/null +++ b/app/src/lib/app-access/mutations.ts @@ -0,0 +1,101 @@ +import { mutationOptions, type QueryClient } from "@tanstack/react-query"; +import { client } from "@/lib/client"; +import { pluginKeys } from "@/lib/plugins/queries"; +import type { AppAccessRequest } from "./queries"; + +const FALLBACK = "That request could not be sent."; + +/* + * EVERY PLUGIN SCREEN, AND ON `onSettled`, for the reason `invalidatePlugins` in + * `lib/plugins/mutations.ts` sets out: approving does several durable things in a row — switch the + * app on, grant its tools, record a Shared approval — and a refusal partway leaves the earlier ones + * standing. The app-access keys sit under `pluginKeys.all`, so one invalidation covers the Bot page, + * the card, the inbox and the plugin screens the approval just changed. + */ +const refresh = (queryClient: QueryClient) => + queryClient.invalidateQueries({ queryKey: pluginKeys.all }); + +/** + * Ask an administrator for access to one app on one Bot. The server decides what is asked for from + * the app's state; a second press answers with the request already pending (`created: false`). + */ +export function requestAppAccessMutationOptions(queryClient: QueryClient) { + return mutationOptions({ + mutationFn: async (variables: { + agentId: string; + app: string; + }): Promise<{ request: AppAccessRequest; created: boolean }> => { + const response = await client("/api/plugins/access-requests", { + method: "POST", + body: variables, + fallback: FALLBACK, + }); + return response.json(); + }, + onSettled: () => refresh(queryClient), + }); +} + +/** Withdraw one's own pending request. */ +export function cancelAppAccessRequestMutationOptions( + queryClient: QueryClient, +) { + return mutationOptions({ + mutationFn: (id: string): Promise => + client( + `/api/plugins/access-requests/${encodeURIComponent(id)}`, + "request", + { + method: "DELETE", + fallback: "That request could not be withdrawn.", + }, + ), + onSettled: () => refresh(queryClient), + }); +} + +/** + * Approve a request by doing what it asks. `refs` names the tools to grant; absent, the Bot is + * granted the app's read-only tools only, and an app with none is answered 409 with a sentence + * pointing at the Bot's page, where the others are chosen. Answers with what changed. + */ +export function approveAppAccessRequestMutationOptions( + queryClient: QueryClient, +) { + return mutationOptions({ + mutationFn: async (variables: { + id: string; + refs?: string[]; + }): Promise<{ + request: AppAccessRequest; + enabled: boolean; + granted: string[]; + }> => { + const response = await client( + `/api/plugins/access-requests/${encodeURIComponent(variables.id)}/approve`, + { + method: "POST", + ...(variables.refs ? { body: { refs: variables.refs } } : {}), + fallback: "That request could not be approved.", + }, + ); + return response.json(); + }, + onSettled: () => refresh(queryClient), + }); +} + +/** Dismiss a request without changing anything else. */ +export function dismissAppAccessRequestMutationOptions( + queryClient: QueryClient, +) { + return mutationOptions({ + mutationFn: (id: string): Promise => + client( + `/api/plugins/access-requests/${encodeURIComponent(id)}/dismiss`, + "request", + { method: "POST", fallback: "That request could not be dismissed." }, + ), + onSettled: () => refresh(queryClient), + }); +} diff --git a/app/src/lib/app-access/queries.ts b/app/src/lib/app-access/queries.ts new file mode 100644 index 0000000000..2c4313f2b5 --- /dev/null +++ b/app/src/lib/app-access/queries.ts @@ -0,0 +1,172 @@ +import { queryOptions } from "@tanstack/react-query"; +import { client } from "@/lib/client"; + +/** + * What each work app is for the person looking, on one Bot, and the requests they file when only an + * administrator can change it. + * + * THE STATE IS THE SERVER'S. The browser draws `state` and `requestKind` as they arrive and never + * derives either from the other fields: whether an app is switched off, held by the Bot, connected + * by this person or waiting on a team account is read on the server from the same tables every + * call gate reads. + */ + +/** + * The seven things a person can be told about one app on one Bot. + * + * `ready` — usable now. `not_connected` / `reconnect` — waiting on an account: this person's own, + * which they connect or reconnect themselves, or on somebody else's Team Bot the owner's, which only + * the owner can (see `accountHolder`). `shared_not_connected` — a Shared app whose team account an + * administrator has not connected. `shared_not_approved` — a Shared app whose team account IS + * connected, but an administrator has not approved this Bot's audience for it, so every call is + * refused; the refused call files the administrator's request itself, so there is none to press. + * `disabled` — switched off at the deployment, for the reason `unavailable` gives. `not_granted` — + * enabled, and this Bot holds none of it. + */ +export type AppAccessState = + | "ready" + | "not_connected" + | "reconnect" + | "shared_not_connected" + | "shared_not_approved" + | "disabled" + | "not_granted"; + +/** + * Why an app is switched off. `absent` — nobody switched it on here. `composio_unconfigured` — a + * brokered app (Composio or Arcade) on a deployment with no key for its broker. The other three — a scheme the server cannot + * read, an OAuth client nobody registered, a token nobody pasted — are a set-up left unfinished. + */ +export type AppUnavailable = + | "absent" + | "composio_unconfigured" + | "scheme_unreadable" + | "oauth_client_missing" + | "token_missing"; + +/** What a press of Request access asks an administrator for. */ +export type AppAccessRequestKind = "enable" | "grant" | "connect_shared"; + +export type AppAccess = { + /** The server id the app has, or the one enabling it would mint. What a request names. */ + key: string; + /** The app's own name — "Gmail", never a humanised server id. */ + displayName: string; + logo: string | null; + /** A brokered app names its broker; a catalogue or custom app is reached directly. */ + provider: "composio" | "arcade" | "catalogue" | "custom"; + /** Whose account calls land in: each person's own, one team account, or nobody's. */ + account: "personal" | "shared" | "deployment"; + /** How the account is connected: this deployment's OAuth flow, a broker's consent, or a key form. */ + connect: "oauth" | "consent" | "key" | null; + authScheme: string | null; + accountMode: "personal" | "shared" | null; + state: AppAccessState; + /** Why the app is switched off, when state is "disabled"; null otherwise. */ + unavailable: AppUnavailable | null; + /** + * Whose account a personal-account app's calls use for this person on this Bot: "you", or "owner" + * when this is somebody else's published Team Bot and its owner has connected the app (calls then + * run on the owner's account). null when the app is not a personal-account app. + */ + accountHolder: "you" | "owner" | null; + /** + * True when calls will use this person's OWN account on somebody else's Team Bot and they have not + * given standing consent: the first use raises an "Allow once / Always allow" card. State is "ready". + */ + asksFirst: boolean; + /** Whether this Bot holds any of the app's tools. */ + granted: boolean; + /** + * When the account this app runs in for this person was connected, or null. Read by the chat card + * to tell an account lost at the vendor from one connected again since. See `AppAccessCard`. + */ + connectedAt: string | null; + /** The request Request access files for this state; null where the next step is the person's. */ + requestKind: AppAccessRequestKind | null; + /** This person's pending request of exactly that kind — drawn as "Requested". */ + request: { id: string; kind: AppAccessRequestKind; createdAt: string } | null; +}; + +/** A request as the person who filed it and the administrator answering it both read it. */ +export type AppAccessRequest = { + id: string; + appKey: string; + appTitle: string; + kind: AppAccessRequestKind; + status: "pending" | "approved" | "dismissed" | "cancelled"; + agentId: string | null; + botName: string | null; + requestedBy: string; + requesterEmail: string | null; + requesterName: string | null; + createdAt: string; + resolvedAt: string | null; + resolvedBy: string | null; +}; + +/** The administrator's inbox: the requests asked for, and a count per app for the Plugins list. */ +export type AppAccessInbox = { + requests: AppAccessRequest[]; + counts: { total: number; byApp: Record }; +}; + +/* + * UNDER `["plugins"]`, so every plugin write that already refreshes the plugin screens — connecting + * an account, enabling an app, granting a tool — refreshes what these say too, without each of + * those writes having to know this view exists. + */ +export const appAccessKeys = { + all: ["plugins", "app-access"] as const, + bot: (agentId: string) => ["plugins", "app-access", "bot", agentId] as const, + app: (agentId: string, app: string) => + ["plugins", "app-access", "bot", agentId, "app", app] as const, + inbox: (app?: string) => + ["plugins", "app-access", "inbox", { app: app ?? null }] as const, +}; + +/** Every work app this Bot holds, with what each is for the person asking. */ +export function botAppsQueryOptions(agentId: string) { + return queryOptions({ + queryKey: appAccessKeys.bot(agentId), + queryFn: (): Promise => + client(`/api/plugins/for/${encodeURIComponent(agentId)}/apps`, "apps", { + fallback: "This Bot's apps could not be loaded.", + }), + }); +} + +/** + * One app, by key or by name, whether or not this Bot holds it — what a card in a conversation + * asks, so it never draws a stale state. + */ +export function botAppQueryOptions(agentId: string, app: string) { + return queryOptions({ + queryKey: appAccessKeys.app(agentId, app), + queryFn: (): Promise => + client( + `/api/plugins/for/${encodeURIComponent(agentId)}/apps/${encodeURIComponent(app)}`, + "app", + { fallback: `What ${app} is for this Bot could not be loaded.` }, + ), + }); +} + +/** + * The pending requests an administrator answers: one app's, or all of them. The counts are always + * the whole deployment's. `enabled` is false for anybody who is not an administrator, who would be + * answered 403. + */ +export function appAccessInboxQueryOptions(enabled: boolean, app?: string) { + return queryOptions({ + queryKey: appAccessKeys.inbox(app), + queryFn: async (): Promise => { + const query = app ? `?app=${encodeURIComponent(app)}` : ""; + const response = await client(`/api/plugins/access-requests${query}`, { + fallback: "Access requests could not be loaded.", + }); + return response.json(); + }, + enabled, + }); +} diff --git a/app/src/lib/plugins/tool-name.ts b/app/src/lib/plugins/tool-name.ts index b7306abbbb..3c23d69ad6 100644 --- a/app/src/lib/plugins/tool-name.ts +++ b/app/src/lib/plugins/tool-name.ts @@ -1,3 +1,4 @@ +import { SHOW_APP_ACCESS_TOOL } from "@openbot/shared/connect-prompt"; import { RESEARCH_ROLE_LABELS, researchRoleOfTool, @@ -21,7 +22,9 @@ export type ToolName = { detail?: string; }; -export function readToolName(name: string): ToolName { +export function readToolName(name: string, running = false): ToolName { + const own = ownToolLabel(name, running); + if (own) return { label: own }; const parts = name.split("__"); if (parts.length < 3 || parts[0] !== "mcp") return { label: name }; @@ -74,6 +77,28 @@ export function readToolName(name: string): ToolName { return named ? { label } : { label, detail: broker ?? server }; } +/** + * The tools every Bot holds that this deployment wrote itself, named in words somebody chose. + * + * These are not MCP names, so nothing above rewrites them, and the bare identifier is what a line + * would otherwise say: "show_app_access", shimmering, while the Bot works out what an app is for the + * person. Named twice, for the moment and for afterwards, because only these lines are drawn while + * running AND left on screen once done. + */ +const OWN_TOOLS: Record = { + [SHOW_APP_ACCESS_TOOL]: { + running: "Checking app access", + done: "Checked app access", + }, +}; + +/** One of this deployment's own tools in words, or null for any other name. */ +export function ownToolLabel(name: string, running = false): string | null { + const own = OWN_TOOLS[name]; + if (!own) return null; + return running ? own.running : own.done; +} + /** * The app behind a brokered server key, or null for a server this deployment dials itself. * diff --git a/app/src/routes/_authed/admin/plugins/$key.tsx b/app/src/routes/_authed/admin/plugins/$key.tsx index 88b04b9ee6..56a4ebbda2 100644 --- a/app/src/routes/_authed/admin/plugins/$key.tsx +++ b/app/src/routes/_authed/admin/plugins/$key.tsx @@ -13,6 +13,7 @@ import { PageSection, PageShell, } from "@/components/layout/page-shell"; +import { AccessRequestsSection } from "@/components/plugins/access-requests"; import { AccountModeDialog } from "@/components/plugins/account-mode-dialog"; import { BrokeredAccountRow, @@ -631,6 +632,13 @@ function RouteComponent() { + {/* + * People asking for this app: to switch it on, give it to a Bot, or connect its shared + * account. Only when somebody is; the section draws nothing otherwise. Requests name who + * asked — connections still name nobody. + */} + + !open && setModeTarget(null)} diff --git a/app/src/routes/_authed/admin/plugins/index.tsx b/app/src/routes/_authed/admin/plugins/index.tsx index 4a6b122e72..20d1dc8000 100644 --- a/app/src/routes/_authed/admin/plugins/index.tsx +++ b/app/src/routes/_authed/admin/plugins/index.tsx @@ -14,6 +14,10 @@ import { PageShell, } from "@/components/layout/page-shell"; import { RowMark } from "@/components/layout/row-mark"; +import { + AccessRequestsSection, + usePendingRequestCounts, +} from "@/components/plugins/access-requests"; import { Item, ItemActions, @@ -102,6 +106,11 @@ function summaryFor( function RouteComponent() { const plugins = useQuery(pluginsPageQueryOptions()); const connections = useQuery(connectionsQueryOptions()); + /* + * Requests waiting per app, so an administrator sees which app somebody is asking about from the + * list, without opening each one. Every request is also listed in full above the lists. + */ + const requestCounts = usePendingRequestCounts(); // A NAME for the "More apps" row, which cannot be read off a row that may not exist yet. const provider = pluginsProvider(plugins.data); const moreApps = moreAppsRow( @@ -140,6 +149,12 @@ function RouteComponent() {

) : ( <> + {/* + * First, because each one is somebody waiting. All of them, not only those about an app + * that is connected: an app nobody has switched on has no page of its own to list them on. + */} + + + {summaryFor( server, @@ -238,6 +256,9 @@ function RouteComponent() { {entry.summary} + Not added @@ -308,3 +329,16 @@ function RouteComponent() { ); } + +/** + * "2 requests", beside a row's summary, in the foreground colour so it is the first thing on the + * row that reads as something to do. Nothing at zero: a count of nothing on every row is noise. + */ +function PendingRequests({ count }: { count: number }) { + if (count === 0) return null; + return ( + + {count} {count === 1 ? "request" : "requests"} + + ); +} diff --git a/app/src/routes/_authed/settings/connected-accounts/$key.tsx b/app/src/routes/_authed/settings/connected-accounts/$key.tsx index ac21348848..81a113072b 100644 --- a/app/src/routes/_authed/settings/connected-accounts/$key.tsx +++ b/app/src/routes/_authed/settings/connected-accounts/$key.tsx @@ -340,6 +340,24 @@ function RouteComponent() { ); } + /* + * A COMPOSIO APP ON A DEPLOYMENT WITH NO COMPOSIO KEY, BEFORE EITHER BRANCH BELOW DRAWS A ROW. + * + * The list on the way in no longer offers one, but a link from elsewhere still lands here, and the + * brokered row's own sentence for this case names an environment variable — an instruction for + * whoever runs the deployment, read by somebody who cannot act on it. + */ + if (server?.provenance === "composio" && !plugins.data.composioConfigured) { + return ( + + + {server.title} is not available on this OpenBot yet. An administrator + has to finish setting it up before anybody can connect it. + + + ); + } + /* * A SHARED APP, BEFORE THE BROKERED BRANCH BELOW GETS A LOOK AT IT. * diff --git a/app/src/routes/_authed/settings/connected-accounts/index.tsx b/app/src/routes/_authed/settings/connected-accounts/index.tsx index e72fd5f8c9..ef34e1f7f2 100644 --- a/app/src/routes/_authed/settings/connected-accounts/index.tsx +++ b/app/src/routes/_authed/settings/connected-accounts/index.tsx @@ -216,6 +216,23 @@ export function brokeredAccountsListedOn( ); } +/** + * The servers this page may list at all: none reached through Composio when the deployment has no + * Composio key. + * + * An app an administrator enabled while a key was set keeps its row after the key is removed, and + * listing it offered a person an account they cannot connect, under a sentence about an environment + * variable only an administrator can set. Without the broker there is nothing for them to decide. + */ +export function serversListedFor(page: { + servers: PluginServer[]; + composioConfigured: boolean; +}): PluginServer[] { + return page.composioConfigured + ? page.servers + : page.servers.filter((server) => server.provenance !== "composio"); +} + /** Apps whose account is the organisation's, shown so a person knows a Bot does not act as them there. */ export function sharedAccountsListedOn( servers: PluginServer[], @@ -346,16 +363,17 @@ function RouteComponent() { * unrecorded scheme included: a row whose column was never written is far likelier to be a key or * consent app, and dropping it here would hide a connection somebody does have. */ - const brokered = brokeredAccountsListedOn(plugins.data?.servers ?? []); + const listed = plugins.data ? serversListedFor(plugins.data) : []; + const brokered = brokeredAccountsListedOn(listed); /* * Listed, never connected here. A Shared app's account is the organisation's, not this person's to * make or to break, so this list draws no Connect action and links nowhere — it exists only so a * person can see that a Bot calling this app acts as the team, not as them. */ - const shared = sharedAccountsListedOn(plugins.data?.servers ?? []); + const shared = sharedAccountsListedOn(listed); const sectionsToShow = connectedAccountSections( yours.length + brokered.length, - plugins.data?.servers ?? [], + listed, ); const accounts = [ ...yours.map((entry) => { diff --git a/app/tests/access-requests.test.tsx b/app/tests/access-requests.test.tsx new file mode 100644 index 0000000000..e7949f560b --- /dev/null +++ b/app/tests/access-requests.test.tsx @@ -0,0 +1,315 @@ +import { afterAll, afterEach, beforeAll, expect, test } from "bun:test"; +import { GlobalRegistrator } from "@happy-dom/global-registrator"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, + Outlet, + RouterProvider, +} from "@tanstack/react-router"; +import { + cleanup, + fireEvent, + render, + waitFor, + within, +} from "@testing-library/react"; +import type { ReactNode } from "react"; +import { AccessRequestsSection } from "@/components/plugins/access-requests"; +import type { AppAccessRequest } from "@/lib/app-access/queries"; +import { Route as PluginsIndexRoute } from "@/routes/_authed/admin/plugins/index"; +import { settleReactWork } from "./settle-react-work"; + +/** + * The administrator's side of an access request: the Requests section on an app's admin page and + * at the top of the Plugins list, and the count on each row of that list. + * + * Approve and Dismiss go to the routes that do the work; a refusal from Approve — the step only an + * administrator can take first, with the request left pending — is drawn under the request it is + * about. Who asked is shown, because they asked; nobody's connection is. + */ + +beforeAll(() => GlobalRegistrator.register()); +afterEach(cleanup); +afterAll(async () => { + await settleReactWork(); + GlobalRegistrator.unregister(); +}); + +const originalFetch = global.fetch; +afterEach(() => { + global.fetch = originalFetch; +}); + +const request = ( + overrides: Partial = {}, +): AppAccessRequest => ({ + id: "req_1", + appKey: "composio-gmail", + appTitle: "Gmail", + kind: "grant", + status: "pending", + agentId: "expenses", + botName: "Expenses", + requestedBy: "u_ada", + requesterEmail: "ada@example.test", + requesterName: "Ada", + createdAt: new Date(Date.now() - 5 * 60_000).toISOString(), + resolvedAt: null, + resolvedBy: null, + ...overrides, +}); + +function serving(options: { + requests: AppAccessRequest[]; + approve?: { status: number; body: unknown }; +}) { + const sent: string[] = []; + global.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + const method = init?.method ?? "GET"; + sent.push(`${method} ${url}`); + if (method === "POST" && url.endsWith("/approve")) + return Response.json(options.approve?.body ?? { request: {} }, { + status: options.approve?.status ?? 200, + }); + if (method === "POST" && url.endsWith("/dismiss")) + return Response.json({ request: {} }); + if (url.startsWith("/api/plugins/access-requests")) { + const app = new URL(url, "http://x").searchParams.get("app"); + const byApp: Record = {}; + for (const each of options.requests) + byApp[each.appKey] = (byApp[each.appKey] ?? 0) + 1; + return Response.json({ + requests: app + ? options.requests.filter((each) => each.appKey === app) + : options.requests, + counts: { total: options.requests.length, byApp }, + }); + } + if (url === "/api/plugins/connections") + return Response.json({ connections: [], redirectUri: null }); + if (url === "/api/plugins") + return Response.json({ + catalogue: [], + servers: [ + { + id: "composio-gmail", + title: "Gmail", + summary: "Mail.", + tools: [], + lastError: null, + }, + { + id: "notion", + title: "Notion", + summary: "Docs.", + tools: [], + lastError: null, + }, + ], + composioConfigured: true, + botsMayCallBack: true, + redirectUri: null, + skills: [], + }); + return Response.json({}); + }) as unknown as typeof fetch; + return sent; +} + +function draw(Page: () => ReactNode) { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + const root = createRootRoute({ component: Outlet }); + const page = createRoute({ + getParentRoute: () => root, + path: "/", + component: Page, + }); + const app = createRoute({ + getParentRoute: () => root, + path: "/admin/plugins/$key", + component: () => null, + }); + const composio = createRoute({ + getParentRoute: () => root, + path: "/admin/plugins/composio", + component: () => null, + }); + const bot = createRoute({ + getParentRoute: () => root, + path: "/admin/plugins/$key/bots/$agentId", + component: () => null, + }); + const router = createRouter({ + routeTree: root.addChildren([page, app, composio, bot]), + history: createMemoryHistory({ initialEntries: ["/"] }), + }); + return render( + + + , + ); +} + +test("lists who asked for what, for which Bot, and when — and nothing about anybody's connection", async () => { + serving({ + requests: [ + request(), + request({ id: "req_2", kind: "enable", appTitle: "Gmail" }), + request({ + id: "req_3", + kind: "connect_shared", + requesterName: null, + }), + ], + }); + const view = draw(() => ); + expect(await view.findByText("Give Gmail to Expenses")).toBeTruthy(); + expect(view.getByText("Turn on Gmail for Expenses")).toBeTruthy(); + expect(view.getByText("Connect the shared Gmail account")).toBeTruthy(); + expect(view.getAllByText(/Ada asked 5 minutes ago/)).toHaveLength(2); + // No name on the request falls back to the address they asked from. + expect(view.getByText(/ada@example\.test asked/)).toBeTruthy(); + expect(view.queryByText(/connected/i)).toBeNull(); +}); + +test("approve does what was asked through the approve route", async () => { + const sent = serving({ requests: [request()] }); + const view = draw(() => ); + fireEvent.click(await view.findByRole("button", { name: "Approve" })); + await waitFor(() => + expect(sent).toContain("POST /api/plugins/access-requests/req_1/approve"), + ); +}); + +test("an approval the server cannot complete says why, on the request, and leaves it there", async () => { + serving({ + requests: [request({ kind: "connect_shared" })], + approve: { + status: 409, + body: { + error: + "The shared Gmail account is not connected yet. Connect it on Gmail's page under Plugins, then approve this request.", + }, + }, + }); + const view = draw(() => ); + fireEvent.click(await view.findByRole("button", { name: "Approve" })); + const row = within( + (await view.findByRole("alert")).closest("[data-request]") as HTMLElement, + ); + expect(row.getByRole("alert").textContent).toMatch( + /^The shared Gmail account is not connected yet\./, + ); + expect(row.getByRole("button", { name: "Approve" })).toBeTruthy(); +}); + +test("a grant says approving gives the Bot read-only actions, and links to where the others are chosen", async () => { + serving({ requests: [request()] }); + const view = draw(() => ); + const element = (await view.findByText("Give Gmail to Expenses")).closest( + "[data-request]", + ) as HTMLElement; + const row = within(element); + expect(element.textContent).toContain( + "Approving gives Expenses only Gmail's read-only actions. Choose any others on the Bot's page under Gmail.", + ); + const page = row.getByRole("link", { + name: "the Bot's page under Gmail", + }); + expect(page.getAttribute("href")).toBe( + "/admin/plugins/composio-gmail/bots/expenses", + ); +}); + +test("switching an app on for a Bot says the same, with no link to a page the app does not have yet", async () => { + serving({ requests: [request({ kind: "enable" })] }); + const view = draw(() => ); + const row = within( + (await view.findByText("Turn on Gmail for Expenses")).closest( + "[data-request]", + ) as HTMLElement, + ); + expect( + row.getByText( + /Approving gives Expenses only Gmail's read-only actions\. Choose any others on the Bot's page under Gmail\./, + ), + ).toBeTruthy(); + expect(row.queryByRole("link")).toBeNull(); +}); + +test("connecting the shared account says nothing about actions", async () => { + serving({ requests: [request({ kind: "connect_shared" })] }); + const view = draw(() => ); + await view.findByText("Connect the shared Gmail account"); + expect(view.queryByText(/read-only/)).toBeNull(); +}); + +test("an app with no read-only actions is refused on the row, which stays", async () => { + serving({ + requests: [request()], + approve: { + status: 409, + body: { + error: + "Gmail has no read-only actions to grant. Choose which actions Expenses may use on its page under Gmail.", + }, + }, + }); + const view = draw(() => ); + fireEvent.click(await view.findByRole("button", { name: "Approve" })); + expect((await view.findByRole("alert")).textContent).toMatch( + /^Gmail has no read-only actions to grant\./, + ); + expect(view.getByText("Give Gmail to Expenses")).toBeTruthy(); +}); + +test("dismiss goes to the dismiss route", async () => { + const sent = serving({ requests: [request()] }); + const view = draw(() => ); + fireEvent.click(await view.findByRole("button", { name: "Dismiss" })); + await waitFor(() => + expect(sent).toContain("POST /api/plugins/access-requests/req_1/dismiss"), + ); +}); + +test("nothing waiting draws no section at all", async () => { + const sent = serving({ requests: [] }); + const view = draw(() => ); + await waitFor(() => + expect(sent).toContain( + "GET /api/plugins/access-requests?app=composio-gmail", + ), + ); + await settleReactWork(); + expect(view.queryByText("Requests")).toBeNull(); +}); + +test("the Plugins list puts every request first and counts them on each app's row", async () => { + serving({ + requests: [ + request(), + request({ id: "req_2", kind: "enable" }), + // An app with no row of its own: its request is still listed, at the top. + request({ + id: "req_3", + appKey: "composio-slack", + appTitle: "Slack", + kind: "enable", + }), + ], + }); + const Page = PluginsIndexRoute.options.component as () => ReactNode; + const view = draw(Page); + expect(await view.findByText("Requests")).toBeTruthy(); + expect(view.getByText("Turn on Slack for Expenses")).toBeTruthy(); + const gmail = within(await view.findByTestId("plugin-composio-gmail")); + expect(gmail.getByText("2 requests")).toBeTruthy(); + const notion = within(view.getByTestId("plugin-notion")); + expect(notion.queryByText(/request/)).toBeNull(); +}); diff --git a/app/tests/app-access-action.test.tsx b/app/tests/app-access-action.test.tsx new file mode 100644 index 0000000000..728aac0806 --- /dev/null +++ b/app/tests/app-access-action.test.tsx @@ -0,0 +1,448 @@ +import { afterAll, afterEach, beforeAll, expect, test } from "bun:test"; +import { GlobalRegistrator } from "@happy-dom/global-registrator"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, + Outlet, + RouterProvider, +} from "@tanstack/react-router"; +import { + act, + cleanup, + fireEvent, + render, + waitFor, +} from "@testing-library/react"; +import { useState } from "react"; +import { + AppAccessAction, + appAccessSentence, +} from "@/components/plugins/app-access-action"; +import type { AppAccess } from "@/lib/app-access/queries"; +import { appAccess } from "./app-access-fixture"; +import { settleReactWork } from "./settle-react-work"; + +/** + * The one piece the Bot page and the chat card both draw an app's state with. + * + * Each of the seven states must say whose move it is and offer exactly that move — and nothing for a + * state that needs none, or a move that is somebody else's: an administrator's, or on somebody else's + * Team Bot its owner's. The two moves that write are asserted against the request they send: the + * browser names a Bot and an app and never a kind, which is the server's to decide. + */ + +let originalOpen: typeof window.open; +beforeAll(() => { + GlobalRegistrator.register(); + originalOpen = window.open; +}); +afterEach(cleanup); +afterAll(async () => { + await settleReactWork(); + GlobalRegistrator.unregister(); +}); + +const originalFetch = global.fetch; +afterEach(() => { + global.fetch = originalFetch; + window.open = originalOpen; +}); + +type Sent = { method: string; url: string; body: unknown }; + +function mounted( + access: AppAccess, + answer?: { status: number; body: unknown }, +) { + const sent: Sent[] = []; + const opened: string[] = []; + // The tab the last Connect claimed, so a test can close it the way a person would. + const tabs: { closed: boolean }[] = []; + window.open = (() => { + const tab = { + opener: {} as unknown, + closed: false, + location: { replace: (next: string) => opened.push(next) }, + close: () => {}, + }; + tabs.push(tab); + return tab as unknown as Window; + }) as typeof window.open; + global.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + const method = init?.method ?? "GET"; + sent.push({ + method, + url, + body: init?.body ? JSON.parse(String(init.body)) : undefined, + }); + if (answer && method !== "GET") + return Response.json(answer.body, { status: answer.status }); + if (url.endsWith("/connect?returnTo=settings")) + return Response.json({ authorizationUrl: "https://gmail.test/consent" }); + return Response.json({ request: { id: "req_1" }, created: true }); + }) as unknown as typeof fetch; + + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + // Stands in for the refetch after a write: the server's next answer, handed to the same row. + let swap: (next: AppAccess) => void = () => {}; + function Row() { + const [current, setCurrent] = useState(access); + swap = setCurrent; + return ( +
+

{appAccessSentence(current)}

+ +
+ ); + } + const root = createRootRoute({ component: Outlet }); + const page = createRoute({ + getParentRoute: () => root, + path: "/", + component: Row, + }); + const settings = createRoute({ + getParentRoute: () => root, + path: "/settings/connected-accounts/$key", + component: () => null, + }); + const router = createRouter({ + routeTree: root.addChildren([page, settings]), + history: createMemoryHistory({ initialEntries: ["/"] }), + }); + const view = render( + + + , + ); + return { + view, + sent, + opened, + tabs, + swap: (next: AppAccess) => act(() => swap(next)), + }; +} + +const COPY: [string, Partial, RegExp][] = [ + ["ready", {}, /^Ready to use\.$/], + [ + "ready, on the owner's account", + { accountHolder: "owner" }, + /^Ready to use\. This Bot uses its owner's Gmail account\.$/, + ], + [ + "ready, asking first", + { asksFirst: true }, + /^Ready to use with your own Gmail account\. You'll be asked to allow it the first time\.$/, + ], + [ + "not_connected", + { state: "not_connected" }, + /You haven't connected your Gmail account yet\./, + ], + [ + "reconnect", + { state: "reconnect" }, + /^Your Gmail connection needs reconnecting\.$/, + ], + [ + "reconnect, on the owner's account", + { state: "reconnect", accountHolder: "owner" }, + /^This Bot uses its owner's Gmail account, which needs reconnecting\. Only the Bot's owner can reconnect it\.$/, + ], + [ + "shared_not_connected", + { state: "shared_not_connected" }, + /Your organisation's Gmail account isn't connected yet\. Only an administrator can connect it\./, + ], + [ + "shared_not_approved", + { state: "shared_not_approved" }, + /^This Bot reaches more people than an administrator has approved for your organisation's Gmail account\. Only an administrator can approve it\.$/, + ], + [ + "disabled, never switched on", + { state: "disabled", unavailable: "absent" }, + /^Gmail isn't switched on for this OpenBot\. Only an administrator can switch it on\.$/, + ], + [ + "disabled, with no reason given", + { state: "disabled", unavailable: null }, + /^Gmail isn't switched on for this OpenBot\./, + ], + [ + "disabled, with no Composio key", + { state: "disabled", unavailable: "composio_unconfigured" }, + /^Gmail is not available on this OpenBot yet\. An administrator has to finish setting it up\.$/, + ], + ...( + ["scheme_unreadable", "oauth_client_missing", "token_missing"] as const + ).map((unavailable): [string, Partial, RegExp] => [ + `disabled, ${unavailable}`, + { state: "disabled", unavailable }, + /^Gmail isn't fully set up yet\. Only an administrator can finish setting it up\.$/, + ]), + [ + "not_granted", + { state: "not_granted" }, + /This Bot hasn't been given Gmail\. Only an administrator can give it\./, + ], +]; + +test.each(COPY)("says whose move it is: %s", (_, overrides, sentence) => { + expect(appAccessSentence(appAccess(overrides))).toMatch(sentence); +}); + +test("no sentence about a switched-off app says an administrator turned it off", () => { + for (const [, overrides] of COPY.filter(([, o]) => o.state === "disabled")) + expect(appAccessSentence(appAccess(overrides))).not.toMatch(/Turned off/); +}); + +test("ready offers nothing to press", async () => { + const { view } = mounted(appAccess()); + await view.findByText("Ready to use."); + expect(view.queryByRole("button")).toBeNull(); + expect(view.queryByRole("link")).toBeNull(); +}); + +test("not connected offers Connect, which starts the person's own consent in a new tab", async () => { + const { view, sent, opened } = mounted(appAccess({ state: "not_connected" })); + fireEvent.click(await view.findByRole("button", { name: "Connect Gmail" })); + await waitFor(() => expect(opened).toEqual(["https://gmail.test/consent"])); + expect(sent.map((call) => `${call.method} ${call.url}`)).toContain( + "POST /api/plugins/servers/composio-gmail/connect?returnTo=settings", + ); + await view.findByText("Finish in the tab that opened."); +}); + +test("an app connected with a typed key goes to its page in Settings, where the form is", async () => { + const { view } = mounted( + appAccess({ state: "not_connected", connect: "key" }), + ); + // Drawn as a button (`Button` over a `Link`), and an anchor underneath with the address. + const link = (await view.findByText("Connect")).closest("a"); + expect(link?.getAttribute("href")).toBe( + "/settings/connected-accounts/composio-gmail", + ); +}); + +test("a Composio app to reconnect goes to Settings, because the connect route refuses a row that stands", async () => { + const { view } = mounted(appAccess({ state: "reconnect" })); + const link = (await view.findByText("Reconnect")).closest("a"); + expect(link?.getAttribute("href")).toBe( + "/settings/connected-accounts/composio-gmail", + ); +}); + +test("this deployment's own OAuth app reconnects in place, with a fresh consent", async () => { + const { view, opened } = mounted( + appAccess({ + key: "notion", + displayName: "Notion", + provider: "catalogue", + connect: "oauth", + state: "reconnect", + }), + ); + fireEvent.click( + await view.findByRole("button", { name: "Reconnect Notion" }), + ); + await waitFor(() => expect(opened).toHaveLength(1)); +}); + +/** Whether this tab has the focus, as `document.hasFocus` answers it, for the length of `run`. */ +async function withFocus(focused: boolean, run: () => Promise) { + const real = document.hasFocus; + document.hasFocus = () => focused; + try { + await run(); + } finally { + document.hasFocus = real; + } +} + +test("closing the sign-in tab ends the wait, rather than waiting for it forever", async () => { + await withFocus(true, async () => { + const { view, tabs } = mounted(appAccess({ state: "not_connected" })); + fireEvent.click(await view.findByRole("button", { name: "Connect Gmail" })); + await view.findByText("Waiting for Gmail…"); + const [tab] = tabs; + if (!tab) throw new Error("no tab was claimed"); + tab.closed = true; + await waitFor( + () => + expect( + view.getByRole("button", { name: "Connect Gmail" }), + ).toBeTruthy(), + { timeout: 3_000 }, + ); + expect(view.queryByText("Waiting for Gmail…")).toBeNull(); + expect(view.queryByText("Finish in the tab that opened.")).toBeNull(); + }); +}); + +test("a sign-in tab that only reads closed keeps the wait until this tab has the focus back", async () => { + await withFocus(false, async () => { + const { view, tabs } = mounted(appAccess({ state: "not_connected" })); + fireEvent.click(await view.findByRole("button", { name: "Connect Gmail" })); + await view.findByText("Waiting for Gmail…"); + const [tab] = tabs; + if (!tab) throw new Error("no tab was claimed"); + // A vendor's opener policy severs the handle while the person is still on its consent screen. + tab.closed = true; + await new Promise((resolve) => setTimeout(resolve, 1_500)); + expect(view.getByText("Waiting for Gmail…")).toBeTruthy(); + // They come back to this tab: there is an answer to show now. + window.dispatchEvent(new Event("focus")); + await waitFor(() => + expect(view.queryByText("Waiting for Gmail…")).toBeNull(), + ); + }); +}); + +test("a Reconnect on the owner's account offers nothing: only the owner can reconnect it", async () => { + const { view } = mounted( + appAccess({ state: "reconnect", accountHolder: "owner" }), + ); + await view.findByText(/Only the Bot's owner can reconnect it/); + expect(view.queryByRole("button")).toBeNull(); + expect(view.queryByRole("link")).toBeNull(); +}); + +test("a Shared app waiting on an administrator's approval offers nothing: the refused call already asked", async () => { + const { view } = mounted( + appAccess({ + state: "shared_not_approved", + account: "shared", + accountMode: "shared", + }), + ); + await view.findByText(/Only an administrator can approve it/); + expect(view.queryByRole("button")).toBeNull(); +}); + +test.each([ + [{ accountHolder: "owner" }, /owner's Gmail account/], + [{ asksFirst: true }, /asked to allow it the first time/], +] as const)( + "ready on somebody else's Team Bot offers nothing to press", + async (overrides, sentence) => { + const { view } = mounted(appAccess(overrides)); + await view.findByText(sentence); + expect(view.queryByRole("button")).toBeNull(); + }, +); + +test.each([ + ["disabled", "enable"], + ["not_granted", "grant"], + ["shared_not_connected", "connect_shared"], +] as const)( + "%s offers Request access, naming the Bot and the app and never the kind", + async (state, kind) => { + const { view, sent } = mounted(appAccess({ state, requestKind: kind })); + fireEvent.click( + await view.findByRole("button", { name: "Request access to Gmail" }), + ); + await waitFor(() => + expect(sent.find((call) => call.method === "POST")).toEqual({ + method: "POST", + url: "/api/plugins/access-requests", + body: { agentId: "expenses", app: "composio-gmail" }, + }), + ); + }, +); + +test("a pending request reads Requested, and Withdraw cancels it", async () => { + const { view, sent } = mounted( + appAccess({ + state: "disabled", + requestKind: "enable", + request: { + id: "req_9", + kind: "enable", + createdAt: "2026-10-20T10:00:00.000Z", + }, + }), + ); + await view.findByText("Requested"); + expect( + view.queryByRole("button", { name: "Request access to Gmail" }), + ).toBeNull(); + fireEvent.click( + view.getByRole("button", { name: "Withdraw request for Gmail" }), + ); + await waitFor(() => + expect( + sent.some( + (call) => + call.method === "DELETE" && + call.url === "/api/plugins/access-requests/req_9", + ), + ).toBe(true), + ); +}); + +test("the server's refusal of a request is drawn beside the button", async () => { + const { view } = mounted( + appAccess({ state: "not_granted", requestKind: "grant" }), + { status: 409, body: { error: "Gmail is ready to use." } }, + ); + fireEvent.click( + await view.findByRole("button", { name: "Request access to Gmail" }), + ); + expect((await view.findByRole("alert")).textContent).toBe( + "Gmail is ready to use.", + ); +}); + +test("a state with no request to file offers none, rather than a button the server would refuse", async () => { + const { view } = mounted( + appAccess({ state: "shared_not_connected", requestKind: null }), + ); + await view.findByText(/organisation's Gmail account/); + expect(view.queryByRole("button")).toBeNull(); +}); + +test("the focus follows the press: Request access hands it to Withdraw, and Withdraw back", async () => { + const asked = appAccess({ state: "not_granted", requestKind: "grant" }); + const { view, sent, swap } = mounted(asked); + const ask = await view.findByRole("button", { + name: "Request access to Gmail", + }); + ask.focus(); + fireEvent.click(ask); + await waitFor(() => + expect(sent.some((call) => call.method === "POST")).toBe(true), + ); + const pending = { + ...asked, + request: { + id: "req_1", + kind: "grant" as const, + createdAt: "2026-10-20T10:00:00.000Z", + }, + }; + await swap(pending); + const withdraw = await view.findByRole("button", { + name: "Withdraw request for Gmail", + }); + expect(document.activeElement).toBe(withdraw); + + fireEvent.click(withdraw); + await waitFor(() => + expect(sent.some((call) => call.method === "DELETE")).toBe(true), + ); + await swap(asked); + expect(document.activeElement).toBe( + await view.findByRole("button", { name: "Request access to Gmail" }), + ); +}); diff --git a/app/tests/app-access-card.test.tsx b/app/tests/app-access-card.test.tsx new file mode 100644 index 0000000000..d9746cc06d --- /dev/null +++ b/app/tests/app-access-card.test.tsx @@ -0,0 +1,390 @@ +import { afterAll, afterEach, beforeAll, expect, test } from "bun:test"; +import { GlobalRegistrator } from "@happy-dom/global-registrator"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, + Outlet, + RouterProvider, +} from "@tanstack/react-router"; +import { cleanup, render, waitFor } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { AppAccessCard } from "@/components/channels/app-access-card"; +import { ServerToolLine } from "@/components/channels/chat-transcript"; +import type { AppAccess } from "@/lib/app-access/queries"; +import { ActiveBotProvider, useActiveBot } from "@/lib/copilot/active-bot"; +import { REFUSAL_MARKER } from "@/lib/plugins/tool-result"; +import { + type ConnectPrompt, + connectAppRefusal, + SHOW_APP_ACCESS_TOOL, +} from "@openbot/shared/connect-prompt"; +import { appAccess } from "./app-access-fixture"; +import { settleReactWork } from "./settle-react-work"; + +/** + * The card a conversation draws when a Bot could not use an app, for every way that happens: the + * person has not connected, a Shared app has no team account, the vendor lost the account, and the + * `show_app_access` tool a Bot calls for an app it holds nothing for (switched off, not granted). + * + * The card never draws what the refusal said: it asks the server what the app is for this person + * on the Bot that refused, and draws that. The one exception is an account the vendor lost, which + * the server cannot know about — drawn as Reconnect over a live `ready`, until a connection newer + * than the refusal says it has been put back. + */ + +beforeAll(() => GlobalRegistrator.register()); +afterEach(cleanup); +afterAll(async () => { + await settleReactWork(); + GlobalRegistrator.unregister(); +}); + +const originalFetch = global.fetch; +afterEach(() => { + global.fetch = originalFetch; +}); + +/** Answers the live read with `live`, and records which Bot and app it was asked about. */ +function serving(live: AppAccess | null) { + const asked: string[] = []; + global.fetch = (async (input: RequestInfo | URL) => { + const url = String(input); + asked.push(url); + if (url.includes("/apps/")) + return live + ? Response.json({ app: live }) + : Response.json({ error: "nope" }, { status: 500 }); + if (url.includes("/api/plugins/connections")) + return Response.json({ connections: [], redirectUri: null }); + return Response.json({}); + }) as unknown as typeof fetch; + return asked; +} + +function mount(children: ReactNode) { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + const root = createRootRoute({ component: Outlet }); + const page = createRoute({ + getParentRoute: () => root, + path: "/", + component: () => <>{children}, + }); + const settings = createRoute({ + getParentRoute: () => root, + path: "/settings/connected-accounts/$key", + component: () => null, + }); + const router = createRouter({ + routeTree: root.addChildren([page, settings]), + history: createMemoryHistory({ initialEntries: ["/"] }), + }); + return render( + + + , + ); +} + +const prompt = (overrides: Partial = {}): ConnectPrompt => ({ + serverId: "composio-gmail", + title: "Gmail", + message: + "You have not connected your Gmail account. Connect it and ask again.", + agentId: "expenses", + ...overrides, +}); + +const card = (view: ReturnType) => + waitFor(() => { + const found = view.container.querySelector("[data-app-access]"); + if (!found?.getAttribute("data-state")) throw new Error("not drawn yet"); + return found as HTMLElement; + }); + +test("asks about the app on the Bot that refused, not on another", async () => { + const asked = serving(appAccess({ state: "not_connected" })); + mount(); + await waitFor(() => + expect(asked).toContain("/api/plugins/for/expenses/apps/composio-gmail"), + ); +}); + +test("not connected: names the app and offers Connect", async () => { + serving(appAccess({ state: "not_connected" })); + const view = mount(); + const drawn = await card(view); + expect(drawn.textContent).toContain("Gmail"); + expect(drawn.textContent).toContain( + "You haven't connected your Gmail account yet.", + ); + expect(view.getByRole("button", { name: "Connect Gmail" })).toBeTruthy(); +}); + +test("connected since the refusal: says so and offers nothing stale", async () => { + serving(appAccess({ state: "ready" })); + const view = mount(); + const drawn = await card(view); + expect(drawn.textContent).toContain( + "Gmail is ready now. Ask again and the Bot can use it.", + ); + expect(view.queryByRole("button")).toBeNull(); +}); + +test("a Shared app with no team account: Request access, not a sentence", async () => { + serving( + appAccess({ + state: "shared_not_connected", + account: "shared", + accountMode: "shared", + requestKind: "connect_shared", + }), + ); + const view = mount( + , + ); + const drawn = await card(view); + expect(drawn.textContent).toContain( + "Your organisation's Gmail account isn't connected yet.", + ); + expect( + view.getByRole("button", { name: "Request access to Gmail" }), + ).toBeTruthy(); +}); + +test("an account the vendor lost: Reconnect, though the server still reads ready", async () => { + serving( + appAccess({ state: "ready", connectedAt: "2026-10-01T00:00:00.000Z" }), + ); + const view = mount( + , + ); + const drawn = await card(view); + expect(drawn.getAttribute("data-state")).toBe("reconnect"); + expect(drawn.textContent).toContain( + "Your Gmail connection needs reconnecting.", + ); + expect( + (await view.findByText("Reconnect")).closest("a")?.getAttribute("href"), + ).toBe("/settings/connected-accounts/composio-gmail"); +}); + +test("an account lost and then connected again: the newer connection wins", async () => { + serving( + appAccess({ state: "ready", connectedAt: "2026-10-20T12:05:00.000Z" }), + ); + const view = mount( + , + ); + const drawn = await card(view); + expect(drawn.getAttribute("data-state")).toBe("ready"); + expect(view.queryByText("Reconnect")).toBeNull(); +}); + +test("a team account the vendor lost: says an administrator has to reconnect it, with nothing to press", async () => { + serving(appAccess({ state: "ready", account: "shared" })); + const view = mount(); + const drawn = await card(view); + expect(drawn.textContent).toContain( + "Your organisation's Gmail account needs reconnecting. Only an administrator can reconnect it.", + ); + expect(view.queryByRole("button")).toBeNull(); +}); + +test.each([ + ["disabled", "enable", /Gmail isn't switched on for this OpenBot/], + ["not_granted", "grant", /This Bot hasn't been given Gmail/], +] as const)( + "the Bot asked about an app that is %s: Request access", + async (state, kind, sentence) => { + serving(appAccess({ state, requestKind: kind, granted: false })); + const view = mount( + , + ); + const drawn = await card(view); + expect(drawn.textContent).toMatch(sentence); + expect( + view.getByRole("button", { name: "Request access to Gmail" }), + ).toBeTruthy(); + }, +); + +test("asked about an app that is ready, it says so without claiming it was just connected", async () => { + serving(appAccess()); + const view = mount(); + const drawn = await card(view); + expect(drawn.textContent).toContain("Ready to use."); +}); + +test("an account lost on somebody else's Team Bot that runs on its owner's: only the owner can reconnect it", async () => { + serving( + appAccess({ + state: "ready", + accountHolder: "owner", + connectedAt: "2026-10-01T00:00:00.000Z", + }), + ); + const view = mount( + , + ); + const drawn = await card(view); + expect(drawn.getAttribute("data-state")).toBe("reconnect"); + expect(drawn.textContent).toContain( + "This Bot uses its owner's Gmail account, which needs reconnecting. Only the Bot's owner can reconnect it.", + ); + expect(view.queryByRole("button")).toBeNull(); + expect(view.queryByRole("link")).toBeNull(); +}); + +test("a Shared app whose audience is not approved: says so, with nothing to press", async () => { + serving( + appAccess({ + state: "shared_not_approved", + account: "shared", + accountMode: "shared", + }), + ); + const view = mount(); + const drawn = await card(view); + expect(drawn.textContent).toContain("Only an administrator can approve it."); + expect(view.queryByRole("button")).toBeNull(); +}); + +/** Declares a Bot the way a channel does, around whatever it is given. */ +function Declaring({ + botId, + children, +}: { + botId: string; + children: ReactNode; +}) { + useActiveBot(botId); + return <>{children}; +} + +test("an older refusal that names no Bot asks about the Bot the surface declared", async () => { + const asked = serving(appAccess({ state: "not_connected" })); + const view = mount( + + + + + , + ); + const drawn = await card(view); + expect(drawn.getAttribute("data-state")).toBe("not_connected"); + expect(asked).toContain("/api/plugins/for/travel/apps/composio-gmail"); +}); + +test("with no Bot to ask about at all, it is the Connect card it always was", async () => { + const asked = serving(null); + const view = mount(); + expect(await view.findByText("Connect Gmail")).toBeTruthy(); + expect(asked.some((url) => url.includes("/apps/"))).toBe(false); +}); + +test("a live read that fails falls back to the Connect card for a connect refusal", async () => { + serving(null); + const view = mount(); + expect(await view.findByText("Connect Gmail")).toBeTruthy(); +}); + +test("a live read that fails says so in the person's words, never the model's", async () => { + serving(null); + const view = mount( + , + ); + const alert = await view.findByRole("alert"); + expect(alert.textContent).toBe( + "Gmail's status couldn't be loaded. Try again in a moment.", + ); + expect(view.container.textContent).not.toContain("Do not browse"); +}); + +/* + * THROUGH THE TRANSCRIPT'S OWN LINE. The card is drawn from a server tool's answer — a refusal, or + * the `show_app_access` result — by `ServerToolLine`, and only those two are read for a marker. + * That it is drawn rather than folded into "Used N tools" is `tool-activity.test.tsx`'s. + */ +test("the transcript draws a refusal's card", async () => { + serving(appAccess({ state: "not_connected" })); + const view = mount( + , + ); + const drawn = await card(view); + expect(drawn.getAttribute("data-state")).toBe("not_connected"); +}); + +test("the transcript draws show_app_access's card from its ordinary answer", async () => { + serving( + appAccess({ state: "disabled", requestKind: "enable", granted: false }), + ); + const view = mount( + , + ); + const drawn = await card(view); + expect(drawn.getAttribute("data-state")).toBe("disabled"); +}); + +test("an ordinary tool's answer is never read for a card, whatever it starts with", async () => { + serving(appAccess({ state: "not_connected" })); + const view = mount( + , + ); + await settleReactWork(); + expect(view.container.querySelector("[data-app-access]")).toBeNull(); +}); + +test("show_app_access is named for the person while it runs, not by its identifier", () => { + const view = mount(); + return waitFor(() => { + expect(view.getByText("Checking app access")).toBeTruthy(); + expect(view.container.textContent).not.toContain(SHOW_APP_ACCESS_TOOL); + }); +}); + +test("show_app_access about an app nobody can name draws the line, never its words to the model", async () => { + const view = mount( + , + ); + expect(await view.findByText("Checked app access")).toBeTruthy(); + expect(view.container.textContent).not.toContain("Say so plainly"); + // Nothing behind a disclosure either: the line is not expandable. + expect(view.container.querySelector("details")).toBeNull(); +}); diff --git a/app/tests/app-access-fixture.ts b/app/tests/app-access-fixture.ts new file mode 100644 index 0000000000..2a6a99e7e8 --- /dev/null +++ b/app/tests/app-access-fixture.ts @@ -0,0 +1,27 @@ +import type { AppAccess } from "@/lib/app-access/queries"; + +/** + * One app as `GET /api/plugins/for/:agentId/apps` answers it: Gmail, through Composio, under each + * person's own account, ready. Each test overrides only the fields its case is about. + */ +export function appAccess(overrides: Partial = {}): AppAccess { + return { + key: "composio-gmail", + displayName: "Gmail", + logo: null, + provider: "composio", + account: "personal", + connect: "consent", + authScheme: "OAUTH2", + accountMode: "personal", + state: "ready", + unavailable: null, + accountHolder: "you", + asksFirst: false, + granted: true, + connectedAt: null, + requestKind: null, + request: null, + ...overrides, + }; +} diff --git a/app/tests/app-access-mutations.test.ts b/app/tests/app-access-mutations.test.ts new file mode 100644 index 0000000000..91f4a06ffd --- /dev/null +++ b/app/tests/app-access-mutations.test.ts @@ -0,0 +1,123 @@ +import { afterEach, expect, test } from "bun:test"; +import { MutationObserver, QueryClient } from "@tanstack/react-query"; +import { + approveAppAccessRequestMutationOptions, + cancelAppAccessRequestMutationOptions, + dismissAppAccessRequestMutationOptions, + requestAppAccessMutationOptions, +} from "../src/lib/app-access/mutations"; +import { appAccessKeys } from "../src/lib/app-access/queries"; +import { pluginKeys } from "../src/lib/plugins/queries"; + +/** + * The access-request writes, as the browser makes them: what each sends, and that each refreshes + * every plugin screen on the way out — refused or not, for the reason `invalidatePlugins` gives. + */ + +const realFetch = globalThis.fetch; +afterEach(() => { + globalThis.fetch = realFetch; +}); + +function answering(status: number, body: unknown) { + const sent: { url: string; method: string; body?: unknown }[] = []; + globalThis.fetch = (async (url: string, init?: RequestInit) => { + // A request with no body is recorded with no `body` key, so its absence can be asserted. + sent.push({ + url, + method: init?.method ?? "GET", + ...(init?.body ? { body: JSON.parse(String(init.body)) } : {}), + }); + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); + }) as unknown as typeof fetch; + return sent; +} + +async function run( + build: (queryClient: QueryClient) => unknown, + variables: unknown, +) { + const queryClient = new QueryClient({ + defaultOptions: { mutations: { retry: false } }, + }); + const asked: unknown[] = []; + const invalidate = queryClient.invalidateQueries.bind(queryClient); + queryClient.invalidateQueries = ((filters: never) => { + asked.push(filters); + return invalidate(filters); + }) as typeof queryClient.invalidateQueries; + const observer = new MutationObserver( + queryClient, + build(queryClient) as never, + ); + const result = await observer + .mutate(variables as never) + .catch((error: Error) => error); + return { asked, result }; +} + +test("every app-access key sits under the plugin root, so plugin writes refresh it", () => { + for (const key of [ + appAccessKeys.all, + appAccessKeys.bot("b"), + appAccessKeys.app("b", "Gmail"), + appAccessKeys.inbox("composio-gmail"), + ]) + expect(key.slice(0, 1)).toEqual([...pluginKeys.all]); +}); + +test("asking names the Bot and the app and nothing else", async () => { + const sent = answering(201, { request: { id: "r1" }, created: true }); + const { asked, result } = await run(requestAppAccessMutationOptions, { + agentId: "bot-1", + app: "Gmail", + }); + expect(sent).toEqual([ + { + url: "/api/plugins/access-requests", + method: "POST", + body: { agentId: "bot-1", app: "Gmail" }, + }, + ]); + expect(result).toEqual({ request: { id: "r1" }, created: true }); + expect(asked).toEqual([{ queryKey: pluginKeys.all }]); +}); + +test("approving sends refs only when given", async () => { + const sent = answering(200, { request: {}, enabled: false, granted: [] }); + await run(approveAppAccessRequestMutationOptions, { id: "r1" }); + await run(approveAppAccessRequestMutationOptions, { + id: "r2", + refs: ["composio-gmail/SEND"], + }); + expect(sent).toEqual([ + { url: "/api/plugins/access-requests/r1/approve", method: "POST" }, + { + url: "/api/plugins/access-requests/r2/approve", + method: "POST", + body: { refs: ["composio-gmail/SEND"] }, + }, + ]); + // `toEqual` passes over a key it is not given, so the first approval's missing body is its own + // assertion: no refs means no body at all, not an empty one. + expect(sent[0]).not.toHaveProperty("body"); +}); + +test("a refused write still refreshes every plugin screen", async () => { + for (const build of [ + requestAppAccessMutationOptions, + cancelAppAccessRequestMutationOptions, + approveAppAccessRequestMutationOptions, + dismissAppAccessRequestMutationOptions, + ]) { + answering(409, { error: "That request has already been answered." }); + const { asked, result } = await run(build, "r1"); + expect((result as Error).message).toBe( + "That request has already been answered.", + ); + expect(asked).toEqual([{ queryKey: pluginKeys.all }]); + } +}); diff --git a/app/tests/bot-access-apps.test.tsx b/app/tests/bot-access-apps.test.tsx new file mode 100644 index 0000000000..c5de93cedd --- /dev/null +++ b/app/tests/bot-access-apps.test.tsx @@ -0,0 +1,291 @@ +import { afterAll, afterEach, beforeAll, expect, test } from "bun:test"; +import { GlobalRegistrator } from "@happy-dom/global-registrator"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, + Outlet, + RouterProvider, +} from "@tanstack/react-router"; +import { cleanup, render, within } from "@testing-library/react"; +import { AccessSections } from "@/components/bot-profile/access"; +import type { AgentProfile } from "@/lib/agents/queries"; +import type { AppAccess } from "@/lib/app-access/queries"; +import { appAccess } from "./app-access-fixture"; +import { settleReactWork } from "./settle-react-work"; + +/** + * The Granted section of a Bot's page, which used to list what a Bot holds and say nothing about + * whether the person reading could use any of it. + * + * Each app is named by its own name — never "Composio Gmail" — and says what it is for the person + * looking, with the one move they have. An administrator keeps the way to the Bot's grants for each + * app; nobody gets anybody else's connection state, because the read answers for the asker alone. + */ + +beforeAll(() => GlobalRegistrator.register()); +afterEach(cleanup); +afterAll(async () => { + await settleReactWork(); + GlobalRegistrator.unregister(); +}); + +const originalFetch = global.fetch; +afterEach(() => { + global.fetch = originalFetch; +}); + +const BOT: AgentProfile = { + id: "expenses", + name: "Expenses", + title: "Finance", + roleDescription: "Receipts.", + avatarSeed: "e", + visibility: "public", + endpoint: null, + builtIn: true, + hasAuth: false, + hasCallbackToken: false, + hidden: false, + pinned: false, + systemOwned: false, + canManage: false, + mine: false, +}; + +const tool = (ref: string, toolName: string) => ({ + ref, + toolName, + description: "", + inputSchema: {}, +}); + +function serving(options: { + role?: "admin" | "user"; + apps?: AppAccess[] | "fail"; + tools?: ReturnType[]; +}) { + const { role = "user", apps = [], tools = [] } = options; + global.fetch = (async (input: RequestInfo | URL) => { + const url = String(input); + if (url.endsWith("/api/plugins/for/expenses/apps")) + return apps === "fail" + ? Response.json({ error: "nope" }, { status: 500 }) + : Response.json({ apps }); + if (url.endsWith("/api/plugins/for/expenses")) + return Response.json({ tools, skills: [] }); + if (url.includes("/handoff")) + return Response.json({ + handoff: { + enabled: false, + canGrant: false, + reachable: [], + grantable: false, + }, + }); + if (url.includes("/api/agents")) return Response.json({ agents: [] }); + return Response.json({ + user: { id: "me", role, email: "me@example.test", name: "Me" }, + }); + }) as unknown as typeof fetch; +} + +function draw() { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + const root = createRootRoute({ component: Outlet }); + const page = createRoute({ + getParentRoute: () => root, + path: "/", + component: () => , + }); + const grants = createRoute({ + getParentRoute: () => root, + path: "/admin/plugins/$key/bots/$agentId", + component: () => null, + }); + const settings = createRoute({ + getParentRoute: () => root, + path: "/settings/connected-accounts/$key", + component: () => null, + }); + const router = createRouter({ + routeTree: root.addChildren([page, grants, settings]), + history: createMemoryHistory({ initialEntries: ["/"] }), + }); + return render( + + + , + ); +} + +const row = async (view: ReturnType, key: string) => { + await view.findByText("Granted"); + const found = await view.findByText((_, element) => + Boolean(element?.getAttribute("data-app") === key), + ); + return within(found); +}; + +test("each app is named by its own name and says it is ready, with its tools", async () => { + serving({ + apps: [appAccess()], + tools: [ + tool( + "composio-gmail/GMAIL_SEND_EMAIL", + "mcp__composio-gmail__GMAIL_SEND_EMAIL", + ), + tool( + "composio-gmail/GMAIL_FETCH_EMAILS", + "mcp__composio-gmail__GMAIL_FETCH_EMAILS", + ), + ], + }); + const view = draw(); + const gmail = await row(view, "composio-gmail"); + expect(gmail.getByText("Gmail")).toBeTruthy(); + expect(gmail.getByText(/^Ready to use\./)).toBeTruthy(); + expect(gmail.getByText("2 tools")).toBeTruthy(); + expect(view.queryByText(/Composio Gmail/)).toBeNull(); + expect(gmail.queryByRole("button")).toBeNull(); +}); + +test("an app this person has not connected says so and offers Connect", async () => { + serving({ apps: [appAccess({ state: "not_connected" })] }); + const gmail = await row(draw(), "composio-gmail"); + expect( + gmail.getByText("You haven't connected your Gmail account yet."), + ).toBeTruthy(); + expect(gmail.getByRole("button", { name: "Connect Gmail" })).toBeTruthy(); +}); + +test("an app only an administrator can fix offers Request access, and a pending one reads Requested", async () => { + serving({ + apps: [ + appAccess({ + key: "composio-slack", + displayName: "Slack", + state: "shared_not_connected", + account: "shared", + accountMode: "shared", + requestKind: "connect_shared", + }), + appAccess({ + key: "notion", + displayName: "Notion", + provider: "catalogue", + state: "disabled", + requestKind: "enable", + request: { + id: "req_1", + kind: "enable", + createdAt: "2026-10-20T10:00:00.000Z", + }, + }), + ], + }); + const view = draw(); + const slack = await row(view, "composio-slack"); + expect( + slack.getByText( + "Your organisation's Slack account isn't connected yet. Only an administrator can connect it.", + ), + ).toBeTruthy(); + expect( + slack.getByRole("button", { name: "Request access to Slack" }), + ).toBeTruthy(); + const notion = await row(view, "notion"); + expect( + notion.getByText( + "Notion isn't switched on for this OpenBot. Only an administrator can switch it on.", + ), + ).toBeTruthy(); + expect(notion.getByText("Requested")).toBeTruthy(); + expect( + notion.getByRole("button", { name: "Withdraw request for Notion" }), + ).toBeTruthy(); +}); + +test("an administrator keeps the way to this Bot's grants on each app, beside the action", async () => { + serving({ role: "admin", apps: [appAccess({ state: "not_connected" })] }); + const gmail = await row(draw(), "composio-gmail"); + const manage = gmail.getByRole("button", { + name: "Manage Gmail on this Bot", + }); + expect(manage.getAttribute("href")).toBe( + "/admin/plugins/composio-gmail/bots/expenses", + ); + expect(gmail.getByRole("button", { name: "Connect Gmail" })).toBeTruthy(); +}); + +test("an app's sentence is never cut short, so who can fix it stays on screen at a phone's width", async () => { + serving({ + apps: [ + appAccess({ + state: "disabled", + unavailable: "composio_unconfigured", + requestKind: "enable", + }), + ], + }); + const gmail = await row(draw(), "composio-gmail"); + const sentence = gmail.getByText( + "Gmail is not available on this OpenBot yet. An administrator has to finish setting it up.", + ); + expect(sentence.className).toContain("line-clamp-none"); + expect(sentence.className).not.toContain("line-clamp-2"); +}); + +test("on somebody else's Team Bot, an app on its owner's account says so, and an owner's reconnection offers nothing", async () => { + serving({ + apps: [ + appAccess({ accountHolder: "owner" }), + appAccess({ + key: "notion", + displayName: "Notion", + provider: "catalogue", + connect: "oauth", + state: "reconnect", + accountHolder: "owner", + }), + ], + }); + const view = draw(); + const gmail = await row(view, "composio-gmail"); + expect( + gmail.getByText( + /^Ready to use\. This Bot uses its owner's Gmail account\./, + ), + ).toBeTruthy(); + const notion = await row(view, "notion"); + expect( + notion.getByText( + "This Bot uses its owner's Notion account, which needs reconnecting. Only the Bot's owner can reconnect it.", + ), + ).toBeTruthy(); + expect(notion.queryByRole("button")).toBeNull(); +}); + +test("a person who is not an administrator gets no way to the grants", async () => { + serving({ apps: [appAccess()] }); + const gmail = await row(draw(), "composio-gmail"); + expect(gmail.queryByRole("button", { name: /Manage/ })).toBeNull(); +}); + +test("when the states cannot be read, the apps are still listed and the failure is said", async () => { + serving({ + apps: "fail", + tools: [ + tool("google-drive/search_files", "mcp__google-drive__search_files"), + ], + }); + const view = draw(); + expect((await view.findByRole("alert")).textContent).toContain( + "Whether each app is ready for you could not be loaded.", + ); + expect(await view.findByText("Google Drive")).toBeTruthy(); +}); diff --git a/app/tests/bot-access-skills.test.tsx b/app/tests/bot-access-skills.test.tsx index f8bab06846..fc7d4eee84 100644 --- a/app/tests/bot-access-skills.test.tsx +++ b/app/tests/bot-access-skills.test.tsx @@ -73,6 +73,9 @@ function serving() { grants.push(`${init?.method ?? "GET"} ${url} ${init?.body ?? ""}`); return Response.json({ ok: true }); } + if (url.includes("/api/plugins/for/") && url.endsWith("/apps")) { + return Response.json({ apps: [] }); + } if (url.includes("/api/plugins/for/")) { return Response.json({ tools: [], skills: [] }); } diff --git a/app/tests/bot-route-default-agent.fixture.tsx b/app/tests/bot-route-default-agent.fixture.tsx index 2754e68b0a..dcab617581 100644 --- a/app/tests/bot-route-default-agent.fixture.tsx +++ b/app/tests/bot-route-default-agent.fixture.tsx @@ -19,9 +19,14 @@ mock.module("@copilotkit/react-core/v2", () => ({ ), })); -mock.module("@/lib/copilot/active-bot", () => ({ - useActiveBot: () => undefined, -})); +/* + * `@/lib/copilot/active-bot` IS NOT MOCKED, though the route calls `useActiveBot`. Drawn without an + * `ActiveBotProvider`, as these tests draw it, the real hook finds no holder and does nothing — which + * is all the mock that used to be here did. And a module mock is not this file's to keep: bun cannot + * take one back off, so a replacement exporting only `useActiveBot` stood in for the whole module in + * every file walked into the same process, and `useDeclaredBotId` — the app-access card's fallback + * for a refusal that names no Bot — stopped existing there. + */ mock.module("@/lib/copilot/bot-thread", () => ({ useBotThread: (agentId: string) => { diff --git a/app/tests/brokered-account-row.test.tsx b/app/tests/brokered-account-row.test.tsx index 97b95f5583..335722fd3a 100644 --- a/app/tests/brokered-account-row.test.tsx +++ b/app/tests/brokered-account-row.test.tsx @@ -809,7 +809,11 @@ test("a disconnect that lands stops reading Connected and offers Connect again", expect(server.deletes).toBe(1); }); -test("a deployment with no Composio key names the setting and offers neither action", async () => { +/* + * The setting is named on the admin page below, to somebody who can set it. Here the reader is the + * person who would connect the account, so the page says only that the app is not available yet. + */ +test("a deployment with no Composio key says the app is not available yet and offers neither action", async () => { installDeployment({ composioConfigured: false, // A row left over from when a key WAS set: the app keeps its row and its grants, which is the @@ -820,8 +824,10 @@ test("a deployment with no Composio key names the setting and offers neither act const view = renderAccountScreen(queryClient()); - expect(await view.findByText(NAMES_THE_SETTING)).toBeTruthy(); - expect(view.queryByText("Key missing")).toBeTruthy(); + expect( + await view.findByText(/is not available on this OpenBot yet/), + ).toBeTruthy(); + expect(view.queryByText(NAMES_THE_SETTING)).toBeNull(); expect(view.queryByText("Connected")).toBeNull(); expect(view.queryByRole("button", { name: "Disconnect" })).toBeNull(); expect(view.queryByRole("button", { name: "Connect" })).toBeNull(); diff --git a/app/tests/composio-unconfigured-account.test.tsx b/app/tests/composio-unconfigured-account.test.tsx new file mode 100644 index 0000000000..f9252f7e8e --- /dev/null +++ b/app/tests/composio-unconfigured-account.test.tsx @@ -0,0 +1,189 @@ +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + expect, + test, +} from "bun:test"; +import { GlobalRegistrator } from "@happy-dom/global-registrator"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, + Outlet, + RouterProvider, +} from "@tanstack/react-router"; +import { cleanup, render } from "@testing-library/react"; +import { + type PluginConnections, + type PluginServer, + type PluginsPage, + pluginKeys, +} from "@/lib/plugins/queries"; +import { Route as ConnectedAccountRoute } from "@/routes/_authed/settings/connected-accounts/$key"; +import { serversListedFor } from "@/routes/_authed/settings/connected-accounts/index"; +import { settleReactWork } from "./settle-react-work"; + +/** + * A Composio app on a deployment with no Composio key, as the person who would connect it sees it. + * + * An app an administrator enabled while a key was set keeps its row after the key goes. The + * Connected accounts list offered it, and its page drew "Set COMPOSIO_API_KEY on this deployment" — + * an instruction for whoever runs the deployment, shown to somebody who cannot act on it. The list + * now leaves it off, and the page says plainly that it is not available yet. + * + * The harness is `plugin-read-failure.test.tsx`'s: the exported `Route` singleton is captured and + * restored around its `.update()`. + */ + +beforeAll(() => GlobalRegistrator.register()); +afterEach(cleanup); +// React may still have a passive effect queued when the last test ends; unregistering under it +// throws "window is not defined" between tests, which fails the run though every test passed. +afterAll(async () => { + await settleReactWork(); + GlobalRegistrator.unregister(); +}); + +const originalFetch = global.fetch; + +beforeEach(() => { + // Every read this page makes is seeded below; anything else is a request the test did not expect. + global.fetch = Object.assign( + async () => new Response(null, { status: 404 }), + { preconnect: originalFetch.preconnect }, + ); +}); + +afterEach(() => { + global.fetch = originalFetch; +}); + +function server(overrides: Partial & { id: string }) { + return { + title: "App", + summary: "", + vendor: "Composio", + url: `composio://${overrides.id}`, + provenance: "composio", + authScheme: "OAUTH2", + tools: [], + ...overrides, + } as PluginServer; +} + +const gmail = server({ id: "composio-gmail", title: "Gmail" }); +const drive = server({ + id: "google-drive", + title: "Google Drive", + provenance: "catalogue", + vendor: "Google", +} as Partial & { id: string }); + +test("lists no Composio app when the deployment has no Composio key", () => { + expect( + serversListedFor({ + servers: [gmail, drive], + composioConfigured: false, + }).map((row) => row.id), + ).toEqual(["google-drive"]); +}); + +test("lists Composio apps as before once the key is set", () => { + expect( + serversListedFor({ + servers: [gmail, drive], + composioConfigured: true, + }).map((row) => row.id), + ).toEqual(["composio-gmail", "google-drive"]); +}); + +type Updatable = { update: (options: unknown) => unknown }; + +const pristine = { + ...ConnectedAccountRoute, + options: { ...ConnectedAccountRoute.options }, +} as Record; + +afterEach(() => { + for (const key of Object.keys(ConnectedAccountRoute)) { + if (!(key in pristine)) { + delete (ConnectedAccountRoute as unknown as Record)[key]; + } + } + Object.assign(ConnectedAccountRoute, pristine); +}); + +function renderAccount(composioConfigured: boolean) { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + const page: PluginsPage = { + catalogue: [], + servers: [gmail], + skills: [], + botsMayCallBack: true, + redirectUri: null, + composioConfigured, + // Composio's answer: its auth config fixes one state for a whole toolkit. + authorizesPerAction: false, + }; + const connections: PluginConnections = { connections: [], redirectUri: null }; + client.setQueryData(pluginKeys.page(), page); + client.setQueryData(pluginKeys.connections(), connections); + + const rootRoute = createRootRoute({ component: Outlet }); + const authedRoute = createRoute({ + id: "/_authed", + getParentRoute: () => rootRoute, + component: Outlet, + }); + const settingsRoute = createRoute({ + path: "/settings", + getParentRoute: () => authedRoute, + component: Outlet, + }); + const indexRoute = createRoute({ + path: "/connected-accounts/", + getParentRoute: () => settingsRoute, + component: () => null, + }); + const account = (ConnectedAccountRoute as unknown as Updatable).update({ + id: "/connected-accounts/$key", + path: "/connected-accounts/$key", + getParentRoute: () => settingsRoute, + }); + const router = createRouter({ + routeTree: rootRoute.addChildren([ + authedRoute.addChildren([ + settingsRoute.addChildren([indexRoute, account as never]), + ]), + ]), + history: createMemoryHistory({ + initialEntries: ["/settings/connected-accounts/composio-gmail"], + }), + }); + return render( + + + , + ); +} + +test("the app's page says it is not available yet, and names no setting", async () => { + const view = renderAccount(false); + expect( + await view.findByText(/Gmail is not available on this OpenBot yet/), + ).toBeTruthy(); + expect(view.queryByText(/COMPOSIO_API_KEY/)).toBeNull(); + expect(view.queryByRole("button", { name: "Connect" })).toBeNull(); +}); + +test("with the key set, the app's page offers the account as before", async () => { + const view = renderAccount(true); + expect(await view.findByText("Your account")).toBeTruthy(); + expect(view.queryByText(/not available on this OpenBot yet/)).toBeNull(); +}); diff --git a/app/tests/tool-activity.test.tsx b/app/tests/tool-activity.test.tsx index f395a81126..529e470613 100644 --- a/app/tests/tool-activity.test.tsx +++ b/app/tests/tool-activity.test.tsx @@ -169,6 +169,55 @@ test("a request to connect an app at all is never folded away either", () => { expect(grouped.map((item) => item.id)).toContain("connect"); }); +test("a refusal that needs an administrator, or a lost account, is never folded away either", () => { + for (const reason of ["shared", "reconnect"]) { + const prompt = call( + reason, + "mcp__composio-gmail__GMAIL_FETCH_EMAILS", + {}, + JSON.stringify( + `Refused. [connect-app]{"serverId":"composio-gmail","title":"Gmail","agentId":"expenses","reason":"${reason}"} Gmail needs an account.`, + ), + ); + const grouped = groupToolSteps([search("a"), prompt, search("b")], drawn); + expect(grouped.map((item) => item.id)).toContain(reason); + } +}); + +test("the app-access card stays out of a group, even before its answer arrives", () => { + /* + * A server tool, so no renderer registers it and the registry cannot say it is drawn. Folded + * while it ran, the card would land inside "Used N tools" and then jump out once answered. + */ + for (const result of [ + undefined, + JSON.stringify( + '[connect-app]{"serverId":"composio-gmail","title":"Gmail","agentId":"expenses","reason":"access"} Gmail is switched off on this deployment.', + ), + ]) { + const card = call("card", "show_app_access", { app: "gmail" }, result); + const grouped = groupToolSteps([search("a"), card, search("b")], drawn); + expect(grouped.map((item) => item.id)).toEqual(["a", "card", "b"]); + } +}); + +test("show_app_access is named in words in a step's details too, and keeps its words to the model out", () => { + const running = stepDetails(call("card", "show_app_access", { app: "x" })); + expect(running.label).toBe("Checking app access"); + const done = stepDetails( + call( + "card", + "show_app_access", + { app: "x" }, + JSON.stringify( + "This deployment has no app called x, so there is nothing to show. Say so plainly; do not browse to it instead.", + ), + ), + ); + expect(done.label).toBe("Checked app access"); + expect(done.output).toBeUndefined(); +}); + test("the summary says what was done, never which provider searched", () => { const steps = [ search(), diff --git a/app/tests/tool-name.test.ts b/app/tests/tool-name.test.ts index 7a9c5bbf4d..2f67894305 100644 --- a/app/tests/tool-name.test.ts +++ b/app/tests/tool-name.test.ts @@ -148,3 +148,18 @@ describe("naming a tool call", () => { }); }); }); + +describe("this deployment's own tools", () => { + test("show_app_access is named for what it is doing, then for what it did", () => { + expect(readToolName("show_app_access", true)).toEqual({ + label: "Checking app access", + }); + expect(readToolName("show_app_access")).toEqual({ + label: "Checked app access", + }); + }); + + test("any other bare name is left exactly as it is", () => { + expect(readToolName("run_command", true)).toEqual({ label: "run_command" }); + }); +}); diff --git a/docs/architecture.md b/docs/architecture.md index 68248991e8..fa5c727431 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -313,6 +313,8 @@ A catalogue entry says whose credential a Bot reaches it with, which is a differ Every MCP call checks the grant first, then evaluates the same action policy engine with MCP context, then audits the result. +Whether a person can use an app through a Bot is answered once, on the server, from those same facts — the app's row, the Bot's grants, the person's own connection, a Shared app's team account — as one of six states: `ready`, `not_connected`, `reconnect`, `shared_not_connected`, `disabled` or `not_granted` (`server/src/plugins/app-access.ts`). The last three are an administrator's to fix, so a person can file a request (`app_access_requests`) for exactly what the state is missing, and approving one performs it — switching the app on, granting it to the Bot — through the same code paths the admin screens use. See [Composio](plugins/composio.md#what-an-app-is-for-one-person-on-one-bot). + ### Writing a skill in a conversation A skill can be written from the composer as well as from `/skills`. The deployment ships a skill called `skill-creator` whose instruction is how to interview somebody about the skill they want; a Bot holding it is also offered four tools the app registers — `list_skills`, `read_skill`, `list_skill_tools`, and `save_skill`, which suspends the run on a card showing the command, the title and the whole instruction. Nothing is written until the person presses the button. diff --git a/docs/plugins/composio.md b/docs/plugins/composio.md index 7b31b1a600..0e5afe5144 100644 --- a/docs/plugins/composio.md +++ b/docs/plugins/composio.md @@ -17,6 +17,8 @@ Setting it up takes three hands, and none of them can do another's: | An administrator | Grants its actions to a Bot | `/admin/plugins/composio-`, then that page's per-Bot screen | | Each person | Connects their own account to that app | `/settings/connected-accounts` | | An administrator | Chooses Personal or Shared for an app, and connects a Shared app's account | `/admin/plugins/composio-` | +| Each person | Asks for access to an app a Bot needs and only an administrator can fix | The Bot's page, or the card in a conversation | +| An administrator | Approves or dismisses that request — approving does the change | `/admin/plugins/composio-` | The key is the row that is easiest to misread, so it is stated twice: it is an environment variable and nothing else. No administrator, however permissioned, can turn Composio on from a page, and @@ -495,6 +497,85 @@ the ordering everywhere in this connector, so a failure between the two leaves a than live and unreachable; pressing Disconnect again is the whole recovery. Audited as `mcp.account_disconnected`. +## What an app is for one person on one Bot + +Every app a Bot holds is in one of six states for the person looking, and the server decides which +— from the app's row, the grants, the person's own connection and the team account — so the Bot's +page and a card in a conversation say the same thing a call would find: + +| State | Means | Next step | +| ---------------------- | -------------------------------------------------------------------------- | --------------------------------- | +| `ready` | Usable now: their own account, a connected team account, or no account | None | +| `not_connected` | Enabled and granted; used under each person's account, and theirs is not connected | **Connect**, the Settings flow | +| `reconnect` | Connected once; the credential was withdrawn, or the vendor refused the key at the last check | **Reconnect** | +| `shared_not_connected` | A Shared app whose team account is not connected, or whose key was refused | **Request access** | +| `disabled` | Switched off: no row, no `COMPOSIO_API_KEY`, or something only an administrator can supply is missing | **Request access** | +| `not_granted` | Enabled, and this Bot holds none of its actions | **Request access** | + +"Connected" is what this deployment last recorded — the same `verified` and probe columns the +Connected accounts page reads. Nothing asks the vendor per page load, so an account ended in +Composio's dashboard reads as `ready` until the next call or re-check finds out — and the call that +finds out is refused with a card offering **Reconnect** (below). + +On the Bot's page, Connect opens the app's consent screen in a new tab through the same connect route +Settings uses, and the row turns ready once the connection is recorded. An app connected with a typed +key, and Reconnect on a Composio app (whose row has to be disconnected before a new account can be +attached), go to the app's own page under `/settings/connected-accounts` instead. + +### In a conversation + +A Bot that needs an app it cannot use puts a card in the transcript naming the app, its state and the +next step, drawn from the same state as the Bot's page: + +- a call refused because the person has not connected the app; +- a call refused because a Shared app's team account is not connected — **Request access** rather + than the sentence "Ask an administrator to connect it."; +- a call Composio answered with "no connected account" — **Reconnect**, or for a Shared app a line + saying an administrator has to connect it again; +- the `show_app_access` tool, which every Bot holds and is told to call for an app it holds nothing + for, switched off or not granted — **Request access**. + +The card asks `GET /api/plugins/for/:agentId/apps/:app` for the Bot that made the call when it is +drawn, so a card scrolled back to later shows what is true now. The one case it takes the refusal's +word over the live state is the lost account, which the server cannot see; a connection made after +the refusal ends that. The card is drawn in the transcript, never folded into the "Used N tools" line. + +`GET /api/plugins/for/:agentId/apps` lists every app the Bot holds, including a grant whose app is +gone; `GET /api/plugins/for/:agentId/apps/:app` answers for one app by key or by name, held or not. +Both answer only for a Bot the person may use. Settings still lists only enabled apps, and the +directory is never listed to anybody but an administrator: an app nobody enabled appears only where +a Bot needs it. + +### Requesting access + +Where only an administrator can fix it, **Request access** files a request. The person names the Bot +and the app; what is asked for comes from the state — `enable` for `disabled`, `grant` for +`not_granted`, `connect_shared` for `shared_not_connected` — so a request cannot ask for something +the app does not need. Asking twice answers with the request already waiting: one pending request +per person, app, Bot and kind, held by a unique index rather than a check, so two replicas cannot +both file one. A person can withdraw their own. + +Requests wait on the app's own page under `/admin/plugins`, and all of them at the top of the +Plugins list, with a count on each app's row — an app nobody has switched on has no page of its own. They +show who asked and for which Bot — they asked — and nothing about anybody's connections. +**Approve** does what was asked in the same press: + +- **enable** switches the app on through the same path as the directory's Add, then grants it to + the Bot; +- **grant** grants the Bot every action the app advertises — or the ones the administrator picked — + with the Shared approval a grant from the app's page would record; +- **connect_shared** is approved once the team account is connected, which only the consent screen + on the app's page can do. + +What only an administrator can type — a missing `COMPOSIO_API_KEY`, a curated connector's OAuth +client or token — is refused with the step to take, and the request stays pending. **Dismiss** +changes nothing but the request. Filing, withdrawing, approving, dismissing and every refusal are +audited as `app_access.*`. + +This is a different thing from the Shared account requests above: those ask an administrator to +approve who may use a team account that is already connected; these ask for the app, the grant or +the account itself. + ## The two paths that end somebody else's access Both of these used to stop at this deployment's own tables, which was the only thing they could do diff --git a/server/drizzle/0060_app_access_requests.sql b/server/drizzle/0060_app_access_requests.sql new file mode 100644 index 0000000000..6de21e2346 --- /dev/null +++ b/server/drizzle/0060_app_access_requests.sql @@ -0,0 +1,19 @@ +CREATE TABLE "app_access_requests" ( + "id" text PRIMARY KEY NOT NULL, + "requested_by" text NOT NULL, + "agent_id" text, + "app_key" text NOT NULL, + "app_title" text NOT NULL, + "kind" text NOT NULL, + "status" text DEFAULT 'pending' NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "resolved_at" timestamp with time zone, + "resolved_by" text, + CONSTRAINT "app_access_requests_kind_check" CHECK ("app_access_requests"."kind" IN ('enable', 'grant', 'connect_shared')), + CONSTRAINT "app_access_requests_status_check" CHECK ("app_access_requests"."status" IN ('pending', 'approved', 'dismissed', 'cancelled')) +); +--> statement-breakpoint +ALTER TABLE "app_access_requests" ADD CONSTRAINT "app_access_requests_requested_by_users_id_fk" FOREIGN KEY ("requested_by") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "app_access_requests" ADD CONSTRAINT "app_access_requests_agent_id_agents_id_fk" FOREIGN KEY ("agent_id") REFERENCES "public"."agents"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE UNIQUE INDEX "app_access_requests_pending_idx" ON "app_access_requests" USING btree ("requested_by","app_key",coalesce("agent_id", ''),"kind") WHERE "app_access_requests"."status" = 'pending';--> statement-breakpoint +CREATE INDEX "app_access_requests_status_idx" ON "app_access_requests" USING btree ("status","app_key"); \ No newline at end of file diff --git a/server/drizzle/meta/0060_snapshot.json b/server/drizzle/meta/0060_snapshot.json new file mode 100644 index 0000000000..6f1ec67ae7 --- /dev/null +++ b/server/drizzle/meta/0060_snapshot.json @@ -0,0 +1,10176 @@ +{ + "id": "19f75c87-43a6-43f8-85f9-e930e5adaab4", + "prevId": "53b701d2-39fe-47c6-aabb-5be79754acb9", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.accounts": { + "name": "accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "accounts_provider_account_idx": { + "name": "accounts_provider_account_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "accounts_user_id_users_id_fk": { + "name": "accounts_user_id_users_id_fk", + "tableFrom": "accounts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agents": { + "name": "agents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "agent_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "configuration": { + "name": "configuration", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "package_id": { + "name": "package_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "override": { + "name": "override", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "agents_package_id_deployment_packages_id_fk": { + "name": "agents_package_id_deployment_packages_id_fk", + "tableFrom": "agents", + "tableTo": "deployment_packages", + "columnsFrom": [ + "package_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.attachments": { + "name": "attachments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size_bytes": { + "name": "size_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "bytes": { + "name": "bytes", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "attached_at": { + "name": "attached_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "upload_group": { + "name": "upload_group", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "attachments_channel_idx": { + "name": "attachments_channel_idx", + "columns": [ + { + "expression": "channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "attachments_uploaded_by_idx": { + "name": "attachments_uploaded_by_idx", + "columns": [ + { + "expression": "uploaded_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "attachments_upload_group_idx": { + "name": "attachments_upload_group_idx", + "columns": [ + { + "expression": "channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "uploaded_by", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "upload_group", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"attachments\".\"attached_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "attachments_staged_idx": { + "name": "attachments_staged_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"attachments\".\"attached_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "attachments_channel_id_channels_id_fk": { + "name": "attachments_channel_id_channels_id_fk", + "tableFrom": "attachments", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "attachments_uploaded_by_users_id_fk": { + "name": "attachments_uploaded_by_users_id_fk", + "tableFrom": "attachments", + "tableTo": "users", + "columnsFrom": [ + "uploaded_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_events": { + "name": "audit_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "initiator_kind": { + "name": "initiator_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'person'" + }, + "initiator_id": { + "name": "initiator_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_events_created_at_idx": { + "name": "audit_events_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_events_type_time_idx": { + "name": "audit_events_type_time_idx", + "columns": [ + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_events_actor_time_idx": { + "name": "audit_events_actor_time_idx", + "columns": [ + { + "expression": "actor_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_events_target_time_idx": { + "name": "audit_events_target_time_idx", + "columns": [ + { + "expression": "target_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_events_initiator_time_idx": { + "name": "audit_events_initiator_time_idx", + "columns": [ + { + "expression": "initiator_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.channel_agents": { + "name": "channel_agents", + "schema": "", + "columns": { + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "channel_agents_channel_id_channels_id_fk": { + "name": "channel_agents_channel_id_channels_id_fk", + "tableFrom": "channel_agents", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "channel_agents_agent_id_agents_id_fk": { + "name": "channel_agents_agent_id_agents_id_fk", + "tableFrom": "channel_agents", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "channel_agents_channel_id_agent_id_pk": { + "name": "channel_agents_channel_id_agent_id_pk", + "columns": [ + "channel_id", + "agent_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.channel_memberships": { + "name": "channel_memberships", + "schema": "", + "columns": { + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pinned_at": { + "name": "pinned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_read_at": { + "name": "last_read_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "channel_memberships_channel_id_channels_id_fk": { + "name": "channel_memberships_channel_id_channels_id_fk", + "tableFrom": "channel_memberships", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "channel_memberships_user_id_users_id_fk": { + "name": "channel_memberships_user_id_users_id_fk", + "tableFrom": "channel_memberships", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "channel_memberships_channel_id_user_id_pk": { + "name": "channel_memberships_channel_id_user_id_pk", + "columns": [ + "channel_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.channels": { + "name": "channels", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "suggested_prompts": { + "name": "suggested_prompts", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "allowed_groups": { + "name": "allowed_groups", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "package_id": { + "name": "package_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "override": { + "name": "override", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "summary_at": { + "name": "summary_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_message": { + "name": "last_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_message_source_id": { + "name": "last_message_source_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_message_at": { + "name": "last_message_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_message_agent_id": { + "name": "last_message_agent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "channels_recent_activity_idx": { + "name": "channels_recent_activity_idx", + "columns": [ + { + "expression": "COALESCE(\"last_message_at\", \"created_at\") DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "channels_awaiting_summary_idx": { + "name": "channels_awaiting_summary_idx", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"channels\".\"summary\" is null and \"channels\".\"deleted_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "channels_package_id_deployment_packages_id_fk": { + "name": "channels_package_id_deployment_packages_id_fk", + "tableFrom": "channels", + "tableTo": "deployment_packages", + "columnsFrom": [ + "package_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "channels_last_message_agent_id_agents_id_fk": { + "name": "channels_last_message_agent_id_agents_id_fk", + "tableFrom": "channels", + "tableTo": "agents", + "columnsFrom": [ + "last_message_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credentials": { + "name": "credentials", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "kind": { + "name": "kind", + "type": "credential_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_value": { + "name": "encrypted_value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_id": { + "name": "key_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credentials_active_key_idx": { + "name": "credentials_active_key_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"credentials\".\"revoked_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deployment_packages": { + "name": "deployment_packages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_path": { + "name": "source_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "checksum": { + "name": "checksum", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "loaded_at": { + "name": "loaded_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "deployment_packages_tenant_id_unique": { + "name": "deployment_packages_tenant_id_unique", + "nullsNotDistinct": false, + "columns": [ + "tenant_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.intelligence_channel_mappings": { + "name": "intelligence_channel_mappings", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "intelligence_channel_mappings_thread_idx": { + "name": "intelligence_channel_mappings_thread_idx", + "columns": [ + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "intelligence_channel_mappings_user_id_users_id_fk": { + "name": "intelligence_channel_mappings_user_id_users_id_fk", + "tableFrom": "intelligence_channel_mappings", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "intelligence_channel_mappings_channel_id_channels_id_fk": { + "name": "intelligence_channel_mappings_channel_id_channels_id_fk", + "tableFrom": "intelligence_channel_mappings", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "intelligence_channel_mappings_user_id_channel_id_pk": { + "name": "intelligence_channel_mappings_user_id_channel_id_pk", + "columns": [ + "user_id", + "channel_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.revoked_access": { + "name": "revoked_access", + "schema": "", + "columns": { + "email": { + "name": "email", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "revoked_by": { + "name": "revoked_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sessions_token_unique": { + "name": "sessions_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sso_providers": { + "name": "sso_providers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oidc_config": { + "name": "oidc_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "saml_config": { + "name": "saml_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "sso_providers_user_id_users_id_fk": { + "name": "sso_providers_user_id_users_id_fk", + "tableFrom": "sso_providers", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sso_providers_provider_id_unique": { + "name": "sso_providers_provider_id_unique", + "nullsNotDistinct": false, + "columns": [ + "provider_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_instructions": { + "name": "user_instructions", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "instructions": { + "name": "instructions", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_instructions_user_id_users_id_fk": { + "name": "user_instructions_user_id_users_id_fk", + "tableFrom": "user_instructions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_roles": { + "name": "user_roles", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_roles_user_id_users_id_fk": { + "name": "user_roles_user_id_users_id_fk", + "tableFrom": "user_roles", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_roles_user_id_role_pk": { + "name": "user_roles_user_id_role_pk", + "columns": [ + "user_id", + "role" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "preferences": { + "name": "preferences", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "groups": { + "name": "groups", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "onboarding_step": { + "name": "onboarding_step", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "onboarding_completed_at": { + "name": "onboarding_completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_signed_in_at": { + "name": "last_signed_in_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verifications": { + "name": "verifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.action_policy": { + "name": "action_policy", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deny": { + "name": "deny", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "allow": { + "name": "allow", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.computer_page_frame": { + "name": "computer_page_frame", + "schema": "", + "columns": { + "computer_id": { + "name": "computer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_call_id": { + "name": "tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "frame": { + "name": "frame", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "captured_at": { + "name": "captured_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "computer_page_frame_captured_idx": { + "name": "computer_page_frame_captured_idx", + "columns": [ + { + "expression": "captured_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "computer_page_frame_computer_id_tool_call_id_pk": { + "name": "computer_page_frame_computer_id_tool_call_id_pk", + "columns": [ + "computer_id", + "tool_call_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.computer_snapshot": { + "name": "computer_snapshot", + "schema": "", + "columns": { + "computer_id": { + "name": "computer_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "snapshot_id": { + "name": "snapshot_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "elements": { + "name": "elements", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "taken_at": { + "name": "taken_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "session": { + "name": "session", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_preferences": { + "name": "agent_preferences", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hidden_at": { + "name": "hidden_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "pinned_at": { + "name": "pinned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "agent_preferences_user_id_users_id_fk": { + "name": "agent_preferences_user_id_users_id_fk", + "tableFrom": "agent_preferences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_preferences_agent_id_agents_id_fk": { + "name": "agent_preferences_agent_id_agents_id_fk", + "tableFrom": "agent_preferences", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "agent_preferences_user_id_agent_id_pk": { + "name": "agent_preferences_user_id_agent_id_pk", + "columns": [ + "user_id", + "agent_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_profiles": { + "name": "agent_profiles", + "schema": "", + "columns": { + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role_description": { + "name": "role_description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "avatar_seed": { + "name": "avatar_seed", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "visibility": { + "name": "visibility", + "type": "agent_visibility", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "callback_token_hash": { + "name": "callback_token_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "callback_token_issued_at": { + "name": "callback_token_issued_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agent_profiles_visibility_deleted_idx": { + "name": "agent_profiles_visibility_deleted_idx", + "columns": [ + { + "expression": "visibility", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_profiles_agent_id_agents_id_fk": { + "name": "agent_profiles_agent_id_agents_id_fk", + "tableFrom": "agent_profiles", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_profiles_owner_user_id_users_id_fk": { + "name": "agent_profiles_owner_user_id_users_id_fk", + "tableFrom": "agent_profiles", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.routine_runs": { + "name": "routine_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "routine_id": { + "name": "routine_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "routine_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "waiting": { + "name": "waiting", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'schedule'" + } + }, + "indexes": { + "routine_runs_by_routine_idx": { + "name": "routine_runs_by_routine_idx", + "columns": [ + { + "expression": "routine_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "routine_runs_routine_id_routines_id_fk": { + "name": "routine_runs_routine_id_routines_id_fk", + "tableFrom": "routine_runs", + "tableTo": "routines", + "columnsFrom": [ + "routine_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.routine_sweeps": { + "name": "routine_sweeps", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "swept_at": { + "name": "swept_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "owner": { + "name": "owner", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.routines": { + "name": "routines", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "instruction": { + "name": "instruction", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cron": { + "name": "cron", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'UTC'" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "next_run_at": { + "name": "next_run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "enabled_at": { + "name": "enabled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "routines_due_idx": { + "name": "routines_due_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "routines_by_owner_idx": { + "name": "routines_by_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "routines_owner_user_id_users_id_fk": { + "name": "routines_owner_user_id_users_id_fk", + "tableFrom": "routines", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "routines_agent_id_agents_id_fk": { + "name": "routines_agent_id_agents_id_fk", + "tableFrom": "routines", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.component_exclusions": { + "name": "component_exclusions", + "schema": "", + "columns": { + "component_name": { + "name": "component_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "withheld_by": { + "name": "withheld_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "component_exclusions_component_name_components_name_fk": { + "name": "component_exclusions_component_name_components_name_fk", + "tableFrom": "component_exclusions", + "tableTo": "components", + "columnsFrom": [ + "component_name" + ], + "columnsTo": [ + "name" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "component_exclusions_agent_id_agents_id_fk": { + "name": "component_exclusions_agent_id_agents_id_fk", + "tableFrom": "component_exclusions", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "component_exclusions_component_name_agent_id_pk": { + "name": "component_exclusions_component_name_agent_id_pk", + "columns": [ + "component_name", + "agent_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.component_functions": { + "name": "component_functions", + "schema": "", + "columns": { + "component_name": { + "name": "component_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "function_name": { + "name": "function_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "granted_by": { + "name": "granted_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "component_functions_component_name_components_name_fk": { + "name": "component_functions_component_name_components_name_fk", + "tableFrom": "component_functions", + "tableTo": "components", + "columnsFrom": [ + "component_name" + ], + "columnsTo": [ + "name" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "component_functions_component_name_function_name_pk": { + "name": "component_functions_component_name_function_name_pk", + "columns": [ + "component_name", + "function_name" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.components": { + "name": "components", + "schema": "", + "columns": { + "name": { + "name": "name", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "draft_description": { + "name": "draft_description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "published_description": { + "name": "published_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published": { + "name": "published", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_access_requests": { + "name": "app_access_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "requested_by": { + "name": "requested_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "app_key": { + "name": "app_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "app_title": { + "name": "app_title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "resolved_by": { + "name": "resolved_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "app_access_requests_pending_idx": { + "name": "app_access_requests_pending_idx", + "columns": [ + { + "expression": "requested_by", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "app_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"agent_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"app_access_requests\".\"status\" = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "app_access_requests_status_idx": { + "name": "app_access_requests_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "app_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "app_access_requests_requested_by_users_id_fk": { + "name": "app_access_requests_requested_by_users_id_fk", + "tableFrom": "app_access_requests", + "tableTo": "users", + "columnsFrom": [ + "requested_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "app_access_requests_agent_id_agents_id_fk": { + "name": "app_access_requests_agent_id_agents_id_fk", + "tableFrom": "app_access_requests", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "app_access_requests_kind_check": { + "name": "app_access_requests_kind_check", + "value": "\"app_access_requests\".\"kind\" IN ('enable', 'grant', 'connect_shared')" + }, + "app_access_requests_status_check": { + "name": "app_access_requests_status_check", + "value": "\"app_access_requests\".\"status\" IN ('pending', 'approved', 'dismissed', 'cancelled')" + } + }, + "isRLSEnabled": false + }, + "public.brokered_connections": { + "name": "brokered_connections", + "schema": "", + "columns": { + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "app": { + "name": "app", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "holder": { + "name": "holder", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "vendor_user_id": { + "name": "vendor_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connected_by": { + "name": "connected_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "connected_at": { + "name": "connected_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "verified": { + "name": "verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "verified_at": { + "name": "verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "probe_action": { + "name": "probe_action", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "brokered_connections_person_idx": { + "name": "brokered_connections_person_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "app", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"brokered_connections\".\"holder\" = 'person'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "brokered_connections_deployment_idx": { + "name": "brokered_connections_deployment_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "app", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"brokered_connections\".\"holder\" = 'deployment'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "brokered_connections_user_idx": { + "name": "brokered_connections_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "brokered_connections_holder_check": { + "name": "brokered_connections_holder_check", + "value": "(\"brokered_connections\".\"holder\" = 'person' AND \"brokered_connections\".\"user_id\" IS NOT NULL) OR (\"brokered_connections\".\"holder\" = 'deployment' AND \"brokered_connections\".\"user_id\" IS NULL)" + }, + "brokered_connections_provider_check": { + "name": "brokered_connections_provider_check", + "value": "\"brokered_connections\".\"provider\" IN ('composio', 'arcade')" + } + }, + "isRLSEnabled": false + }, + "public.composio_connections": { + "name": "composio_connections", + "schema": "", + "columns": { + "toolkit": { + "name": "toolkit", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connected_at": { + "name": "connected_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "verified": { + "name": "verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "verified_at": { + "name": "verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "probe_action": { + "name": "probe_action", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "composio_connections_user_idx": { + "name": "composio_connections_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "composio_connections_toolkit_user_id_pk": { + "name": "composio_connections_toolkit_user_id_pk", + "columns": [ + "toolkit", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_servers": { + "name": "mcp_servers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "vendor": { + "name": "vendor", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provenance": { + "name": "provenance", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'first-party'" + }, + "credential_id": { + "name": "credential_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "auth_scheme": { + "name": "auth_scheme", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "account_mode": { + "name": "account_mode", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "shared_vendor_user_id": { + "name": "shared_vendor_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tools_refreshed_at": { + "name": "tools_refreshed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "skipped_tools": { + "name": "skipped_tools", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "added_by": { + "name": "added_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "mcp_servers_credential_id_credentials_id_fk": { + "name": "mcp_servers_credential_id_credentials_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "mcp_servers_account_mode_check": { + "name": "mcp_servers_account_mode_check", + "value": "\"mcp_servers\".\"account_mode\" IS NULL OR \"mcp_servers\".\"account_mode\" IN ('personal', 'shared')" + } + }, + "isRLSEnabled": false + }, + "public.mcp_tools": { + "name": "mcp_tools", + "schema": "", + "columns": { + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "vendor_name": { + "name": "vendor_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "input_schema": { + "name": "input_schema", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "effect": { + "name": "effect", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "destructive": { + "name": "destructive", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "mcp_tools_server_id_mcp_servers_id_fk": { + "name": "mcp_tools_server_id_mcp_servers_id_fk", + "tableFrom": "mcp_tools", + "tableTo": "mcp_servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "mcp_tools_server_id_name_pk": { + "name": "mcp_tools_server_id_name_pk", + "columns": [ + "server_id", + "name" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_user_credentials": { + "name": "mcp_user_credentials", + "schema": "", + "columns": { + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connected_at": { + "name": "connected_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_user_credentials_user_idx": { + "name": "mcp_user_credentials_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_user_credentials_server_id_mcp_servers_id_fk": { + "name": "mcp_user_credentials_server_id_mcp_servers_id_fk", + "tableFrom": "mcp_user_credentials", + "tableTo": "mcp_servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_user_credentials_user_id_users_id_fk": { + "name": "mcp_user_credentials_user_id_users_id_fk", + "tableFrom": "mcp_user_credentials", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_user_credentials_credential_id_credentials_id_fk": { + "name": "mcp_user_credentials_credential_id_credentials_id_fk", + "tableFrom": "mcp_user_credentials", + "tableTo": "credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "mcp_user_credentials_server_id_user_id_pk": { + "name": "mcp_user_credentials_server_id_user_id_pk", + "columns": [ + "server_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pending_authorizations": { + "name": "pending_authorizations", + "schema": "", + "columns": { + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "holder": { + "name": "holder", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "vendor_user_id": { + "name": "vendor_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "auth_id": { + "name": "auth_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "pending_authorizations_server_idx": { + "name": "pending_authorizations_server_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pending_authorizations_actor_id_users_id_fk": { + "name": "pending_authorizations_actor_id_users_id_fk", + "tableFrom": "pending_authorizations", + "tableTo": "users", + "columnsFrom": [ + "actor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pending_authorizations_server_id_mcp_servers_id_fk": { + "name": "pending_authorizations_server_id_mcp_servers_id_fk", + "tableFrom": "pending_authorizations", + "tableTo": "mcp_servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "pending_authorizations_holder_check": { + "name": "pending_authorizations_holder_check", + "value": "(\"pending_authorizations\".\"holder\" = 'person' AND \"pending_authorizations\".\"user_id\" IS NOT NULL) OR (\"pending_authorizations\".\"holder\" = 'deployment' AND \"pending_authorizations\".\"user_id\" IS NULL)" + }, + "pending_authorizations_outcome_check": { + "name": "pending_authorizations_outcome_check", + "value": "\"pending_authorizations\".\"outcome\" IS NULL OR \"pending_authorizations\".\"outcome\" IN ('completed', 'failed', 'unreconciled', 'withdrawn')" + }, + "pending_authorizations_completion_check": { + "name": "pending_authorizations_completion_check", + "value": "(\"pending_authorizations\".\"completed_at\" IS NULL) = (\"pending_authorizations\".\"outcome\" IS NULL)" + }, + "pending_authorizations_user_is_actor_check": { + "name": "pending_authorizations_user_is_actor_check", + "value": "\"pending_authorizations\".\"user_id\" IS NULL OR \"pending_authorizations\".\"user_id\" = \"pending_authorizations\".\"actor_id\"" + } + }, + "isRLSEnabled": false + }, + "public.plugin_grants": { + "name": "plugin_grants", + "schema": "", + "columns": { + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ref": { + "name": "ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "granted_by": { + "name": "granted_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "plugin_grants_agent_idx": { + "name": "plugin_grants_agent_idx", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "plugin_grants_agent_id_agents_id_fk": { + "name": "plugin_grants_agent_id_agents_id_fk", + "tableFrom": "plugin_grants", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "plugin_grants_kind_ref_agent_id_pk": { + "name": "plugin_grants_kind_ref_agent_id_pk", + "columns": [ + "kind", + "ref", + "agent_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sandboxed_components": { + "name": "sandboxed_components", + "schema": "", + "columns": { + "name": { + "name": "name", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "draft_description": { + "name": "draft_description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "draft_html": { + "name": "draft_html", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "draft_css": { + "name": "draft_css", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "draft_js_functions": { + "name": "draft_js_functions", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "draft_argument_schema": { + "name": "draft_argument_schema", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "published_description": { + "name": "published_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_html": { + "name": "published_html", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_css": { + "name": "published_css", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_js_functions": { + "name": "published_js_functions", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_argument_schema": { + "name": "published_argument_schema", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "sample_arguments": { + "name": "sample_arguments", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "published": { + "name": "published", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "authored_by": { + "name": "authored_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skill_tools": { + "name": "skill_tools", + "schema": "", + "columns": { + "skill_id": { + "name": "skill_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ref": { + "name": "ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "declared_by": { + "name": "declared_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skill_tools_ref_idx": { + "name": "skill_tools_ref_idx", + "columns": [ + { + "expression": "ref", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skill_tools_skill_id_skills_id_fk": { + "name": "skill_tools_skill_id_skills_id_fk", + "tableFrom": "skill_tools", + "tableTo": "skills", + "columnsFrom": [ + "skill_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "skill_tools_skill_id_ref_pk": { + "name": "skill_tools_skill_id_ref_pk", + "columns": [ + "skill_id", + "ref" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skills": { + "name": "skills", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "instructions": { + "name": "instructions", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'yours'" + }, + "installed_by": { + "name": "installed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skills_slug_key": { + "name": "skills_slug_key", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "skills_owner_idx": { + "name": "skills_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skills_owner_user_id_users_id_fk": { + "name": "skills_owner_user_id_users_id_fk", + "tableFrom": "skills", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.work_items": { + "name": "work_items", + "schema": "", + "columns": { + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_at": { + "name": "run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "claimed_by": { + "name": "claimed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lease_until": { + "name": "lease_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "work_items_claimable_idx": { + "name": "work_items_claimable_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "work_items_kind_key_pk": { + "name": "work_items_kind_key_pk", + "columns": [ + "kind", + "key" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.voice_sessions": { + "name": "voice_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "anchor_message_id": { + "name": "anchor_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "duration_seconds": { + "name": "duration_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "transcript": { + "name": "transcript", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "summary_status": { + "name": "summary_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'failed'" + } + }, + "indexes": { + "voice_sessions_channel_started_idx": { + "name": "voice_sessions_channel_started_idx", + "columns": [ + { + "expression": "channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "voice_sessions_channel_id_channels_id_fk": { + "name": "voice_sessions_channel_id_channels_id_fk", + "tableFrom": "voice_sessions", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "voice_sessions_user_id_users_id_fk": { + "name": "voice_sessions_user_id_users_id_fk", + "tableFrom": "voice_sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "voice_sessions_duration_check": { + "name": "voice_sessions_duration_check", + "value": "\"voice_sessions\".\"duration_seconds\" >= 0" + }, + "voice_sessions_summary_check": { + "name": "voice_sessions_summary_check", + "value": "(\"voice_sessions\".\"summary_status\" = 'ready' AND \"voice_sessions\".\"summary\" IS NOT NULL) OR (\"voice_sessions\".\"summary_status\" = 'failed' AND \"voice_sessions\".\"summary\" IS NULL)" + } + }, + "isRLSEnabled": false + }, + "public.learning_settings": { + "name": "learning_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.learning_thread_bindings": { + "name": "learning_thread_bindings", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "container_id": { + "name": "container_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "learning_thread_bindings_user_id_thread_id_pk": { + "name": "learning_thread_bindings_user_id_thread_id_pk", + "columns": [ + "user_id", + "thread_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.responsibilities": { + "name": "responsibilities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "instruction": { + "name": "instruction", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "success_criteria": { + "name": "success_criteria", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "responsibility_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "progress": { + "name": "progress", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "last_result": { + "name": "last_result", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subscriptions": { + "name": "subscriptions", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "responsibilities_owner_status_idx": { + "name": "responsibilities_owner_status_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "responsibilities_owner_user_id_users_id_fk": { + "name": "responsibilities_owner_user_id_users_id_fk", + "tableFrom": "responsibilities", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "responsibilities_agent_id_agents_id_fk": { + "name": "responsibilities_agent_id_agents_id_fk", + "tableFrom": "responsibilities", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.responsibility_events": { + "name": "responsibility_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "responsibility_events_source_id_idx": { + "name": "responsibility_events_source_id_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "responsibility_events_owner_user_id_users_id_fk": { + "name": "responsibility_events_owner_user_id_users_id_fk", + "tableFrom": "responsibility_events", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.responsibility_runs": { + "name": "responsibility_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "responsibility_id": { + "name": "responsibility_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_id": { + "name": "event_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "responsibility_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "reply_text": { + "name": "reply_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "waiting": { + "name": "waiting", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "continuation": { + "name": "continuation", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "responsibility_runs_event_idx": { + "name": "responsibility_runs_event_idx", + "columns": [ + { + "expression": "responsibility_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "responsibility_runs_status_idx": { + "name": "responsibility_runs_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "responsibility_runs_responsibility_id_responsibilities_id_fk": { + "name": "responsibility_runs_responsibility_id_responsibilities_id_fk", + "tableFrom": "responsibility_runs", + "tableTo": "responsibilities", + "columnsFrom": [ + "responsibility_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "responsibility_runs_event_id_responsibility_events_id_fk": { + "name": "responsibility_runs_event_id_responsibility_events_id_fk", + "tableFrom": "responsibility_runs", + "tableTo": "responsibility_events", + "columnsFrom": [ + "event_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.responsibility_source_bindings": { + "name": "responsibility_source_bindings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repository": { + "name": "repository", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "responsibility_source_bindings_owner_idx": { + "name": "responsibility_source_bindings_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "responsibility_source_bindings_owner_user_id_users_id_fk": { + "name": "responsibility_source_bindings_owner_user_id_users_id_fk", + "tableFrom": "responsibility_source_bindings", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "responsibility_source_bindings_credential_id_credentials_id_fk": { + "name": "responsibility_source_bindings_credential_id_credentials_id_fk", + "tableFrom": "responsibility_source_bindings", + "tableTo": "credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.responsibility_triggers": { + "name": "responsibility_triggers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "responsibility_id": { + "name": "responsibility_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "responsibility_triggers_owner_idx": { + "name": "responsibility_triggers_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "responsibility_triggers_responsibility_idx": { + "name": "responsibility_triggers_responsibility_idx", + "columns": [ + { + "expression": "responsibility_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "responsibility_triggers_kind_idx": { + "name": "responsibility_triggers_kind_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "responsibility_triggers_owner_user_id_users_id_fk": { + "name": "responsibility_triggers_owner_user_id_users_id_fk", + "tableFrom": "responsibility_triggers", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "responsibility_triggers_responsibility_id_responsibilities_id_fk": { + "name": "responsibility_triggers_responsibility_id_responsibilities_id_fk", + "tableFrom": "responsibility_triggers", + "tableTo": "responsibilities", + "columnsFrom": [ + "responsibility_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "responsibility_triggers_credential_id_credentials_id_fk": { + "name": "responsibility_triggers_credential_id_credentials_id_fk", + "tableFrom": "responsibility_triggers", + "tableTo": "credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.approval_preferences": { + "name": "approval_preferences", + "schema": "", + "columns": { + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "auto_review": { + "name": "auto_review", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "host_commands": { + "name": "host_commands", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'ask'" + } + }, + "indexes": {}, + "foreignKeys": { + "approval_preferences_owner_user_id_users_id_fk": { + "name": "approval_preferences_owner_user_id_users_id_fk", + "tableFrom": "approval_preferences", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "approval_preferences_host_commands_check": { + "name": "approval_preferences_host_commands_check", + "value": "\"approval_preferences\".\"host_commands\" IN ('ask', 'allow', 'never')" + } + }, + "isRLSEnabled": false + }, + "public.approval_requests": { + "name": "approval_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_call_id": { + "name": "tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action_digest": { + "name": "action_digest", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "decision": { + "name": "decision", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "decided_at": { + "name": "decided_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "consumed_at": { + "name": "consumed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "approval_requests_action_idx": { + "name": "approval_requests_action_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "approval_requests_inbox_idx": { + "name": "approval_requests_inbox_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "approval_requests_owner_user_id_users_id_fk": { + "name": "approval_requests_owner_user_id_users_id_fk", + "tableFrom": "approval_requests", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "approval_status_check": { + "name": "approval_status_check", + "value": "\"approval_requests\".\"status\" IN ('pending', 'approved', 'denied', 'consumed', 'completed')" + }, + "approval_decision_check": { + "name": "approval_decision_check", + "value": "\"approval_requests\".\"decision\" IN ('allow_once', 'allow_always', 'deny', 'handled')" + } + }, + "isRLSEnabled": false + }, + "public.approval_rules": { + "name": "approval_rules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bot_id": { + "name": "bot_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_ref": { + "name": "tool_ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "effect": { + "name": "effect", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "behaviour": { + "name": "behaviour", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'allow'" + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "approval_rules_owner_idx": { + "name": "approval_rules_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "approval_rules_owner_user_id_users_id_fk": { + "name": "approval_rules_owner_user_id_users_id_fk", + "tableFrom": "approval_rules", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "approval_rules_behaviour_check": { + "name": "approval_rules_behaviour_check", + "value": "\"approval_rules\".\"behaviour\" IN ('allow', 'pre_approved', 'ask', 'hand_off')" + } + }, + "isRLSEnabled": false + }, + "public.approval_team_rules": { + "name": "approval_team_rules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "bot_id": { + "name": "bot_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_ref": { + "name": "tool_ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "effect": { + "name": "effect", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "behaviour": { + "name": "behaviour", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "approval_team_rules_created_by_users_id_fk": { + "name": "approval_team_rules_created_by_users_id_fk", + "tableFrom": "approval_team_rules", + "tableTo": "users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "approval_team_rules_behaviour_check": { + "name": "approval_team_rules_behaviour_check", + "value": "\"approval_team_rules\".\"behaviour\" IN ('allow', 'pre_approved', 'ask', 'hand_off')" + } + }, + "isRLSEnabled": false + }, + "public.approval_team_settings": { + "name": "approval_team_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "enforce_auto_review": { + "name": "enforce_auto_review", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "custom_rules_enabled": { + "name": "custom_rules_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "host_commands_cap": { + "name": "host_commands_cap", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'allow'" + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "approval_team_settings_updated_by_users_id_fk": { + "name": "approval_team_settings_updated_by_users_id_fk", + "tableFrom": "approval_team_settings", + "tableTo": "users", + "columnsFrom": [ + "updated_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "approval_team_settings_host_commands_cap_check": { + "name": "approval_team_settings_host_commands_cap_check", + "value": "\"approval_team_settings\".\"host_commands_cap\" IN ('ask', 'allow', 'never')" + } + }, + "isRLSEnabled": false + }, + "public.memory_sources": { + "name": "memory_sources", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_ref": { + "name": "tool_ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "args": { + "name": "args", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "sync_status": { + "name": "sync_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'idle'" + }, + "sync_error": { + "name": "sync_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_sync_at": { + "name": "last_sync_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "next_sync_at": { + "name": "next_sync_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "memory_sources_owner_idx": { + "name": "memory_sources_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_sources_due_idx": { + "name": "memory_sources_due_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_sync_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "memory_sources_owner_user_id_users_id_fk": { + "name": "memory_sources_owner_user_id_users_id_fk", + "tableFrom": "memory_sources", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "memory_sources_agent_id_agents_id_fk": { + "name": "memory_sources_agent_id_agents_id_fk", + "tableFrom": "memory_sources", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.personal_memories": { + "name": "personal_memories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "import_digest": { + "name": "import_digest", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provenance": { + "name": "provenance", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "review_state": { + "name": "review_state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'unreviewed'" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "formed_by": { + "name": "formed_by", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'person'" + }, + "formed_by_agent_id": { + "name": "formed_by_agent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_app": { + "name": "source_app", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_ref": { + "name": "source_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_link": { + "name": "source_link", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "observed_at": { + "name": "observed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "intelligence_memory_id": { + "name": "intelligence_memory_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "intelligence_digest": { + "name": "intelligence_digest", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "personal_memories_owner_idx": { + "name": "personal_memories_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "personal_memories_intelligence_idx": { + "name": "personal_memories_intelligence_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "intelligence_memory_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "personal_memories_formed_idx": { + "name": "personal_memories_formed_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "formed_by_agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "personal_memories_import_idx": { + "name": "personal_memories_import_idx", + "columns": [ + { + "expression": "source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "personal_memories_formed_digest_idx": { + "name": "personal_memories_formed_digest_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "import_digest", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"personal_memories\".\"formed_by\" = 'bot'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "personal_memories_owner_user_id_users_id_fk": { + "name": "personal_memories_owner_user_id_users_id_fk", + "tableFrom": "personal_memories", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "personal_memories_source_id_memory_sources_id_fk": { + "name": "personal_memories_source_id_memory_sources_id_fk", + "tableFrom": "personal_memories", + "tableTo": "memory_sources", + "columnsFrom": [ + "source_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "personal_memories_formed_by_agent_id_agents_id_fk": { + "name": "personal_memories_formed_by_agent_id_agents_id_fk", + "tableFrom": "personal_memories", + "tableTo": "agents", + "columnsFrom": [ + "formed_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.default_opt_outs": { + "name": "default_opt_outs", + "schema": "", + "columns": { + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "feature": { + "name": "feature", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "default_opt_outs_owner_user_id_users_id_fk": { + "name": "default_opt_outs_owner_user_id_users_id_fk", + "tableFrom": "default_opt_outs", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "default_opt_outs_agent_id_agents_id_fk": { + "name": "default_opt_outs_agent_id_agents_id_fk", + "tableFrom": "default_opt_outs", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "default_opt_outs_owner_user_id_agent_id_feature_scope_pk": { + "name": "default_opt_outs_owner_user_id_agent_id_feature_scope_pk", + "columns": [ + "owner_user_id", + "agent_id", + "feature", + "scope" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "default_opt_outs_feature_check": { + "name": "default_opt_outs_feature_check", + "value": "\"default_opt_outs\".\"feature\" IN ('memory_source', 'proactive_research')" + } + }, + "isRLSEnabled": false + }, + "public.proactive_settings": { + "name": "proactive_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "focus": { + "name": "focus", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "interval_minutes": { + "name": "interval_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 240 + }, + "next_run_at": { + "name": "next_run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_status": { + "name": "last_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'idle'" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "proactive_settings_owner_agent_idx": { + "name": "proactive_settings_owner_agent_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "proactive_settings_due_idx": { + "name": "proactive_settings_due_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "proactive_settings_owner_user_id_users_id_fk": { + "name": "proactive_settings_owner_user_id_users_id_fk", + "tableFrom": "proactive_settings", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "proactive_settings_agent_id_agents_id_fk": { + "name": "proactive_settings_agent_id_agents_id_fk", + "tableFrom": "proactive_settings", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "proactive_settings_interval_check": { + "name": "proactive_settings_interval_check", + "value": "\"proactive_settings\".\"interval_minutes\" >= 60 AND \"proactive_settings\".\"interval_minutes\" <= 1440" + }, + "proactive_settings_status_check": { + "name": "proactive_settings_status_check", + "value": "\"proactive_settings\".\"last_status\" IN ('idle', 'running', 'succeeded', 'error')" + } + }, + "isRLSEnabled": false + }, + "public.proactive_suggestions": { + "name": "proactive_suggestions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "setting_id": { + "name": "setting_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "detail": { + "name": "detail", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_app": { + "name": "source_app", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_ref": { + "name": "source_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_link": { + "name": "source_link", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "delivered_at": { + "name": "delivered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "proactive_suggestions_owner_idx": { + "name": "proactive_suggestions_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "proactive_suggestions_run_idx": { + "name": "proactive_suggestions_run_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "proactive_suggestions_owner_user_id_users_id_fk": { + "name": "proactive_suggestions_owner_user_id_users_id_fk", + "tableFrom": "proactive_suggestions", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "proactive_suggestions_agent_id_agents_id_fk": { + "name": "proactive_suggestions_agent_id_agents_id_fk", + "tableFrom": "proactive_suggestions", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "proactive_suggestions_setting_id_proactive_settings_id_fk": { + "name": "proactive_suggestions_setting_id_proactive_settings_id_fk", + "tableFrom": "proactive_suggestions", + "tableTo": "proactive_settings", + "columnsFrom": [ + "setting_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "proactive_suggestions_status_check": { + "name": "proactive_suggestions_status_check", + "value": "\"proactive_suggestions\".\"status\" IN ('open', 'dismissed', 'started')" + } + }, + "isRLSEnabled": false + }, + "public.demonstrations": { + "name": "demonstrations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bot_id": { + "name": "bot_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'recording'" + }, + "actions": { + "name": "actions", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "draft": { + "name": "draft", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "skill_slug": { + "name": "skill_slug", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "demonstrations_owner_idx": { + "name": "demonstrations_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "demonstrations_active_bot_idx": { + "name": "demonstrations_active_bot_idx", + "columns": [ + { + "expression": "bot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"demonstrations\".\"status\" = 'recording'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "demonstrations_owner_user_id_users_id_fk": { + "name": "demonstrations_owner_user_id_users_id_fk", + "tableFrom": "demonstrations", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "demonstrations_bot_id_agents_id_fk": { + "name": "demonstrations_bot_id_agents_id_fk", + "tableFrom": "demonstrations", + "tableTo": "agents", + "columnsFrom": [ + "bot_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.delivery_bindings": { + "name": "delivery_bindings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "transport": { + "name": "transport", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "identity": { + "name": "identity", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "realm": { + "name": "realm", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mention_id": { + "name": "mention_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "opted_out_at": { + "name": "opted_out_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "delivery_binding_identity_idx": { + "name": "delivery_binding_identity_idx", + "columns": [ + { + "expression": "transport", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "realm", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "identity", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "delivery_bindings_owner_idx": { + "name": "delivery_bindings_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "delivery_bindings_owner_user_id_users_id_fk": { + "name": "delivery_bindings_owner_user_id_users_id_fk", + "tableFrom": "delivery_bindings", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "delivery_bindings_channel_id_channels_id_fk": { + "name": "delivery_bindings_channel_id_channels_id_fk", + "tableFrom": "delivery_bindings", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "delivery_bindings_agent_id_agents_id_fk": { + "name": "delivery_bindings_agent_id_agents_id_fk", + "tableFrom": "delivery_bindings", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.delivery_challenges": { + "name": "delivery_challenges", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "transport": { + "name": "transport", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "consumed_at": { + "name": "consumed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "delivery_challenges_owner_user_id_users_id_fk": { + "name": "delivery_challenges_owner_user_id_users_id_fk", + "tableFrom": "delivery_challenges", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "delivery_challenges_channel_id_channels_id_fk": { + "name": "delivery_challenges_channel_id_channels_id_fk", + "tableFrom": "delivery_challenges", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "delivery_challenges_agent_id_agents_id_fk": { + "name": "delivery_challenges_agent_id_agents_id_fk", + "tableFrom": "delivery_challenges", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.delivery_inbox": { + "name": "delivery_inbox", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "binding_id": { + "name": "binding_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "realm": { + "name": "realm", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "delivery_inbox_dedupe_idx": { + "name": "delivery_inbox_dedupe_idx", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "realm", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "delivery_inbox_owner_idx": { + "name": "delivery_inbox_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "delivery_inbox_owner_user_id_users_id_fk": { + "name": "delivery_inbox_owner_user_id_users_id_fk", + "tableFrom": "delivery_inbox", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "delivery_inbox_channel_id_channels_id_fk": { + "name": "delivery_inbox_channel_id_channels_id_fk", + "tableFrom": "delivery_inbox", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "delivery_inbox_agent_id_agents_id_fk": { + "name": "delivery_inbox_agent_id_agents_id_fk", + "tableFrom": "delivery_inbox", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.delivery_outbox": { + "name": "delivery_outbox", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dedupe_key": { + "name": "dedupe_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "binding_id": { + "name": "binding_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "device_id": { + "name": "device_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "transport": { + "name": "transport", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "delivery_outbox_owner_idx": { + "name": "delivery_outbox_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "delivery_outbox_provider_idx": { + "name": "delivery_outbox_provider_idx", + "columns": [ + { + "expression": "transport", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "delivery_outbox_owner_user_id_users_id_fk": { + "name": "delivery_outbox_owner_user_id_users_id_fk", + "tableFrom": "delivery_outbox", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "delivery_outbox_channel_id_channels_id_fk": { + "name": "delivery_outbox_channel_id_channels_id_fk", + "tableFrom": "delivery_outbox", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "delivery_outbox_agent_id_agents_id_fk": { + "name": "delivery_outbox_agent_id_agents_id_fk", + "tableFrom": "delivery_outbox", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "delivery_outbox_dedupe_key_unique": { + "name": "delivery_outbox_dedupe_key_unique", + "nullsNotDistinct": false, + "columns": [ + "dedupe_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.push_devices": { + "name": "push_devices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "platform": { + "name": "platform", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "push_devices_owner_idx": { + "name": "push_devices_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "push_devices_owner_user_id_users_id_fk": { + "name": "push_devices_owner_user_id_users_id_fk", + "tableFrom": "push_devices", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "push_devices_token_unique": { + "name": "push_devices_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.group_bot_threads": { + "name": "group_bot_threads", + "schema": "", + "columns": { + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "group_bot_threads_thread_idx": { + "name": "group_bot_threads_thread_idx", + "columns": [ + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "group_bot_threads_owner_user_id_users_id_fk": { + "name": "group_bot_threads_owner_user_id_users_id_fk", + "tableFrom": "group_bot_threads", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "group_bot_threads_channel_id_channels_id_fk": { + "name": "group_bot_threads_channel_id_channels_id_fk", + "tableFrom": "group_bot_threads", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "group_bot_threads_owner_user_id_channel_id_agent_id_pk": { + "name": "group_bot_threads_owner_user_id_channel_id_agent_id_pk", + "columns": [ + "owner_user_id", + "channel_id", + "agent_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.group_messages": { + "name": "group_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'completed'" + }, + "details": { + "name": "details", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "group_messages_channel_idx": { + "name": "group_messages_channel_idx", + "columns": [ + { + "expression": "channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "group_messages_channel_id_channels_id_fk": { + "name": "group_messages_channel_id_channels_id_fk", + "tableFrom": "group_messages", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "group_messages_owner_user_id_users_id_fk": { + "name": "group_messages_owner_user_id_users_id_fk", + "tableFrom": "group_messages", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "group_messages_status_check": { + "name": "group_messages_status_check", + "value": "\"group_messages\".\"status\" IN ('queued', 'running', 'waiting', 'completed', 'failed')" + } + }, + "isRLSEnabled": false + }, + "public.action_records": { + "name": "action_records", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "surface": { + "name": "surface", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bot_id": { + "name": "bot_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "command": { + "name": "command", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "action_records_created_idx": { + "name": "action_records_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "action_records_bot_idx": { + "name": "action_records_bot_idx", + "columns": [ + { + "expression": "bot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "action_records_surface_check": { + "name": "action_records_surface_check", + "value": "\"action_records\".\"surface\" IN ('cloud', 'local')" + } + }, + "isRLSEnabled": false + }, + "public.capability_settings": { + "name": "capability_settings", + "schema": "", + "columns": { + "scope_kind": { + "name": "scope_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_id": { + "name": "scope_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "capability": { + "name": "capability", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "allowed": { + "name": "allowed", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "capability_settings_scope_kind_scope_id_capability_pk": { + "name": "capability_settings_scope_kind_scope_id_capability_pk", + "columns": [ + "scope_kind", + "scope_id", + "capability" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "capability_settings_scope_kind_check": { + "name": "capability_settings_scope_kind_check", + "value": "\"capability_settings\".\"scope_kind\" IN ('organization', 'role', 'group')" + } + }, + "isRLSEnabled": false + }, + "public.enterprise_settings": { + "name": "enterprise_settings", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "value": { + "name": "value", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.model_usage": { + "name": "model_usage", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "allowed": { + "name": "allowed", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "model_usage_created_idx": { + "name": "model_usage_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "model_usage_model_idx": { + "name": "model_usage_model_idx", + "columns": [ + { + "expression": "model", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "model_usage_source_check": { + "name": "model_usage_source_check", + "value": "\"model_usage\".\"source\" IN ('configured', 'observed', 'unknown')" + } + }, + "isRLSEnabled": false + }, + "public.network_policies": { + "name": "network_policies", + "schema": "", + "columns": { + "scope_kind": { + "name": "scope_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_id": { + "name": "scope_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "rules": { + "name": "rules", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{\"entries\":[]}'::jsonb" + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "network_policies_scope_kind_scope_id_pk": { + "name": "network_policies_scope_kind_scope_id_pk", + "columns": [ + "scope_kind", + "scope_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "network_policies_scope_kind_check": { + "name": "network_policies_scope_kind_check", + "value": "\"network_policies\".\"scope_kind\" IN ('organization', 'group')" + }, + "network_policies_mode_check": { + "name": "network_policies_mode_check", + "value": "\"network_policies\".\"mode\" IN ('allow_all', 'defaults_plus_allowlist', 'allowlist_only')" + } + }, + "isRLSEnabled": false + }, + "public.scim_connection_bindings": { + "name": "scim_connection_bindings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connection_key": { + "name": "connection_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provisioning_domain_id": { + "name": "provisioning_domain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "decommissioned_at": { + "name": "decommissioned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "decommission_status": { + "name": "decommission_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "decommission_cursor_user_id": { + "name": "decommission_cursor_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "decommission_reconciled_user_count": { + "name": "decommission_reconciled_user_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "decommission_batch_count": { + "name": "decommission_batch_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "decommission_revision": { + "name": "decommission_revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "decommission_completed_at": { + "name": "decommission_completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "decommission_lease_id": { + "name": "decommission_lease_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "decommission_lease_expires_at": { + "name": "decommission_lease_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "scim_connection_bindings_connection_idx": { + "name": "scim_connection_bindings_connection_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_connection_bindings_connection_key_unique": { + "name": "scim_connection_bindings_connection_key_unique", + "nullsNotDistinct": false, + "columns": [ + "connection_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_group_members": { + "name": "scim_group_members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scim_user_id": { + "name": "scim_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "membership_key": { + "name": "membership_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "scim_group_members_connection_idx": { + "name": "scim_group_members_connection_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_group_members_group_idx": { + "name": "scim_group_members_group_idx", + "columns": [ + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_group_members_scim_user_idx": { + "name": "scim_group_members_scim_user_idx", + "columns": [ + { + "expression": "scim_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_group_members_group_id_scim_groups_id_fk": { + "name": "scim_group_members_group_id_scim_groups_id_fk", + "tableFrom": "scim_group_members", + "tableTo": "scim_groups", + "columnsFrom": [ + "group_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "scim_group_members_scim_user_id_scim_users_id_fk": { + "name": "scim_group_members_scim_user_id_scim_users_id_fk", + "tableFrom": "scim_group_members", + "tableTo": "scim_users", + "columnsFrom": [ + "scim_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_group_members_membership_key_unique": { + "name": "scim_group_members_membership_key_unique", + "nullsNotDistinct": false, + "columns": [ + "membership_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_groups": { + "name": "scim_groups", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provisioning_domain_id": { + "name": "provisioning_domain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name_key": { + "name": "display_name_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id_key": { + "name": "external_id_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "order_key": { + "name": "order_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "scim_groups_connection_idx": { + "name": "scim_groups_connection_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_groups_domain_idx": { + "name": "scim_groups_domain_idx", + "columns": [ + { + "expression": "provisioning_domain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_groups_display_name_key_unique": { + "name": "scim_groups_display_name_key_unique", + "nullsNotDistinct": false, + "columns": [ + "display_name_key" + ] + }, + "scim_groups_external_id_key_unique": { + "name": "scim_groups_external_id_key_unique", + "nullsNotDistinct": false, + "columns": [ + "external_id_key" + ] + }, + "scim_groups_order_key_unique": { + "name": "scim_groups_order_key_unique", + "nullsNotDistinct": false, + "columns": [ + "order_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_identity_tombstones": { + "name": "scim_identity_tombstones", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provisioning_domain_id": { + "name": "provisioning_domain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id_key": { + "name": "external_id_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "profile": { + "name": "profile", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "scim_identity_tombstones_connection_idx": { + "name": "scim_identity_tombstones_connection_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_identity_tombstones_domain_idx": { + "name": "scim_identity_tombstones_domain_idx", + "columns": [ + { + "expression": "provisioning_domain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_identity_tombstones_user_idx": { + "name": "scim_identity_tombstones_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_identity_tombstones_user_id_users_id_fk": { + "name": "scim_identity_tombstones_user_id_users_id_fk", + "tableFrom": "scim_identity_tombstones", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_identity_tombstones_external_id_key_unique": { + "name": "scim_identity_tombstones_external_id_key_unique", + "nullsNotDistinct": false, + "columns": [ + "external_id_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_projection_grants": { + "name": "scim_projection_grants", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provisioning_domain_id": { + "name": "provisioning_domain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scim_user_id": { + "name": "scim_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_kind": { + "name": "source_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_value": { + "name": "source_value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "grant_key": { + "name": "grant_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "scim_projection_grants_connection_idx": { + "name": "scim_projection_grants_connection_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_projection_grants_domain_idx": { + "name": "scim_projection_grants_domain_idx", + "columns": [ + { + "expression": "provisioning_domain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_projection_grants_scim_user_idx": { + "name": "scim_projection_grants_scim_user_idx", + "columns": [ + { + "expression": "scim_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_projection_grants_user_idx": { + "name": "scim_projection_grants_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_projection_grants_scim_user_id_scim_users_id_fk": { + "name": "scim_projection_grants_scim_user_id_scim_users_id_fk", + "tableFrom": "scim_projection_grants", + "tableTo": "scim_users", + "columnsFrom": [ + "scim_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "scim_projection_grants_user_id_users_id_fk": { + "name": "scim_projection_grants_user_id_users_id_fk", + "tableFrom": "scim_projection_grants", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_projection_grants_grant_key_unique": { + "name": "scim_projection_grants_grant_key_unique", + "nullsNotDistinct": false, + "columns": [ + "grant_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_subjects": { + "name": "scim_subjects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "profile_source_id": { + "name": "profile_source_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "scim_subjects_profile_source_idx": { + "name": "scim_subjects_profile_source_idx", + "columns": [ + { + "expression": "profile_source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_subjects_user_id_users_id_fk": { + "name": "scim_subjects_user_id_users_id_fk", + "tableFrom": "scim_subjects", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_subjects_user_id_unique": { + "name": "scim_subjects_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_users": { + "name": "scim_users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provisioning_domain_id": { + "name": "provisioning_domain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connection_user_key": { + "name": "connection_user_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_name": { + "name": "user_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_name_key": { + "name": "user_name_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "primary_email": { + "name": "primary_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "work_email_value_index": { + "name": "work_email_value_index", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_value_index": { + "name": "email_value_index", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "formatted_name": { + "name": "formatted_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "given_name": { + "name": "given_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "family_name": { + "name": "family_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "serialized_emails": { + "name": "serialized_emails", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "serialized_attributes": { + "name": "serialized_attributes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id_key": { + "name": "external_id_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "order_key": { + "name": "order_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "scim_users_connection_idx": { + "name": "scim_users_connection_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_users_domain_idx": { + "name": "scim_users_domain_idx", + "columns": [ + { + "expression": "provisioning_domain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_users_user_idx": { + "name": "scim_users_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_users_user_id_users_id_fk": { + "name": "scim_users_user_id_users_id_fk", + "tableFrom": "scim_users", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_users_connection_user_key_unique": { + "name": "scim_users_connection_user_key_unique", + "nullsNotDistinct": false, + "columns": [ + "connection_user_key" + ] + }, + "scim_users_user_name_key_unique": { + "name": "scim_users_user_name_key_unique", + "nullsNotDistinct": false, + "columns": [ + "user_name_key" + ] + }, + "scim_users_external_id_key_unique": { + "name": "scim_users_external_id_key_unique", + "nullsNotDistinct": false, + "columns": [ + "external_id_key" + ] + }, + "scim_users_order_key_unique": { + "name": "scim_users_order_key_unique", + "nullsNotDistinct": false, + "columns": [ + "order_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.bot_lifecycle": { + "name": "bot_lifecycle", + "schema": "", + "columns": { + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "paused_at": { + "name": "paused_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "notify": { + "name": "notify", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'all'" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "bot_lifecycle_owner_user_id_users_id_fk": { + "name": "bot_lifecycle_owner_user_id_users_id_fk", + "tableFrom": "bot_lifecycle", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "bot_lifecycle_agent_id_agents_id_fk": { + "name": "bot_lifecycle_agent_id_agents_id_fk", + "tableFrom": "bot_lifecycle", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "bot_lifecycle_owner_user_id_agent_id_pk": { + "name": "bot_lifecycle_owner_user_id_agent_id_pk", + "columns": [ + "owner_user_id", + "agent_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.update_routing_preferences": { + "name": "update_routing_preferences", + "schema": "", + "columns": { + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "transports": { + "name": "transports", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "update_routing_preferences_owner_user_id_users_id_fk": { + "name": "update_routing_preferences_owner_user_id_users_id_fk", + "tableFrom": "update_routing_preferences", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "update_routing_preferences_owner_user_id_kind_pk": { + "name": "update_routing_preferences_owner_user_id_kind_pk", + "columns": [ + "owner_user_id", + "kind" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.saved_logins": { + "name": "saved_logins", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_password": { + "name": "encrypted_password", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "saved_logins_owner_origin_username_idx": { + "name": "saved_logins_owner_origin_username_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "origin", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "username", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "saved_logins_owner_idx": { + "name": "saved_logins_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "saved_logins_owner_user_id_users_id_fk": { + "name": "saved_logins_owner_user_id_users_id_fk", + "tableFrom": "saved_logins", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sign_in_requests": { + "name": "sign_in_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bot_id": { + "name": "bot_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "method": { + "name": "method", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tool_call_id": { + "name": "tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "control_request_id": { + "name": "control_request_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "continuation": { + "name": "continuation", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "filling_until": { + "name": "filling_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "sign_in_requests_owner_idx": { + "name": "sign_in_requests_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sign_in_requests_owner_user_id_users_id_fk": { + "name": "sign_in_requests_owner_user_id_users_id_fk", + "tableFrom": "sign_in_requests", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "sign_in_requests_status_check": { + "name": "sign_in_requests_status_check", + "value": "\"sign_in_requests\".\"status\" IN ('pending', 'filling', 'code_required', 'taken_over', 'signed_in', 'failed', 'cancelled', 'expired')" + }, + "sign_in_requests_method_check": { + "name": "sign_in_requests_method_check", + "value": "\"sign_in_requests\".\"method\" IS NULL OR \"sign_in_requests\".\"method\" IN ('typed', 'saved', 'takeover')" + } + }, + "isRLSEnabled": false + }, + "public.team_bot_assignments": { + "name": "team_bot_assignments", + "schema": "", + "columns": { + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "group_name": { + "name": "group_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "assigned_by": { + "name": "assigned_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "assigned_at": { + "name": "assigned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "team_bot_assignments_agent_id_agents_id_fk": { + "name": "team_bot_assignments_agent_id_agents_id_fk", + "tableFrom": "team_bot_assignments", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "team_bot_assignments_assigned_by_users_id_fk": { + "name": "team_bot_assignments_assigned_by_users_id_fk", + "tableFrom": "team_bot_assignments", + "tableTo": "users", + "columnsFrom": [ + "assigned_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "team_bot_assignments_agent_id_group_name_pk": { + "name": "team_bot_assignments_agent_id_group_name_pk", + "columns": [ + "agent_id", + "group_name" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.team_bot_audience": { + "name": "team_bot_audience", + "schema": "", + "columns": { + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "team_bot_audience_agent_id_agents_id_fk": { + "name": "team_bot_audience_agent_id_agents_id_fk", + "tableFrom": "team_bot_audience", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "team_bot_audience_agent_id_kind_value_pk": { + "name": "team_bot_audience_agent_id_kind_value_pk", + "columns": [ + "agent_id", + "kind", + "value" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "team_bot_audience_kind_check": { + "name": "team_bot_audience_kind_check", + "value": "\"team_bot_audience\".\"kind\" IN ('user', 'group')" + } + }, + "isRLSEnabled": false + }, + "public.team_bot_consent_defaults": { + "name": "team_bot_consent_defaults", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "team_bot_consent_defaults_user_id_users_id_fk": { + "name": "team_bot_consent_defaults_user_id_users_id_fk", + "tableFrom": "team_bot_consent_defaults", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "team_bot_consent_defaults_user_id_server_id_pk": { + "name": "team_bot_consent_defaults_user_id_server_id_pk", + "columns": [ + "user_id", + "server_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.team_bot_consents": { + "name": "team_bot_consents", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "decision": { + "name": "decision", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "team_bot_consents_user_id_users_id_fk": { + "name": "team_bot_consents_user_id_users_id_fk", + "tableFrom": "team_bot_consents", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "team_bot_consents_agent_id_agents_id_fk": { + "name": "team_bot_consents_agent_id_agents_id_fk", + "tableFrom": "team_bot_consents", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "team_bot_consents_user_id_agent_id_server_id_pk": { + "name": "team_bot_consents_user_id_agent_id_server_id_pk", + "columns": [ + "user_id", + "agent_id", + "server_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "team_bot_consents_decision_check": { + "name": "team_bot_consents_decision_check", + "value": "\"team_bot_consents\".\"decision\" IN ('always', 'once')" + } + }, + "isRLSEnabled": false + }, + "public.team_bot_publications": { + "name": "team_bot_publications", + "schema": "", + "columns": { + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "published_by": { + "name": "published_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "audience": { + "name": "audience", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "team_bot_publications_agent_id_agents_id_fk": { + "name": "team_bot_publications_agent_id_agents_id_fk", + "tableFrom": "team_bot_publications", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "team_bot_publications_published_by_users_id_fk": { + "name": "team_bot_publications_published_by_users_id_fk", + "tableFrom": "team_bot_publications", + "tableTo": "users", + "columnsFrom": [ + "published_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "team_bot_publications_audience_check": { + "name": "team_bot_publications_audience_check", + "value": "\"team_bot_publications\".\"audience\" IN ('team', 'people')" + } + }, + "isRLSEnabled": false + }, + "public.shared_use_approval_members": { + "name": "shared_use_approval_members", + "schema": "", + "columns": { + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "shared_use_approval_members_approval_fk": { + "name": "shared_use_approval_members_approval_fk", + "tableFrom": "shared_use_approval_members", + "tableTo": "shared_use_approvals", + "columnsFrom": [ + "agent_id", + "server_id" + ], + "columnsTo": [ + "agent_id", + "server_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "shared_use_approval_members_agent_id_server_id_kind_value_pk": { + "name": "shared_use_approval_members_agent_id_server_id_kind_value_pk", + "columns": [ + "agent_id", + "server_id", + "kind", + "value" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "shared_use_approval_members_kind_check": { + "name": "shared_use_approval_members_kind_check", + "value": "\"shared_use_approval_members\".\"kind\" IN ('user', 'group')" + } + }, + "isRLSEnabled": false + }, + "public.shared_use_approvals": { + "name": "shared_use_approvals", + "schema": "", + "columns": { + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "audience": { + "name": "audience", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "outside_input": { + "name": "outside_input", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "approved_by": { + "name": "approved_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "approved_at": { + "name": "approved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "shared_use_approvals_agent_id_agents_id_fk": { + "name": "shared_use_approvals_agent_id_agents_id_fk", + "tableFrom": "shared_use_approvals", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_use_approvals_server_id_mcp_servers_id_fk": { + "name": "shared_use_approvals_server_id_mcp_servers_id_fk", + "tableFrom": "shared_use_approvals", + "tableTo": "mcp_servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "shared_use_approvals_agent_id_server_id_pk": { + "name": "shared_use_approvals_agent_id_server_id_pk", + "columns": [ + "agent_id", + "server_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "shared_use_approvals_audience_check": { + "name": "shared_use_approvals_audience_check", + "value": "\"shared_use_approvals\".\"audience\" IN ('owner', 'people', 'team')" + } + }, + "isRLSEnabled": false + }, + "public.shared_use_requests": { + "name": "shared_use_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "proposed_audience": { + "name": "proposed_audience", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "proposed_outside_input": { + "name": "proposed_outside_input", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "proposed_members": { + "name": "proposed_members", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "requested_by": { + "name": "requested_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "decided_by": { + "name": "decided_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "decided_at": { + "name": "decided_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "shared_use_requests_pending_idx": { + "name": "shared_use_requests_pending_idx", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"shared_use_requests\".\"status\" = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "shared_use_requests_status_idx": { + "name": "shared_use_requests_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "shared_use_requests_agent_id_agents_id_fk": { + "name": "shared_use_requests_agent_id_agents_id_fk", + "tableFrom": "shared_use_requests", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_use_requests_server_id_mcp_servers_id_fk": { + "name": "shared_use_requests_server_id_mcp_servers_id_fk", + "tableFrom": "shared_use_requests", + "tableTo": "mcp_servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "shared_use_requests_audience_check": { + "name": "shared_use_requests_audience_check", + "value": "\"shared_use_requests\".\"proposed_audience\" IN ('owner', 'people', 'team')" + }, + "shared_use_requests_reason_check": { + "name": "shared_use_requests_reason_check", + "value": "\"shared_use_requests\".\"reason\" IN ('publish', 'trigger', 'grant', 'refused_call')" + }, + "shared_use_requests_status_check": { + "name": "shared_use_requests_status_check", + "value": "\"shared_use_requests\".\"status\" IN ('pending', 'approved', 'declined', 'superseded')" + } + }, + "isRLSEnabled": false + }, + "public.identity_link_challenges": { + "name": "identity_link_challenges", + "schema": "", + "columns": { + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "identity_link_challenges_expiry_idx": { + "name": "identity_link_challenges_expiry_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "identity_link_challenges_user_provider_idx": { + "name": "identity_link_challenges_user_provider_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.identity_links": { + "name": "identity_links", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "realm": { + "name": "realm", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "handle": { + "name": "handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "verified_by": { + "name": "verified_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "identity_links_identity_idx": { + "name": "identity_links_identity_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "realm", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "identity_links_user_realm_idx": { + "name": "identity_links_user_realm_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "realm", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "identity_links_credential_idx": { + "name": "identity_links_credential_idx", + "columns": [ + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"identity_links\".\"credential_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "identity_links_credential_id_credentials_id_fk": { + "name": "identity_links_credential_id_credentials_id_fk", + "tableFrom": "identity_links", + "tableTo": "credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "identity_links_verified_by_check": { + "name": "identity_links_verified_by_check", + "value": "\"identity_links\".\"verified_by\" IN ('challenge', 'oauth')" + }, + "identity_links_status_check": { + "name": "identity_links_status_check", + "value": "\"identity_links\".\"status\" IN ('active', 'needs_reconnect')" + } + }, + "isRLSEnabled": false + } + }, + "enums": { + "public.agent_type": { + "name": "agent_type", + "schema": "public", + "values": [ + "built_in", + "remote_ag_ui", + "remote_mastra" + ] + }, + "public.credential_kind": { + "name": "credential_kind", + "schema": "public", + "values": [ + "model", + "connector", + "agent", + "mcp", + "mcp_oauth_client", + "mcp_user_token" + ] + }, + "public.role": { + "name": "role", + "schema": "public", + "values": [ + "admin", + "user" + ] + }, + "public.agent_visibility": { + "name": "agent_visibility", + "schema": "public", + "values": [ + "public", + "private" + ] + }, + "public.routine_run_status": { + "name": "routine_run_status", + "schema": "public", + "values": [ + "succeeded", + "failed", + "skipped", + "waiting" + ] + }, + "public.responsibility_run_status": { + "name": "responsibility_run_status", + "schema": "public", + "values": [ + "queued", + "running", + "waiting", + "succeeded", + "failed", + "skipped" + ] + }, + "public.responsibility_status": { + "name": "responsibility_status", + "schema": "public", + "values": [ + "active", + "paused", + "completed" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/server/drizzle/meta/_journal.json b/server/drizzle/meta/_journal.json index a1c2ae38a4..87ab84da4d 100644 --- a/server/drizzle/meta/_journal.json +++ b/server/drizzle/meta/_journal.json @@ -421,6 +421,13 @@ "when": 1792251361575, "tag": "0059_skipped_tools", "breakpoints": true + }, + { + "idx": 60, + "version": "7", + "when": 1792608466985, + "tag": "0060_app_access_requests", + "breakpoints": true } ] } \ No newline at end of file diff --git a/server/src/audit.ts b/server/src/audit.ts index 85d91e70a9..441c2f5902 100644 --- a/server/src/audit.ts +++ b/server/src/audit.ts @@ -268,6 +268,18 @@ export const auditEventTypes = [ "shared_use.approved", // An administrator answered a request to use a Shared app through a Bot. "shared_use.declined", + /* + * A person asking an administrator to switch a work app on, grant it to a Bot, or connect a + * Shared app's team account (`app_access_requests`), and what became of it: cancelled by the + * person, approved or dismissed by an administrator, or refused — a request that could not be + * filed, or an approval that could not be carried out. Target the app key; payload `request`, + * `app`, `bot`, `kind`, and for a refusal `reason`. + */ + "app_access.requested", + "app_access.cancelled", + "app_access.approved", + "app_access.dismissed", + "app_access.refused", // Every action a Bot takes on its computer, allowed or refused. Both, always: a trail that records // only what was permitted cannot answer whether the Bot tried. "computer.action_allowed", diff --git a/server/src/db/schema/plugins.ts b/server/src/db/schema/plugins.ts index 85232e3259..3c64f186f4 100644 --- a/server/src/db/schema/plugins.ts +++ b/server/src/db/schema/plugins.ts @@ -870,3 +870,73 @@ export const sandboxedComponents = pgTable("sandboxed_components", { createdAt: createdAt(), updatedAt: updatedAt(), }); + +/** + * A person asking an administrator to make one work app usable through one Bot. + * + * WHAT IS ASKED FOR IS THE SERVER'S, NEVER THE BROWSER'S. `kind` is derived from the app's state for + * the person and the Bot at the moment of asking — switched off is `enable`, held by no grant is + * `grant`, a Shared app with no team account is `connect_shared` — so a request cannot be worded as + * something the deployment does not actually need, and approving it does exactly the one thing + * that state was missing. + * + * `app_key` IS THE SERVER ID THE APP HAS OR WOULD HAVE, and is not a foreign key. A request to + * switch an app on names an app with no `mcp_servers` row yet — `composio-` for a Composio app, + * the catalogue key for one this deployment ships — and is the row's id the moment it is enabled, + * so one key follows the app across the change being asked for. `app_title` is the app's name as + * the server resolved it when the request was filed, so an administrator's list can name an app + * that has no row to read the name from. + * + * `agent_id` IS NULLABLE FOR A REQUEST ABOUT THE APP ALONE. Every path that files one today names + * the Bot, so approving a request to switch an app on can grant it to that Bot in the same step; + * the column admits none because "switch this on" is a complete question without one. + * + * ONE PENDING REQUEST PER (PERSON, APP, BOT, KIND), held by the partial unique index rather than by + * a read-then-write, so two replicas filing the same request at once cannot both insert. The Bot is + * read through `coalesce` because Postgres treats two NULLs as distinct, and an index on the bare + * column would let the same app-only request pile up. + * + * NO ROW IS DELETED WHEN IT IS ANSWERED. Approved, dismissed and cancelled requests stay, with who + * resolved them and when; the trail says the same thing in its own rows. + */ +export const appAccessRequests = pgTable( + "app_access_requests", + { + id: text("id").primaryKey(), + requestedBy: text("requested_by") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + agentId: text("agent_id").references(() => agents.id, { + onDelete: "cascade", + }), + appKey: text("app_key").notNull(), + appTitle: text("app_title").notNull(), + kind: text("kind").$type<"enable" | "grant" | "connect_shared">().notNull(), + status: text("status") + .$type<"pending" | "approved" | "dismissed" | "cancelled">() + .notNull() + .default("pending"), + createdAt: createdAt(), + resolvedAt: timestamp("resolved_at", { withTimezone: true }), + resolvedBy: text("resolved_by"), + }, + (table) => [ + uniqueIndex("app_access_requests_pending_idx") + .on( + table.requestedBy, + table.appKey, + sql`coalesce(${table.agentId}, '')`, + table.kind, + ) + .where(sql`${table.status} = 'pending'`), + index("app_access_requests_status_idx").on(table.status, table.appKey), + check( + "app_access_requests_kind_check", + sql`${table.kind} IN ('enable', 'grant', 'connect_shared')`, + ), + check( + "app_access_requests_status_check", + sql`${table.status} IN ('pending', 'approved', 'dismissed', 'cancelled')`, + ), + ], +); diff --git a/server/src/index.ts b/server/src/index.ts index 7f43b5ca20..77faeca575 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -180,6 +180,7 @@ import { createIntelligenceMemory } from "./memory/intelligence"; import { createMemoryStore } from "./memory/store"; import { reportModelAvailability } from "./model-availability-check"; import { memoryTools } from "./memory/tools"; +import { appAccessTools } from "./plugins/app-access-tool"; import { createSignInNotifier, createSignInResumer } from "./passwords/resume"; import { createSignInService } from "./passwords/service"; import { createPasswordStore } from "./passwords/store"; @@ -1364,6 +1365,18 @@ const loadToolsForActor = ownerUserId: actorId, agentId: botId, }), + /* + * Held by every Bot, granted or not: how a Bot puts an app it cannot reach in front of the + * person with the step that fixes it, instead of naming an administrator. A read; see the file. + */ + ...appAccessTools({ + store: pluginStore, + botId, + actorId, + composioConfigured: Boolean(composio), + ...(composio ? { directory: () => composio.broker.listApps() } : {}), + sharedAudience: sharedUseStore.admitsPerson, + }), ...(await grantedTools({ store: pluginStore, botId, diff --git a/server/src/memory/ingestion.ts b/server/src/memory/ingestion.ts index f6cc574f0a..2ad0cf932d 100644 --- a/server/src/memory/ingestion.ts +++ b/server/src/memory/ingestion.ts @@ -1,4 +1,5 @@ import { createHash } from "node:crypto"; +import { readConnectPrompt } from "@openbot/shared/connect-prompt"; import { accessFor } from "../plugins/access"; import type { Decides } from "../plugins/broker"; import { catalogueEntry } from "../plugins/catalogue"; @@ -280,14 +281,37 @@ export function createMemoryIngestion(options: { }; } catch (error) { const message = - error instanceof MemoryRefusedError || error instanceof PluginRefusedError - ? error.message - : "The source could not sync. Try again or check the connected app."; + ? syncRefusal(error.message) + : error instanceof MemoryRefusedError + ? error.message + : "The source could not sync. Try again or check the connected app."; await store.failSync(ownerUserId, id, message); throw error; } } + /** + * What the Memory sources page says about a refusal: the refusal's own sentence, except one that + * carries an app card. + * + * A CARD MARKER IS FOR A TRANSCRIPT, AND THIS IS NOT ONE. The marker is a JSON prefix the chat + * draws as a card with a Connect button; stored as the sync error it reached the sources page + * raw, and the sentence after it is written for a conversation ("ask again"). So a refusal with a + * marker is said for this page instead, naming where the account is connected. + */ + function syncRefusal(text: string): string { + const prompt = readConnectPrompt(text); + if (!prompt) return text; + switch (prompt.reason) { + case "shared": + return `The shared ${prompt.title} account is not connected. An administrator has to connect it before this source can sync.`; + case "reconnect": + return `Your ${prompt.title} account needs reconnecting. Reconnect it under Settings, Connected accounts, then sync again.`; + default: + return `Your ${prompt.title} account is not connected. Connect it under Settings, Connected accounts, then sync again.`; + } + } + async function eligibleSources(ownerUserId: string, botId: string) { const sources = (await store.sources(ownerUserId)).filter( (source) => diff --git a/server/src/plugins/app-access-tool.ts b/server/src/plugins/app-access-tool.ts new file mode 100644 index 0000000000..c9c4ec25ab --- /dev/null +++ b/server/src/plugins/app-access-tool.ts @@ -0,0 +1,119 @@ +import { + connectAppRefusal, + SHOW_APP_ACCESS_TOOL, +} from "@openbot/shared/connect-prompt"; +import type { GrantedTool } from "@openbot/toolkit/granted-tool"; +import { z } from "zod"; +import type { AppAccess, AppAccessState } from "./app-access"; +import type { + AppDirectory, + SharedAudienceCheck, +} from "./plugin-store/app-access"; +import type { PluginStore } from "./store"; + +/** + * The tool every Bot holds for putting a work app's state in front of the person asking. + * + * WHY A TOOL AND NOT A SENTENCE. A Bot that met an app it does not hold used to be told to "say an + * administrator can grant it", and did — correctly, and uselessly: the person was handed the name of + * a role and no way to reach it, about an app the Bot could have had all along. The other ways an + * app is out of reach already drew a card, because each of them is a refused call and the refusal + * carries one (`@openbot/shared/connect-prompt`). An app the Bot holds nothing for is never called, + * so nothing was refused and there was nothing to draw. This is the call. + * + * THE ANSWER IS THE SAME MARKER A REFUSAL CARRIES, with `reason: "access"`, so the transcript draws + * it with the same card, and the card asks the server for the app's state for this person and this + * Bot when it is drawn — never trusting what this answer said, which is only what was true when the + * model asked. The sentence after the marker is what the model reads: the state in words, and what + * the card offers, so it does not go on to repeat the card at length. + * + * A READ, AND ONLY EVER ONE. Nothing here enables, grants or files anything — pressing Request + * access on the card does that, as the person, through the same route the Bot page uses, where the + * server decides what is asked for. So the tool is safe to hand every Bot: the worst a model can do + * with it is show somebody an accurate card about an app they did not ask about. + */ +export function appAccessTools(options: { + store: Pick; + botId: string; + actorId: string; + composioConfigured: boolean; + /** Names an app this deployment has no row for. Absent without the broker's key. */ + directory?: AppDirectory; + /** The shared-use gate's answer for this person, so a Shared app it would refuse says so. */ + sharedAudience?: SharedAudienceCheck; +}): GrantedTool[] { + const parameters = z + .object({ + app: z + .string() + .trim() + .min(1) + .max(200) + .describe( + "The app's name (Gmail, Slack, Google Drive) or its id as your instructions list it.", + ), + }) + .strict(); + return [ + { + name: SHOW_APP_ACCESS_TOOL, + ref: `app-access/${SHOW_APP_ACCESS_TOOL}`, + description: + "Show the person a card saying whether they can use one work app through you, and the one thing that would let them: connecting their own account, or asking an administrator to switch the app on, grant it to you, or connect its shared account. Call it whenever a question needs an app you hold no tools for, instead of only saying it is missing.", + parameters, + async execute(args) { + const parsed = parameters.safeParse(args); + if (!parsed.success) return "Name the app to show."; + const { app } = parsed.data; + const inspected = await options.store.inspectApp({ + app, + agentId: options.botId, + userId: options.actorId || null, + composioConfigured: options.composioConfigured, + ...(options.directory ? { directory: options.directory } : {}), + ...(options.sharedAudience + ? { sharedAudience: options.sharedAudience } + : {}), + }); + /* + * No card for an app nobody can name. A card about it would have nothing to ask the server + * and nothing to offer, and the model is better told plainly so it can say so. + */ + if (!inspected) + return `This deployment has no app called ${app}, so there is nothing to show. Say so plainly; do not browse to it instead.`; + const { access } = inspected; + return connectAppRefusal({ + serverId: access.key, + title: access.displayName, + agentId: options.botId, + reason: "access", + message: toldTheModel(access), + }); + }, + }, + ]; +} + +/** + * What the model reads about the card it just put on screen: the state in words, and that the card + * already offers the next step, so the reply around it is a sentence rather than a set of + * instructions the person can already see. + */ +function toldTheModel(access: AppAccess): string { + const name = access.displayName; + const owners = access.accountHolder === "owner"; + const said: Record = { + ready: access.asksFirst + ? `${name} is ready for this person to use through you, on their own account; the first call will ask them to allow that. Use its tools rather than the card.` + : `${name} is ready for this person to use through you. Use its tools rather than the card.`, + not_connected: `${name} is granted to you, and this person has not connected their own account. The card in front of them offers Connect; ask them to try again once they have.`, + reconnect: owners + ? `${name} is granted to you and runs on your owner's account, whose connection needs reconnecting. Only your owner can reconnect it; the card in front of the person says so.` + : `${name} is granted to you, and this person's connection needs reconnecting. The card in front of them offers Reconnect; ask them to try again once they have.`, + shared_not_connected: `${name} uses one shared account for this deployment, and an administrator has not connected it yet. The card in front of the person lets them ask an administrator for it.`, + shared_not_approved: `${name} uses one shared account for this deployment, and you reach more people than an administrator approved for it, so its calls are refused. Only an administrator can approve that; the card in front of the person says so.`, + disabled: `${name} is switched off on this deployment. The card in front of the person lets them ask an administrator to switch it on.`, + not_granted: `${name} is switched on, and you have not been granted it. The card in front of the person lets them ask an administrator to grant it to you.`, + }; + return `${said[access.state]} Do not browse to its website or ask them to sign in there.`; +} diff --git a/server/src/plugins/app-access.ts b/server/src/plugins/app-access.ts new file mode 100644 index 0000000000..749fe5a826 --- /dev/null +++ b/server/src/plugins/app-access.ts @@ -0,0 +1,268 @@ +import { type BrokerProvider, brokeredProvenance } from "./access"; +import { schemeKind } from "./broker"; +import type { CatalogueEntry } from "./catalogue"; + +/** + * Whether one person can use one work app through one Bot, and if not, what the next step is and + * whose it is. + * + * WHY THIS EXISTS AS ONE ANSWER. Every fact it reads already had a home: whether the app is + * enabled is a row in `mcp_servers`, whether the broker is configured is a key in the environment, + * whether the Bot holds the app is `plugin_grants`, whether the person connected is + * `brokered_connections` or `mcp_user_credentials`, and whether a Shared app has its team account + * is a deployment row in `brokered_connections`. Each screen that wanted to say "you can use this" + * re-derived it from whichever of those it had loaded, and the Bot page had none of the + * connection facts at all — so it listed what a Bot holds with no word about whether the person + * looking could use any of it. Resolved here, once, from the same tables every gate reads, and sent + * to the browser as a word it draws rather than a rule it recomputes. + * + * NOTHING HERE ASKS A VENDOR. "Connected" is what this deployment last recorded — the `verified` + * and `probe` columns a connect or a re-check wrote, the `revoked_at` a withdrawal set — exactly + * as `GET /api/plugins/connections` reports it. A connection that died at the vendor since then + * reads as `ready` until the next call or re-check finds out; asking the vendor on every page load + * would be a network call per app per person per render, which is the cost those columns exist to + * avoid. + */ + +/** + * The seven things a person can be told about one app on one Bot. + * + * `ready` — usable now, as far as this deployment knows. + * `not_connected` — enabled and held, and this app is used under each person's own account, which + * this person has not connected. Theirs to fix. + * `reconnect` — they connected once, and the connection is withdrawn or its key was refused. Theirs + * to fix. + * `shared_not_connected` — a Shared app whose one team account is not connected, or whose key was + * refused. Only an administrator can connect it. + * `shared_not_approved` — a Shared app whose team account is connected, on a Bot that reaches more + * people than an administrator approved for that account. Every call is refused by the shared-use + * gate, which files the administrator's request itself; nothing is left for a press to ask. + * `disabled` — switched off at the deployment: no row, the broker not configured, or an app that + * still needs something only an administrator can supply. + * `not_granted` — enabled, and this Bot holds none of its tools. Only an administrator can grant. + */ +export const APP_ACCESS_STATES = [ + "ready", + "not_connected", + "reconnect", + "shared_not_connected", + "shared_not_approved", + "disabled", + "not_granted", +] as const; + +export type AppAccessState = (typeof APP_ACCESS_STATES)[number]; + +/** What an administrator would be asked to do. See `app_access_requests`. */ +export type AppAccessRequestKind = "enable" | "grant" | "connect_shared"; + +/** + * Which request each state files, and null where the next step is the person's own. + * + * A TABLE RATHER THAN A BRANCH, so the compiler asks the question again for any state added later. + * The browser never names a kind: it asks for access, and the server files the kind this table + * names for the state it computed. + */ +export const REQUEST_KIND_FOR: Record< + AppAccessState, + AppAccessRequestKind | null +> = { + ready: null, + not_connected: null, + reconnect: null, + shared_not_connected: "connect_shared", + shared_not_approved: null, + disabled: "enable", + not_granted: "grant", +}; + +/** Whose account the app's calls land in. */ +export type AppAccount = + /** Each person's own, which each person connects. */ + | "personal" + /** One team account the deployment holds, which only an administrator connects. */ + | "shared" + /** No person's account: a deployment token, a server added by URL, or no credential at all. */ + | "deployment"; + +/** + * How the account is connected, for the screen that starts it: the deployment's own OAuth flow, a + * broker's consent screen, a broker's key form, or nothing to connect. + */ +export type AppConnect = "oauth" | "consent" | "key" | null; + +/** + * Where the app comes from, which decides what enabling it takes. + * + * A BROKERED APP IS NAMED BY ITS BROKER, `composio` or `arcade`, and every rule below asks + * {@link brokeredProvenance} rather than comparing to one of them, so the two are read alike. + */ +export type AppProvider = BrokerProvider | "catalogue" | "custom"; + +/** One app as the Bot page and the chat card draw it. */ +export type AppAccess = { + /** The server id the app has, or the one enabling it would mint. What a request names. */ + key: string; + /** + * The app's own name — "Gmail", never "Composio Gmail" and never a humanised `composio-gmail`. + * A brokered row's title is the directory's name for the app; a catalogue row's is the entry's. + */ + displayName: string; + logo: string | null; + provider: AppProvider; + account: AppAccount; + connect: AppConnect; + /** The row's recorded scheme, for a brokered app; null otherwise. */ + authScheme: string | null; + /** The app's mode, off its answering row; null for an app that is not brokered. */ + accountMode: "personal" | "shared" | null; + state: AppAccessState; + /** Why nobody can use the app, when `state` is `disabled`; null otherwise. See {@link Unavailable}. */ + unavailable: Unavailable | null; + /** + * Whose own account a personal app's calls go out on for this person: theirs, or — on somebody + * else's published Team Bot whose owner connected the app — the owner's, which only the owner can + * reconnect. Null where the app is not used under a person's own account. + * + * THE SAME ANSWER `credentialActorFor` GIVES A CALL, read without its side effects: the owner's + * account wherever the owner has one, and otherwise the person's own. + */ + accountHolder: "you" | "owner" | null; + /** + * Whether the first call on the person's own account through somebody else's Team Bot will stop + * to ask them to allow it, because they have given that Bot no standing consent. The state is + * still `ready`: the question is asked in the conversation, and answering it is the next step. + */ + asksFirst: boolean; + /** Whether this Bot holds any of the app's tools. */ + granted: boolean; + /** + * When the account this app runs in for this person was connected — their own, or the team's for + * a Shared app — and null where none is, or where no account is involved. + * + * FOR THE ONE FACT THE STATE CANNOT ALWAYS CARRY. The call that finds an account lost at the + * vendor now deletes its row, so the state says so itself from then on; a refusal written before + * that, over a row that still stands, reads `ready` here and its card says Reconnect anyway. This + * is how that card tells "lost since the refusal" from "connected again since the refusal": a + * connection newer than the refusal is the reconnection, and the live state wins. + */ + connectedAt: string | null; + /** The request a press of Request access files for this state, and null where there is none. */ + requestKind: AppAccessRequestKind | null; + /** + * This person's pending request for exactly that kind, if they already asked. A pending request + * of another kind is left out: it asks for something this state no longer needs. + */ + request: { id: string; kind: AppAccessRequestKind; createdAt: string } | null; +}; + +/** + * Everything the state is decided from, gathered by the store. Each field is a fact some table + * already holds; nothing here is a judgement. + */ +export type AppAccessFacts = { + provider: AppProvider; + /** Whether a row stands for the app at all. */ + present: boolean; + /** Whether this deployment has its broker's key, for a brokered app. */ + composioConfigured: boolean; + /** The scheme the app's answering row records, for a brokered app. */ + authScheme: string | null; + accountMode: "personal" | "shared" | null; + /** The catalogue entry's auth, for an app this deployment ships. */ + catalogueAuth: CatalogueEntry["auth"] | null; + /** Whether the row holds a credential: the OAuth client, or the deployment's token. */ + hasCredential: boolean; + granted: boolean; + /** This person's own connection, or null where they have none. */ + personal: { usable: boolean } | null; + /** The deployment's account at a Shared app, or null where none is connected. */ + shared: { usable: boolean } | null; + /** + * Whether the shared-use gate would let this person's calls through this Bot reach the team + * account, or null where nobody asked — no person named, or not a Shared app. + */ + sharedAdmitted: boolean | null; +}; + +/** Whose account, from the same facts `accessFor` resolves a call with. */ +export function accountOf(facts: AppAccessFacts): AppAccount { + if (brokeredProvenance(facts.provider)) { + if (schemeKind(facts.authScheme) === "none") return "deployment"; + return facts.accountMode === "shared" ? "shared" : "personal"; + } + if (facts.provider === "catalogue") + return facts.catalogueAuth?.kind === "user-oauth" + ? "personal" + : "deployment"; + return "deployment"; +} + +export function connectOf(facts: AppAccessFacts): AppConnect { + if (brokeredProvenance(facts.provider)) { + const kind = schemeKind(facts.authScheme); + return kind === "consent" || kind === "key" ? kind : null; + } + return facts.catalogueAuth?.kind === "user-oauth" ? "oauth" : null; +} + +/** + * Whether the deployment has what it needs for anybody to use the app, before any grant or + * connection is asked about; null when it does, and the reason when it does not. + * + * THE REASONS ARE THE ONES APPROVING AN `enable` REQUEST HAS TO ANSWER, so the approve route reads + * this too and refuses with the step that is missing rather than recording an approval nothing + * changed. + */ +export type Unavailable = + | "absent" + | "composio_unconfigured" + | "scheme_unreadable" + | "oauth_client_missing" + | "token_missing"; + +export function unavailableReason(facts: AppAccessFacts): Unavailable | null { + if (!facts.present) return "absent"; + if (brokeredProvenance(facts.provider)) { + if (!facts.composioConfigured) return "composio_unconfigured"; + if (schemeKind(facts.authScheme) === "unreadable") + return "scheme_unreadable"; + return null; + } + const auth = facts.catalogueAuth; + if (auth?.kind === "user-oauth") { + /* + * A client that registers itself on first use is ready the moment the row exists; a manual + * one waits for an administrator to paste it in, and until then nobody can connect — the + * connect route answers that with the same sentence for everybody. + */ + if (auth.clientRegistration !== "dynamic" && !facts.hasCredential) + return "oauth_client_missing"; + return null; + } + if (auth?.kind === "deployment-bearer" && !facts.hasCredential) + return "token_missing"; + return null; +} + +/** + * The rule, in the order a person meets the obstacles: the deployment first, then the Bot, then + * the account — and, for a team account, whether this Bot's audience was approved for it. + * + * A KEY THE VENDOR REFUSED READS AS `reconnect`, AND AN UNCHECKED ONE AS `ready`. The store + * reduces each connection to `usable` the way `brokeredConnectionsFor` sets out its four states: a + * probe that ran and was refused is a dead key; no probe at all is a key accepted without being + * tried, which is exactly what the settings page says about it too. + */ +export function appAccessState(facts: AppAccessFacts): AppAccessState { + if (unavailableReason(facts) !== null) return "disabled"; + if (!facts.granted) return "not_granted"; + const account = accountOf(facts); + if (account === "deployment") return "ready"; + if (account === "shared") { + if (!facts.shared?.usable) return "shared_not_connected"; + return facts.sharedAdmitted === false ? "shared_not_approved" : "ready"; + } + if (!facts.personal) return "not_connected"; + return facts.personal.usable ? "ready" : "reconnect"; +} diff --git a/server/src/plugins/broker.ts b/server/src/plugins/broker.ts index 33fb0b6c8d..caa69f7e49 100644 --- a/server/src/plugins/broker.ts +++ b/server/src/plugins/broker.ts @@ -910,6 +910,26 @@ export class BrokerUnconfiguredError extends BrokerRefusalError { } } +/** + * An account this deployment still has a row for and the vendor no longer holds. + * + * ITS OWN CLASS BECAUSE IT IS THE ONE VENDOR CONDITION A PERSON CAN FIX FROM THE CONVERSATION. A + * grant withdrawn at the provider and an account removed in Composio's dashboard both arrive like + * this, nothing on this deployment records either — the `brokered_connections` row still stands and + * the app still reads as ready — and connecting again is the whole remedy. Telling it apart by type + * is what lets the tool call that met it hand the transcript a card with Reconnect on it instead of + * a sentence naming a settings page, without anything matching on the sentence. + * + * A {@link BrokerRefusalError} because it keeps that class's promise about the message, and because + * every reader that already prefers an authored sentence goes on preferring this one. + */ +export class BrokerAccountGoneError extends BrokerRefusalError { + constructor(message: string, options?: ErrorOptions) { + super(message, options); + this.name = "BrokerAccountGoneError"; + } +} + /** * A return address that could not bring anybody back, refused before a consent is spent on it. * diff --git a/server/src/plugins/composio-vendor.ts b/server/src/plugins/composio-vendor.ts index 9e29f74fde..76cb5b0e12 100644 --- a/server/src/plugins/composio-vendor.ts +++ b/server/src/plugins/composio-vendor.ts @@ -1,4 +1,5 @@ import { + BrokerAccountGoneError, type BrokerApp, type BrokerConnection, BrokerRefusalError, @@ -1752,8 +1753,13 @@ function vendorRefusal( * neither; connecting again is what puts an account back under the row. */ case "ComposioConnectedAccountNotFoundError": - return refusal( + /* + * Raised as its own class so the tool call that met it can put Reconnect in front of the + * person rather than this sentence alone. See {@link BrokerAccountGoneError}. + */ + return new BrokerAccountGoneError( `Composio holds no connected account for this person and ${app}, so ${outcome}. A grant withdrawn at the provider and an account removed in Composio's own dashboard both read exactly like this, and a retry reaches neither: connecting ${app} again on this deployment's Connected accounts page is what restores it.`, + { cause: error }, ); /* diff --git a/server/src/plugins/composio.ts b/server/src/plugins/composio.ts index 240a926ffd..7a8dc07271 100644 --- a/server/src/plugins/composio.ts +++ b/server/src/plugins/composio.ts @@ -1,5 +1,5 @@ import { cutAtCodeUnits } from "../channels/text"; -import { brokerSentence, flagOf } from "./broker"; +import { BrokerAccountGoneError, brokerSentence, flagOf } from "./broker"; import { type ListedTool, MAX_RESULT_CHARS, type McpCallResult } from "./mcp"; /** @@ -1767,6 +1767,18 @@ export async function askAction( * vendor's own words below, exactly as before. */ const authored = brokerSentence(error); + /* + * THE ONE AUTHORED CONDITION THAT TRAVELS AS A FACT AS WELL AS A SENTENCE. An account the + * vendor no longer holds is something the person can fix themselves, from the conversation, + * and nothing on this deployment's rows knows it happened — so the result says so in a field + * the store reads, rather than leaving it to be recognised from the wording. See + * {@link BrokerAccountGoneError} and `McpCallResult.accountGone`. + */ + if (authored !== null && error instanceof BrokerAccountGoneError) + return { + result: { ...failure(authored), accountGone: true }, + answered: false, + }; if (authored !== null) return unreached(authored); /* diff --git a/server/src/plugins/mcp.ts b/server/src/plugins/mcp.ts index b2265c1ca6..5134c8e60d 100644 --- a/server/src/plugins/mcp.ts +++ b/server/src/plugins/mcp.ts @@ -446,6 +446,12 @@ export type McpCallResult = { /** True when the server itself reported the call as an error rather than failing to answer. */ isError: boolean; truncated: boolean; + /** + * The vendor no longer holds the account this call was made in, though this deployment still has + * a row for it. Only the Composio transport sets it, and only on an `isError` result; the store + * turns it into a refusal that offers Reconnect. Absent everywhere else. + */ + accountGone?: true; }; /** diff --git a/server/src/plugins/plugin-store/app-access.ts b/server/src/plugins/plugin-store/app-access.ts new file mode 100644 index 0000000000..3ab22b6005 --- /dev/null +++ b/server/src/plugins/plugin-store/app-access.ts @@ -0,0 +1,1091 @@ +import { randomUUID } from "node:crypto"; +import { + and, + asc, + count, + desc, + eq, + inArray, + isNull, + or, + type SQL, +} from "drizzle-orm"; +import { type AuditEventInput, recordAuditEvent } from "../../audit"; +import { + agentProfiles, + agents, + appAccessRequests, + brokeredConnections, + credentials as credentialRows, + mcpServers, + mcpTools, + mcpUserCredentials, + pluginGrants, + teamBotConsentDefaults, + teamBotConsents, + teamBotPublications, + users, +} from "../../db/schema"; +import { BROKERED_PROFILE, brokeredProvenance, toolkitOfFor } from "../access"; +import { + type AppAccess, + type AppAccessFacts, + type AppAccessRequestKind, + type AppProvider, + accountOf, + appAccessState, + connectOf, + REQUEST_KIND_FOR, + type Unavailable, + unavailableReason, +} from "../app-access"; +import type { BrokerApp } from "../broker"; +import { CATALOGUE, type CatalogueEntry, catalogueEntry } from "../catalogue"; +import type { StoreContext } from "./context"; +import { iso } from "./model"; + +/** + * One app as this module resolves it before anybody's connection is asked about: which rows are + * it, which of them answers for it, and what the deployment holds for it. + * + * `rowIds` IS EVERY ROW THAT IS THIS APP, so a grant made through a duplicate row at a brokered + * app's url counts as holding the app — the same "the apps, not the rows" rule + * `sharedAppsHeldBy` follows. `answeringId` is the one row whose columns decide, and the one a + * grant made by approving a request is written against. + */ +type AppCandidate = { + key: string; + title: string; + logo: string | null; + provider: AppProvider; + present: boolean; + answeringId: string | null; + rowIds: string[]; + toolkit: string | null; + entry: CatalogueEntry | null; + authScheme: string | null; + accountMode: "personal" | "shared" | null; + hasCredential: boolean; + /** + * Whether anything but the caller's own word says this app exists: a row, a catalogue entry, or + * the vendor's directory. False only for an id in the broker's namespace (`composio-`, + * `arcade-`) the directory did not list — named so a grant that outlived its row still reads, + * and never something a request may be filed to enable. + */ + vouched: boolean; +}; + +type ServerRow = { + id: string; + title: string; + logo: string | null; + url: string; + provenance: string; + authScheme: string | null; + accountMode: "personal" | "shared" | null; + credentialId: string | null; +}; + +/** The directory, asked only to name an app this deployment has no row for. */ +export type AppDirectory = () => Promise; + +/** What one app is for the deployment and, where a person was named, for them. */ +export type AppInspection = { + access: AppAccess; + /** Why nobody can use it yet, as {@link unavailableReason} answers; null when somebody can. */ + unavailable: Unavailable | null; + /** The row a grant is written against, and null for an app with no row. */ + answeringId: string | null; + /** The app at the broker, for a brokered one. */ + toolkit: string | null; + /** The catalogue entry, for an app this deployment ships. */ + entry: CatalogueEntry | null; + /** Whether the Shared team account is connected and its key was not refused. */ + sharedUsable: boolean; + /** See {@link AppCandidate.vouched}. */ + vouched: boolean; +}; + +/** + * Whether the shared-use gate would admit this person's calls through this Bot to a Shared app's + * team account — the gate's own rule, without the request it files when it refuses. + */ +export type SharedAudienceCheck = (input: { + botId: string; + serverId: string; + actorId: string; +}) => Promise; + +export type AppAccessRequestStatus = + | "pending" + | "approved" + | "dismissed" + | "cancelled"; + +/** A request as the person who filed it and the administrator answering it both read it. */ +export type AppAccessRequestRecord = { + id: string; + appKey: string; + appTitle: string; + kind: AppAccessRequestKind; + status: AppAccessRequestStatus; + agentId: string | null; + /** The Bot's name, or null for a request about the app alone. */ + botName: string | null; + /** Who asked. Shown to an administrator because they asked; nothing about their connections is. */ + requestedBy: string; + requesterEmail: string | null; + requesterName: string | null; + createdAt: string; + resolvedAt: string | null; + resolvedBy: string | null; +}; + +/** + * Whether `error` is a Postgres unique_violation (SQLSTATE 23505). Bun's driver puts the SQLSTATE in + * `errno`, and drizzle wraps the driver error as `cause` — the same reading `identity/store.ts` makes. + */ +function isUniqueViolation(error: unknown): boolean { + type Driver = { errno?: unknown }; + return [ + error as Driver | null | undefined, + (error as { cause?: Driver } | null | undefined)?.cause, + ].some((candidate) => candidate?.errno === "23505"); +} + +/** + * Per-person, per-Bot status of the work apps a Bot holds, and the requests a person files when + * only an administrator can change it. See `../app-access.ts` for the rule itself. + * + * ONE BROKER, THE STORE'S. Which rows are brokered apps, which connections are theirs, the scheme + * an app's url is minted at and the prefix its row id takes all come from `brokerProvider` and + * {@link BROKERED_PROFILE}, so a personal Arcade app is read exactly as a personal Composio app is + * — and a row left by the broker this deployment does not run is no brokered app here, as it is + * no brokered app to any other reader in the store. + */ +export function appAccessMethods(context: StoreContext) { + const { database, auditStore, brokeredAppRowsAt, brokerProvider } = context; + const { scheme, idPrefix } = BROKERED_PROFILE[brokerProvider]; + /** Whether an id is in the namespace `addBrokeredApp` mints this broker's rows into. */ + const isBrokeredId = (id: string) => id.startsWith(idPrefix); + /** An id with that namespace taken off, and any other word as it came. */ + const slugOfId = (id: string) => + isBrokeredId(id) ? id.slice(idPrefix.length) : id; + + /* + * EVERY ROW, ONCE. `mcp_servers` is the deployment's list of apps — tens of rows, not thousands — + * and every question below is about which of them are one app. Reading the table once and + * grouping it here costs one query; asking per app would cost one per app and per duplicate. + * Ordered by id so the grouping meets rows in the order the answering rule names them. + */ + async function allRows(): Promise { + return database + .select({ + id: mcpServers.id, + title: mcpServers.title, + logo: mcpServers.logo, + url: mcpServers.url, + provenance: mcpServers.provenance, + authScheme: mcpServers.authScheme, + accountMode: mcpServers.accountMode, + credentialId: mcpServers.credentialId, + }) + .from(mcpServers) + .orderBy(asc(mcpServers.id)); + } + + /** + * The rows grouped into apps, keyed by every row id, so a grant's server half finds its app. + * + * A BROKERED APP IS ITS ANSWERING ROW, whichever row a grant names: the id, title, scheme and + * mode all come off the row {@link brokeredAppRowsAt} names for the app's canonical url — the one + * rule every brokered reader in the store answers by. A brokered row with no row at the + * canonical url is an app of its own whose scheme nobody recorded, which the state reads as + * switched off; the gate refuses such a row too. + */ + async function appsByRow(rows: ServerRow[]) { + const toolkitOfRow = new Map(); + for (const row of rows) { + const toolkit = + row.provenance === brokerProvider + ? toolkitOfFor(brokerProvider, row.url) + : null; + if (toolkit && !catalogueEntry(row.id)) toolkitOfRow.set(row.id, toolkit); + } + const answering = await brokeredAppRowsAt([ + ...new Set([...toolkitOfRow.values()].map((t) => `${scheme}${t}`)), + ]); + const byId = new Map(rows.map((row) => [row.id, row])); + const apps = new Map(); + const byRow = new Map(); + + for (const row of rows) { + const toolkit = toolkitOfRow.get(row.id) ?? null; + const head = toolkit ? answering.get(`${scheme}${toolkit}`) : undefined; + const key = head?.id ?? row.id; + let app = apps.get(key); + if (!app) { + const source = byId.get(key) ?? row; + const entry = toolkit ? null : catalogueEntry(row.id); + app = { + key, + title: entry?.title ?? source.title, + logo: source.logo, + provider: toolkit ? brokerProvider : entry ? "catalogue" : "custom", + present: true, + answeringId: key, + rowIds: [], + toolkit, + entry, + authScheme: toolkit ? (head?.authScheme ?? null) : source.authScheme, + accountMode: toolkit ? (head?.accountMode ?? null) : null, + hasCredential: source.credentialId !== null, + vouched: true, + }; + apps.set(key, app); + } + app.rowIds.push(row.id); + byRow.set(row.id, app); + } + return byRow; + } + + /** + * An app with no row: a catalogue entry nobody added, or a brokered app nobody enabled. + * + * `key` is the id enabling it would mint — the entry's key, or the broker's prefix and the slug + * (`composio-`, `arcade-`) as `addBrokeredApp` names a row — so a request filed + * now names the row the approval creates. + */ + function absent(input: { + key: string; + title: string; + logo?: string | null; + provider: AppProvider; + toolkit?: string | null; + entry?: CatalogueEntry | null; + vouched?: boolean; + }): AppCandidate { + return { + key: input.key, + title: input.title, + logo: input.logo ?? null, + provider: input.provider, + present: false, + answeringId: null, + rowIds: [], + toolkit: input.toolkit ?? null, + entry: input.entry ?? null, + authScheme: null, + accountMode: null, + hasCredential: false, + vouched: input.vouched ?? true, + }; + } + + /* + * THE DIRECTORY IS ASKED ONLY FOR AN APP THIS DEPLOYMENT HAS NO ROW FOR, and its failure is an + * absence rather than an error: it is the one read here that leaves this process, it is cached by + * the adapter, and a vendor that will not answer leaves an app unnamed rather than a page that + * cannot load. + */ + async function readDirectory(directory: AppDirectory | undefined) { + if (!directory) return null; + try { + return await directory(); + } catch { + return null; + } + } + + /** A key with no row, named as well as the deployment can name it. */ + async function absentFor( + key: string, + directory: AppDirectory | undefined, + listed: BrokerApp[] | null | undefined, + ): Promise { + const entry = catalogueEntry(key); + if (entry) + return absent({ + key: entry.key, + title: entry.title, + provider: "catalogue", + entry, + }); + const slug = slugOfId(key); + if (toolkitOfFor(brokerProvider, `${scheme}${slug}`) !== slug) return null; + const apps = listed === undefined ? await readDirectory(directory) : listed; + const app = apps?.find((candidate) => candidate.slug === slug); + if (app) + return absent({ + key: `${idPrefix}${app.slug}`, + title: app.name, + logo: app.logo, + provider: brokerProvider, + toolkit: app.slug, + }); + /* + * An id in the broker's namespace that the directory cannot vouch for is still an app somebody + * once enabled — a grant can outlive its row — so it is named by its slug rather than dropped. + * A bare word that matches nothing is not an app at all. + */ + if (isBrokeredId(key)) + return absent({ + key, + title: slug, + provider: brokerProvider, + toolkit: slug, + vouched: false, + }); + return null; + } + + /** + * One app by whatever a caller called it: a server id, a catalogue key, a brokered app's slug + * with or without its id prefix (`composio-`, `arcade-`), or the app's name. Null for a word that + * names no app here or in the directory. + * + * A SLUG IS MATCHED AS TYPED FIRST, THEN LOWERCASED. Arcade's toolkit names are case-sensitive + * (`Gmail`), so the word as the caller typed it is the one that can reach them; Composio's slugs + * are lowercase, so the lowercased word is the one that always reached those, and still does. + * + * A NAME FINDS WHAT IS ENABLED BEFORE WHAT COULD BE. "Google Drive" is the title of the built-in + * `google-drive` entry and of a brokered row alike; on a deployment that enabled the brokered one + * and never added the built-in, the name is about the app that is there, and answering with the + * entry instead would call it switched off and file a request to enable something nobody needs. + * So an exact id or key is taken as it was given, and a bare name is matched against the rows + * first — the one this Bot holds, where two rows share it — and only then against the catalogue. + */ + async function candidateFor( + app: string, + directory: AppDirectory | undefined, + granted: Set, + ): Promise { + const asked = app.trim(); + if (!asked) return null; + const lower = asked.toLowerCase(); + const rows = await allRows(); + const byRow = await appsByRow(rows); + const enabled = [...new Set(byRow.values())]; + + const byId = byRow.get(asked); + if (byId) return byId; + + const typed = slugOfId(asked); + const slug = slugOfId(lower); + for (const wanted of new Set([typed, slug])) + for (const candidate of enabled) + if (candidate.toolkit === wanted) return candidate; + + const keyed = catalogueEntry(lower); + if (keyed) + return byRow.get(keyed.key) ?? absentFor(keyed.key, undefined, null); + + const titled = enabled.filter( + (candidate) => candidate.title.toLowerCase() === lower, + ); + const held = titled.find((candidate) => + [candidate.key, ...candidate.rowIds].some((id) => granted.has(id)), + ); + if (held ?? titled[0]) return held ?? titled[0] ?? null; + + const entry = CATALOGUE.find((item) => item.title.toLowerCase() === lower); + if (entry) return absentFor(entry.key, undefined, null); + + const listed = await readDirectory(directory); + const named = + (typed !== slug + ? listed?.find((candidate) => candidate.slug === typed) + : undefined) ?? + listed?.find( + (candidate) => + candidate.slug === slug || candidate.name.toLowerCase() === lower, + ); + if (named) return absentFor(`${idPrefix}${named.slug}`, undefined, listed); + return absentFor(isBrokeredId(lower) ? lower : asked, undefined, listed); + } + + /** The server halves of a Bot's MCP grants. */ + async function grantedServerIds(agentId: string): Promise> { + const grants = await database + .select({ ref: pluginGrants.ref }) + .from(pluginGrants) + .where( + and(eq(pluginGrants.kind, "mcp"), eq(pluginGrants.agentId, agentId)), + ); + return new Set( + grants.map((grant) => grant.ref.split("/")[0] ?? "").filter(Boolean), + ); + } + + /** + * The connection facts for a set of apps, for one person or for nobody. + * + * THE SAME COLUMNS `GET /connections` REPORTS, read without the broker. A brokered connection is + * usable unless a check ran and the vendor refused its key (`probe_action` set, `verified` + * false) — the one of `brokeredConnectionsFor`'s four states that is a dead key. A held one is + * usable unless its credential was withdrawn. + */ + async function connectionFacts(apps: AppCandidate[], userId: string | null) { + const toolkits = [ + ...new Set(apps.map((app) => app.toolkit).filter((t) => t !== null)), + ] as string[]; + const heldIds = apps + .filter((app) => app.provider === "catalogue" && app.present) + .flatMap((app) => app.rowIds); + const usable = (row: { verified: boolean; probeAction: string | null }) => + !(row.probeAction !== null && !row.verified); + const fact = (row: { + verified: boolean; + probeAction: string | null; + connectedAt: Date | null; + }) => ({ usable: usable(row), connectedAt: iso(row.connectedAt) }); + + const [personal, deployment, held] = await Promise.all([ + userId && toolkits.length > 0 + ? database + .select({ + app: brokeredConnections.app, + verified: brokeredConnections.verified, + probeAction: brokeredConnections.probeAction, + connectedAt: brokeredConnections.connectedAt, + }) + .from(brokeredConnections) + .where( + and( + eq(brokeredConnections.provider, brokerProvider), + eq(brokeredConnections.holder, "person"), + eq(brokeredConnections.userId, userId), + inArray(brokeredConnections.app, toolkits), + ), + ) + : [], + toolkits.length > 0 + ? database + .select({ + app: brokeredConnections.app, + verified: brokeredConnections.verified, + probeAction: brokeredConnections.probeAction, + connectedAt: brokeredConnections.connectedAt, + }) + .from(brokeredConnections) + .where( + and( + eq(brokeredConnections.provider, brokerProvider), + eq(brokeredConnections.holder, "deployment"), + inArray(brokeredConnections.app, toolkits), + ), + ) + : [], + userId && heldIds.length > 0 + ? database + .select({ + serverId: mcpUserCredentials.serverId, + revokedAt: credentialRows.revokedAt, + connectedAt: mcpUserCredentials.connectedAt, + }) + .from(mcpUserCredentials) + .innerJoin( + credentialRows, + eq(credentialRows.id, mcpUserCredentials.credentialId), + ) + .where( + and( + eq(mcpUserCredentials.userId, userId), + inArray(mcpUserCredentials.serverId, heldIds), + ), + ) + : [], + ]); + return { + personalBrokered: new Map(personal.map((row) => [row.app, fact(row)])), + deploymentBrokered: new Map( + deployment.map((row) => [row.app, fact(row)]), + ), + personalHeld: new Map( + held.map((row) => [ + row.serverId, + { + usable: row.revokedAt === null, + connectedAt: iso(row.connectedAt), + }, + ]), + ), + }; + } + + /** This person's pending requests about these apps, on this Bot or on no Bot. */ + async function pendingRequestsFor( + userId: string, + agentId: string, + keys: string[], + ) { + if (keys.length === 0) return []; + return database + .select({ + id: appAccessRequests.id, + appKey: appAccessRequests.appKey, + kind: appAccessRequests.kind, + createdAt: appAccessRequests.createdAt, + }) + .from(appAccessRequests) + .where( + and( + eq(appAccessRequests.requestedBy, userId), + eq(appAccessRequests.status, "pending"), + inArray(appAccessRequests.appKey, keys), + or( + eq(appAccessRequests.agentId, agentId), + isNull(appAccessRequests.agentId), + ), + ), + ) + .orderBy(asc(appAccessRequests.createdAt)); + } + + /** + * The owner of a published Team Bot, when the person looking is somebody else; null for any other + * Bot, or for its owner. The same publication `credentialActorFor` reads before it decides whose + * account a call goes out on. + */ + async function teamBotOwnerFor( + agentId: string, + userId: string, + ): Promise { + const [row] = await database + .select({ ownerUserId: agentProfiles.ownerUserId }) + .from(teamBotPublications) + .innerJoin( + agentProfiles, + eq(agentProfiles.agentId, teamBotPublications.agentId), + ) + .where( + and( + eq(teamBotPublications.agentId, agentId), + isNull(agentProfiles.deletedAt), + ), + ) + .limit(1); + const owner = row?.ownerUserId ?? null; + return owner && owner !== userId ? owner : null; + } + + /** + * The servers this person lets one Team Bot use their own account on without asking: an + * "always" or a not-yet-spent "once" for this Bot, or a default for every Bot. + */ + async function standingConsents( + userId: string, + agentId: string, + ): Promise> { + const [forBot, forEvery] = await Promise.all([ + database + .select({ serverId: teamBotConsents.serverId }) + .from(teamBotConsents) + .where( + and( + eq(teamBotConsents.userId, userId), + eq(teamBotConsents.agentId, agentId), + ), + ), + database + .select({ serverId: teamBotConsentDefaults.serverId }) + .from(teamBotConsentDefaults) + .where(eq(teamBotConsentDefaults.userId, userId)), + ]); + return new Set([...forBot, ...forEvery].map((row) => row.serverId)); + } + + /** + * Every candidate described for one person and one Bot. + * + * THE ACCOUNT IS THE ONE THE CALL WILL USE. On somebody else's published Team Bot, a personal + * app's calls go out on the owner's account wherever the owner has one — connected, withdrawn or + * refused alike, exactly as `credentialActorFor` decides from `connectedServers` — and only + * otherwise on the person's own, after they allow it. So the owner's connection decides the state + * there, and a teammate is never offered a Connect that changes nothing, nor told `ready` over a + * call that will stop to ask them first without saying so. + */ + async function describe( + apps: AppCandidate[], + input: { + userId: string | null; + agentId: string; + composioConfigured: boolean; + sharedAudience?: SharedAudienceCheck; + }, + ): Promise { + const { userId } = input; + const owner = userId ? await teamBotOwnerFor(input.agentId, userId) : null; + const [granted, connections, ownerConnections, consented, pending] = + await Promise.all([ + grantedServerIds(input.agentId), + connectionFacts(apps, userId), + owner ? connectionFacts(apps, owner) : null, + owner && userId + ? standingConsents(userId, input.agentId) + : new Set(), + userId + ? pendingRequestsFor( + userId, + input.agentId, + apps.map((app) => app.key), + ) + : [], + ]); + const personalIn = ( + found: Awaited>, + app: AppCandidate, + ) => + brokeredProvenance(app.provider) && app.toolkit + ? found.personalBrokered.get(app.toolkit) + : app.provider === "catalogue" + ? app.rowIds + .map((id) => found.personalHeld.get(id)) + .find((value) => value !== undefined) + : undefined; + return Promise.all( + apps.map(async (app) => { + const ids = [app.key, ...app.rowIds]; + const teamAccount = app.toolkit + ? connections.deploymentBrokered.get(app.toolkit) + : undefined; + const sharedUsable = teamAccount?.usable === true; + const own = personalIn(connections, app); + const owners = ownerConnections + ? personalIn(ownerConnections, app) + : undefined; + const personal = owners ?? own; + const known: AppAccessFacts = { + provider: app.provider, + present: app.present, + composioConfigured: input.composioConfigured, + authScheme: app.authScheme, + accountMode: app.accountMode, + catalogueAuth: app.entry?.auth ?? null, + hasCredential: app.hasCredential, + granted: ids.some((id) => granted.has(id)), + personal: personal === undefined ? null : { usable: personal.usable }, + shared: teamAccount ? { usable: sharedUsable } : null, + sharedAdmitted: null, + }; + const account = accountOf(known); + /* + * Asked only where it can change the answer: a Shared app this Bot holds, whose team account + * is there, for a named person. The gate is asked again on every call; this is what it would + * say now. + */ + const facts: AppAccessFacts = + account === "shared" && + sharedUsable && + known.granted && + userId && + input.sharedAudience && + app.answeringId + ? { + ...known, + sharedAdmitted: await input.sharedAudience({ + botId: input.agentId, + serverId: app.answeringId, + actorId: userId, + }), + } + : known; + const state = appAccessState(facts); + const holder = owners !== undefined ? "owner" : "you"; + const requestKind = REQUEST_KIND_FOR[state]; + const request = + requestKind === null + ? undefined + : pending.find( + (row) => row.appKey === app.key && row.kind === requestKind, + ); + return { + access: { + key: app.key, + displayName: app.title, + logo: app.logo, + provider: app.provider, + account, + connect: connectOf(facts), + authScheme: brokeredProvenance(app.provider) + ? app.authScheme + : null, + accountMode: app.accountMode, + state, + unavailable: unavailableReason(facts), + accountHolder: account === "personal" ? holder : null, + asksFirst: + state === "ready" && + account === "personal" && + holder === "you" && + owner !== null && + !ids.some((id) => consented.has(id)), + granted: facts.granted, + connectedAt: + account === "shared" + ? (teamAccount?.connectedAt ?? null) + : account === "personal" + ? (personal?.connectedAt ?? null) + : null, + requestKind, + request: request + ? { + id: request.id, + kind: request.kind, + createdAt: iso(request.createdAt) ?? "", + } + : null, + }, + unavailable: unavailableReason(facts), + answeringId: app.answeringId, + toolkit: app.toolkit, + entry: app.entry, + sharedUsable, + vouched: app.vouched, + }; + }), + ); + } + + const requestColumns = { + request: appAccessRequests, + botName: agents.name, + requesterEmail: users.email, + requesterName: users.name, + }; + + function toRecord(row: { + request: typeof appAccessRequests.$inferSelect; + botName: string | null; + requesterEmail: string | null; + requesterName: string | null; + }): AppAccessRequestRecord { + return { + id: row.request.id, + appKey: row.request.appKey, + appTitle: row.request.appTitle, + kind: row.request.kind, + status: row.request.status, + agentId: row.request.agentId, + botName: row.botName, + requestedBy: row.request.requestedBy, + requesterEmail: row.requesterEmail, + requesterName: row.requesterName, + createdAt: iso(row.request.createdAt) ?? "", + resolvedAt: iso(row.request.resolvedAt), + resolvedBy: row.request.resolvedBy, + }; + } + + async function requestRows(where: SQL | undefined) { + const rows = await database + .select(requestColumns) + .from(appAccessRequests) + .leftJoin(agents, eq(agents.id, appAccessRequests.agentId)) + .leftJoin(users, eq(users.id, appAccessRequests.requestedBy)) + .where(where) + .orderBy(desc(appAccessRequests.createdAt), asc(appAccessRequests.id)); + return rows.map(toRecord); + } + + /** See `closeAppAccessRequest` below. */ + async function closeRequest(input: { + id: string; + status: Exclude; + by: string; + requestedBy?: string; + }): Promise { + const [closed] = await database + .update(appAccessRequests) + .set({ + status: input.status, + resolvedAt: new Date(), + resolvedBy: input.by, + }) + .where( + and( + eq(appAccessRequests.id, input.id), + eq(appAccessRequests.status, "pending"), + input.requestedBy + ? eq(appAccessRequests.requestedBy, input.requestedBy) + : undefined, + ), + ) + .returning({ id: appAccessRequests.id }); + if (!closed) return null; + const [record] = await requestRows(eq(appAccessRequests.id, closed.id)); + return record ?? null; + } + + return { + /** + * Every work app one Bot holds, each with what it is for this person. + * + * HELD MEANS A GRANT, WHETHER OR NOT THE APP IS STILL THERE. A grant whose row is gone — an app + * removed before removal took its grants with it, or restored from an older backup — is still + * something this Bot was given, and is listed as switched off rather than dropped, because + * "this Bot used to reach Gmail and cannot now" is the one thing a person on the Bot's page + * needs to read about it. + * + * Sorted by name, so two loads draw one order. + */ + async appAccessForBot(input: { + userId: string; + agentId: string; + composioConfigured: boolean; + directory?: AppDirectory; + sharedAudience?: SharedAudienceCheck; + }): Promise { + const granted = await grantedServerIds(input.agentId); + if (granted.size === 0) return []; + const byRow = await appsByRow(await allRows()); + const apps = new Map(); + const missing: string[] = []; + for (const serverId of granted) { + const app = byRow.get(serverId); + if (app) apps.set(app.key, app); + else missing.push(serverId); + } + const listed = missing.some(isBrokeredId) + ? await readDirectory(input.directory) + : null; + for (const serverId of missing) { + const app = + (await absentFor(serverId, undefined, listed)) ?? + absent({ key: serverId, title: serverId, provider: "custom" }); + if (!apps.has(app.key)) apps.set(app.key, app); + } + const described = await describe([...apps.values()], input); + return described + .map((item) => item.access) + .sort((left, right) => + left.displayName.localeCompare(right.displayName), + ); + }, + + /** + * One app, by whatever a caller called it, for this Bot and — where `userId` is given — this + * person. Null for a word that names no app. + * + * Used by the chat card, which asks about an app the Bot may hold nothing for, and by the + * approve route, which asks for the deployment's half alone and passes no person. + */ + async inspectApp(input: { + app: string; + agentId: string; + userId: string | null; + composioConfigured: boolean; + directory?: AppDirectory; + sharedAudience?: SharedAudienceCheck; + }): Promise { + const candidate = await candidateFor( + input.app, + input.directory, + await grantedServerIds(input.agentId), + ); + if (!candidate) return null; + const [described] = await describe([candidate], input); + return described ?? null; + }, + + /** + * Every tool the app's answering row advertises, as grant refs, and whether each is a read. + * + * `read` IS THE EXACT WORD AND NOTHING ELSE, as `classifyTool` reads `effect`: a null or an + * unrecognised value is a write, so a tool nobody classified is never handed out as a read. + */ + async appTools( + serverId: string, + ): Promise<{ ref: string; read: boolean }[]> { + const rows = await database + .select({ name: mcpTools.name, effect: mcpTools.effect }) + .from(mcpTools) + .where(eq(mcpTools.serverId, serverId)) + .orderBy(asc(mcpTools.name)); + return rows.map((row) => ({ + ref: `${serverId}/${row.name}`, + read: row.effect === "read", + })); + }, + + /** + * File a request, or hand back the one already pending. + * + * THE INDEX DECIDES, NOT A READ. `on conflict do nothing` against the partial unique index is + * what keeps two replicas filing the same request from inserting twice; the read after it only + * finds which row won. A request answered between the two is filed again, once. + */ + async fileAppAccessRequest(input: { + requestedBy: string; + agentId: string | null; + appKey: string; + appTitle: string; + kind: AppAccessRequestKind; + }): Promise<{ request: AppAccessRequestRecord; created: boolean }> { + for (let attempt = 0; attempt < 2; attempt += 1) { + const id = randomUUID(); + const inserted = await database + .insert(appAccessRequests) + .values({ id, ...input }) + .onConflictDoNothing() + .returning({ id: appAccessRequests.id }); + const [request] = await requestRows( + inserted.length > 0 + ? eq(appAccessRequests.id, id) + : and( + eq(appAccessRequests.requestedBy, input.requestedBy), + eq(appAccessRequests.appKey, input.appKey), + eq(appAccessRequests.kind, input.kind), + eq(appAccessRequests.status, "pending"), + input.agentId === null + ? isNull(appAccessRequests.agentId) + : eq(appAccessRequests.agentId, input.agentId), + ), + ); + if (request) { + if (inserted.length > 0) + await recordAuditEvent(auditStore, { + eventType: "app_access.requested", + targetType: "mcp_server", + targetId: input.appKey, + actorUserId: input.requestedBy, + payload: { + request: id, + app: input.appKey, + bot: input.agentId, + kind: input.kind, + }, + }); + return { request, created: inserted.length > 0 }; + } + } + throw new Error( + `A request for ${input.appKey} was neither filed nor found pending.`, + ); + }, + + /** How many requests this person has waiting, across every app and Bot. */ + async countPendingAppAccessRequests(requestedBy: string): Promise { + const [row] = await database + .select({ count: count() }) + .from(appAccessRequests) + .where( + and( + eq(appAccessRequests.requestedBy, requestedBy), + eq(appAccessRequests.status, "pending"), + ), + ); + return row?.count ?? 0; + }, + + /** + * Claim a pending request for an approval about to be carried out: closed as approved, by this + * administrator, before anything is changed — or null when somebody else answered it first. + * + * THE CLAIM COMES FIRST SO NOTHING CAN ANSWER IT UNDERNEATH THE CHANGE. Approving switches an + * app on and grants a Bot its tools, and used to close the request only afterwards; a dismiss or + * a withdrawal landing in between closed it first, the approve's close found nothing, and the + * Bot kept a grant under a request that read dismissed, with no `app_access.approved` in the + * trail. Claimed here by the same conditional update every close is, the approval is the answer + * the moment it starts, and anybody else closing meanwhile finds it answered and loses with a + * 409 — before the change, not after it. + * + * NOT A ROW LOCK HELD ACROSS THE CHANGE, which is the other way to get this and the wrong one + * here. The change is made through the store on connections of its own, and a transaction kept + * open around it is the shape `db/client.ts` warns about: every pooled connection inside such a + * transaction, each waiting for a second, is a hang under load and an immediate one on a pool + * of one. + * + * An approval that cannot be carried out puts the request back with + * {@link releaseAppAccessRequest}, so it waits again with the step that is missing. + */ + async claimAppAccessRequest(input: { + id: string; + by: string; + }): Promise { + return closeRequest({ id: input.id, status: "approved", by: input.by }); + }, + + /** + * Put back a request {@link claimAppAccessRequest} claimed and the approval then could not carry + * out. Only this administrator's claim, and only while it still reads approved. + * + * A REQUEST FILED AGAIN MEANWHILE IS THE ONE THAT WAITS. The person saw theirs answered, and the + * partial unique index lets them file the same request again; two cannot both be pending, so + * this one is closed as cancelled instead — the same request, already waiting under the other id. + */ + async releaseAppAccessRequest(input: { id: string; by: string }) { + const claimed = and( + eq(appAccessRequests.id, input.id), + eq(appAccessRequests.status, "approved"), + eq(appAccessRequests.resolvedBy, input.by), + ); + try { + await database + .update(appAccessRequests) + .set({ status: "pending", resolvedAt: null, resolvedBy: null }) + .where(claimed); + } catch (error) { + if (!isUniqueViolation(error)) throw error; + await database + .update(appAccessRequests) + .set({ status: "cancelled", resolvedAt: new Date() }) + .where(claimed); + } + }, + + async appAccessRequest(id: string): Promise { + const [request] = await requestRows(eq(appAccessRequests.id, id)); + return request ?? null; + }, + + /** Pending requests: one person's, one app's, or every one. Newest first. */ + async pendingAppAccessRequests(filter: { + requestedBy?: string; + appKey?: string; + }): Promise { + return requestRows( + and( + eq(appAccessRequests.status, "pending"), + filter.requestedBy + ? eq(appAccessRequests.requestedBy, filter.requestedBy) + : undefined, + filter.appKey + ? eq(appAccessRequests.appKey, filter.appKey) + : undefined, + ), + ); + }, + + /** + * Close a pending request, and only a pending one. + * + * THE CONDITION IS THE WHOLE OF THE CONCURRENCY STORY. Two administrators answering at once, + * or a person cancelling while an administrator approves, both run this; the first update + * finds the row pending and the second finds nothing, and null is how its caller learns it + * lost. `requestedBy`, when given, confines a cancel to the person's own requests in the same + * statement. + */ + closeAppAccessRequest: closeRequest, + + /** + * A row in the trail about a request, written here so the routes write through the same + * redacting writer every other plugin event uses. + */ + async recordAppAccessEvent( + event: Omit & { + eventType: + | "app_access.cancelled" + | "app_access.approved" + | "app_access.dismissed" + | "app_access.refused"; + }, + ): Promise { + await recordAuditEvent(auditStore, { + ...event, + targetType: "mcp_server", + }); + }, + }; +} diff --git a/server/src/plugins/plugin-store/calls.ts b/server/src/plugins/plugin-store/calls.ts index b7666527b2..97e6700f3e 100644 --- a/server/src/plugins/plugin-store/calls.ts +++ b/server/src/plugins/plugin-store/calls.ts @@ -1,3 +1,4 @@ +import { connectAppRefusal } from "@openbot/shared/connect-prompt"; import { and, eq } from "drizzle-orm"; import { currentApprovalContext } from "../../approvals/types"; import { type AuditInitiator, recordAuditEvent } from "../../audit"; @@ -7,7 +8,12 @@ import { policyInitiator, } from "../../computer/policy"; import { withoutStatement } from "../../db/query-failure"; -import { mcpTools, pluginGrants, vendorNameOf } from "../../db/schema"; +import { + brokeredConnections, + mcpTools, + pluginGrants, + vendorNameOf, +} from "../../db/schema"; import { classifyTool, tokenHeaderFor } from "../catalogue"; import { VERSION_ARG } from "../composio"; import { inspectToolArguments } from "../content-governance"; @@ -35,6 +41,7 @@ export function callMethods(context: StoreContext) { injectedVendor, connectionTokenFor, requireServer, + brokerProvider, } = context; return { /** @@ -524,7 +531,11 @@ export function callMethods(context: StoreContext) { }); const answer = { text: result.text, isError: result.isError }; await approval?.complete(answer); - return answer; + /* + * The one answer that leaves this block without returning: see below the catch. Thrown + * from in here, the catch would write a second `mcp.call_failed` row about the same call. + */ + if (!result.accountGone) return answer; } catch (error) { /* * Recorded, then rethrown unchanged. The caller's behaviour is unaffected — what changes is @@ -560,6 +571,57 @@ export function callMethods(context: StoreContext) { }); throw error; } + /* + * THE ACCOUNT IS GONE AT THE VENDOR, AND THE PERSON CAN PUT IT BACK FROM WHERE THEY ARE. The + * only way here: every other answer returned inside the block and every throw left the catch. + * + * Recorded above as the failure it is, with the vendor's condition in the trail. Handed on as + * a refusal carrying an app card rather than as the vendor's error, because nothing on this + * deployment's rows knows the account is gone — the app reads as ready on every screen — and + * the card is the one place the person is told, with Reconnect beside it. A Shared app's + * account is the deployment's, so there the card says an administrator has to reconnect it. + * + * AND THE ROW GOES, as it does when a confirm hears the same no from the vendor: the row is + * only the vendor's last answer, and one that outlived the account would go on reading + * `ready` on every screen — `show_app_access` telling the model to use the app straight after + * this card — and would make the connect route refuse a fresh connection until somebody + * found Disconnect. Deleted, the app reads not connected for the person, or the team account + * not connected for everybody, which is the truth and the state whose button fixes it. Like + * the confirm's, the delete files no trail entry: nobody disconnected anything here. + */ + if (access.credential === "brokered" && access.toolkit) + await database + .delete(brokeredConnections) + .where( + gatedMode === "shared" + ? and( + eq(brokeredConnections.provider, brokerProvider), + eq(brokeredConnections.app, access.toolkit), + eq(brokeredConnections.holder, "deployment"), + ) + : and( + eq(brokeredConnections.provider, brokerProvider), + eq(brokeredConnections.app, access.toolkit), + eq(brokeredConnections.holder, "person"), + eq( + brokeredConnections.userId, + input.credentialActorId ?? input.actorId, + ), + ), + ); + throw new PluginRefusedError( + connectAppRefusal({ + serverId: row.id, + title: row.title, + message: + gatedMode === "shared" + ? `The shared ${row.title} account is no longer connected at the vendor, so this was not done. An administrator has to connect it again.` + : `Your ${row.title} account is no longer connected at the vendor, so this was not done. Reconnect it and ask again.`, + reason: gatedMode === "shared" ? "shared" : "reconnect", + at: new Date().toISOString(), + }), + null, + ); }, }; } diff --git a/server/src/plugins/plugin-store/context.ts b/server/src/plugins/plugin-store/context.ts index 544ee84f86..dfd905e1e5 100644 --- a/server/src/plugins/plugin-store/context.ts +++ b/server/src/plugins/plugin-store/context.ts @@ -44,6 +44,7 @@ import { connectionSiblings, exchangeRefreshTokenOverHttp, INVALID_CLIENT, + INVALID_GRANT, isUsableClient, type OAuthClient, PluginInvariantError, @@ -661,13 +662,22 @@ export function createStoreContext(options: PluginStoreOptions) { .limit(1); if (!connected) { /* - * Only the personal half carries a connect prompt. A Shared app's account belongs to the - * deployment, so the person reading this cannot connect it however clearly they are asked — - * a card offering them the button would be an invitation to a refusal. + * BOTH HALVES CARRY A PROMPT, AND ONLY ONE OF THEM OFFERS A CONNECT BUTTON. A Shared app's + * account belongs to the deployment, so the person reading this cannot connect it however + * clearly they are asked — a card offering them that button would be an invitation to a + * refusal. What the card draws is decided by the app's state for this person, read when it + * is drawn: for a missing team account that is Request access, which files the request an + * administrator answers by connecting it. The sentence stays for the model and for a reader + * with no card. `reason: "shared"` is only a hint; see `ConnectReason`. */ throw new PluginRefusedError( resolved.ref.holder === "deployment" - ? `${row.title} is shared across this deployment and no account is connected to it yet. Ask an administrator to connect it.` + ? connectAppRefusal({ + serverId: row.id, + title: row.title, + message: `${row.title} is shared across this deployment and no account is connected to it yet. Ask an administrator to connect it.`, + reason: "shared", + }) : connectAppRefusal({ serverId: row.id, title: row.title, @@ -928,6 +938,8 @@ export function createStoreContext(options: PluginStoreOptions) { * token it had read on the way in would present the one the first replica just spent, which is * the very double-spend this exists to prevent. */ + /** The credential whose refresh token went to the vendor, once one has. */ + let presented: string | null = null; try { return await database.transaction(async (transaction) => { const [current] = await transaction @@ -985,6 +997,7 @@ export function createStoreContext(options: PluginStoreOptions) { locked.encryptedValue, ); + presented = current.credentialId; const minted = await exchangeRefreshToken({ tokenUrl, client: stored.client, @@ -1018,6 +1031,31 @@ export function createStoreContext(options: PluginStoreOptions) { return { token: minted.accessToken }; }); } catch (error) { + /* + * THE VENDOR DISOWNED THE GRANT, NOT THE CLIENT. A refresh token answered `invalid_grant` + * will never be honoured again, and until now nothing here wrote that down: the connection + * read as connected on every screen, and every call came back as the same folded tool + * error. Revoked here — after the transaction, for the reason the recovery below gives — the + * connection reads as withdrawn, which is Reconnect on the Bot page and on the card this + * refusal draws, and the consent that fixes it replaces the credential as any reconnect does. + */ + if ( + error instanceof TokenRefusedError && + error.code === INVALID_GRANT && + presented + ) { + await credentials.revoke(presented); + throw new PluginRefusedError( + connectAppRefusal({ + serverId: row.id, + title, + message: `Your ${title} access was withdrawn at the vendor, so this was not done. Reconnect it and ask again.`, + reason: "reconnect", + at: new Date().toISOString(), + }), + null, + ); + } /* * Outside the transaction, so the row lock is already released and the vault read this does * is not a second connection held behind this one's. diff --git a/server/src/plugins/plugin-store/model.ts b/server/src/plugins/plugin-store/model.ts index 01d0100b12..b961c4712a 100644 --- a/server/src/plugins/plugin-store/model.ts +++ b/server/src/plugins/plugin-store/model.ts @@ -749,6 +749,14 @@ export class TokenRefusedError extends McpServerError { */ export const INVALID_CLIENT = "invalid_client"; +/** + * The OAuth error code for a refresh token the vendor will no longer honour (RFC 6749 §5.2): it was + * revoked, it expired, or the person withdrew the grant. Nothing this deployment can do brings it + * back — only the person consenting again — so the connection is recorded as withdrawn, the state + * every screen already reads as Reconnect. + */ +export const INVALID_GRANT = "invalid_grant"; + /** * A transaction, as the writes in this module hand one to each other. * @@ -1176,7 +1184,8 @@ export type PluginStoreOptions = { }, toolName: string, args: Record, - ) => Promise<{ text: string; isError: boolean }>; + /** `accountGone` as `McpCallResult` carries it: the vendor lost the account this call ran in. */ + ) => Promise<{ text: string; isError: boolean; accountGone?: true }>; /** Trading a refresh token for a short-lived access token. Defaults to a real HTTP exchange. */ exchangeRefreshToken?: (input: { tokenUrl: string; diff --git a/server/src/plugins/route-groups/app-access.ts b/server/src/plugins/route-groups/app-access.ts new file mode 100644 index 0000000000..684591c0f6 --- /dev/null +++ b/server/src/plugins/route-groups/app-access.ts @@ -0,0 +1,645 @@ +import type { Context } from "hono"; +import { type AppVariables, requireAdmin } from "../../auth/guards"; +import { BROKERED_PROFILE, type BrokerProvider } from "../access"; +import type { Unavailable } from "../app-access"; +import type { CatalogueEntry } from "../catalogue"; +import type { + AppAccessRequestRecord, + AppDirectory, +} from "../plugin-store/app-access"; +import { + CatalogueEntryUnknownError, + CustomServerRefusedError, + deploymentFaultSentence, + isDeploymentFault, +} from "../store"; +import { enableComposioApp } from "./composio-apps"; +import type { PluginRouteContext } from "./context"; +import { sharedGrantApproval } from "./grants"; + +type RouteContext = Context<{ Variables: AppVariables }>; + +/** + * The longest app name or Bot id a request may carry — the same cap the `show_app_access` tool puts + * on what a model may ask about. Both are written into the trail when a filing is refused, and an + * app's name becomes the title an administrator reads in the inbox, so neither is a field a caller + * may fill with whatever they like. + */ +const MAX_NAME = 200; + +/** + * How many requests one person may have waiting at once. A real person asks about the handful of + * apps a question needed; past this the inbox is being filled rather than asked, and each extra row + * is one an administrator has to answer by hand. + */ +export const MAX_PENDING_REQUESTS = 20; + +/** + * What enabling a catalogue entry would need that only an administrator can type, or null when + * adding its row is the whole of it. + * + * A vendor that gives every customer their own host needs that host; a deployment token needs the + * token; an OAuth client that does not register itself needs the client. Approving a request + * cannot invent any of those, so it says which one is missing and leaves the request pending. + */ +function adminInputFor(entry: CatalogueEntry): string | null { + if (entry.host === null) return `the address of your ${entry.title} instance`; + if (entry.auth.kind === "deployment-bearer") + return "the token it is reached with"; + if ( + entry.auth.kind === "user-oauth" && + entry.auth.clientRegistration !== "dynamic" + ) + return "an OAuth client registered with the vendor"; + return null; +} + +/** + * The sentence an approval is refused with when the app is there and still cannot be used. + * + * The broker's name and key come from {@link BROKERED_PROFILE}, so an Arcade deployment is told + * to set ARCADE_API_KEY in the words a Composio one is told to set COMPOSIO_API_KEY. + */ +function unavailableSentence( + reason: Unavailable, + title: string, + provider: BrokerProvider, +): string { + switch (reason) { + case "absent": + return `${title} is not an app this deployment can switch on from here.`; + case "composio_unconfigured": { + const { vendor, envVar } = BROKERED_PROFILE[provider]; + const article = /^[aeiou]/i.test(vendor) ? "an" : "a"; + return `${title} is ${article} ${vendor} app, and this deployment has no ${vendor} key. Set ${envVar}, then approve this request again.`; + } + case "scheme_unreadable": + return `${title} is enabled, but how it connects is not recorded in a way this deployment can read. Remove it and enable it again on its page under Plugins, then approve this request again.`; + case "oauth_client_missing": + return `${title} is added, but has no OAuth client yet, so nobody can connect it. Register one on its page under Plugins, then approve this request again.`; + case "token_missing": + return `${title} is added, but has no token yet. Add one on its page under Plugins, then approve this request again.`; + } +} + +/** + * Where a person sees which work apps a Bot can use for them, asks an administrator for the ones + * only an administrator can fix, and where the administrator answers. + * + * THE STATE IS ALWAYS THE SERVER'S. A person's browser names a Bot and an app; what the app is for + * them, and therefore what a request asks for, is computed here from the same tables every gate + * reads. A request cannot be filed for a state the app is not in, and approving one does the thing + * that state was missing — switching the app on, granting it to the Bot — in the same press. + * + * WHY NOT `shared_use_requests`. That table answers a different question: a Shared app whose team + * account IS connected, and whether an administrator approves the audience a Bot exposes it to. + * "Please connect the team account" is a request about the account itself, before any audience can + * matter, and has none of that table's columns to fill. The style, the conditional decide and the + * audit vocabulary are that flow's; the rows are not. + */ +export function mountAppAccessRoutes({ + routes, + store, + requireUser, + canUseBot, + composio, + shared, + actorEmail, + activeProvider, + activeVendor, +}: PluginRouteContext) { + const composioConfigured = Boolean(composio); + /* + * Asked only to name an app this deployment has no row for — see `readDirectory` in the store. + * The adapter caches the listing, so a card asking about an app nobody enabled costs a vendor + * call at most once per cache window rather than once per render. + */ + const directory: AppDirectory | undefined = composio + ? () => composio.broker.listApps() + : undefined; + /* + * What the shared-use gate would answer this person's chat through this Bot, so a Shared app + * whose audience was never approved reads as refused rather than ready. Without the store there + * is no gate to ask, and the call path refuses every Shared call anyway; the state says `ready` + * there exactly as it did before this was asked. + */ + const sharedAudience = shared?.use.admitsPerson; + + const refused = async ( + context: RouteContext, + input: { + app: string; + reason: string; + error: string; + status: 400 | 403 | 404 | 409 | 429 | 502 | 503; + request?: string; + bot?: string | null; + kind?: string; + }, + ) => { + await store.recordAppAccessEvent({ + eventType: "app_access.refused", + targetId: input.app, + actorUserId: context.var.actor.id, + payload: { + app: input.app, + reason: input.reason, + ...(input.request ? { request: input.request } : {}), + ...(input.bot !== undefined ? { bot: input.bot } : {}), + ...(input.kind ? { kind: input.kind } : {}), + }, + }); + return context.json({ error: input.error }, input.status); + }; + + /** + * Every work app this Bot holds, with what each one is for the person asking. + * + * Only for a Bot the person may use, and answered 404 otherwise, for the reason the grant list + * beside it gives: what a private Bot holds is a fact about that Bot. + */ + routes.get("/for/:agentId/apps", requireUser, async (context) => { + const agentId = context.req.param("agentId"); + if (!(await canUseBot(context.var.actor, agentId))) + return context.json({ error: "There is no such Bot." }, 404); + return context.json({ + apps: await store.appAccessForBot({ + userId: context.var.actor.id, + agentId, + composioConfigured, + directory, + sharedAudience, + }), + }); + }); + + /** + * One app, by key or by name, whether or not this Bot holds it — so a card in a conversation can + * say an app is switched off or not granted, and offer the request that fixes it. + */ + routes.get("/for/:agentId/apps/:app", requireUser, async (context) => { + const agentId = context.req.param("agentId"); + if (!(await canUseBot(context.var.actor, agentId))) + return context.json({ error: "There is no such Bot." }, 404); + const app = context.req.param("app"); + const inspected = + app.length > MAX_NAME + ? null + : await store.inspectApp({ + app, + agentId, + userId: context.var.actor.id, + composioConfigured, + directory, + sharedAudience, + }); + if (!inspected) + return context.json( + { error: `This deployment has no app called ${app}.` }, + 404, + ); + return context.json({ app: inspected.access }); + }); + + /** + * Ask an administrator for access. The body names the Bot and the app; the kind is the server's. + * + * A SECOND PRESS IS THE FIRST REQUEST. One pending request per person, app, Bot and kind, so + * asking again answers with the one already waiting and `created: false`, and writes no second + * trail row. + * + * ONLY FOR AN APP SOMETHING VOUCHES FOR, AND ONLY SO MANY. Asking to enable a `composio-` id the + * directory does not list would put whatever the caller typed in front of an administrator as an + * app's name, for an approval that can only fail; it is answered as no such app, unless the Bot + * already holds a grant for it and the request is about putting back something it was given. And + * a person with {@link MAX_PENDING_REQUESTS} waiting is told to wait for those to be answered. + */ + routes.post("/access-requests", requireUser, async (context) => { + const body = (await context.req.json().catch(() => null)) as { + agentId?: unknown; + app?: unknown; + } | null; + const agentId = + typeof body?.agentId === "string" ? body.agentId.trim() : ""; + const app = typeof body?.app === "string" ? body.app.trim() : ""; + if (!agentId || !app) + return context.json({ error: "Name the Bot and the app." }, 400); + if (agentId.length > MAX_NAME || app.length > MAX_NAME) + return context.json( + { + error: `A Bot or an app is named in at most ${MAX_NAME} characters.`, + }, + 400, + ); + if (!(await canUseBot(context.var.actor, agentId))) + return refused(context, { + app, + bot: agentId, + reason: "no_such_bot", + error: "There is no such Bot.", + status: 404, + }); + const found = await store.inspectApp({ + app, + agentId, + userId: context.var.actor.id, + composioConfigured, + directory, + sharedAudience, + }); + const inspected = + found && (found.vouched || found.access.granted) ? found : null; + if (!inspected) + return refused(context, { + app, + bot: agentId, + reason: "no_such_app", + error: `This deployment has no app called ${app}.`, + status: 404, + }); + const { access } = inspected; + if (!access.requestKind) + return refused(context, { + app: access.key, + bot: agentId, + reason: `nothing_to_request:${access.state}`, + error: + access.state === "ready" + ? `${access.displayName} is ready to use. There is nothing to ask an administrator for.` + : `${access.displayName} is waiting on your own account, not on an administrator. Connect it yourself.`, + status: 409, + }); + /* + * The one already waiting is answered as itself before the cap is counted, so a person at the + * cap pressing again on a request they filed is shown it, not refused. + */ + if ( + !access.request && + (await store.countPendingAppAccessRequests(context.var.actor.id)) >= + MAX_PENDING_REQUESTS + ) + return refused(context, { + app: access.key, + bot: agentId, + reason: "too_many_pending", + kind: access.requestKind, + error: `You already have ${MAX_PENDING_REQUESTS} requests waiting for an administrator. Withdraw one, or wait for them to be answered, then ask again.`, + status: 429, + }); + const filed = await store.fileAppAccessRequest({ + requestedBy: context.var.actor.id, + agentId, + appKey: access.key, + appTitle: access.displayName, + kind: access.requestKind, + }); + return context.json(filed, filed.created ? 201 : 200); + }); + + /** The person's own pending requests, newest first. */ + routes.get("/access-requests/mine", requireUser, async (context) => + context.json({ + requests: await store.pendingAppAccessRequests({ + requestedBy: context.var.actor.id, + }), + }), + ); + + /** + * Withdraw one's own pending request. Somebody else's reads exactly like one that does not exist, + * so this route cannot be used to learn who asked for what. + */ + routes.delete("/access-requests/:id", requireUser, async (context) => { + const id = context.req.param("id"); + const closed = await store.closeAppAccessRequest({ + id, + status: "cancelled", + by: context.var.actor.id, + requestedBy: context.var.actor.id, + }); + if (!closed) { + const existing = await store.appAccessRequest(id); + const mine = existing?.requestedBy === context.var.actor.id; + return refused(context, { + app: mine ? (existing?.appKey ?? "") : "", + request: id, + reason: mine ? "already_resolved" : "no_such_request", + error: mine + ? "That request has already been answered." + : "There is no such request.", + status: mine ? 409 : 404, + }); + } + await store.recordAppAccessEvent({ + eventType: "app_access.cancelled", + targetId: closed.appKey, + actorUserId: context.var.actor.id, + payload: { + request: closed.id, + app: closed.appKey, + bot: closed.agentId, + kind: closed.kind, + }, + }); + return context.json({ request: closed }); + }); + + /** + * Every pending request, for an administrator: one app's with `?app=`, or all of them, and a + * count per app either way so the Plugins list can badge each app without a second read. + */ + routes.get("/access-requests", requireUser, async (context) => { + const forbidden = requireAdmin(context); + if (forbidden) return forbidden; + const all = await store.pendingAppAccessRequests({}); + const byApp: Record = {}; + for (const request of all) + byApp[request.appKey] = (byApp[request.appKey] ?? 0) + 1; + const app = context.req.query("app")?.trim(); + return context.json({ + requests: app ? all.filter((request) => request.appKey === app) : all, + counts: { total: all.length, byApp }, + }); + }); + + /** + * Approve a request by doing what it asks: switch the app on, grant its tools to the Bot, or — + * for a team account, which only a consent screen can connect — confirm it has been connected. + * + * CLAIMED, THEN CHANGED. The request is closed as approved by this administrator before anything + * is changed, so a dismiss, a withdrawal or a second approval arriving meanwhile finds it answered + * and loses, and the change can never land under a request somebody else answered. A change that + * cannot be carried out puts the request back, and it waits again with the step that is missing; + * so does one that throws. See `claimAppAccessRequest` in the store, which says why this is not a + * row lock held across the change. + * + * `refs` NARROWS THE GRANT, AND WITHOUT IT ONLY THE READS ARE GRANTED. One press in the inbox + * gives the Bot every action the app has marked read-only — what the Bot page's bulk switch + * offers, and nothing that sends, writes or deletes. Anything more is chosen action by action, on + * the Bot's page for the app or by naming them here. An app with no read-only action is refused + * with that sentence rather than granted everything. A ref outside the app is refused rather than + * dropped. + */ + routes.post("/access-requests/:id/approve", requireUser, async (context) => { + const forbidden = requireAdmin(context); + if (forbidden) return forbidden; + const id = context.req.param("id"); + const body = (await context.req.json().catch(() => null)) as { + refs?: unknown; + } | null; + if ( + body?.refs !== undefined && + (!Array.isArray(body.refs) || + body.refs.some((ref) => typeof ref !== "string")) + ) + return context.json({ error: "Tools must be a list of refs." }, 400); + const chosen = body?.refs as string[] | undefined; + + let carriedOut = false; + const approve = async (request: AppAccessRequestRecord) => { + const deny = ( + reason: string, + error: string, + status: 400 | 404 | 409 | 502 | 503, + ) => + refused(context, { + app: request.appKey, + request: request.id, + bot: request.agentId, + kind: request.kind, + reason, + error, + status, + }); + + const inspect = () => + store.inspectApp({ + app: request.appKey, + agentId: request.agentId ?? "", + userId: null, + composioConfigured, + directory, + }); + + let inspected = await inspect(); + if (!inspected) + return deny( + "no_such_app", + `${request.appTitle} is no longer an app this deployment can find.`, + 409, + ); + + let enabled = false; + if (request.kind !== "connect_shared" && inspected.unavailable) { + const title = inspected.access.displayName; + if (inspected.unavailable !== "absent") + return deny( + inspected.unavailable, + unavailableSentence(inspected.unavailable, title, activeProvider), + inspected.unavailable === "composio_unconfigured" ? 503 : 409, + ); + if (inspected.toolkit) { + if (!composio) + return deny( + "composio_unconfigured", + unavailableSentence( + "composio_unconfigured", + title, + activeProvider, + ), + 503, + ); + const outcome = await enableComposioApp( + { store, composio, activeProvider, activeVendor }, + inspected.toolkit, + actorEmail(context), + ); + if (!outcome.ok) + return deny("enable_failed", outcome.error, outcome.status); + } else if (inspected.entry) { + const needed = adminInputFor(inspected.entry); + if (needed) + return deny( + "needs_admin_input", + `${title} needs ${needed}, which only an administrator can supply. Add it on its page under Plugins, then approve this request again.`, + 409, + ); + try { + await store.addServer({ + key: inspected.entry.key, + by: actorEmail(context), + }); + } catch (error) { + if ( + error instanceof CatalogueEntryUnknownError || + error instanceof CustomServerRefusedError + ) + return deny("enable_failed", error.message, 400); + if (isDeploymentFault(error)) + return deny("enable_failed", deploymentFaultSentence(error), 409); + throw error; + } + } else { + return deny( + "absent", + unavailableSentence("absent", title, activeProvider), + 409, + ); + } + enabled = true; + inspected = await inspect(); + if (!inspected || inspected.unavailable) + return deny( + "enable_failed", + `${title} was switched on and still cannot be used. Open its page under Plugins to see why.`, + 409, + ); + } + + let granted: string[] = []; + if ( + request.kind !== "connect_shared" && + request.agentId && + !inspected.access.granted + ) { + const agentId = request.agentId; + const serverId = inspected.answeringId; + const name = inspected.access.displayName; + if (!(await store.agentIsRegistered(agentId)) || !serverId) + return deny("no_such_bot", "That Bot is no longer here.", 409); + const tools = await store.appTools(serverId); + const advertised = tools.map((tool) => tool.ref); + const refs = + chosen ?? tools.filter((tool) => tool.read).map((tool) => tool.ref); + const outside = refs.filter((ref) => !advertised.includes(ref)); + if (outside.length > 0) + return deny( + "refs_outside_app", + `${outside.join(", ")} ${outside.length === 1 ? "is" : "are"} not among ${name}'s tools.`, + 400, + ); + if (advertised.length === 0) + return deny( + "no_tools", + `${name} advertises no tools to grant yet. Refresh its tools on its page under Plugins, then approve this request again.`, + 409, + ); + if (refs.length === 0) + return deny( + "no_read_only_tools", + `None of ${name}'s actions is marked read-only, so approving here would grant nothing. Choose which actions this Bot may use on ${name}'s page for it under Plugins, then approve this request again.`, + 409, + ); + for (const ref of refs) { + await store.grant("mcp", ref, agentId, actorEmail(context)); + } + const sharedDecision = await sharedGrantApproval( + { store, shared }, + refs[0] ?? "", + agentId, + undefined, + ); + if ( + sharedDecision !== "unreadable" && + sharedDecision.approval && + shared + ) + await shared.use.setApproval({ + botId: agentId, + serverId: sharedDecision.appId, + approval: sharedDecision.approval, + by: context.var.actor.id, + }); + granted = refs; + } + + if ( + request.kind === "connect_shared" && + inspected.access.account === "shared" && + !inspected.sharedUsable + ) + return deny( + "shared_not_connected", + `The shared ${inspected.access.displayName} account is not connected yet. Connect it on ${inspected.access.displayName}'s page under Plugins, then approve this request.`, + 409, + ); + + carriedOut = true; + const closed = request; + await store.recordAppAccessEvent({ + eventType: "app_access.approved", + targetId: closed.appKey, + actorUserId: context.var.actor.id, + payload: { + request: closed.id, + app: closed.appKey, + bot: closed.agentId, + kind: closed.kind, + enabled, + granted: granted.length, + }, + }); + return context.json({ request: closed, enabled, granted }); + }; + + const claimed = await store.claimAppAccessRequest({ + id, + by: context.var.actor.id, + }); + if (!claimed) { + const existing = await store.appAccessRequest(id); + return refused(context, { + app: existing?.appKey ?? "", + request: id, + reason: existing ? "already_resolved" : "no_such_request", + error: existing + ? "That request has already been answered." + : "There is no such request.", + status: existing ? 409 : 404, + }); + } + try { + return await approve(claimed); + } finally { + if (!carriedOut) + await store.releaseAppAccessRequest({ id, by: context.var.actor.id }); + } + }); + + /** Dismiss a request: nothing changes but the request, which stops waiting. */ + routes.post("/access-requests/:id/dismiss", requireUser, async (context) => { + const forbidden = requireAdmin(context); + if (forbidden) return forbidden; + const id = context.req.param("id"); + const closed: AppAccessRequestRecord | null = + await store.closeAppAccessRequest({ + id, + status: "dismissed", + by: context.var.actor.id, + }); + if (!closed) { + const existing = await store.appAccessRequest(id); + return refused(context, { + app: existing?.appKey ?? "", + request: id, + reason: existing ? "already_resolved" : "no_such_request", + error: existing + ? "That request has already been answered." + : "There is no such request.", + status: existing ? 409 : 404, + }); + } + await store.recordAppAccessEvent({ + eventType: "app_access.dismissed", + targetId: closed.appKey, + actorUserId: context.var.actor.id, + payload: { + request: closed.id, + app: closed.appKey, + bot: closed.agentId, + kind: closed.kind, + }, + }); + return context.json({ request: closed }); + }); +} diff --git a/server/src/plugins/route-groups/composio-apps.ts b/server/src/plugins/route-groups/composio-apps.ts index ca7c4ead90..2636afa8b5 100644 --- a/server/src/plugins/route-groups/composio-apps.ts +++ b/server/src/plugins/route-groups/composio-apps.ts @@ -13,6 +13,7 @@ import { deploymentFaultSentence, isDeploymentFault, PluginRefusedError, + type ServerRecord, } from "../store"; import { brokerRefusal, @@ -158,16 +159,6 @@ export function mountComposioAppRoutes({ const forbidden = requireAdmin(context); if (forbidden) return forbidden; - if (!composio) { - return context.json( - { - error: new BrokerUnconfiguredError(activeProvider, "operator") - .message, - }, - 503, - ); - } - const body = (await context.req.json().catch(() => null)) as { slug?: unknown; } | null; @@ -183,177 +174,224 @@ export function mountComposioAppRoutes({ * about that, so the narrowing answers with `undefined` rather than a second sentence. */ const slug = typeof body?.slug === "string" ? body.slug.trim() : undefined; - let directory: BrokerApp[] = []; - if (slug) { - try { - directory = await withVendorDeadline(() => composio.broker.listApps()); - } catch (error) { - /* - * The same failure the directory route answers, in the same words, because it is the same - * call. Unhandled it was the worst 500 of the three: an administrator pressing Add on a - * deployment whose key is wrong was told nothing at all, on the one screen where the key - * had just been set. - */ - const refusal = brokerRefusal( - error, - directoryUnavailable(activeProvider), - ); - return context.json({ error: refusal.error }, refusal.status); - } - } - const app = directory.find((candidate) => candidate.slug === slug); - if (!app) { - return context.json( - { - error: slug - ? `${slug} is not an app ${BROKERED_PROFILE[activeProvider].vendor} lists for this deployment.` - : "An app is required.", - }, - 400, - ); + const outcome = await enableComposioApp( + { store, composio, activeProvider, activeVendor }, + slug, + actorEmail(context), + ); + return outcome.ok + ? context.json({ server: outcome.server }, 201) + : context.json({ error: outcome.error }, outcome.status); + }); +} + +/** What enabling one Composio app answered: the row it wrote, or the refusal and its status. */ +export type EnableOutcome = + | { ok: true; server: ServerRecord } + | { ok: false; error: string; status: 400 | 409 | 502 | 503 }; + +/** + * Enable one app of Composio's catalogue, by slug: the body of `POST /composio/apps`, and what + * approving a person's request to switch an app on runs. + * + * ONE FUNCTION FOR BOTH DOORS, so an app switched on from the request inbox is checked against + * the live directory, refused when this deployment cannot drive it, and mapped to the same + * sentences as one switched on from the picker. The caller decides who may call it; both callers + * are administrator-only. + */ +export async function enableComposioApp( + { + store, + composio, + activeProvider, + activeVendor, + }: Pick< + PluginRouteContext, + "store" | "composio" | "activeProvider" | "activeVendor" + >, + slug: string | undefined, + by: string, +): Promise { + if (!composio) { + return { + ok: false, + error: new BrokerUnconfiguredError(activeProvider, "operator").message, + status: 503, + }; + } + let directory: BrokerApp[] = []; + if (slug) { + try { + directory = await withVendorDeadline(() => composio.broker.listApps()); + } catch (error) { + /* + * The same failure the directory route answers, in the same words, because it is the same + * call. Unhandled it was the worst 500 of the three: an administrator pressing Add on a + * deployment whose key is wrong was told nothing at all, on the one screen where the key + * had just been set. + */ + return { + ok: false, + ...brokerRefusal(error, directoryUnavailable(activeProvider)), + }; } + } + const app = directory.find((candidate) => candidate.slug === slug); + if (!app) { + return { + ok: false, + error: slug + ? `${slug} is not an app ${BROKERED_PROFILE[activeProvider].vendor} lists for this deployment.` + : "An app is required.", + status: 400, + }; + } + + /* + * AN APP THIS DEPLOYMENT CANNOT CONNECT IS NOT AN APP TO ADD, and the GET half above is not + * enough on its own: it hides every `unsupported` app from the picker, so nobody presses Add on + * one, but this route validates against the unfiltered `directory` — so a request that names + * one by hand arrives here past that filter. + * + * WHAT IT WOULD REACH IS A STORE METHOD THAT RECORDS A MISLEADING ROW. `schemeFor` writes + * `null` on `auth_scheme` for an unsupported connection, and a null there is read everywhere + * else as "not a brokered row at all" — so the row enabling would write is one whose recorded + * kind contradicts what it is. + * + * A SECOND GUARD RATHER THAN A REPLACEMENT: the adapter keeps its own refusal, and the two + * catch different things. This one stops a CALLER OF THIS ROUTE reaching a store method that + * would write that row — which is a fact about `addBrokeredApp`, and holds for whatever broker + * is behind it, including one that would happily create the config. The adapter's stops ANY + * caller at all — this route, a script, a future path — from reaching the vendor for an app + * this deployment has nowhere to hold an OAuth client for. Neither subsumes the other, and the + * store itself still has no guard, which is precisely why the cheap one here is worth having. + * + * The derivation's own `reason` is the sentence, because it names what is missing for this app + * rather than for unsupported apps in general. 503 because this is a refusal this deployment + * authored — the same status `brokerRefusal` gives an authored refusal raised a layer down, and + * for the same reading: the brokered surface is unavailable for this app until somebody here + * changes something, rather than unavailable because a third party is down. Not the 400 above, + * which says the app does not exist; this one does exist, and this deployment cannot drive it. + */ + if (app.connection.kind === "unsupported") { + return { ok: false, error: app.connection.reason, status: 503 }; + } + try { + const server = await store.addBrokeredApp({ + slug: app.slug, + title: app.name, + logo: app.logo, + by, + // Off the directory entry the administrator chose, never derived a second time here: a + // second derivation is a second answer, which is the one thing `BrokerConnection` exists to + // prevent. It decides what config the store creates at the vendor, and what the row records. + connection: app.connection, + }); + return { ok: true, server }; + } catch (error) { + // The same mapping the add routes above make, for the same reason: a refusal an + // administrator can correct comes back as itself rather than as a 500. + if ( + error instanceof CustomServerRefusedError || + error instanceof PluginRefusedError + ) { + return { ok: false, error: error.message, status: 400 }; + } + // And, as on those routes, enabling refreshes before it answers, so every fault + // `refreshTools` raises arrives here as well. Its sentence blames nobody and names the row, + // which is the honest answer whichever side of the insert the fault arrived on. + if (isDeploymentFault(error)) { + return { + ok: false, + error: deploymentFaultSentence(error), + status: 409, + }; + } /* - * AN APP THIS DEPLOYMENT CANNOT CONNECT IS NOT AN APP TO ADD, and the GET half above is not - * enough on its own: it hides every `unsupported` app from the picker, so nobody presses Add on - * one, but this route validates against the unfiltered `directory` — so a request that names - * one by hand arrives here past that filter. + * THE ONE REFUSAL THIS ROUTE CAN ONLY MEET AFTER THE ROW IS COMMITTED, and the only one its + * three sibling add routes map and it did not. * - * WHAT IT WOULD REACH IS A STORE METHOD THAT RECORDS A MISLEADING ROW. `schemeFor` writes - * `null` on `auth_scheme` for an unsupported connection, and a null there is read everywhere - * else as "not a brokered row at all" — so the row enabling would write is one whose recorded - * kind contradicts what it is. + * CRITERION. A failure thrown after `addBrokeredApp` has written and audited the row is not + * reported as an app that was not enabled, and is not reported as Composio's doing. * - * A SECOND GUARD RATHER THAN A REPLACEMENT: the adapter keeps its own refusal, and the two - * catch different things. This one stops a CALLER OF THIS ROUTE reaching a store method that - * would write that row — which is a fact about `addBrokeredApp`, and holds for whatever broker - * is behind it, including one that would happily create the config. The adapter's stops ANY - * caller at all — this route, a script, a future path — from reaching the vendor for an app - * this deployment has nowhere to hold an OAuth client for. Neither subsumes the other, and the - * store itself still has no guard, which is precisely why the cheap one here is worth having. + * WHERE IT ARRIVES FROM. `addBrokeredApp` creates the auth config, inserts the row and files + * its `configuration.changed` entry, and only THEN refreshes and reads the row back out of + * `listServers` — raising this when it is not there, as `requireServer` does one call + * deeper. Every one of those steps has already committed by then. * - * The derivation's own `reason` is the sentence, because it names what is missing for this app - * rather than for unsupported apps in general. 503 because this is a refusal this deployment - * authored — the same status `brokerRefusal` gives an authored refusal raised a layer down, and - * for the same reading: the brokered surface is unavailable for this app until somebody here - * changes something, rather than unavailable because a third party is down. Not the 400 above, - * which says the app does not exist; this one does exist, and this deployment cannot drive it. + * WHAT IT USED TO BE ANSWERED WITH. Nothing caught it, so it fell through to the broker tail + * below and an administrator was told "Slack could not be enabled, and Composio said nothing + * about why. Try again, and check this deployment's Composio key if it persists." That is + * wrong three times over: the app WAS enabled, Composio had no part in the step that failed, + * and the one thing the reader now holds — a row on their Plugins page — went unmentioned + * while they were sent to check a key that is fine. + * + * 409 RATHER THAN THE 400 ITS SIBLINGS GIVE THIS CLASS, because it is not the same fact + * wearing the same class. On the add routes above, an unknown key is a caller naming a + * server this deployment will not connect to, which is a malformed request. Here the row was + * written and this deployment cannot see it: two of its own reads disagree, and the request + * cannot be answered until one of them changes — the reading the refresh route's own 409 + * spells out. The message is written here rather than passed through, because + * {@link CatalogueEntryUnknownError}'s own sentence is the one for the other case. */ - if (app.connection.kind === "unsupported") { - return context.json({ error: app.connection.reason }, 503); + if (error instanceof CatalogueEntryUnknownError) { + return { + ok: false, + error: `${app.name} was added, and then could not be read back out of this deployment's own servers — so the row and its trail entry stand, and what is on this page may be missing it. Reload the Plugins page: if ${app.name} is there it is enabled and there is nothing to redo, and if it is not, adding it again is safe.`, + status: 409, + }; + } + /* + * THE SAME MAPPING THE DIRECTORY READ ABOVE MAKES, FOR THE SAME REASON, and its absence here + * is what left an administrator with nothing. Composio's own sentence — "Default auth config + * not found for toolkit linear_mcp. Composio does not have managed credentials for this + * toolkit." — travelled as an unhandled throw, so the route answered a bodyless 500 and the + * browser fell back to its own "That app could not be added". Everything that explains the + * failure existed; nothing carried it the last step. + */ + /* + * WHAT THE MAPPING COST, PUT BACK. A throw used to reach Hono's default handler, which is a + * bad answer and a good log: the administrator got a bodyless 500, but the stack was printed + * where an operator could find it. Catching everything fixed the answer and silenced the log, + * and the failure that needs the log most is the one left here — not a refusal this + * deployment authored, which `brokerSentence` names and `isDeploymentFault` already took one + * branch above, and not a sentence Composio wrote, which `vendorSentence` reaches for. Both + * null is a fault nobody has explained, most often a programmer error on this side, and it + * would otherwise leave only the generic "Composio said nothing about why" in a browser + * nobody is reading a console from. + * + * The same shape and the same restraint as the connection-not-recorded log below: the slug, + * because it is the app an operator is about to be asked about; what the person was told, so + * the console line and the support request can be matched up; and the error read through + * {@link reasonWithoutStatement}, never spread, logged as an object or reached into. No key, + * no vendor response, no request body, and no statement — the two sentence tests below do not + * exclude a query failure, they are the branch one arrives in. + * + * AND THE NAME ON IT IS TRUE OF EVERY FAILURE THAT STILL REACHES IT. The two branches above + * take the whole of what `addBrokeredApp` can raise once the row is committed — the shelf, + * and the read-back that could not find it — so what is left arrives from the steps before + * the insert, where nothing was written and the app really was not enabled. The generic + * sentence below names Composio for the same reason: the one call in front of the insert is + * `ensureAuthConfig`, which is a call to the vendor. + */ + if (brokerSentence(error) === null && vendorSentence(error) === null) { + console.error( + JSON.stringify({ + type: "composio-app-not-enabled", + slug: app.slug, + note: `Enabling a brokered app failed for a reason neither this deployment nor ${activeVendor} put a sentence to. The administrator was answered 502 with the generic sentence.`, + error: reasonWithoutStatement(error), + }), + ); } - try { - const server = await store.addBrokeredApp({ - slug: app.slug, - title: app.name, - logo: app.logo, - by: actorEmail(context), - // Off the directory entry the administrator chose, never derived a second time here: a - // second derivation is a second answer, which is the one thing `BrokerConnection` exists to - // prevent. It decides what config the store creates at the vendor, and what the row records. - connection: app.connection, - }); - return context.json({ server }, 201); - } catch (error) { - // The same mapping the add routes above make, for the same reason: a refusal an - // administrator can correct comes back as itself rather than as a 500. - if ( - error instanceof CustomServerRefusedError || - error instanceof PluginRefusedError - ) { - return context.json({ error: error.message }, 400); - } - // And, as on those routes, enabling refreshes before it answers, so every fault - // `refreshTools` raises arrives here as well. Its sentence blames nobody and names the row, - // which is the honest answer whichever side of the insert the fault arrived on. - if (isDeploymentFault(error)) { - return context.json({ error: deploymentFaultSentence(error) }, 409); - } - /* - * THE ONE REFUSAL THIS ROUTE CAN ONLY MEET AFTER THE ROW IS COMMITTED, and the only one its - * three sibling add routes map and it did not. - * - * CRITERION. A failure thrown after `addBrokeredApp` has written and audited the row is not - * reported as an app that was not enabled, and is not reported as Composio's doing. - * - * WHERE IT ARRIVES FROM. `addBrokeredApp` creates the auth config, inserts the row and files - * its `configuration.changed` entry, and only THEN refreshes and reads the row back out of - * `listServers` — raising this when it is not there, as `requireServer` does one call - * deeper. Every one of those steps has already committed by then. - * - * WHAT IT USED TO BE ANSWERED WITH. Nothing caught it, so it fell through to the broker tail - * below and an administrator was told "Slack could not be enabled, and Composio said nothing - * about why. Try again, and check this deployment's Composio key if it persists." That is - * wrong three times over: the app WAS enabled, Composio had no part in the step that failed, - * and the one thing the reader now holds — a row on their Plugins page — went unmentioned - * while they were sent to check a key that is fine. - * - * 409 RATHER THAN THE 400 ITS SIBLINGS GIVE THIS CLASS, because it is not the same fact - * wearing the same class. On the add routes above, an unknown key is a caller naming a - * server this deployment will not connect to, which is a malformed request. Here the row was - * written and this deployment cannot see it: two of its own reads disagree, and the request - * cannot be answered until one of them changes — the reading the refresh route's own 409 - * spells out. The message is written here rather than passed through, because - * {@link CatalogueEntryUnknownError}'s own sentence is the one for the other case. - */ - if (error instanceof CatalogueEntryUnknownError) { - return context.json( - { - error: `${app.name} was added, and then could not be read back out of this deployment's own servers — so the row and its trail entry stand, and what is on this page may be missing it. Reload the Plugins page: if ${app.name} is there it is enabled and there is nothing to redo, and if it is not, adding it again is safe.`, - }, - 409, - ); - } - /* - * THE SAME MAPPING THE DIRECTORY READ ABOVE MAKES, FOR THE SAME REASON, and its absence here - * is what left an administrator with nothing. Composio's own sentence — "Default auth config - * not found for toolkit linear_mcp. Composio does not have managed credentials for this - * toolkit." — travelled as an unhandled throw, so the route answered a bodyless 500 and the - * browser fell back to its own "That app could not be added". Everything that explains the - * failure existed; nothing carried it the last step. - */ - /* - * WHAT THE MAPPING COST, PUT BACK. A throw used to reach Hono's default handler, which is a - * bad answer and a good log: the administrator got a bodyless 500, but the stack was printed - * where an operator could find it. Catching everything fixed the answer and silenced the log, - * and the failure that needs the log most is the one left here — not a refusal this - * deployment authored, which `brokerSentence` names and `isDeploymentFault` already took one - * branch above, and not a sentence Composio wrote, which `vendorSentence` reaches for. Both - * null is a fault nobody has explained, most often a programmer error on this side, and it - * would otherwise leave only the generic "Composio said nothing about why" in a browser - * nobody is reading a console from. - * - * The same shape and the same restraint as the connection-not-recorded log below: the slug, - * because it is the app an operator is about to be asked about; what the person was told, so - * the console line and the support request can be matched up; and the error read through - * {@link reasonWithoutStatement}, never spread, logged as an object or reached into. No key, - * no vendor response, no request body, and no statement — the two sentence tests below do not - * exclude a query failure, they are the branch one arrives in. - * - * AND THE NAME ON IT IS TRUE OF EVERY FAILURE THAT STILL REACHES IT. The two branches above - * take the whole of what `addBrokeredApp` can raise once the row is committed — the shelf, - * and the read-back that could not find it — so what is left arrives from the steps before - * the insert, where nothing was written and the app really was not enabled. The generic - * sentence below names Composio for the same reason: the one call in front of the insert is - * `ensureAuthConfig`, which is a call to the vendor. - */ - if (brokerSentence(error) === null && vendorSentence(error) === null) { - console.error( - JSON.stringify({ - type: "composio-app-not-enabled", - slug: app.slug, - note: `Enabling a brokered app failed for a reason neither this deployment nor ${activeVendor} put a sentence to. The administrator was answered 502 with the generic sentence.`, - error: reasonWithoutStatement(error), - }), - ); - } - - const refusal = brokerRefusal( + return { + ok: false, + ...brokerRefusal( error, `${app.name} could not be enabled, and ${activeVendor} said nothing about why. Try again, and check this deployment's ${activeVendor} key if it persists.`, - ); - return context.json({ error: refusal.error }, refusal.status); - } - }); + ), + }; + } } diff --git a/server/src/plugins/route-groups/grants.ts b/server/src/plugins/route-groups/grants.ts index dd1b0cb3a6..5000301b88 100644 --- a/server/src/plugins/route-groups/grants.ts +++ b/server/src/plugins/route-groups/grants.ts @@ -13,6 +13,46 @@ import { } from "../store"; import type { PluginRouteContext } from "./context"; +/** + * What a grant of one MCP tool must record beside it: the approval for who may use a Shared app's + * one account through this Bot. + * + * Without an approval the gate refuses every call the grant just allowed, so a grant with none sent + * approves the Bot's exposure as it stands now — unless an approval is already recorded, which is + * an administrator's earlier decision and not this grant's to widen. "Is Shared" is the app's + * answer (its answering row), never the granted row's own column. Null `approval` is nothing to + * record; `unreadable` is an approval sent that this deployment cannot read, which the caller + * refuses. + * + * A FUNCTION RATHER THAN LINES IN THE ROUTE because approving an access request grants through the + * same rule, and a Shared app granted from the request inbox must land with the same approval it + * would have had from the grant switch. + */ +export async function sharedGrantApproval( + { store, shared }: Pick, + ref: string, + agentId: string, + sent: unknown, +): Promise< + { appId: string; approval: SharedUseApproval | null } | "unreadable" +> { + if (!shared) return { appId: "", approval: null }; + const [serverId] = ref.split("/"); + const appId = await shared.use.appIdOf(serverId ?? ""); + if ((await store.serverAddress(appId))?.accountMode !== "shared") + return { appId, approval: null }; + if (sent !== undefined) { + const approval = asSharedUseApproval(sent); + return approval ? { appId, approval } : "unreadable"; + } + if (await shared.use.approvalFor(agentId, appId)) + return { appId, approval: null }; + return { + appId, + approval: exposureOf(await shared.use.botFacts(agentId)), + }; +} + /** Granting and revoking a server, skill or Bot, and a Bot's tool calls back. */ export function mountGrantRoutes({ routes, @@ -235,37 +275,30 @@ export function mountGrantRoutes({ if (refusal) return context.json({ error: refusal }, 403); /* - * A SHARED APP'S ACTION IS GRANTED WITH WHO MAY USE IT, in the one request. Without an approval - * the gate refuses every call the grant just allowed, so a grant with none sent approves the - * Bot's exposure as it stands now — unless an approval is already recorded, which is an - * administrator's earlier decision and not this request's to widen. "Is Shared" is the app's - * answer (its answering row), never the granted row's own column. + * A SHARED APP'S ACTION IS GRANTED WITH WHO MAY USE IT, in the one request. See + * {@link sharedGrantApproval} for what is recorded when none is sent. */ - let approval: SharedUseApproval | null = null; - let appId = ""; - if (kind === "mcp" && shared) { - const [serverId] = grantRef.split("/"); - appId = await shared.use.appIdOf(serverId ?? ""); - if ((await store.serverAddress(appId))?.accountMode === "shared") { - if (body?.approval !== undefined) { - approval = asSharedUseApproval(body.approval); - if (!approval) { - return context.json( - { error: "That approval is not one this deployment can read." }, - 400, - ); - } - } else if (!(await shared.use.approvalFor(grantAgentId, appId))) { - approval = exposureOf(await shared.use.botFacts(grantAgentId)); - } - } + const decided = + kind === "mcp" + ? await sharedGrantApproval( + { store, shared }, + grantRef, + grantAgentId, + body?.approval, + ) + : null; + if (decided === "unreadable") { + return context.json( + { error: "That approval is not one this deployment can read." }, + 400, + ); } await store.grant(kind, grantRef, grantAgentId, actorEmail(context)); - if (approval && shared) { + if (decided?.approval && shared) { await shared.use.setApproval({ botId: grantAgentId, - serverId: appId, - approval, + serverId: decided.appId, + approval: decided.approval, by: context.var.actor.id, }); } diff --git a/server/src/plugins/routes.ts b/server/src/plugins/routes.ts index e78ce8b17a..f3032f8b03 100644 --- a/server/src/plugins/routes.ts +++ b/server/src/plugins/routes.ts @@ -3,6 +3,7 @@ import type { BotAccessCheck } from "../agents/profile-policy"; import type { AppVariables } from "../auth/guards"; import { BROKERED_PROFILE, type BrokerProvider, toolkitOfFor } from "./access"; import type { AccountModeSwitch } from "./account-mode"; +import { mountAppAccessRoutes } from "./route-groups/app-access"; import type { ComposioBroker } from "./broker"; import { mountComposioAppRoutes } from "./route-groups/composio-apps"; import { mountConnectionRoutes } from "./route-groups/connections"; @@ -213,6 +214,7 @@ export function createPluginRoutes( mountOAuthCallbackRoute(context); mountSkillRoutes(context); mountGrantRoutes(context); + mountAppAccessRoutes(context); return routes; } diff --git a/server/src/plugins/shared-use-store.ts b/server/src/plugins/shared-use-store.ts index 1ac9f96096..58a071655f 100644 --- a/server/src/plugins/shared-use-store.ts +++ b/server/src/plugins/shared-use-store.ts @@ -676,9 +676,34 @@ export function createSharedUseStore( return row; } + /** + * Whether the gate would let this person's own chat with this Bot reach the app's team account — + * the same approval, owner and audience test the gate makes for a person-started run, and nothing + * it does after refusing. For a screen saying what a call would meet; the gate still decides + * every call. + */ + async function admitsPerson(input: { + botId: string; + serverId: string; + actorId: string; + }): Promise { + const approval = await approvalFor(input.botId, input.serverId); + if (!approval) return false; + const facts = await botFacts(input.botId); + return admits( + approval, + facts.ownerUserId, + await actorFacts(input.actorId), + { + outside: false, + }, + ); + } + return { appIdOf, approvalFor, + admitsPerson, setApproval, deleteApprovalsFor, botFacts, diff --git a/server/src/plugins/store.ts b/server/src/plugins/store.ts index e74eb9e77e..b06db612ca 100644 --- a/server/src/plugins/store.ts +++ b/server/src/plugins/store.ts @@ -1,3 +1,4 @@ +import { appAccessMethods } from "./plugin-store/app-access"; import { callMethods } from "./plugin-store/calls"; import { connectionMethods } from "./plugin-store/connections"; import { createStoreContext } from "./plugin-store/context"; @@ -52,6 +53,7 @@ export function createPluginStore(options: PluginStoreOptions) { ...grantMethods(context), ...connectionMethods(context), ...callMethods(context), + ...appAccessMethods(context), }; } diff --git a/server/src/plugins/tools.ts b/server/src/plugins/tools.ts index baa73f39af..9496f2f6a9 100644 --- a/server/src/plugins/tools.ts +++ b/server/src/plugins/tools.ts @@ -8,6 +8,10 @@ import { catalogueEntry, researchRoleFor, } from "./catalogue"; +import { + addressedTo, + SHOW_APP_ACCESS_TOOL, +} from "@openbot/shared/connect-prompt"; import { type ResearchRole, RESEARCH_TOOLS } from "@openbot/shared/research"; import type { SelectableSkill } from "./selection"; import { @@ -100,10 +104,19 @@ export function grantedToolGuidance( */ connectedButNotHeld: readonly string[] = [], ): string { - if (tools.length === 0 && connectedButNotHeld.length === 0) return ""; + /* + * THE CARD TOOL IS NOT A SYSTEM. It is offered to every Bot so a missing app can be put in front of + * the person with the step that fixes it, and it reaches nothing — listed beside the vendors it + * would read as one more system this Bot "can reach directly, as the person asking". It is named + * below instead, at exactly the sentences that used to end on "an administrator can grant it". + */ + const showsCards = tools.some((tool) => tool.name === SHOW_APP_ACCESS_TOOL); + const reaching = tools.filter((tool) => tool.name !== SHOW_APP_ACCESS_TOOL); + if (reaching.length === 0 && connectedButNotHeld.length === 0 && !showsCards) + return ""; const bySystem = new Map(); - for (const tool of tools) { + for (const tool of reaching) { // `mcp__server__tool`, which is the shape the model is offered. const parts = tool.name.replace(/^mcp__/, "").split("__"); const system = parts.length > 1 ? (parts[0] as string) : "this deployment"; @@ -116,10 +129,10 @@ export function grantedToolGuidance( (system) => !held.includes(system), ); - const research = researchProviders(tools); + const research = researchProviders(reaching); return [ - ...(tools.length > 0 + ...(reaching.length > 0 ? [ "You can reach these systems directly, as the person asking, with their own access:", ] @@ -127,7 +140,7 @@ export function grantedToolGuidance( ...[...bySystem.entries()].map( ([system, names]) => `- ${system}: ${names.join(", ")}`, ), - ...(tools.length > 0 + ...(reaching.length > 0 ? [ "Use them for anything about those systems. Do NOT browse to one of their websites instead: your", "browser is signed in as nobody, so it sees less than these tools do and will meet a sign-in wall", @@ -151,12 +164,35 @@ export function grantedToolGuidance( */ ...(missing.length > 0 ? [ - ...(tools.length > 0 ? [""] : []), + ...(reaching.length > 0 ? [""] : []), `This deployment also connects to: ${missing.join(", ")}. You hold none of their tools.`, - "If a question needs one of them, say plainly that you have not been granted it and that an", - "administrator can grant it on that connector. Do NOT browse to its website: that is not the", - "same thing, your browser is signed in as nobody, and it will meet a sign-in wall that the", - "connector exists to avoid. Do not ask the person to sign in there either.", + ...(showsCards + ? [ + `If a question needs one of them, call ${SHOW_APP_ACCESS_TOOL} with its name: that puts a card in`, + "front of the person saying you have not been granted it, with a button to ask an administrator", + "for it. Then say so in a sentence; the card already carries the rest.", + ] + : [ + "If a question needs one of them, say plainly that you have not been granted it and that an", + "administrator can grant it on that connector.", + ]), + "Do NOT browse to its website: that is not the same thing, your browser is signed in as nobody,", + "and it will meet a sign-in wall that the connector exists to avoid. Do not ask the person to", + "sign in there either.", + ] + : []), + /* + * AN APP THIS DEPLOYMENT DOES NOT CONNECT AT ALL is the one case nothing above names, and the + * one a Bot met most bluntly: asked about Gmail on a deployment that has not switched it on, it + * had no word for Gmail anywhere in its instructions and either browsed or said it could not. + * The card says whether it can be switched on and lets the person ask for it. + */ + ...(showsCards + ? [ + ...(reaching.length > 0 || missing.length > 0 ? [""] : []), + `If the person needs a work app that is not named here at all (Gmail, Slack, Salesforce), call`, + `${SHOW_APP_ACCESS_TOOL} with its name before saying you cannot reach it: the card tells them whether it`, + "can be switched on and lets them ask an administrator. Do not browse to its website instead.", ] : []), ].join("\n"); @@ -281,7 +317,12 @@ export async function grantedTools(options: { error instanceof PluginRefusedError || error instanceof ApprovalRefusedError ) { - return `${REFUSAL_MARKER} ${error.message}`; + /* + * A refusal that carries an app card names the Bot here, the one place both are known: + * in a channel several Bots answer in, the card has to ask what the app is for this + * person on THIS Bot, not on whichever one the surface happens to have declared. + */ + return `${REFUSAL_MARKER} ${addressedTo(error.message, botId)}`; } /* * A contradiction in this deployment's own tables says nothing to a model. diff --git a/server/tests/app-access-routes.integration.test.ts b/server/tests/app-access-routes.integration.test.ts new file mode 100644 index 0000000000..1fccc556c9 --- /dev/null +++ b/server/tests/app-access-routes.integration.test.ts @@ -0,0 +1,1551 @@ +// server/tests/app-access-routes.integration.test.ts +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + test, +} from "bun:test"; +import { randomUUID } from "node:crypto"; +import { and, eq, inArray, like } from "drizzle-orm"; +import { Hono, type MiddlewareHandler } from "hono"; +import type { AuditEventInput, AuditStore } from "../src/audit"; +import type { AppVariables } from "../src/auth/guards"; +import { createDatabase } from "../src/db/client"; +import { + agentProfiles, + agents, + appAccessRequests, + brokeredConnections, + credentials, + mcpServers, + mcpTools, + mcpUserCredentials, + pluginGrants, + sharedUseApprovals, + teamBotConsents, + teamBotPublications, + users, +} from "../src/db/schema"; +import type { AppAccess } from "../src/plugins/app-access"; +import type { BrokerApp } from "../src/plugins/broker"; +import type { AppAccessRequestRecord } from "../src/plugins/plugin-store/app-access"; +import { MAX_PENDING_REQUESTS } from "../src/plugins/route-groups/app-access"; +import { createPluginRoutes } from "../src/plugins/routes"; +import { exposureOf } from "../src/plugins/shared-use"; +import { createSharedUseStore } from "../src/plugins/shared-use-store"; +import { + createPluginStore, + type PluginStore, + type ServerRecord, +} from "../src/plugins/store"; +import { TEST_POOL, testDatabaseUrl } from "./support/database"; +import { stubBroker } from "./support/stub-broker"; + +/** + * What a work app is for one person on one Bot, the requests they file when only an administrator + * can change it, and what approving one does — against the real tables, through the real routes. + * + * The vendor is the one thing faked. `addBrokeredApp` refreshes an app's tools from Composio + * itself, which no test can reach, so the store's enable is replaced with one that writes the row + * and a tool the way the real one ends; everything around it — the directory check, the grant, the + * Shared approval, the conditional close, the trail — is the shipped code. + */ + +const database = createDatabase(testDatabaseUrl(), TEST_POOL); +const suite = randomUUID().slice(0, 8); +const id = (name: string) => `${name}-${suite}`; + +const person = id("person"); +const other = id("other"); +const admin = id("admin"); +const bot = id("bot"); +const hidden = id("hidden"); + +/** A Composio slug for this suite, and the row id `addBrokeredApp` would give it. */ +const slug = (name: string) => `${name}_${suite}`; +const row = (name: string) => `composio-${slug(name)}`; + +const GMAIL = row("gmail"); +const SLACK = row("slack"); +const LINEAR = row("linear"); +const NEWS = row("news"); +const CALENDAR = row("calendar"); +const GONE = row("gone"); +const DROPBOX = row("dropbox"); + +const events: AuditEventInput[] = []; +const audit: AuditStore = { + insert: async (event) => { + events.push(event); + }, +}; +const eventsOf = (type: string) => + events.filter((event) => event.eventType === type); + +const directory: BrokerApp[] = [ + { + slug: slug("dropbox"), + name: "Dropbox", + description: "Files", + logo: null, + categories: [], + actionCount: 1, + classifiedCount: null, + permissionCount: null, + scopedActionCount: null, + connection: { kind: "consent" }, + }, + { + slug: slug("gone"), + name: "Gone App", + description: "", + logo: null, + categories: [], + actionCount: 1, + classifiedCount: null, + permissionCount: null, + scopedActionCount: null, + connection: { kind: "consent" }, + }, +]; + +/** + * The broker, built by `stubBroker`: every member this file does not provide THROWS AND RECORDS, + * and the check after each test reads the record, so a path that reaches past what is provided is + * a red even where production catches the throw. Nothing here undoes one account, so + * `revokeAccount` is not provided rather than stubbed to answer "done" for a revoke that never ran. + */ +const stub = stubBroker({ + // Composio's own declarations: the grant is held here, and there is no authorization flow. + grantIsLocal: true, + authorizations: null, + listApps: async () => directory, + ensureAuthConfig: async () => "created", + deleteAuthConfig: async () => {}, + authorize: async () => ({ + redirectUrl: "https://vendor.example/consent", + authorizationId: null, + }), + isConnected: async () => true, + revoke: async () => true, + connectionFields: async () => [], + connectWithFields: async () => ({ accountId: "ca_1" }), + accountName: async () => "team", +}); +const broker = stub.broker; +afterEach(() => { + stub.expectNothingUnprovidedReached(); +}); + +const enabled: string[] = []; + +/** + * Run before every grant the routes make, when set — how a test holds an approval partway through + * its change while something else answers the same request. + */ +let beforeGrant: (() => Promise) | null = null; + +function storeFor(db = database): PluginStore { + const real = createPluginStore({ + brokerProvider: "composio", + database: db, + auditStore: audit, + credentials: {} as never, + encryptionKey: "x".repeat(44), + policy: () => ({ mode: "enforce", deny: [], allow: ["true"] }), + broker, + deploymentId: `acme-${suite}`, + }); + return { + ...real, + async grant(...args) { + await beforeGrant?.(); + return real.grant(...args); + }, + async addBrokeredApp(input) { + enabled.push(input.slug); + await database.insert(mcpServers).values({ + id: `composio-${input.slug}`, + title: input.title, + vendor: "Composio", + url: `composio://${input.slug}`, + provenance: "composio", + authScheme: "OAUTH2", + accountMode: "personal", + }); + await database.insert(mcpTools).values({ + serverId: `composio-${input.slug}`, + name: "LIST_FILES", + description: "List files", + inputSchema: {}, + effect: "read", + }); + return { id: `composio-${input.slug}` } as ServerRecord; + }, + }; +} + +function appAs( + actorId: string, + role: "admin" | "user" = "user", + options: { composio?: boolean; database?: typeof database } = {}, +) { + const store = storeFor(options.database); + const signedIn: MiddlewareHandler<{ Variables: AppVariables }> = async ( + context, + next, + ) => { + context.set("actor", { + id: actorId, + email: `${actorId}@example.test`, + role, + } as never); + await next(); + }; + const sharedUse = createSharedUseStore( + options.database ?? database, + "composio", + ); + return new Hono().route( + "/api/plugins", + createPluginRoutes( + store, + signedIn, + // Everybody may use the suite's Bot; nobody but an administrator may use the hidden one. + async (actor, agentId) => agentId !== hidden || actor.role === "admin", + undefined, + options.composio === false ? undefined : { broker, provider: "composio" }, + { modes: {} as never, use: sharedUse }, + ), + ); +} + +async function json(response: Response): Promise { + return (await response.json()) as T; +} + +const post = (body: unknown) => ({ + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), +}); + +async function seedApp(input: { + key: string; + title: string; + toolkit?: string; + authScheme?: string | null; + accountMode?: "personal" | "shared" | null; + tools?: string[]; + /** Each tool's `effect`, by name; a tool left out is unclassified, which reads as a write. */ + effects?: Record; + destructive?: string[]; + grant?: boolean; +}) { + await database.insert(mcpServers).values({ + id: input.key, + title: input.title, + vendor: "Composio", + url: `composio://${input.toolkit ?? input.key.replace(/^composio-/, "")}`, + provenance: "composio", + authScheme: input.authScheme === undefined ? "OAUTH2" : input.authScheme, + accountMode: + input.accountMode === undefined ? "personal" : input.accountMode, + sharedVendorUserId: + input.accountMode === "shared" ? `openbot-deployment:${suite}` : null, + }); + for (const tool of input.tools ?? ["READ"]) + await database.insert(mcpTools).values({ + serverId: input.key, + name: tool, + description: tool, + inputSchema: {}, + effect: input.effects?.[tool] ?? null, + destructive: input.destructive?.includes(tool) ?? false, + }); + if (input.grant !== false) + await database.insert(pluginGrants).values({ + kind: "mcp", + ref: `${input.key}/${(input.tools ?? ["READ"])[0]}`, + agentId: bot, + }); +} + +const ourServers = like(mcpServers.id, `%${suite}%`); + +/** An administrator's approval of the suite Bot's audience for a Shared app, as granting writes. */ +async function approveAudience(serverId: string) { + const sharedUse = createSharedUseStore(database, "composio"); + await sharedUse.setApproval({ + botId: bot, + serverId, + approval: exposureOf(await sharedUse.botFacts(bot)), + by: admin, + }); +} + +async function connectTeamAccount(name: string) { + await database.insert(brokeredConnections).values({ + provider: "composio", + app: slug(name), + holder: "deployment", + vendorUserId: `openbot-deployment:${suite}`, + verified: true, + }); +} + +async function clearApps() { + await database + .delete(teamBotPublications) + .where(inArray(teamBotPublications.agentId, [bot, hidden])); + await database + .delete(teamBotConsents) + .where(inArray(teamBotConsents.userId, [person, other, admin])); + await database + .delete(appAccessRequests) + .where(inArray(appAccessRequests.requestedBy, [person, other, admin])); + await database + .delete(pluginGrants) + .where(inArray(pluginGrants.agentId, [bot, hidden])); + await database + .delete(brokeredConnections) + .where(like(brokeredConnections.app, `%${suite}%`)); + await database + .delete(sharedUseApprovals) + .where(eq(sharedUseApprovals.agentId, bot)); + await database.delete(mcpServers).where(ourServers); + await database + .delete(mcpUserCredentials) + .where(eq(mcpUserCredentials.serverId, "google-drive")); + await database.delete(mcpServers).where(eq(mcpServers.id, "google-drive")); + await database + .delete(credentials) + .where(eq(credentials.provider, `google-drive-${suite}`)); +} + +beforeAll(async () => { + for (const user of [person, other, admin]) + await database.insert(users).values({ + id: user, + email: `${user}@example.test`, + name: user, + emailVerified: true, + }); + for (const agent of [bot, hidden]) { + await database.insert(agents).values({ + id: agent, + name: `Bot ${agent}`, + type: "built_in", + configuration: {}, + }); + await database.insert(agentProfiles).values({ + agentId: agent, + ownerUserId: person, + visibility: "private", + title: "Helper", + roleDescription: "Helper", + avatarSeed: "x", + }); + } +}); + +beforeEach(async () => { + events.length = 0; + enabled.length = 0; + beforeGrant = null; + await clearApps(); +}); + +afterAll(async () => { + await clearApps(); + await database.delete(agents).where(inArray(agents.id, [bot, hidden])); + await database.delete(users).where(inArray(users.id, [person, other, admin])); +}); + +describe("what each app a Bot holds is, for the person asking", () => { + test("every state a held app can be in, named by the app's own name", async () => { + await seedApp({ key: GMAIL, title: "Gmail" }); + await seedApp({ key: SLACK, title: "Slack", accountMode: "shared" }); + await seedApp({ key: LINEAR, title: "Linear", authScheme: "API_KEY" }); + await seedApp({ + key: NEWS, + title: "Hacker News", + authScheme: "NO_AUTH", + accountMode: null, + }); + await seedApp({ key: CALENDAR, title: "Google Calendar", grant: false }); + // A grant whose app is gone: still something this Bot was given. + await database + .insert(pluginGrants) + .values({ kind: "mcp", ref: `${GONE}/SEND`, agentId: bot }); + // Linear connected, and its key refused by the vendor at the last check. + await database.insert(brokeredConnections).values({ + provider: "composio", + app: slug("linear"), + holder: "person", + userId: person, + vendorUserId: `vendor-${person}`, + verified: false, + probeAction: "LINEAR_LIST", + }); + + const response = await appAs(person).request( + `/api/plugins/for/${bot}/apps`, + ); + expect(response.status).toBe(200); + const { apps } = await json<{ apps: AppAccess[] }>(response); + const byKey = new Map(apps.map((app) => [app.key, app])); + + expect(byKey.get(GMAIL)).toMatchObject({ + displayName: "Gmail", + state: "not_connected", + account: "personal", + connect: "consent", + requestKind: null, + granted: true, + }); + expect(byKey.get(SLACK)).toMatchObject({ + state: "shared_not_connected", + account: "shared", + requestKind: "connect_shared", + }); + expect(byKey.get(LINEAR)).toMatchObject({ + state: "reconnect", + connect: "key", + }); + expect(byKey.get(NEWS)).toMatchObject({ + state: "ready", + account: "deployment", + }); + expect(byKey.get(GONE)).toMatchObject({ + displayName: "Gone App", + state: "disabled", + requestKind: "enable", + }); + // Not held, so not on the Bot's list. + expect(byKey.has(CALENDAR)).toBe(false); + // Sorted by name. + expect(apps.map((app) => app.displayName)).toEqual( + [...apps.map((app) => app.displayName)].sort((a, b) => + a.localeCompare(b), + ), + ); + }); + + test("a usable connection is ready, and somebody else's is not mine", async () => { + await seedApp({ key: GMAIL, title: "Gmail" }); + await database.insert(brokeredConnections).values({ + provider: "composio", + app: slug("gmail"), + holder: "person", + userId: other, + vendorUserId: `vendor-${other}`, + verified: true, + connectedAt: new Date("2026-10-20T12:00:00.000Z"), + }); + const mine = await json<{ apps: AppAccess[] }>( + await appAs(person).request(`/api/plugins/for/${bot}/apps`), + ); + expect(mine.apps[0]?.state).toBe("not_connected"); + // Nothing of theirs leaks into mine, not even when they connected. + expect(mine.apps[0]?.connectedAt).toBeNull(); + const theirs = await json<{ apps: AppAccess[] }>( + await appAs(other).request(`/api/plugins/for/${bot}/apps`), + ); + expect(theirs.apps[0]?.state).toBe("ready"); + // When, so a chat card over a lost account can tell a reconnection from the loss. + expect(theirs.apps[0]?.connectedAt).toBe("2026-10-20T12:00:00.000Z"); + }); + + test("a Shared app with its team account connected and its audience approved is ready", async () => { + await seedApp({ key: SLACK, title: "Slack", accountMode: "shared" }); + await approveAudience(SLACK); + await database.insert(brokeredConnections).values({ + provider: "composio", + app: slug("slack"), + holder: "deployment", + vendorUserId: `openbot-deployment:${suite}`, + verified: true, + connectedAt: new Date("2026-10-20T09:30:00.000Z"), + }); + const { apps } = await json<{ apps: AppAccess[] }>( + await appAs(person).request(`/api/plugins/for/${bot}/apps`), + ); + expect(apps[0]?.state).toBe("ready"); + // The team account's, since that is the account the app runs in for everybody. + expect(apps[0]?.connectedAt).toBe("2026-10-20T09:30:00.000Z"); + }); + + test("with no Composio key every Composio app is switched off", async () => { + await seedApp({ key: GMAIL, title: "Gmail" }); + const { apps } = await json<{ apps: AppAccess[] }>( + await appAs(person, "user", { composio: false }).request( + `/api/plugins/for/${bot}/apps`, + ), + ); + expect(apps[0]).toMatchObject({ state: "disabled", requestKind: "enable" }); + }); + + test("a Bot the person cannot use answers 404", async () => { + const response = await appAs(person).request( + `/api/plugins/for/${hidden}/apps`, + ); + expect(response.status).toBe(404); + }); + + test("a per-person catalogue app reads its credential, withdrawn or not", async () => { + const [client] = await database + .insert(credentials) + .values({ + kind: "mcp_oauth_client", + provider: `google-drive-${suite}`, + keyId: "oauth-client", + metadata: {}, + encryptedValue: "sealed", + }) + .returning({ id: credentials.id }); + await database.insert(mcpServers).values({ + id: "google-drive", + title: "Google Drive", + vendor: "Google", + url: "https://www.googleapis.com/drive/v3", + provenance: "first-party", + credentialId: client?.id ?? null, + }); + await database + .insert(pluginGrants) + .values({ kind: "mcp", ref: "google-drive/search", agentId: bot }); + const [credential] = await database + .insert(credentials) + .values({ + kind: "mcp_user_token", + provider: `google-drive-${suite}`, + keyId: person, + metadata: {}, + encryptedValue: "sealed", + revokedAt: new Date(), + }) + .returning({ id: credentials.id }); + if (!credential) throw new Error("credential not stored"); + await database.insert(mcpUserCredentials).values({ + serverId: "google-drive", + userId: person, + credentialId: credential.id, + scope: "drive.readonly", + }); + const { apps } = await json<{ apps: AppAccess[] }>( + await appAs(person).request(`/api/plugins/for/${bot}/apps`), + ); + expect(apps[0]).toMatchObject({ + key: "google-drive", + displayName: "Google Drive", + provider: "catalogue", + connect: "oauth", + state: "reconnect", + }); + }); +}); + +describe("one app, by key or by name, held or not", () => { + test("an enabled app the Bot does not hold is not_granted, found by its name", async () => { + await seedApp({ key: CALENDAR, title: "Google Calendar", grant: false }); + for (const asked of [CALENDAR, slug("calendar"), "google calendar"]) { + const response = await appAs(person).request( + `/api/plugins/for/${bot}/apps/${encodeURIComponent(asked)}`, + ); + expect(response.status).toBe(200); + expect((await json<{ app: AppAccess }>(response)).app).toMatchObject({ + key: CALENDAR, + state: "not_granted", + requestKind: "grant", + }); + } + }); + + test("an app only the directory knows is disabled, under the id enabling would mint", async () => { + const response = await appAs(person).request( + `/api/plugins/for/${bot}/apps/Dropbox`, + ); + expect((await json<{ app: AppAccess }>(response)).app).toMatchObject({ + key: DROPBOX, + displayName: "Dropbox", + provider: "composio", + state: "disabled", + requestKind: "enable", + }); + }); + + test("a catalogue entry nobody added is disabled under its key", async () => { + const response = await appAs(person).request( + `/api/plugins/for/${bot}/apps/notion`, + ); + expect((await json<{ app: AppAccess }>(response)).app).toMatchObject({ + key: "notion", + displayName: "Notion", + provider: "catalogue", + state: "disabled", + }); + }); + + test("a word that names no app is 404", async () => { + const response = await appAs(person).request( + `/api/plugins/for/${bot}/apps/${encodeURIComponent("Not An App")}`, + ); + expect(response.status).toBe(404); + }); +}); + +describe("asking for access", () => { + test("files the kind the state needs, once, and the status shows it", async () => { + await seedApp({ key: CALENDAR, title: "Google Calendar", grant: false }); + const first = await appAs(person).request( + "/api/plugins/access-requests", + post({ agentId: bot, app: "Google Calendar" }), + ); + expect(first.status).toBe(201); + const filed = await json<{ + request: AppAccessRequestRecord; + created: boolean; + }>(first); + expect(filed).toMatchObject({ + created: true, + request: { + appKey: CALENDAR, + appTitle: "Google Calendar", + kind: "grant", + status: "pending", + agentId: bot, + requestedBy: person, + }, + }); + + const again = await appAs(person).request( + "/api/plugins/access-requests", + post({ agentId: bot, app: CALENDAR }), + ); + expect(again.status).toBe(200); + expect( + await json<{ request: { id: string }; created: boolean }>(again), + ).toMatchObject({ + created: false, + request: { id: filed.request.id }, + }); + expect(eventsOf("app_access.requested")).toHaveLength(1); + + const status = await json<{ app: AppAccess }>( + await appAs(person).request(`/api/plugins/for/${bot}/apps/${CALENDAR}`), + ); + expect(status.app.request).toMatchObject({ + id: filed.request.id, + kind: "grant", + }); + // Somebody else asking files their own. + const theirs = await appAs(other).request( + "/api/plugins/access-requests", + post({ agentId: bot, app: CALENDAR }), + ); + expect(theirs.status).toBe(201); + }); + + test("two presses at once still leave one pending request", async () => { + await seedApp({ key: CALENDAR, title: "Google Calendar", grant: false }); + const responses = await Promise.all( + [1, 2, 3].map(() => + appAs(person).request( + "/api/plugins/access-requests", + post({ agentId: bot, app: CALENDAR }), + ), + ), + ); + const ids = new Set( + await Promise.all( + responses.map( + async (response) => + (await json<{ request: { id: string } }>(response)).request.id, + ), + ), + ); + expect(ids.size).toBe(1); + const rows = await database + .select() + .from(appAccessRequests) + .where( + and( + eq(appAccessRequests.requestedBy, person), + eq(appAccessRequests.status, "pending"), + ), + ); + expect(rows).toHaveLength(1); + }); + + test("a state the person can fix themselves is refused, and the refusal is in the trail", async () => { + await seedApp({ key: GMAIL, title: "Gmail" }); + const response = await appAs(person).request( + "/api/plugins/access-requests", + post({ agentId: bot, app: GMAIL }), + ); + expect(response.status).toBe(409); + expect(eventsOf("app_access.refused")[0]?.payload).toMatchObject({ + app: GMAIL, + reason: "nothing_to_request:not_connected", + }); + }); + + test("a Bot the person cannot use, and an unknown app, are refused and recorded", async () => { + const hiddenBot = await appAs(person).request( + "/api/plugins/access-requests", + post({ agentId: hidden, app: "Dropbox" }), + ); + expect(hiddenBot.status).toBe(404); + const unknown = await appAs(person).request( + "/api/plugins/access-requests", + post({ agentId: bot, app: "Not An App" }), + ); + expect(unknown.status).toBe(404); + expect( + eventsOf("app_access.refused").map((event) => event.payload.reason), + ).toEqual(["no_such_bot", "no_such_app"]); + const malformed = await appAs(person).request( + "/api/plugins/access-requests", + post({ agentId: 7 }), + ); + expect(malformed.status).toBe(400); + }); + + test("a person lists and cancels their own, and cannot touch anybody else's", async () => { + await seedApp({ key: CALENDAR, title: "Google Calendar", grant: false }); + const { request } = await json<{ request: AppAccessRequestRecord }>( + await appAs(person).request( + "/api/plugins/access-requests", + post({ agentId: bot, app: CALENDAR }), + ), + ); + const mine = await json<{ requests: AppAccessRequestRecord[] }>( + await appAs(person).request("/api/plugins/access-requests/mine"), + ); + expect(mine.requests.map((row) => row.id)).toEqual([request.id]); + expect( + ( + await json<{ requests: AppAccessRequestRecord[] }>( + await appAs(other).request("/api/plugins/access-requests/mine"), + ) + ).requests, + ).toEqual([]); + + const notTheirs = await appAs(other).request( + `/api/plugins/access-requests/${request.id}`, + { method: "DELETE" }, + ); + expect(notTheirs.status).toBe(404); + + const cancelled = await appAs(person).request( + `/api/plugins/access-requests/${request.id}`, + { method: "DELETE" }, + ); + expect(cancelled.status).toBe(200); + expect( + (await json<{ request: AppAccessRequestRecord }>(cancelled)).request, + ).toMatchObject({ status: "cancelled", resolvedBy: person }); + expect(eventsOf("app_access.cancelled")).toHaveLength(1); + + const twice = await appAs(person).request( + `/api/plugins/access-requests/${request.id}`, + { method: "DELETE" }, + ); + expect(twice.status).toBe(409); + }); +}); + +describe("answering a request", () => { + async function file(app: string, as = person) { + const response = await appAs(as).request( + "/api/plugins/access-requests", + post({ agentId: bot, app }), + ); + return (await json<{ request: AppAccessRequestRecord }>(response)).request; + } + + test("only an administrator sees the inbox and its counts", async () => { + await seedApp({ key: CALENDAR, title: "Google Calendar", grant: false }); + await file(CALENDAR); + await file(CALENDAR, other); + await file("Dropbox"); + + expect( + (await appAs(person).request("/api/plugins/access-requests")).status, + ).toBe(403); + const inbox = await json<{ + requests: AppAccessRequestRecord[]; + counts: { total: number; byApp: Record }; + }>( + await appAs(admin, "admin").request( + `/api/plugins/access-requests?app=${CALENDAR}`, + ), + ); + expect(inbox.requests).toHaveLength(2); + expect(inbox.requests[0]).toMatchObject({ + botName: `Bot ${bot}`, + requesterEmail: expect.stringContaining("@example.test"), + }); + expect(inbox.counts.byApp[CALENDAR]).toBe(2); + expect(inbox.counts.byApp[DROPBOX]).toBe(1); + expect(inbox.counts.total).toBeGreaterThanOrEqual(3); + }); + + test("approving a grant request grants the app's tools and records the Shared approval", async () => { + await seedApp({ + key: SLACK, + title: "Slack", + accountMode: "shared", + tools: ["SEND", "LIST"], + effects: { LIST: "read", SEND: "write" }, + grant: false, + }); + const request = await file(SLACK); + expect(request.kind).toBe("grant"); + + expect( + ( + await appAs(person).request( + `/api/plugins/access-requests/${request.id}/approve`, + { method: "POST" }, + ) + ).status, + ).toBe(403); + + const response = await appAs(admin, "admin").request( + `/api/plugins/access-requests/${request.id}/approve`, + { method: "POST" }, + ); + expect(response.status).toBe(200); + // The read only: sending is chosen on the Bot's page, never handed out by one press. + expect(await response.json()).toMatchObject({ + request: { status: "approved", resolvedBy: admin }, + enabled: false, + granted: [`${SLACK}/LIST`], + }); + const grants = await database + .select({ ref: pluginGrants.ref }) + .from(pluginGrants) + .where(eq(pluginGrants.agentId, bot)); + expect(grants.map((grant) => grant.ref).sort()).toEqual([`${SLACK}/LIST`]); + const [approval] = await database + .select() + .from(sharedUseApprovals) + .where(eq(sharedUseApprovals.agentId, bot)); + expect(approval?.serverId).toBe(SLACK); + expect(eventsOf("app_access.approved")[0]?.payload).toMatchObject({ + request: request.id, + kind: "grant", + granted: 1, + }); + + // The person now sees the next step, which is not theirs to ask for. + const { app } = await json<{ app: AppAccess }>( + await appAs(person).request(`/api/plugins/for/${bot}/apps/${SLACK}`), + ); + expect(app).toMatchObject({ state: "shared_not_connected", request: null }); + + const twice = await appAs(admin, "admin").request( + `/api/plugins/access-requests/${request.id}/approve`, + { method: "POST" }, + ); + expect(twice.status).toBe(409); + }); + + test("`refs` narrows the grant, and a ref outside the app is refused", async () => { + await seedApp({ + key: CALENDAR, + title: "Google Calendar", + tools: ["READ", "WRITE"], + grant: false, + }); + const request = await file(CALENDAR); + const outside = await appAs(admin, "admin").request( + `/api/plugins/access-requests/${request.id}/approve`, + post({ refs: [`${GMAIL}/READ`] }), + ); + expect(outside.status).toBe(400); + expect(eventsOf("app_access.refused").at(-1)?.payload).toMatchObject({ + reason: "refs_outside_app", + request: request.id, + }); + const narrowed = await appAs(admin, "admin").request( + `/api/plugins/access-requests/${request.id}/approve`, + post({ refs: [`${CALENDAR}/READ`] }), + ); + expect(narrowed.status).toBe(200); + expect((await json<{ granted: string[] }>(narrowed)).granted).toEqual([ + `${CALENDAR}/READ`, + ]); + }); + + test("approving an enable request switches the app on and grants it to the Bot", async () => { + const request = await file("Dropbox"); + expect(request).toMatchObject({ kind: "enable", appKey: DROPBOX }); + const response = await appAs(admin, "admin").request( + `/api/plugins/access-requests/${request.id}/approve`, + { method: "POST" }, + ); + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ + enabled: true, + granted: [`${DROPBOX}/LIST_FILES`], + }); + expect(enabled).toEqual([slug("dropbox")]); + const { app } = await json<{ app: AppAccess }>( + await appAs(person).request(`/api/plugins/for/${bot}/apps/${DROPBOX}`), + ); + expect(app).toMatchObject({ + state: "not_connected", + displayName: "Dropbox", + }); + }); + + test("an app that needs what only an administrator can type stays pending", async () => { + const request = await file("google-drive"); + expect(request.kind).toBe("enable"); + const response = await appAs(admin, "admin").request( + `/api/plugins/access-requests/${request.id}/approve`, + { method: "POST" }, + ); + expect(response.status).toBe(409); + expect((await json<{ error: string }>(response)).error).toContain( + "OAuth client", + ); + const [row] = await database + .select({ status: appAccessRequests.status }) + .from(appAccessRequests) + .where(eq(appAccessRequests.id, request.id)); + expect(row?.status).toBe("pending"); + expect(eventsOf("app_access.refused").at(-1)?.payload).toMatchObject({ + reason: "needs_admin_input", + }); + }); + + test("with no Composio key an enable request is refused with the setting to change", async () => { + await seedApp({ key: GMAIL, title: "Gmail" }); + const response = await appAs(person, "user", { composio: false }).request( + "/api/plugins/access-requests", + post({ agentId: bot, app: GMAIL }), + ); + const { request } = await json<{ request: AppAccessRequestRecord }>( + response, + ); + expect(request.kind).toBe("enable"); + const approve = await appAs(admin, "admin", { composio: false }).request( + `/api/plugins/access-requests/${request.id}/approve`, + { method: "POST" }, + ); + expect(approve.status).toBe(503); + expect((await json<{ error: string }>(approve)).error).toContain( + "COMPOSIO_API_KEY", + ); + }); + + test("a team-account request is approved only once the account is connected", async () => { + await seedApp({ key: SLACK, title: "Slack", accountMode: "shared" }); + await approveAudience(SLACK); + const request = await file(SLACK); + expect(request.kind).toBe("connect_shared"); + const early = await appAs(admin, "admin").request( + `/api/plugins/access-requests/${request.id}/approve`, + { method: "POST" }, + ); + expect(early.status).toBe(409); + await database.insert(brokeredConnections).values({ + provider: "composio", + app: slug("slack"), + holder: "deployment", + vendorUserId: `openbot-deployment:${suite}`, + verified: true, + }); + const later = await appAs(admin, "admin").request( + `/api/plugins/access-requests/${request.id}/approve`, + { method: "POST" }, + ); + expect(later.status).toBe(200); + const { app } = await json<{ app: AppAccess }>( + await appAs(person).request(`/api/plugins/for/${bot}/apps/${SLACK}`), + ); + expect(app.state).toBe("ready"); + }); + + test("dismissing changes nothing but the request", async () => { + await seedApp({ key: CALENDAR, title: "Google Calendar", grant: false }); + const request = await file(CALENDAR); + const response = await appAs(admin, "admin").request( + `/api/plugins/access-requests/${request.id}/dismiss`, + { method: "POST" }, + ); + expect(response.status).toBe(200); + expect( + (await json<{ request: AppAccessRequestRecord }>(response)).request, + ).toMatchObject({ status: "dismissed", resolvedBy: admin }); + expect(eventsOf("app_access.dismissed")).toHaveLength(1); + const { app } = await json<{ app: AppAccess }>( + await appAs(person).request(`/api/plugins/for/${bot}/apps/${CALENDAR}`), + ); + expect(app).toMatchObject({ state: "not_granted", request: null }); + const again = await appAs(admin, "admin").request( + `/api/plugins/access-requests/${request.id}/dismiss`, + { method: "POST" }, + ); + expect(again.status).toBe(409); + }); +}); + +/* + * THE SAME RULE ON AN ARCADE DEPLOYMENT. Which rows are brokered apps, which connections count and + * which id an app nobody enabled would get all come from the store's broker, so a personal Arcade + * app reads exactly as a personal Composio app does — under Arcade's case-sensitive toolkit name. + */ +describe("on an Arcade deployment", () => { + const toolkit = `ArcMail_${suite}`; + const ARC_MAIL = `arcade-${toolkit}`; + const docs = `ArcDocs_${suite}`; + + function arcadeStore(): PluginStore { + return createPluginStore({ + brokerProvider: "arcade", + database, + auditStore: audit, + credentials: {} as never, + encryptionKey: "x".repeat(44), + policy: () => ({ mode: "enforce", deny: [], allow: ["true"] }), + broker, + deploymentId: `acme-${suite}`, + }); + } + + async function seedArcadeApp() { + await database.insert(mcpServers).values({ + id: ARC_MAIL, + title: "Arc Mail", + vendor: "Arcade", + url: `arcade://${toolkit}`, + provenance: "arcade", + authScheme: "OAUTH2", + accountMode: "personal", + }); + await database.insert(mcpTools).values({ + serverId: ARC_MAIL, + name: "READ", + description: "READ", + inputSchema: {}, + }); + await database + .insert(pluginGrants) + .values({ kind: "mcp", ref: `${ARC_MAIL}/READ`, agentId: bot }); + } + + const forBot = (composioConfigured = true) => + arcadeStore().appAccessForBot({ + userId: person, + agentId: bot, + composioConfigured, + }); + + test("a personal app is not_connected until the person connects it, then ready", async () => { + await seedArcadeApp(); + expect(await forBot()).toMatchObject([ + { + key: ARC_MAIL, + provider: "arcade", + account: "personal", + connect: "consent", + authScheme: "OAUTH2", + state: "not_connected", + }, + ]); + // Somebody else's connection, and a Composio one at the same app name, are not this person's. + await database.insert(brokeredConnections).values([ + { + provider: "arcade", + app: toolkit, + holder: "person", + userId: other, + vendorUserId: `vendor-${other}`, + verified: true, + }, + { + provider: "composio", + app: toolkit, + holder: "person", + userId: person, + vendorUserId: `vendor-${person}`, + verified: true, + }, + ]); + expect((await forBot())[0]?.state).toBe("not_connected"); + await database.insert(brokeredConnections).values({ + provider: "arcade", + app: toolkit, + holder: "person", + userId: person, + vendorUserId: `vendor-${person}`, + verified: true, + connectedAt: new Date("2026-10-21T08:00:00.000Z"), + }); + expect(await forBot()).toMatchObject([ + { + key: ARC_MAIL, + state: "ready", + connectedAt: "2026-10-21T08:00:00.000Z", + }, + ]); + }); + + test("with no Arcade key the app is switched off at the deployment", async () => { + await seedArcadeApp(); + const inspected = await arcadeStore().inspectApp({ + app: ARC_MAIL, + agentId: bot, + userId: person, + composioConfigured: false, + }); + expect(inspected?.unavailable).toBe("composio_unconfigured"); + expect(inspected?.access.state).toBe("disabled"); + }); + + test("an app is found by its id or its toolkit name as typed", async () => { + await seedArcadeApp(); + for (const asked of [ARC_MAIL, toolkit, "arc mail"]) { + const inspected = await arcadeStore().inspectApp({ + app: asked, + agentId: bot, + userId: person, + composioConfigured: true, + }); + expect(inspected?.access.key).toBe(ARC_MAIL); + expect(inspected?.toolkit).toBe(toolkit); + } + }); + + test("an app only the directory knows is disabled, under the arcade- id enabling would mint", async () => { + const inspected = await arcadeStore().inspectApp({ + app: "Arc Docs", + agentId: bot, + userId: person, + composioConfigured: true, + directory: async () => [ + { + slug: docs, + name: "Arc Docs", + description: "", + logo: null, + categories: [], + actionCount: 1, + classifiedCount: null, + permissionCount: null, + scopedActionCount: null, + connection: { kind: "consent" }, + }, + ], + }); + expect(inspected?.access).toMatchObject({ + key: `arcade-${docs}`, + displayName: "Arc Docs", + provider: "arcade", + state: "disabled", + requestKind: "enable", + }); + expect(inspected?.toolkit).toBe(docs); + }); +}); + +describe("what approving grants, and when it can be answered", () => { + async function file(app: string, as = person) { + const response = await appAs(as).request( + "/api/plugins/access-requests", + post({ agentId: bot, app }), + ); + return (await json<{ request: AppAccessRequestRecord }>(response)).request; + } + const statusOf = async (id: string) => + ( + await database + .select({ status: appAccessRequests.status }) + .from(appAccessRequests) + .where(eq(appAccessRequests.id, id)) + )[0]?.status; + + test("without refs only the read-only actions are granted, never a send or a delete", async () => { + await seedApp({ + key: GMAIL, + title: "Gmail", + tools: ["READ_MAIL", "SEND_MAIL", "DELETE_ALL", "UNLABELLED"], + effects: { READ_MAIL: "read", SEND_MAIL: "write", DELETE_ALL: "write" }, + destructive: ["DELETE_ALL"], + grant: false, + }); + const request = await file("Gmail"); + expect(request.kind).toBe("grant"); + const response = await appAs(admin, "admin").request( + `/api/plugins/access-requests/${request.id}/approve`, + { method: "POST" }, + ); + expect(response.status).toBe(200); + expect((await json<{ granted: string[] }>(response)).granted).toEqual([ + `${GMAIL}/READ_MAIL`, + ]); + const grants = await database + .select({ ref: pluginGrants.ref }) + .from(pluginGrants) + .where(eq(pluginGrants.agentId, bot)); + expect(grants.map((grant) => grant.ref)).toEqual([`${GMAIL}/READ_MAIL`]); + }); + + test("an app with no read-only action is refused with where to choose, and stays pending", async () => { + await seedApp({ + key: GMAIL, + title: "Gmail", + tools: ["SEND_MAIL"], + effects: { SEND_MAIL: "write" }, + grant: false, + }); + const request = await file("Gmail"); + const response = await appAs(admin, "admin").request( + `/api/plugins/access-requests/${request.id}/approve`, + { method: "POST" }, + ); + expect(response.status).toBe(409); + expect((await json<{ error: string }>(response)).error).toContain( + "read-only", + ); + expect(await statusOf(request.id)).toBe("pending"); + expect( + await database + .select() + .from(pluginGrants) + .where(eq(pluginGrants.agentId, bot)), + ).toEqual([]); + // Naming the actions is how an administrator grants more than the reads. + const chosen = await appAs(admin, "admin").request( + `/api/plugins/access-requests/${request.id}/approve`, + post({ refs: [`${GMAIL}/SEND_MAIL`] }), + ); + expect(chosen.status).toBe(200); + }); + + for (const [loser, answer] of [ + [ + "a dismissal", + (id: string) => + appAs(admin, "admin").request( + `/api/plugins/access-requests/${id}/dismiss`, + { method: "POST" }, + ), + ], + [ + "a withdrawal", + (id: string) => + appAs(person).request(`/api/plugins/access-requests/${id}`, { + method: "DELETE", + }), + ], + ] as const) { + test(`${loser} arriving while an approval is granting loses to it`, async () => { + await seedApp({ + key: CALENDAR, + title: "Google Calendar", + tools: ["READ"], + effects: { READ: "read" }, + grant: false, + }); + const request = await file(CALENDAR); + + let release = () => {}; + let granting = () => {}; + const held = new Promise((resolve) => { + release = resolve; + }); + const reached = new Promise((resolve) => { + granting = resolve; + }); + beforeGrant = async () => { + granting(); + await held; + }; + try { + const approving = appAs(admin, "admin").request( + `/api/plugins/access-requests/${request.id}/approve`, + { method: "POST" }, + ); + await reached; + // Answered while the grant is still being written: the approval already claimed it. + const lost = await answer(request.id); + expect(lost.status).toBe(409); + release(); + const approved = await approving; + expect(approved.status).toBe(200); + expect(await statusOf(request.id)).toBe("approved"); + expect(eventsOf("app_access.approved")).toHaveLength(1); + expect(eventsOf("app_access.dismissed")).toHaveLength(0); + expect(eventsOf("app_access.cancelled")).toHaveLength(0); + } finally { + release(); + beforeGrant = null; + } + }); + } + + test("an approval that throws partway puts the request back", async () => { + await seedApp({ + key: CALENDAR, + title: "Google Calendar", + tools: ["READ"], + effects: { READ: "read" }, + grant: false, + }); + const request = await file(CALENDAR); + beforeGrant = async () => { + throw new Error("the grant failed"); + }; + const response = await appAs(admin, "admin").request( + `/api/plugins/access-requests/${request.id}/approve`, + { method: "POST" }, + ); + expect(response.status).toBe(500); + expect(await statusOf(request.id)).toBe("pending"); + expect(eventsOf("app_access.approved")).toHaveLength(0); + }); + + test("a request filed again while the first was claimed is the one left waiting", async () => { + await seedApp({ + key: GMAIL, + title: "Gmail", + tools: ["SEND_MAIL"], + effects: { SEND_MAIL: "write" }, + grant: false, + }); + const first = await file("Gmail"); + const store = storeFor(); + expect( + await store.claimAppAccessRequest({ id: first.id, by: admin }), + ).not.toBeNull(); + const second = await file("Gmail"); + expect(second.id).not.toBe(first.id); + await store.releaseAppAccessRequest({ id: first.id, by: admin }); + expect(await statusOf(first.id)).toBe("cancelled"); + expect(await statusOf(second.id)).toBe("pending"); + }); +}); + +describe("what a request may carry", () => { + const ask = (app: string, agentId = bot) => + appAs(person).request( + "/api/plugins/access-requests", + post({ agentId, app }), + ); + + test("an app or Bot name past the cap is refused before anything is read or written", async () => { + const long = "x".repeat(201); + expect((await ask(`composio-${long}`)).status).toBe(400); + expect((await ask(GMAIL, long)).status).toBe(400); + expect(eventsOf("app_access.refused")).toEqual([]); + expect( + (await appAs(person).request(`/api/plugins/for/${bot}/apps/${long}`)) + .status, + ).toBe(404); + }); + + test("enabling a composio- id the directory does not list is no such app", async () => { + const response = await ask(row("invented")); + expect(response.status).toBe(404); + const filed = await database + .select() + .from(appAccessRequests) + .where(eq(appAccessRequests.requestedBy, person)); + expect(filed).toEqual([]); + }); + + test("a grant that outlived its app can still be asked about", async () => { + await database + .insert(pluginGrants) + .values({ kind: "mcp", ref: `${GONE}/SEND`, agentId: bot }); + const response = await appAs(person, "user", { composio: false }).request( + "/api/plugins/access-requests", + post({ agentId: bot, app: GONE }), + ); + expect(response.status).toBe(201); + }); + + test(`at ${MAX_PENDING_REQUESTS} waiting a new one is refused, and one already waiting is still shown`, async () => { + await database.insert(appAccessRequests).values( + Array.from({ length: MAX_PENDING_REQUESTS - 1 }, (_, index) => ({ + id: randomUUID(), + requestedBy: person, + agentId: bot, + appKey: `composio-filler${index}_${suite}`, + appTitle: `Filler ${index}`, + kind: "enable" as const, + })), + ); + await seedApp({ key: CALENDAR, title: "Google Calendar", grant: false }); + const last = await ask(CALENDAR); + expect(last.status).toBe(201); + const over = await ask("Dropbox"); + expect(over.status).toBe(429); + expect(eventsOf("app_access.refused").at(-1)?.payload).toMatchObject({ + reason: "too_many_pending", + }); + const again = await ask(CALENDAR); + expect(again.status).toBe(200); + expect((await json<{ created: boolean }>(again)).created).toBe(false); + }); +}); + +describe("finding an app by its name", () => { + test("a name finds the enabled app before a catalogue entry nobody added", async () => { + const DRIVE = row("googledrive"); + await seedApp({ key: DRIVE, title: "Google Drive", grant: false }); + const { app } = await json<{ app: AppAccess }>( + await appAs(person).request( + `/api/plugins/for/${bot}/apps/Google%20Drive`, + ), + ); + expect(app).toMatchObject({ + key: DRIVE, + state: "not_granted", + requestKind: "grant", + }); + // The entry is still what its own key names. + const { app: entry } = await json<{ app: AppAccess }>( + await appAs(person).request(`/api/plugins/for/${bot}/apps/google-drive`), + ); + expect(entry).toMatchObject({ key: "google-drive", state: "disabled" }); + }); + + test("of two enabled apps with one name, the one this Bot holds", async () => { + await seedApp({ key: row("drive_a"), title: "Drive", grant: false }); + await seedApp({ key: row("drive_b"), title: "Drive" }); + const { app } = await json<{ app: AppAccess }>( + await appAs(person).request(`/api/plugins/for/${bot}/apps/Drive`), + ); + expect(app.key).toBe(row("drive_b")); + }); +}); + +describe("why an app is off, and a Shared app's audience", () => { + test("a switched-off app says why", async () => { + await seedApp({ key: GMAIL, title: "Gmail" }); + const { app } = await json<{ app: AppAccess }>( + await appAs(person, "user", { composio: false }).request( + `/api/plugins/for/${bot}/apps/${GMAIL}`, + ), + ); + expect(app).toMatchObject({ + state: "disabled", + unavailable: "composio_unconfigured", + }); + const { app: ready } = await json<{ app: AppAccess }>( + await appAs(person).request(`/api/plugins/for/${bot}/apps/${GMAIL}`), + ); + expect(ready.unavailable).toBeNull(); + }); + + test("a Shared app whose audience nobody approved is refused, with nothing to file", async () => { + await seedApp({ key: SLACK, title: "Slack", accountMode: "shared" }); + await connectTeamAccount("slack"); + const { app } = await json<{ app: AppAccess }>( + await appAs(person).request(`/api/plugins/for/${bot}/apps/${SLACK}`), + ); + expect(app).toMatchObject({ + state: "shared_not_approved", + requestKind: null, + }); + const filed = await appAs(person).request( + "/api/plugins/access-requests", + post({ agentId: bot, app: SLACK }), + ); + expect(filed.status).toBe(409); + await approveAudience(SLACK); + const { app: approved } = await json<{ app: AppAccess }>( + await appAs(person).request(`/api/plugins/for/${bot}/apps/${SLACK}`), + ); + expect(approved.state).toBe("ready"); + }); +}); + +describe("a teammate on somebody else's Team Bot", () => { + beforeEach(async () => { + await database + .insert(teamBotPublications) + .values({ agentId: bot, publishedBy: person, audience: "team" }); + await seedApp({ key: GMAIL, title: "Gmail" }); + }); + const connect = ( + userId: string, + over: { verified?: boolean; probeAction?: string } = {}, + ) => + database.insert(brokeredConnections).values({ + provider: "composio", + app: slug("gmail"), + holder: "person", + userId, + vendorUserId: `vendor-${userId}`, + verified: over.verified ?? true, + ...(over.probeAction ? { probeAction: over.probeAction } : {}), + }); + const asTeammate = async () => + ( + await json<{ app: AppAccess }>( + await appAs(other).request(`/api/plugins/for/${bot}/apps/${GMAIL}`), + ) + ).app; + + test("the owner's connection is the one their calls use", async () => { + await connect(person); + expect(await asTeammate()).toMatchObject({ + state: "ready", + accountHolder: "owner", + asksFirst: false, + }); + }); + + test("the owner's refused connection is theirs to reconnect, not the teammate's", async () => { + await connect(person, { verified: false, probeAction: "GMAIL_LIST" }); + await connect(other); + expect(await asTeammate()).toMatchObject({ + state: "reconnect", + accountHolder: "owner", + requestKind: null, + }); + }); + + test("with no owner account, the teammate's own, asked first until they always allow it", async () => { + expect(await asTeammate()).toMatchObject({ + state: "not_connected", + accountHolder: "you", + }); + await connect(other); + expect(await asTeammate()).toMatchObject({ + state: "ready", + accountHolder: "you", + asksFirst: true, + }); + await database.insert(teamBotConsents).values({ + userId: other, + agentId: bot, + serverId: GMAIL, + decision: "always", + }); + expect((await asTeammate()).asksFirst).toBe(false); + }); + + test("the owner reads their own account, and is never asked", async () => { + await connect(person); + const { app } = await json<{ app: AppAccess }>( + await appAs(person).request(`/api/plugins/for/${bot}/apps/${GMAIL}`), + ); + expect(app).toMatchObject({ + state: "ready", + accountHolder: "you", + asksFirst: false, + }); + }); +}); diff --git a/server/tests/app-access-state.test.ts b/server/tests/app-access-state.test.ts new file mode 100644 index 0000000000..e2e4428b7a --- /dev/null +++ b/server/tests/app-access-state.test.ts @@ -0,0 +1,196 @@ +// server/tests/app-access-state.test.ts +import { describe, expect, test } from "bun:test"; +import { + type AppAccessFacts, + accountOf, + appAccessState, + connectOf, + REQUEST_KIND_FOR, + unavailableReason, +} from "../src/plugins/app-access"; +import { catalogueEntry } from "../src/plugins/catalogue"; + +/** + * The rule one app's state is decided by, case by case, with every fact spelled out. + * + * The store gathers these facts from the tables; this file pins what the rule makes of them, so a + * change to the rule shows up as a changed expectation here rather than as a Bot page that quietly + * says something different. + */ + +const composio = (overrides: Partial = {}): AppAccessFacts => ({ + provider: "composio", + present: true, + composioConfigured: true, + authScheme: "OAUTH2", + accountMode: "personal", + catalogueAuth: null, + hasCredential: false, + granted: true, + personal: null, + shared: null, + sharedAdmitted: null, + ...overrides, +}); + +const drive = catalogueEntry("google-drive"); +const notion = catalogueEntry("notion"); +const parallel = catalogueEntry("parallel-authenticated"); +if (!drive || !notion || !parallel) + throw new Error("The catalogue entries these cases use are missing."); + +const catalogue = ( + auth: AppAccessFacts["catalogueAuth"], + overrides: Partial = {}, +): AppAccessFacts => ({ + provider: "catalogue", + present: true, + composioConfigured: false, + authScheme: null, + accountMode: null, + catalogueAuth: auth, + hasCredential: true, + granted: true, + personal: null, + shared: null, + sharedAdmitted: null, + ...overrides, +}); + +describe("a Composio app used under each person's own account", () => { + test("not connected, connected, and connected with a refused key", () => { + expect(appAccessState(composio())).toBe("not_connected"); + expect(appAccessState(composio({ personal: { usable: true } }))).toBe( + "ready", + ); + expect(appAccessState(composio({ personal: { usable: false } }))).toBe( + "reconnect", + ); + }); + + test("tells a key form from a consent screen", () => { + expect(connectOf(composio())).toBe("consent"); + expect(connectOf(composio({ authScheme: "API_KEY" }))).toBe("key"); + expect(accountOf(composio())).toBe("personal"); + }); + + test("the Bot holding none of it is not_granted, whatever the person has", () => { + expect( + appAccessState(composio({ granted: false, personal: { usable: true } })), + ).toBe("not_granted"); + }); + + test("off at the deployment is disabled before anything else is asked", () => { + for (const facts of [ + composio({ present: false }), + composio({ composioConfigured: false }), + composio({ authScheme: null }), + composio({ granted: false, present: false }), + ]) + expect(appAccessState(facts)).toBe("disabled"); + expect(unavailableReason(composio({ composioConfigured: false }))).toBe( + "composio_unconfigured", + ); + expect(unavailableReason(composio({ authScheme: "SOMETHING_NEW" }))).toBe( + "scheme_unreadable", + ); + }); +}); + +describe("a Shared Composio app", () => { + test("is ready only with a usable team account, whatever the person holds", () => { + const shared = (over: Partial) => + composio({ accountMode: "shared", ...over }); + expect(appAccessState(shared({}))).toBe("shared_not_connected"); + expect(appAccessState(shared({ personal: { usable: true } }))).toBe( + "shared_not_connected", + ); + expect(appAccessState(shared({ shared: { usable: false } }))).toBe( + "shared_not_connected", + ); + expect(appAccessState(shared({ shared: { usable: true } }))).toBe("ready"); + // Connected, and the gate would refuse this person's calls through this Bot. + expect( + appAccessState( + shared({ shared: { usable: true }, sharedAdmitted: false }), + ), + ).toBe("shared_not_approved"); + expect( + appAccessState( + shared({ shared: { usable: true }, sharedAdmitted: true }), + ), + ).toBe("ready"); + // The audience matters only once there is an account to reach. + expect(appAccessState(shared({ sharedAdmitted: false }))).toBe( + "shared_not_connected", + ); + expect(accountOf(shared({}))).toBe("shared"); + }); +}); + +describe("an app with no account at all", () => { + test("a no-auth Composio app is ready once granted", () => { + const facts = composio({ authScheme: "NO_AUTH" }); + expect(appAccessState(facts)).toBe("ready"); + expect(accountOf(facts)).toBe("deployment"); + expect(connectOf(facts)).toBeNull(); + }); + + test("a server added by URL is ready once granted", () => { + expect( + appAccessState({ + ...catalogue(null), + provider: "custom", + hasCredential: false, + }), + ).toBe("ready"); + }); + + test("a deployment-token entry needs its token first", () => { + expect(appAccessState(catalogue(parallel.auth))).toBe("ready"); + expect( + appAccessState(catalogue(parallel.auth, { hasCredential: false })), + ).toBe("disabled"); + expect( + unavailableReason(catalogue(parallel.auth, { hasCredential: false })), + ).toBe("token_missing"); + }); +}); + +describe("a catalogue app each person signs in to", () => { + test("a manual OAuth client must be registered before anybody can connect", () => { + expect( + unavailableReason(catalogue(drive.auth, { hasCredential: false })), + ).toBe("oauth_client_missing"); + expect(appAccessState(catalogue(drive.auth))).toBe("not_connected"); + expect(connectOf(catalogue(drive.auth))).toBe("oauth"); + }); + + test("a self-registering client is ready as soon as the row is", () => { + expect( + appAccessState(catalogue(notion.auth, { hasCredential: false })), + ).toBe("not_connected"); + }); + + test("a withdrawn credential is reconnect", () => { + expect( + appAccessState(catalogue(drive.auth, { personal: { usable: false } })), + ).toBe("reconnect"); + expect( + appAccessState(catalogue(drive.auth, { personal: { usable: true } })), + ).toBe("ready"); + }); +}); + +test("only the three states an administrator can fix file a request", () => { + expect(REQUEST_KIND_FOR).toEqual({ + ready: null, + not_connected: null, + reconnect: null, + shared_not_connected: "connect_shared", + // The refused call files the administrator's request itself. + shared_not_approved: null, + disabled: "enable", + not_granted: "grant", + }); +}); diff --git a/server/tests/app-access-tool.test.ts b/server/tests/app-access-tool.test.ts new file mode 100644 index 0000000000..74fcbc06b3 --- /dev/null +++ b/server/tests/app-access-tool.test.ts @@ -0,0 +1,159 @@ +import { describe, expect, test } from "bun:test"; +import { + readConnectPrompt, + SHOW_APP_ACCESS_TOOL, +} from "@openbot/shared/connect-prompt"; +import type { AppAccess, AppAccessState } from "../src/plugins/app-access"; +import { appAccessTools } from "../src/plugins/app-access-tool"; +import type { AppInspection } from "../src/plugins/plugin-store/app-access"; +import { PluginRefusedError, type PluginStore } from "../src/plugins/store"; +import { grantedTools, REFUSAL_MARKER } from "../src/plugins/tools"; + +/** + * The tool every Bot holds for putting an app it cannot use in front of the person, and the Bot's + * name on the refusals that already carried a card. + * + * The tool is a read: it asks the store what the app is for this person on this Bot and answers + * with the card marker — the app, the Bot, `reason: "access"` — and a sentence for the model. It + * never files a request; pressing the card does that, as the person. + */ + +function access(state: AppAccessState): AppAccess { + return { + key: "composio-gmail", + displayName: "Gmail", + logo: null, + provider: "composio", + account: "personal", + connect: "consent", + authScheme: "OAUTH2", + accountMode: "personal", + state, + unavailable: state === "disabled" ? "absent" : null, + accountHolder: "you", + asksFirst: false, + granted: state !== "not_granted" && state !== "disabled", + connectedAt: null, + requestKind: null, + request: null, + }; +} + +function toolOver(answer: AppAccess | null) { + const asked: unknown[] = []; + const [tool] = appAccessTools({ + store: { + inspectApp: async (input) => { + asked.push(input); + return answer ? ({ access: answer } as unknown as AppInspection) : null; + }, + }, + botId: "expenses", + actorId: "usr_7", + composioConfigured: true, + }); + if (!tool) throw new Error("no tool"); + return { tool, asked }; +} + +describe("show_app_access", () => { + test("is offered under its shared name, as a read anybody may hold", () => { + const { tool } = toolOver(null); + expect(tool.name).toBe(SHOW_APP_ACCESS_TOOL); + expect(tool.description).toContain("instead of only saying it is missing"); + }); + + test("asks about the app for this person on this Bot", async () => { + const { tool, asked } = toolOver(access("disabled")); + await tool.execute({ app: "Gmail" }); + expect(asked).toEqual([ + { + app: "Gmail", + agentId: "expenses", + userId: "usr_7", + composioConfigured: true, + }, + ]); + }); + + test.each([ + ["ready", /ready for this person to use through you/], + ["not_connected", /offers Connect/], + ["reconnect", /offers Reconnect/], + ["shared_not_connected", /ask an administrator for it/], + ["disabled", /switched off on this deployment/], + ["not_granted", /you have not been granted it/], + ] as const)( + "answers %s with the card marker and the state in words", + async (state, said) => { + const { tool } = toolOver(access(state)); + const answer = await tool.execute({ app: "gmail" }); + // Not a refusal: the transcript reads this tool's ordinary answer for its card. + expect(answer.startsWith(REFUSAL_MARKER)).toBe(false); + const prompt = readConnectPrompt(answer); + expect(prompt).toMatchObject({ + serverId: "composio-gmail", + title: "Gmail", + agentId: "expenses", + reason: "access", + }); + expect(prompt?.message).toMatch(said); + expect(prompt?.message).toContain("Do not browse to its website"); + }, + ); + + test("an app nobody can name gets a plain sentence and no card", async () => { + const { tool } = toolOver(null); + const answer = await tool.execute({ app: "Frobnicator" }); + expect(readConnectPrompt(answer)).toBeNull(); + expect(answer).toContain("no app called Frobnicator"); + }); + + test("arguments that name no app are refused before the store is asked", async () => { + const { tool, asked } = toolOver(access("ready")); + expect(await tool.execute({})).toBe("Name the app to show."); + expect(await tool.execute({ app: " " })).toBe("Name the app to show."); + expect(asked).toEqual([]); + }); +}); + +describe("a refusal carrying a card, on its way to the model", () => { + async function refusedWith(message: string) { + const [tool] = await grantedTools({ + store: { + callTool: async () => { + throw new PluginRefusedError(message, null); + }, + listForAgent: async () => ({ + tools: [ + { + toolName: "mcp__composio-gmail__GMAIL_FETCH_EMAILS", + ref: "composio-gmail/GMAIL_FETCH_EMAILS", + description: "Fetch mail.", + inputSchema: { type: "object" }, + }, + ], + }), + } as unknown as PluginStore, + botId: "expenses", + actorId: "usr_7", + }); + return (await tool?.execute({})) ?? ""; + } + + test("names the Bot that refused, so the card asks about the right one", async () => { + const answer = await refusedWith( + '[connect-app]{"serverId":"composio-gmail","title":"Gmail","reason":"shared"} Gmail is shared across this deployment and no account is connected to it yet. Ask an administrator to connect it.', + ); + expect(answer.startsWith(`${REFUSAL_MARKER} `)).toBe(true); + expect( + readConnectPrompt(answer.slice(REFUSAL_MARKER.length).trim()), + ).toMatchObject({ agentId: "expenses", reason: "shared" }); + }); + + test("leaves an ordinary refusal exactly as it was", async () => { + expect(await refusedWith("That tool is not granted.")).toBe( + `${REFUSAL_MARKER} That tool is not granted.`, + ); + }); +}); diff --git a/server/tests/assertion-shape.test.ts b/server/tests/assertion-shape.test.ts index 2c1f398017..d8e055952c 100644 --- a/server/tests/assertion-shape.test.ts +++ b/server/tests/assertion-shape.test.ts @@ -835,14 +835,18 @@ const ALLOW: Allowed[] = [ }, { file: "server/tests/composio-adapter.test.ts", - line: 5412, + // Moved from 5412 by OB-05, which added fifteen lines of tests above it; the offence is the same + // one and is re-keyed rather than re-found. + line: 5427, shape: "c", finding: "NEW (round 2 did not find this)", why: 'expect( await actions.listActions("gmail", { limit: WHOLE_LISTING }), ).toEqual([ { slug: "GMAIL_FETCH_EMAILS"', }, { file: "server/tests/composio-adapter.test.ts", - line: 5458, + // Moved from 5458 by OB-05, which added fifteen lines of tests above it; the offence is the same + // one and is re-keyed rather than re-found. + line: 5473, shape: "c", finding: "NEW (round 2 did not find this)", why: 'expect( await actions.listActions("gmail", { limit: WHOLE_LISTING }), ).toEqual([ { slug: "GMAIL_FETCH_EMAILS"', diff --git a/server/tests/composio-adapter.test.ts b/server/tests/composio-adapter.test.ts index 19a7423bae..8649330dbf 100644 --- a/server/tests/composio-adapter.test.ts +++ b/server/tests/composio-adapter.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "bun:test"; import { Composio, telemetry } from "@composio/core"; import { + BrokerAccountGoneError, type BrokerConnection, BrokerRefusalError, BrokerUnconfiguredError, @@ -3899,6 +3900,20 @@ describe("each vendor condition reaches the reader as its own remedy", () => { }); } + test("an account Composio no longer holds is raised as its own class, so a call can offer Reconnect", async () => { + const failure = await failureOf( + whileRunning(raising("ComposioConnectedAccountNotFoundError")), + ); + expect(failure).toBeInstanceOf(BrokerAccountGoneError); + // Still an authored refusal, so every reader that prefers this deployment's sentence still does. + expect(failure).toBeInstanceOf(BrokerRefusalError); + // And nothing else is: the class is what the store acts on. + const other = await failureOf( + whileRunning(raising("ComposioRequestCancelledError")), + ); + expect(other).not.toBeInstanceOf(BrokerAccountGoneError); + }); + /** * ONE VENDOR CLASS OVER EVERY WAY A FETCH CAN FAIL, which is a fact about the SDK rather than a * reading of its name. `getRawComposioToolBySlug` wraps its retrieve in a try whose catch diff --git a/server/tests/composio-transport.test.ts b/server/tests/composio-transport.test.ts index 9c1086a55e..022d542fa9 100644 --- a/server/tests/composio-transport.test.ts +++ b/server/tests/composio-transport.test.ts @@ -1,5 +1,8 @@ import { afterEach, describe, expect, test } from "bun:test"; -import { BrokerRefusalError } from "../src/plugins/broker"; +import { + BrokerAccountGoneError, + BrokerRefusalError, +} from "../src/plugins/broker"; import { askAction, type ComposioAction, @@ -3223,6 +3226,50 @@ describe("calling one action", () => { expect(result.text).not.toContain("Invalid request"); }); + test("an account the vendor lost travels as a fact on the result, beside its sentence", async () => { + /* + * The one authored condition the store acts on: it turns this result into a refusal that + * offers Reconnect. Read off a field rather than off the wording, so rephrasing the sentence + * cannot quietly stop the card being drawn — and set on nothing else. + */ + const authored = + "Composio holds no connected account for this person and gmail, so GMAIL_FETCH_EMAILS was not run."; + useComposioClient( + recording({ + execute: async () => { + throw new BrokerAccountGoneError(authored); + }, + }).client, + ); + + const gone = await askAction( + { url: "composio://gmail", actorId: "user_asker" }, + "GMAIL_FETCH_EMAILS", + { __version: "20260903_00" }, + ); + expect(gone.result).toMatchObject({ + isError: true, + text: authored, + accountGone: true, + }); + // Not a verdict on the key: a probe meeting this keeps its "could not find out". + expect(gone.answered).toBe(false); + + useComposioClient( + recording({ + execute: async () => { + throw new BrokerRefusalError("Some other authored refusal."); + }, + }).client, + ); + const other = await callTool( + { url: "composio://gmail", actorId: "user_asker" }, + "GMAIL_FETCH_EMAILS", + { __version: "20260903_00" }, + ); + expect(other.accountGone).toBeUndefined(); + }); + test("a failure this deployment cannot explain still reaches for the vendor's words", async () => { // The other half of the precedence, and the reason it is `brokerSentence` rather than // `error.message`. `./broker` raises its class only where the sentence names the step that diff --git a/server/tests/copilot.test.ts b/server/tests/copilot.test.ts index 48b43149e6..978a0811d9 100644 --- a/server/tests/copilot.test.ts +++ b/server/tests/copilot.test.ts @@ -1069,6 +1069,53 @@ describe("what a Bot is told it holds", () => { ); }); + /* + * THE CARD TOOL. Every Bot holds `show_app_access`, and the sentences that used to end on "an + * administrator can grant it" now tell the Bot to call it — which puts a card with Request access + * in front of the person, where the sentence put the name of a role. + */ + const card = { name: "show_app_access" } as never; + + test("tells a Bot to show the card for a connected vendor it holds nothing for", () => { + const guidance = grantedToolGuidance([card], ["composio-gmail"]) + .toLowerCase() + .replace(/\s+/g, " "); + expect(guidance).toContain("composio-gmail"); + expect(guidance).toContain("you hold none of their tools"); + expect(guidance).toContain("call show_app_access with its name"); + expect(guidance).not.toContain( + "administrator can grant it on that connector", + ); + expect(guidance).toContain("do not browse to its website"); + }); + + test("tells a Bot to show the card for an app this deployment does not connect at all", () => { + const guidance = grantedToolGuidance([card]) + .toLowerCase() + .replace(/\s+/g, " "); + expect(guidance).toContain("not named here at all"); + expect(guidance).toContain("show_app_access"); + expect(guidance).toContain("ask an administrator"); + }); + + test("never lists the card tool as a system the Bot can reach", () => { + // Listed beside the vendors, it would read as one more system reached "as the person asking". + const guidance = grantedToolGuidance([...drive, card]); + expect(guidance).not.toMatch(/^- .*show_app_access/m); + expect(guidance).toContain("search_files"); + expect(grantedToolGuidance([card])).not.toContain( + "You can reach these systems directly", + ); + }); + + test("a Bot not offered the card is still told to name the administrator, as before", () => { + const guidance = grantedToolGuidance([], ["google-drive"]) + .toLowerCase() + .replace(/\s+/g, " "); + expect(guidance).toContain("administrator can grant it on that connector"); + expect(guidance).not.toContain("show_app_access"); + }); + test("says nothing at all when the Bot holds nothing", () => { // A deployment with no connectors must not be told about connectors it does not have. expect(grantedToolGuidance([])).toBe(""); diff --git a/server/tests/memory-store.integration.test.ts b/server/tests/memory-store.integration.test.ts index 00c8079a8b..efa8acb632 100644 --- a/server/tests/memory-store.integration.test.ts +++ b/server/tests/memory-store.integration.test.ts @@ -1,5 +1,6 @@ import { afterAll, beforeAll, expect, test } from "bun:test"; import { randomUUID } from "node:crypto"; +import { connectAppRefusal } from "@openbot/shared/connect-prompt"; import { and, eq, inArray } from "drizzle-orm"; import { createAuditStore } from "../src/audit"; import { createCredentialStore } from "../src/credentials"; @@ -11,7 +12,7 @@ import { mcpServers, mcpTools } from "../src/db/schema/plugins"; import { createMemoryIngestion } from "../src/memory/ingestion"; import { createMemoryStore } from "../src/memory/store"; import { MemoryNotFoundError, MemoryRefusedError } from "../src/memory/types"; -import { createPluginStore } from "../src/plugins/store"; +import { createPluginStore, PluginRefusedError } from "../src/plugins/store"; import { TEST_POOL, testDatabaseUrl } from "./support/database"; const database = createDatabase(testDatabaseUrl(), TEST_POOL); @@ -25,6 +26,8 @@ const ref = `${server}/read_documents`; let vendorText = '[{"id":"doc1","text":"Project launch is Tuesday","title":"Roadmap"}]'; let vendorError = false; +/** Thrown by the vendor instead of answering, when set: how a refused call reaches a sync. */ +let vendorThrows: Error | null = null; const calls: { actorId?: string; botId?: string }[] = []; const plugins = createPluginStore({ brokerProvider: "composio", @@ -35,6 +38,7 @@ const plugins = createPluginStore({ policy: () => ({ mode: "enforce", deny: [], allow: ["true"] }), callVendor: async (connection) => { calls.push(connection); + if (vendorThrows) throw vendorThrows; return { text: vendorText, isError: vendorError }; }, }); @@ -252,6 +256,31 @@ test("vendor failure hides stale source facts and records a visible error", asyn expect((await store.getSource(owner, value.id)).syncStatus).toBe("error"); vendorError = false; }); +test("a refusal carrying an app card is said for the sources page, not stored raw", async () => { + const value = await source(); + vendorThrows = new PluginRefusedError( + connectAppRefusal({ + serverId: server, + title: "Docs", + message: + "Your Docs account is no longer connected at the vendor. Reconnect it and ask again.", + reason: "reconnect", + }), + null, + ); + try { + await expect(ingestion.sync(owner, value.id)).rejects.toBeInstanceOf( + PluginRefusedError, + ); + const { syncError } = await store.getSource(owner, value.id); + expect(syncError).toBe( + "Your Docs account needs reconnecting. Reconnect it under Settings, Connected accounts, then sync again.", + ); + expect(syncError).not.toContain("[connect-app]"); + } finally { + vendorThrows = null; + } +}); test("source leases prevent competing connector calls", async () => { const value = await source(); const results = await Promise.all([ diff --git a/server/tests/plugin-store.integration.test.ts b/server/tests/plugin-store.integration.test.ts index 01d5318d93..aa4cb17ab9 100644 --- a/server/tests/plugin-store.integration.test.ts +++ b/server/tests/plugin-store.integration.test.ts @@ -2809,12 +2809,29 @@ describe("a dynamic client the vendor has evicted", () => { "invalid_grant", ); - await expect(call()).rejects.toThrow("invalid_grant"); + // A withdrawn grant is the person's to fix by connecting again: the refusal draws the card that + // says so, rather than folding the vendor's code into a tool error. + const refusal = await call().catch((error: unknown) => error); + expect(refusal).toBeInstanceOf(PluginRefusedError); + expect(readConnectPrompt((refusal as Error).message)).toMatchObject({ + serverId: dynamicServerId, + reason: "reconnect", + }); - // A withdrawn grant is the person's to fix by connecting again. Minting a client for it would - // leave a spare client behind and still refuse. + // Minting a client for it would leave a spare client behind and still refuse. expect(registrations).toEqual([]); expect(offered).toEqual([EVICTED.clientId]); + + // And the connection now reads as withdrawn, which is Reconnect on every screen. + const [connection] = await dynamicStore.connectionsFor(dynamicUserId); + expect(connection).toMatchObject({ + serverId: dynamicServerId, + revoked: true, + }); + // The next call is refused before anything is presented to the vendor again. + offered.length = 0; + await expect(call()).rejects.toBeInstanceOf(PluginRefusedError); + expect(offered).toEqual([]); }); /** diff --git a/server/tests/shared-brokered-store.integration.test.ts b/server/tests/shared-brokered-store.integration.test.ts index 24f609cf19..e6a2c44f84 100644 --- a/server/tests/shared-brokered-store.integration.test.ts +++ b/server/tests/shared-brokered-store.integration.test.ts @@ -10,7 +10,12 @@ import { mcpServers, mcpTools, } from "../src/db/schema"; -import type { ConnectedAppBroker } from "../src/plugins/broker"; +import { readConnectPrompt } from "@openbot/shared/connect-prompt"; +import { auditEvents } from "../src/db/schema"; +import { + BrokerAccountGoneError, + type ConnectedAppBroker, +} from "../src/plugins/broker"; import { useComposioClient } from "../src/plugins/composio"; import { createPluginStore } from "../src/plugins/store"; import { expectOnlyRefusal } from "./helpers/refusals"; @@ -194,6 +199,12 @@ describe("a shared app", () => { (error: Error) => error.message, ); expectOnlyRefusal(said, "sharedNotConnected", "Team GitHub"); + // And it carries the card, which asks an administrator rather than offering Connect. + expect(readConnectPrompt(said)).toMatchObject({ + serverId: app, + title: "Team GitHub", + reason: "shared", + }); }); test("offboarding the admin who connected it leaves it in place", async () => { @@ -654,3 +665,121 @@ describe("a duplicate row at an app's url", () => { ]); }); }); + +/* + * THE ACCOUNT GONE AT THE VENDOR. Nothing on this deployment's rows knows it happened, so the call + * that meets it is the only place the person can be told: refused with the app card, `reconnect` + * for their own account and `shared` for the team's, stamped with when — and audited once, as the + * failure it is. + */ +describe("an account the vendor no longer holds", () => { + const gone = () => + useComposioClient({ + listActions: async () => [], + execute: async () => { + throw new BrokerAccountGoneError( + "Composio holds no connected account for this person and team-gh, so GITHUB_LIST_ISSUES was not run.", + ); + }, + }); + + /** Who still holds a row at the app, by holder. */ + const rowsFor = async (holder: "person" | "deployment") => + ( + await database + .select({ userId: brokeredConnections.userId }) + .from(brokeredConnections) + .where( + and( + eq(brokeredConnections.app, app), + eq(brokeredConnections.holder, holder), + ), + ) + ).map((row) => row.userId); + + /* Counted before and after: the trail is not cleaned between tests, which share the app id. */ + const failures = async () => + ( + await database + .select({ eventType: auditEvents.eventType }) + .from(auditEvents) + .where(eq(auditEvents.targetId, `${app}/GITHUB_LIST_ISSUES`)) + ).filter((row) => row.eventType === "mcp.call_failed").length; + + test("on a Shared app: refused with a card saying an administrator has to connect it again", async () => { + const store = freshStore(); + await seedShared(store, true); + gone(); + const already = await failures(); + const before = Date.now(); + const said = await store + .callTool({ + ref: `${app}/GITHUB_LIST_ISSUES`, + args: {}, + botId: bot, + actorId: asker, + }) + .then( + () => "called", + (error: Error) => error.message, + ); + const prompt = readConnectPrompt(said); + expect(prompt).toMatchObject({ + serverId: app, + title: "Team GitHub", + reason: "shared", + }); + expect(prompt?.message).toContain( + "An administrator has to connect it again", + ); + expect(Date.parse(prompt?.at ?? "")).toBeGreaterThanOrEqual(before - 1000); + expect(await failures()).toBe(already + 1); + // The team account's row is gone with the account, so every screen reads it as not connected. + expect(await rowsFor("deployment")).toEqual([]); + }); + + test("on a personal app: refused with a card offering Reconnect", async () => { + const store = freshStore(); + await seedShared(store, false); + await database + .update(mcpServers) + .set({ accountMode: "personal", sharedVendorUserId: null }) + .where(eq(mcpServers.id, app)); + await database.insert(brokeredConnections).values([ + { + provider: "composio", + app, + holder: "person", + userId: asker, + vendorUserId: `vendor-${asker}`, + }, + // Somebody else's account at the same app, which the vendor still holds. + { + provider: "composio", + app, + holder: "person", + userId: admin, + vendorUserId: `vendor-${admin}`, + }, + ]); + gone(); + const said = await store + .callTool({ + ref: `${app}/GITHUB_LIST_ISSUES`, + args: {}, + botId: bot, + actorId: asker, + }) + .then( + () => "called", + (error: Error) => error.message, + ); + expect(readConnectPrompt(said)).toMatchObject({ + serverId: app, + reason: "reconnect", + }); + expect(said).toContain("Reconnect it and ask again"); + // Only the asker's row goes, so Connect on their card starts a fresh connection. + expect(await rowsFor("person")).toEqual([admin]); + }); +}); diff --git a/shared/connect-prompt.test.ts b/shared/connect-prompt.test.ts index 630b76437c..336c53058a 100644 --- a/shared/connect-prompt.test.ts +++ b/shared/connect-prompt.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test"; import { + addressedTo, CONNECT_APP_MARKER, connectAppRefusal, readConnectPrompt, @@ -58,3 +59,65 @@ describe("text that is not one", () => { expect(readConnectPrompt(undefined)).toBeNull(); }); }); + +describe("the Bot, the reason and the moment it carries", () => { + test("survive the round trip, and are left out when absent", () => { + const text = connectAppRefusal({ + serverId: "composio-gmail", + title: "Gmail", + message: "Your Gmail account is no longer connected at the vendor.", + agentId: "expenses", + reason: "reconnect", + at: "2026-10-20T12:00:00.000Z", + }); + expect(readConnectPrompt(text)).toEqual({ + serverId: "composio-gmail", + title: "Gmail", + message: "Your Gmail account is no longer connected at the vendor.", + agentId: "expenses", + reason: "reconnect", + at: "2026-10-20T12:00:00.000Z", + }); + expect(connectAppRefusal({ serverId: "s", title: "T", message: "m" })).toBe( + `${CONNECT_APP_MARKER}{"serverId":"s","title":"T"} m`, + ); + }); + + test("a field of the wrong shape is dropped, and the prompt still names its app", () => { + const prompt = readConnectPrompt( + `${CONNECT_APP_MARKER}{"serverId":"composio-gmail","title":"Gmail","agentId":7,"reason":"grant me","at":"yesterday"} hello`, + ); + expect(prompt).toEqual({ + serverId: "composio-gmail", + title: "Gmail", + message: "hello", + }); + }); + + test("addressedTo names the Bot on a prompt that names none, and leaves everything else alone", () => { + const bare = connectAppRefusal({ + serverId: "notion", + title: "Notion", + message: "You have not connected your Notion account.", + reason: "shared", + }); + expect(readConnectPrompt(addressedTo(bare, "expenses"))).toEqual({ + serverId: "notion", + title: "Notion", + message: "You have not connected your Notion account.", + reason: "shared", + agentId: "expenses", + }); + + const named = connectAppRefusal({ + serverId: "notion", + title: "Notion", + message: "m", + agentId: "travel", + }); + expect(addressedTo(named, "expenses")).toBe(named); + expect(addressedTo("That tool is not granted.", "expenses")).toBe( + "That tool is not granted.", + ); + }); +}); diff --git a/shared/connect-prompt.ts b/shared/connect-prompt.ts index 499ea32787..95bafbedb4 100644 --- a/shared/connect-prompt.ts +++ b/shared/connect-prompt.ts @@ -1,5 +1,7 @@ /** - * The refusal a person can act on: an app this call needed, that they have not connected. + * The refusal a person can act on: an app this call needed, that they have not connected — or + * whose account the vendor has lost, or whose team account nobody has connected — and the answer of + * the one tool that says what an app is for them without a call having been refused at all. * * ONE DECLARATION, READ FROM BOTH SIDES, for the reason `./handoff-markers.ts` sets out at length. * A tool that runs on the server reaches the transcript as text meant for a model, so a renderer @@ -16,15 +18,65 @@ /** The first characters of the refusal, immediately followed by its JSON. */ export const CONNECT_APP_MARKER = "[connect-app]"; +/** + * Why the card was drawn, as the server saw it at the moment of the call. + * + * `connect` — this person has no account at the app. `reconnect` — they had one, and the vendor no + * longer holds it: a grant withdrawn at the provider, an account removed in Composio's dashboard. + * `shared` — a Shared app whose one team account is missing, which only an administrator connects. + * `access` — the Bot asked what the app is for this person (`show_app_access`), with no refusal + * behind it: switched off, not granted, or any of the above. + * + * A HINT, NOT THE STATE. The card asks the server for the app's state for this person and this Bot + * when it is drawn, and draws that. The one place the hint wins is an account the vendor has lost + * whose row still stands — a refusal from before the call that found out started deleting the row — + * where the live state reads `ready` and only the refusal knows otherwise. See `AppAccessCard`. + */ +export type ConnectReason = "connect" | "reconnect" | "shared" | "access"; + +const REASONS: readonly ConnectReason[] = [ + "connect", + "reconnect", + "shared", + "access", +]; + export type ConnectPrompt = { - /** The plugin server to connect, as `POST /api/plugins/servers/:serverId/connect` names it. */ + /** + * The app, as the server names it: the plugin server to connect, as + * `POST /api/plugins/servers/:serverId/connect` names it, or the key an app nobody enabled would + * have. What the card asks `GET /api/plugins/for/:agentId/apps/:app` about. + */ serverId: string; /** The app's own name, for a card that says what it is asking for. */ title: string; /** What the Bot would have said, kept intact for a reader with no card to draw. */ message: string; + /** + * The Bot whose call this was, so the card asks about the right one in a channel several Bots + * answer in. Absent on a refusal written before it carried one, where the card falls back to the + * Bot the surface declared. + */ + agentId?: string; + /** See {@link ConnectReason}. Absent reads as `connect`, which is all a refusal used to mean. */ + reason?: ConnectReason; + /** + * When the server refused, as an ISO instant — on a `reconnect` or `shared` refusal, the only + * ones whose hint can outrank the live state. A connection made after this is the reconnection, + * and the card stops saying Reconnect. See `AppAccess.connectedAt`. + */ + at?: string; }; +/** + * The name of the tool every Bot holds for saying what an app is for the person asking. + * + * Here rather than beside the tool, because the transcript reads it too: that tool's answer is the + * one ordinary result (not a refusal) whose marker is drawn as a card, and a renderer may only trust + * a marker on a result it knows this deployment wrote. + */ +export const SHOW_APP_ACCESS_TOOL = "show_app_access"; + /** * The refusal text a `PluginRefusedError` carries for an app the asker has not connected. * @@ -32,8 +84,28 @@ export type ConnectPrompt = { * the MODEL is told, and because a transcript that cannot parse the marker still reads correctly. */ export function connectAppRefusal(prompt: ConnectPrompt): string { - const { serverId, title, message } = prompt; - return `${CONNECT_APP_MARKER}${JSON.stringify({ serverId, title })} ${message}`; + const { serverId, title, message, agentId, reason, at } = prompt; + return `${CONNECT_APP_MARKER}${JSON.stringify({ + serverId, + title, + ...(agentId ? { agentId } : {}), + ...(reason ? { reason } : {}), + ...(at ? { at } : {}), + })} ${message}`; +} + +/** + * The same text with the Bot named on it, for the one caller that knows which Bot asked. + * + * WHY AFTERWARDS RATHER THAN AT THE SITE THAT WROTE IT. The refusals are raised deep in resolving a + * credential, which is a question about a person and an app and never about a Bot — threading a Bot + * id down there would be a parameter nothing else reads. The tool's own wrapper knows the Bot, so it + * adds it on the way out. Text that is not a prompt, or already names a Bot, is returned unchanged. + */ +export function addressedTo(text: string, agentId: string): string { + const prompt = readConnectPrompt(text); + if (!prompt || prompt.agentId || !agentId) return text; + return connectAppRefusal({ ...prompt, agentId }); } /** @@ -53,13 +125,32 @@ export function readConnectPrompt( try { const parsed: unknown = JSON.parse(rest.slice(0, end + 1)); if (!parsed || typeof parsed !== "object") return null; - const { serverId, title } = parsed as { + const { serverId, title, agentId, reason, at } = parsed as { serverId?: unknown; title?: unknown; + agentId?: unknown; + reason?: unknown; + at?: unknown; }; if (typeof serverId !== "string" || typeof title !== "string") return null; if (!serverId || !title) return null; - return { serverId, title, message: rest.slice(end + 1).trim() }; + /* + * The optional fields are dropped rather than failing the whole prompt when they are the + * wrong shape: without them the card still names the right app and asks about the declared Bot, + * which is what every refusal did before they existed. + */ + return { + serverId, + title, + message: rest.slice(end + 1).trim(), + ...(typeof agentId === "string" && agentId ? { agentId } : {}), + ...(REASONS.includes(reason as ConnectReason) + ? { reason: reason as ConnectReason } + : {}), + ...(typeof at === "string" && !Number.isNaN(Date.parse(at)) + ? { at } + : {}), + }; } catch { return null; }