diff --git a/.env.example b/.env.example index 6d46581f2..ad56fe623 100644 --- a/.env.example +++ b/.env.example @@ -338,3 +338,21 @@ AGENT_TOOL_TOKEN= # for a deployment that has not stood up a worker. Set for one that has: openssl rand -base64 32. # Do not accept a default in production. WORKER_SHARED_SECRET= + +# Composio, the broker that holds people's accounts for a few hundred apps, so a Bot can act in Gmail +# or Slack or Notion without this deployment registering an OAuth client with each of them. +# +# Optional. Without this key there is nothing to connect, nothing to grant, and no Composio tool for +# a Bot to call. What does remain is one row that goes nowhere, under "More apps" on the admin +# Plugins page, saying that adding a Composio key enables a catalogue of tools and naming this +# variable — the feature is named rather than hidden, so an administrator who has heard of Composio +# can find out what it wants. There is no directory to browse, no picker, and no brokered app on +# anybody's connected-accounts page. +# +# This variable is the whole of the setting. There is no screen anywhere in the product that sets it: +# it is read from this environment at startup, and an administrator with every permission there is +# cannot turn Composio on from a page. +# +# One key for the whole deployment, not one per person. Composio keeps people apart by a user id sent +# with every call, so one account's connections are never reachable from another's. +COMPOSIO_API_KEY= diff --git a/Dockerfile b/Dockerfile index 674ce392d..79c2f2bca 100644 --- a/Dockerfile +++ b/Dockerfile @@ -56,7 +56,6 @@ RUN cd agent-computer && bun install --frozen-lockfile # A second tree with the build-time dependencies left out, for the runtime stage to take. Vite, # biome and the test tooling are a gigabyte that nothing in a running container imports. RUN mkdir -p /prod && cp package.json bun.lock /prod/ \ - && cp -r app/package.json /prod/app-package.json \ && cd /prod && mkdir -p app server worker \ && cp /src/app/package.json app/package.json \ && cp /src/server/package.json server/package.json \ @@ -100,10 +99,21 @@ RUN case "${TARGETARCH}" in \ WORKDIR /app +# BOTH HALVES OF THE TREE COME FROM /prod. A workspace install is two directories, not one: the +# packages it could hoist go to the root `node_modules`, and a per-workspace `node_modules` sits +# beside each manifest holding the rest. The server's half used to be taken from /src, which is the +# unpruned install, so the prune above bought nothing where the server actually resolves — and +# `@copilotkit/aimock`, a development dependency, shipped as a symlink into a store the prune had +# emptied, along with the two `.bin` shims pointing at it. Every dependency `server/package.json` +# declares resolves from the /prod half; the ones it does not declare are absent now rather than +# present and broken. COPY --from=deps /prod/node_modules node_modules +COPY --from=deps /prod/server/node_modules server/node_modules COPY --from=deps /src/package.json package.json COPY --from=deps /src/bun.lock bun.lock -COPY --from=deps /src/server/node_modules server/node_modules +# The browser's tree is a separate install root with its own lockfile rather than a workspace of the +# one above, so there is no /prod half of it to take and it ships as resolved, `typescript` included. +# Pruning it would mean a second `--production` install in the stage above. COPY --from=deps /src/agent-computer/node_modules agent-computer/node_modules COPY server server @@ -112,6 +122,19 @@ COPY examples examples COPY agent-computer/src agent-computer/src COPY agent-computer/package.json agent-computer/package.json +# `bun run composio:smoke`, because the manifest copied above carries that entry and the question it +# answers belongs here rather than on a laptop: it asks what THIS deployment's Composio key can see, +# and that key is the one in this container's environment. It reads `server/src/plugins/composio*`, +# which is already in the image, so the file itself was the only thing missing and the entry was an +# instruction that could not be followed where it shipped. +# +# ONE FILE, NOT `scripts/`. The others there are the laptop's. `diagram` and `mock:knowledge` reach +# for `roughjs` and `@copilotkit/aimock`, which the prune above removes; `test:ci` runs a suite that +# is not in the image; `generate:app-config` writes a file the build has already baked into +# `app/dist`. Copying the directory ships four more entries with nothing to do here, to fix one that +# has something to do. +COPY scripts/composio-smoke.ts scripts/composio-smoke.ts + # The built app, served by the API on the same origin. There is no CORS in this server, so this is # not a convenience: two origins would simply fail. COPY --from=app-build /src/app/dist app/dist diff --git a/app/src/components/plugins/brokered-account-row.tsx b/app/src/components/plugins/brokered-account-row.tsx new file mode 100644 index 000000000..fa47b6445 --- /dev/null +++ b/app/src/components/plugins/brokered-account-row.tsx @@ -0,0 +1,880 @@ +import { IconArrowUpRight } from "@tabler/icons-react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useEffect, useState } from "react"; +import { ConnectionFields } from "@/components/plugins/connection-fields"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogBody, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + Item, + ItemActions, + ItemContent, + ItemDescription, + ItemTitle, +} from "@/components/ui/item"; +import { + type BrokerField, + brokeredConnectionFieldsMutationOptions, + confirmBrokeredConnectionMutationOptions, + connectAccountMutationOptions, + connectBrokeredWithFieldsMutationOptions, + disconnectBrokeredMutationOptions, + recheckBrokeredConnectionMutationOptions, +} from "@/lib/plugins/mutations"; + +/** + * One person's own brokered account, on whichever screen is asking. + * + * Two screens draw this — the connector's admin page, where an administrator checks the setup they + * have just finished, and that person's own connected-accounts page — and they drew it twice, forty + * lines apiece, comment for comment. The cost was not the duplication: it was that both copies held + * the same two defects, a disconnect that went on reading "Connected" and a deployment with no + * Composio key that said nothing about it, and both had to be found twice to be fixed once. + * + * What genuinely differs between the two stays an argument. An administrator is told their setup is + * complete without this row; a person is told which Bots can read them as them. The consent flow + * returns to whichever screen started it. Everything else — what the row says while there is no + * key, what the dot means, what disconnecting ends — is one answer and lives here. + */ + +/** + * What this person actually does to connect: click through a consent screen, type a secret, or + * nothing at all. + * + * A coarser question than the scheme the app's authorization config was created as. The recorded + * `authScheme` is a vendor literal — `OAUTH2`, `DCR_OAUTH`, `API_KEY`, `NO_AUTH` and the rest — and + * a row that branched on it would be re-asking the same three-way question at every branch, each + * copy free to forget a literal the others remembered. + */ +export type BrokeredAccountKind = "consent" | "fields" | "no-auth"; + +/** The schemes whose secret a person types, which is the whole of what `fields` means here. */ +const FIELD_SCHEMES = ["API_KEY", "BASIC", "BEARER_TOKEN", "BASIC_WITH_JWT"]; + +/** + * Which of the three a recorded scheme is. + * + * Everything that is not `NO_AUTH` and not one of the typed schemes is consent, including a scheme + * this file has never heard of: the catalogue is the vendor's and it may name a new one tomorrow, + * and sending somebody to a consent screen that turns out not to exist is a refusal they can read, + * where an empty form is a box they cannot fill in. + */ +function kindOf(authScheme: string | null): BrokeredAccountKind { + if (authScheme === "NO_AUTH") return "no-auth"; + return authScheme !== null && FIELD_SCHEMES.includes(authScheme) + ? "fields" + : "consent"; +} + +/** The row's state and the things it can do, from {@link useBrokeredAccount}. */ +export type BrokeredAccount = { + /** Whether this person's account is live, as the vendor last answered. */ + connected: boolean; + /** + * Whether this deployment holds a verdict that the account works, which is a different fact for + * each kind and is NOT "a real call was made with this key". + * + * A different fact from `connected`, and the reason both are here. Composio does not check a + * submitted key: a connection created with an obviously wrong value comes back ACTIVE and stays + * ACTIVE, so `connected` for a key app is only that the vendor accepted the row. + * + * ON A CONSENT APP THERE IS NO PROBE BEHIND THIS. The confirm writes it true off the vendor's own + * answer that the account is attached — a consent screen somebody completed is the check — and + * migration 0030 backfilled every consent row that came before. So nothing here may read `true` + * as evidence that a call was spent, nor offer an act that needs one. + * + * AND `false` IS A PLACEHOLDER AS MUCH AS A VERDICT. The store writes it unconditionally on every + * key connection, whether or not the app publishes anything to check a key against, so nothing + * here may explain the `false` off this field. Three states share this one word — nothing to try, + * tried and passed, tried and refused — and {@link BrokeredAccount.probe} is what tells them + * apart. Read the two together or not at all. + */ + verified: boolean; + /** + * When that call was made, as the recorded instant. Null where none ever has been. + * + * Carried beside `verified` rather than derived from it, because a verification is a past tense + * and a screen that says so has to say when: "verified" on its own reads as a present-tense fact + * about a key that may have been revoked at the vendor an hour ago. + */ + verifiedAt: string | null; + /** + * The action that check was spent on, as the last answer named it. + * + * THE THREE STATES BEHIND `verified` ARE THIS FIELD'S DOING, and the sentences below are written + * off it rather than off the flag: + * + * `null` — nothing was tried: at the time of the check this app published nothing safe to + * spend a key on. A fact about the app and not about the key, and about the app + * THEN — whether it has anything to spend one on now is `checkable`'s question. + * a name, verified — the action ran in this person's account and the vendor took the key. + * a name, NOT verified — it ran and the vendor refused the key, and the account it ran in is + * still there: a connect leaves it because its withdrawal failed, a re-check + * because it never withdraws one. The row exists, the key is bad, and something + * of theirs is standing at Composio. This is the worst state the feature has. + * `undefined` — NOT A FOURTH VERDICT BUT THE ABSENCE OF ONE. Nothing has said anything about a + * probe for this row: a held connection, whose rows carry none of this, or an app + * nobody has connected. Nothing may read it as either of the two the server sends. + * + * CARRIED BY THE READ AS WELL AS BY THE ANSWERS, which is what keeps the three states apart after + * a reload. A re-check and a key handed over both come back naming the action, and while this + * came only from an answer a refresh collapsed the third state into the first and told the one + * person with a refused key that nothing had been tried. The connections read carries it now — as + * the RECORD of what the last check spent, written down by the check itself and read back off the + * row — and the freshest answer still wins over it, because an answer is a newer record of the + * same thing. + * + * A PAST TENSE, WHICH IS THE WHOLE OF WHAT IT IS FOR. This is what the sentence is drawn from and + * it is never what the Re-check button is gated on: what the check spent and what there is to + * spend now are two questions, and {@link BrokeredAccount.checkable} is the second one. Asking + * this field the second question is what deadlocked the button — see that field. + */ + probe: string | null | undefined; + /** + * Whether the app has anything to check this key against today. + * + * THE BUTTON'S OWN QUESTION, AND NOT THE SENTENCE'S. It is asked of the APP — has it published an + * action safe to spend a key on — where {@link BrokeredAccount.probe} is asked of the connection, + * and answers what the last check actually spent. The two agreed while the probe was derived on + * every read, and they part company exactly when they should: an app that publishes something now + * and published nothing then. + * + * WITHOUT IT THE ROW DEADLOCKS. A key accepted against an app with nothing to try records no + * action, permanently and correctly; a button gated on that record is withheld permanently too, + * even once the app publishes something — and pressing that button is the only thing that could + * ever write an action into the record. The one act that would end the state is the act being + * withheld. + * + * FALSE WHERE NOTHING HAS SAID, which is not the hedge `probe` needs. There is no sentence to get + * wrong here, only a button to offer or withhold, and withholding is the direction that cannot + * mislead: a press with nothing to spend could only ask for the same answer again. + */ + checkable: boolean; + /** + * Whether this deployment has a Composio key at all. + * + * Carried through the hook rather than passed to the row separately, so a caller wires the row up + * once: with no key there is no broker to ask, nothing to confirm on arrival, and neither action + * below can do anything but fail. + */ + configured: boolean; + /** See {@link BrokeredAccountKind}. Derived here so no branch below re-asks. */ + kind: BrokeredAccountKind; + /** Leave for the vendor's consent screen. */ + connect: () => void; + /** End the account at Composio, not only here. */ + disconnect: () => void; + connecting: boolean; + disconnecting: boolean; + /** + * Whether a disconnect from this screen landed, as opposed to an account that was never made. + * + * Both read as not connected and they are not the same sentence. For an app whose secret somebody + * typed, what disconnecting did NOT do is the part worth saying — the key is still live at the + * vendor — and there is nobody to say it to until they have actually pressed the button. + */ + disconnected: boolean; + /** + * What the app wants typed in, once it has been asked. Null until then, and for every app nobody + * types anything into. + */ + fields: BrokerField[] | null; + /** Ask the app what it needs, which is the first press on a `fields` app. */ + requestFields: () => void; + /** + * Finish the connection with what the person typed. + * + * The values are handed straight to the request and held nowhere else: they are somebody's own + * key, and this hook keeps no copy a later render could read back. + */ + submitFields: (values: Record) => void; + requestingFields: boolean; + submittingFields: boolean; + /** + * Why the last attempt to hand over what somebody typed was refused, or null. + * + * Carried out of the hook as well as reported to the screen's banner, because the form is a + * modal. The banner is behind its backdrop, so Composio's own sentence — the one this path spends + * a dropped `cause` to preserve — arrived where the person could not read it, over a form still + * holding the key it was about. + */ + submissionError: string | null; + /** Spend one read-only call at the vendor to find out whether the key still works. */ + recheck: () => void; + rechecking: boolean; +}; + +export function useBrokeredAccount(input: { + serverId: string; + /** Whether this row is about a brokered app at all. Asked of the recorded row by the caller. */ + brokered: boolean; + /** See {@link BrokeredAccount.configured}. */ + configured: boolean; + /** What this deployment recorded, which is what stands until the vendor has answered anything. */ + recorded: boolean; + /** See {@link BrokeredAccount.verified}, as this deployment last wrote it down. */ + verified: boolean; + /** See {@link BrokeredAccount.verifiedAt}, as this deployment last wrote it down. */ + verifiedAt: string | null; + /** + * Which action the last check of this key SPENT, as the connections read has it recorded. + * + * The read's record and not an answer to anything pressed here, which is exactly what makes it + * worth passing: it is all the row has on a page that has only loaded. Undefined where the + * recorded row carries no such field — a held connection, or an app nobody has connected — and + * null where the check spent nothing. See {@link BrokeredAccount.probe} for what each of those + * means to the sentence the row draws. + */ + probe: string | null | undefined; + /** + * Whether the app has anything to check this key against today, as the connections read answers. + * + * THE ONLY PLACE THIS CAN COME FROM. It is a fact about what the app publishes NOW, so the read + * is what knows it, and — unlike the record above — no answer to anything pressed here improves + * on it. See {@link BrokeredAccount.checkable}, and the return below for why no answer overrides + * it. Flattened to false by the caller where the row carries nothing, because a missing gate and + * a closed gate are the same gate. + */ + checkable: boolean; + /** + * How the app's authorization config was CREATED, as the vendor's own scheme literal. + * + * Read off the recorded server row rather than off a connection row: the connections endpoint + * answers two different row shapes, so the absence of a field there says which READ a row came + * from and never how an app connects. Null where the app is not brokered at all. + */ + authScheme: string | null; + /** Which screen the vendor's callback puts somebody down on. */ + returnTo: "settings" | "admin"; + /** + * Where this row's failures go: the screen's own banner. + * + * Called with null as an action starts, so the reason the last attempt failed is not left sitting + * over the one now in flight. + */ + report: (message: string | null) => void; +}): BrokeredAccount { + const queryClient = useQueryClient(); + const { + authScheme, + brokered, + checkable, + configured, + recorded, + probe, + report, + returnTo, + serverId, + verified, + verifiedAt, + } = input; + + /* + * Ask the vendor whether this person's brokered account is actually live, on arrival. + * + * The return trip from consent is an ordinary redirect with nothing signed in it, so being back + * on the page proves nothing about what happened at the vendor. The row a screen would otherwise + * read is written from that same unproven return, which is why the answer is asked for rather + * than assumed. + * + * Deliberately not wired into the banner. Somebody who abandoned the consent screen — or who has + * simply never connected — arrives with nothing at the vendor to confirm, and that is an ordinary + * state of the page, not a failure of it. It reads as not connected, which is what it is; a red + * sentence across the top would be the page reporting its own question as somebody's problem. + * + * Not asked at all where there is no key. The endpoint answers 503, the screen swallows it, and + * the row falls back to whatever we recorded — so the question can only ever be a wasted request + * that ends in the one state this row has a sentence for anyway. + */ + const confirmation = useMutation( + confirmBrokeredConnectionMutationOptions(queryClient), + ); + const confirmAccount = confirmation.mutate; + /* + * The confirmed answer is thrown away whenever an action changes the account underneath it. + * + * A mutation's `data` is not query state: invalidating the queries refetches the recorded row and + * leaves this answer exactly where it was, and the effect above does not run again because none + * of its dependencies changed. So a successful disconnect kept its dot, its word and its + * Disconnect button, the person read that as a failure and pressed again, and the second DELETE + * wrote a second `mcp.account_disconnected` entry about an account that was already gone. + * + * Cleared on connect for the same reason in the other direction. That path ends in a full page + * navigation, so the held answer is usually thrown away with the document — but a navigation the + * browser declines to make, or one somebody comes back from, would otherwise leave a "not + * connected" answer from before the consent deciding a row about the account it granted. + */ + const forgetConfirmation = confirmation.reset; + useEffect(() => { + if (!(brokered && configured)) return; + confirmAccount(serverId); + }, [brokered, configured, serverId, confirmAccount]); + + /* + * Find out whether the key still works, when somebody presses for it and at no other time. + * + * Deliberately not a second effect beside the confirm above. Composio never re-checks a key once + * it has taken it, so the only way to learn whether one works is to spend a real read-only call at + * the vendor with it — and a verify-on-render would spend the person's own rate limit there, on + * every mount of every screen that draws this row, to redraw a word that was already written down. + * So the row says when it last checked, and the person decides when to check again. + */ + const recheck = useMutation({ + ...recheckBrokeredConnectionMutationOptions(queryClient), + onError: (thrown: Error) => report(thrown.message), + }); + /* + * The check's answer is thrown away whenever an action changes the account it was about. + * + * THE SAME DEFECT `forgetConfirmation` ABOVE EXISTS FOR, in the same shape and for the same + * reason: a mutation's `data` is not query state, so invalidating the queries refetches the + * recorded row and leaves this verdict exactly where it was. Disconnecting would leave the row + * saying a key was "last checked" an hour ago about an account that no longer exists, and + * connecting a fresh key would inherit the old key's verdict — a row reading "last checked" + * about a value nothing has ever tried. + * + * RESET RATHER THAN A GUARD AT THE DRAWING. Hiding it behind `connected` in the render would fix + * the disconnect and not the reconnect, and would leave the hook handing `verified: true` to any + * other reader — the honest thing is for the answer to stop existing when the thing it answered + * about does. + */ + const forgetRecheck = recheck.reset; + + const connect = useMutation({ + ...connectAccountMutationOptions(returnTo), + onError: (thrown: Error) => report(thrown.message), + /* + * A full page navigation, not a fetch. The consent screen is the vendor's own and has to be + * shown to this person in their own browser; there is deliberately nothing here that could + * complete it for them, and nothing about being an administrator changes that. + */ + onSuccess: (authorizationUrl) => { + forgetConfirmation(); + forgetRecheck(); + window.location.href = authorizationUrl; + }, + }); + + /* + * The mutation's own `onSuccess` is called rather than replaced: it is what invalidates every + * plugin query, and spreading these options and then declaring a second `onSuccess` would quietly + * drop it, leaving the recorded row on screen as stale as the confirmed answer. + */ + const disconnectOptions = disconnectBrokeredMutationOptions(queryClient); + const disconnect = useMutation({ + ...disconnectOptions, + onError: (thrown: Error) => report(thrown.message), + onSuccess: (...args) => { + forgetConfirmation(); + forgetRecheck(); + return disconnectOptions.onSuccess?.(...args); + }, + }); + + /* + * The first press on an app nobody consents to: what does it want typed in? + * + * A question about the app rather than about anybody's account, which is why it writes nothing + * and refetches nothing. Its answer is the mutation's own `data` and is not held anywhere else, + * so leaving the screen forgets the form rather than leaving a half-filled one behind. + */ + const fieldsRequest = useMutation({ + ...brokeredConnectionFieldsMutationOptions(), + onError: (thrown: Error) => report(thrown.message), + }); + + /* + * The second press, with the values on it. Its `onSuccess` is called rather than replaced, for + * the reason the disconnect above gives: that is what refetches the recorded row. + * + * The confirmed answer is dropped here too. A row that connects this way arrived with a "not + * connected" answer from the mount, and nothing about typing a key changes the dependencies of + * the effect that asked — so without this the account would go on reading as not connected + * however well the vendor accepted it. + */ + const submitOptions = connectBrokeredWithFieldsMutationOptions(queryClient); + const submission = useMutation({ + ...submitOptions, + onError: (thrown: Error) => report(thrown.message), + onSuccess: (...args) => { + forgetConfirmation(); + forgetRecheck(); + return submitOptions.onSuccess?.(...args); + }, + }); + + /* + * The freshest thing either check has said about this key, or nothing at all. + * + * Two answers name a probe — a re-check, and a key just handed over — and the newer of the two + * wins for the reason `verified` below gives: an answer beats the record, and these two cannot + * both be new. A submission clears the re-check's answer as it lands, and opening the form clears + * the submission's, so whichever is present is the one that was actually last said. + */ + const answered = recheck.data ?? submission.data; + + return { + /* + * What the vendor last answered, and only our own record until it has answered anything. The + * answer wins once there is one, in both directions — an account ended at Composio by somebody + * else reads as not connected here too. A confirm still in flight, or one that could not be + * made at all, leaves the recorded row standing rather than inventing either answer. + */ + connected: confirmation.data?.connected ?? recorded, + /* + * What the last re-check found, and only our own record until one has been made here — the same + * rule `connected` follows above: the answer wins once there is one. + * + * Both read off the one `recheck.data` rather than each falling back on its own, because + * `verifiedAt` is legitimately null in a fresh answer — a check that came back not verified + * records no time — and a `??` on it would pair that answer with the time of the check before, + * leaving the row saying a key failed as of an hour before it was asked. + */ + verified: recheck.data ? recheck.data.verified : verified, + verifiedAt: recheck.data ? recheck.data.verifiedAt : verifiedAt, + /* + * The name off whichever answer is newest, and THE READ'S OWN RECORD UNTIL THERE IS ONE — the + * same rule `connected` and `verified` follow above, and for the same reason: an answer beats + * the record, and a just-finished re-check must not be overruled by a read taken before it. + * + * BOTH SIDES OF THAT `??` ARE THE SAME KIND OF FACT, which is what makes the rule sound here. + * The connections read carries what the last check SPENT, written down by that check; an answer + * carries what the check just made spent. A newer record of one thing replacing an older record + * of the same thing — so its null is the server saying that check spent nothing, exactly as an + * answer's null is. That is why it is passed straight through rather than flattened to + * undefined: undefined is the absence of any record at all, and it is what remains for a row + * whose read carried no such field. See {@link BrokeredAccount.probe}. + */ + probe: answered ? answered.probe : probe, + /* + * AND THE READ'S ANSWER ALONE, WITH NO ANSWER ALLOWED TO OVERRULE IT — deliberately not the + * rule every field above follows, because this is not the same kind of fact as any of them. + * + * AN ANSWER REPORTS A CHECK; THIS IS A QUESTION ABOUT THE APP. What a re-check or a submitted + * key comes back with is `probe`: the action that press SPENT. It is tempting to read a null + * there as "so there was nothing to spend", and at the instant of the press that is even true — + * the same chooser answered both. But a mutation's `data` is not query state. It persists until + * something resets it, while the connections read behind it refetches on every one of these + * mutations, so `answered.probe === null` winning here would PIN the gate shut against every + * later read that learns the app has published something. That is this very deadlock rebuilt + * one layer up, out of the same mistake: a record of a past check asked what is true now. + * + * AND NOTHING IS LOST BY REFUSING IT. Every mutation in this hook invalidates the plugin + * queries, so the read that owns this field is refetched the moment any press lands; the most a + * press can cost is one render on the previous read's answer, and a stale gate is a button + * offered or withheld for an instant, not a sentence anybody is told. + */ + checkable, + recheck: () => { + report(null); + recheck.mutate(serverId); + }, + rechecking: recheck.isPending, + configured, + connect: () => { + report(null); + connect.mutate(serverId); + }, + connecting: connect.isPending, + disconnect: () => { + report(null); + disconnect.mutate(serverId); + }, + disconnecting: disconnect.isPending, + /* + * A disconnect this person made, rather than an account that was never there. Held by the + * mutation because that is where the fact is: the recorded row says only that there is nothing, + * which is equally true of an app nobody ever connected. + */ + disconnected: disconnect.isSuccess, + kind: kindOf(authScheme), + fields: fieldsRequest.data ?? null, + requestFields: () => { + report(null); + /* + * A fresh attempt, so the last one's refusal goes with it. This press is also what opens the + * form, and a mutation's error outlives the dialog that showed it: without this, reopening + * would present the sentence the previous key was refused with, above an empty field. + */ + submission.reset(); + fieldsRequest.mutate(serverId); + }, + requestingFields: fieldsRequest.isPending, + submitFields: (values: Record) => { + report(null); + submission.mutate({ serverId, values }); + }, + submittingFields: submission.isPending, + submissionError: submission.error?.message ?? null, + }; +} + +/** + * The day a check was made, in the reader's own locale. + * + * A day rather than "2 hours ago": the point of the sentence is that the check is a past tense that + * keeps receding, and a relative phrase recomputed on every render reads as a fact about now. + */ +function formatDate(iso: string): string { + return new Date(iso).toLocaleDateString(); +} + +/** + * The line beneath the word, which is where the three kinds actually differ. + * + * THEY DO NOT DIFFER IN THE WORD. A consent screen and a key somebody typed both end in a live + * account, and "Connected" is true of both; a second word for the second kind would invite a + * distinction there is no fact behind. What differs is what that connection rests on, and how much + * this deployment can honestly claim to know about it — which is a sentence, not a label. + * + * COMPOSIO NEVER RE-CHECKS A SUBMITTED KEY. It answers ACTIVE forever, so a key revoked at the + * vendor last week still reads as connected here. The verification makes exactly one moment true, + * so the row names that moment instead of asserting a present tense it does not hold. + */ +function accountSentence(input: { + account: BrokeredAccount; + /** The app's own name, as the screen drawing this row knows it. */ + title: string; + connectedDescription: string; + disconnectedDescription: string; + disconnectedReassurance: string | undefined; +}): string { + const { + account, + connectedDescription, + disconnectedDescription, + disconnectedReassurance, + title, + } = input; + + if (!account.configured) { + return "Set COMPOSIO_API_KEY on this deployment. Without it there is no broker to reach, so this account can be neither connected nor ended from here. The app stays enabled and every grant on its tools still stands."; + } + + if (account.kind === "no-auth") { + /* + * Neither screen's own sentence fits: both are about an account, and there is none to have. + * Capitalised because this is the one position the name opens a sentence in. + */ + return `${title.charAt(0).toUpperCase()}${title.slice(1)} needs no account. A Bot granted these tools can use it as it is.`; + } + + if (account.kind === "fields") { + if (account.connected) { + if (account.verified && account.verifiedAt) { + return `Connected with a key you provided, last checked ${formatDate(account.verifiedAt)}.`; + } + /* + * THE KEY WAS CHECKED AND THE VENDOR REFUSED IT, and the account it was checked in is still + * there — the one state where the sentence below was not vague but FALSE. Three facts are + * this person's to act on and all three go in: their key is bad, an account of theirs is live + * at Composio, and the row says which button ends which. + * + * WHY THE ACCOUNT STANDS IS NOT ASSERTED, because two paths reach this state and they stand + * for different reasons. A connect whose probe failed tried to withdraw the account it had + * just made and could not; a re-check that failed never tried, deliberately, because the + * account predates the press and is the person's own. Naming a failed withdrawal would be + * false on the second path, and the standing account is the actionable half either way. + * + * BOTH WAYS OUT ARE NAMED, because neither is obvious from a row that says "Connected": the + * account ends with the button beside this line, and a key corrected at the vendor is worth a + * second check rather than a second connection. + */ + if (account.probe && !account.verified) { + return `Your key was checked against ${title} and rejected, and the account it was checked in still stands at Composio, so disconnect it here, or fix the key at ${title} and press Re-check.`; + } + /* + * NOTHING WAS SPENT ON IT, WHICH IS A FACT ABOUT THE CHECK AND ABOUT THE APP AT THE TIME. The + * record names no action: when the key was taken this app published none safe to spend it on, + * so the check was not skipped and could not be made. Said plainly because the alternative + * reading — that this deployment doubts the key — is the one a person supplies for themselves + * when a row goes quiet. + * + * ITS SECOND CLAUSE IS AS OF THE CHECK, NOT AS OF TODAY, AND SAYS SO IN ITS TENSE. The app + * published nothing safe to try a key on when the key was taken; whether it publishes + * something now is a question this sentence does not answer, and must not, because a sentence + * drawn from today's listing is how a key nobody tried came to be accused of being rejected. + * Where the app HAS since published something the present-tense half reaches the person as + * the Re-check button beside this line, which `checkable` puts there in exactly that state — + * so the row offers the act that would make this sentence current rather than asserting a + * check it has not made. A past tense is what keeps the two from reading as a contradiction: + * a button to check with, beside a line that never claimed there was nothing to check with + * today. + */ + if (account.probe === null) { + return `Connected with a key you provided. It was accepted without being checked against ${title}, which published nothing safe to try a key on at the time — that is about the app, not about your key.`; + } + /* + * AND NOTHING HAS SAID WHICH, which is no longer the page load: the connections read carries + * the recorded probe now, so a reload lands on one of the two sentences above. What is left + * here is a row nothing has told about a probe either way — a held connection, whose rows + * carry none of this — and all it knows is that the key was taken. Those two sentences are the + * two things a record can say; this is what stands where there is no record, and it must not + * borrow either. + */ + return `Connected with a key you provided. It was accepted without being checked against ${title}.`; + } + /* + * WHAT DISCONNECTING DID NOT DO. The account ends at Composio and the key does not end + * anywhere: it is still valid at the vendor and still works for anyone holding it. Saying + * "disconnected" and stopping would leave somebody believing they had ended access they still + * have live, so the row names the step this deployment cannot take for them. + */ + if (account.disconnected) { + return `Removed from Composio. Your key still works at ${title} — rotate it there if you meant to end its access.`; + } + /* + * NOT THE SCREEN'S OWN SENTENCE, which is written for the kind that leaves: one of the two says + * connecting takes you to Composio and then to the vendor to consent, and pressing Connect here + * opens a form and asks for a secret instead. A sentence that promises a trip nobody is about to + * take is a worse preparation for the dialog than no sentence at all. + * + * THE SCREEN'S REASSURANCE IS KEPT THOUGH ITS SENTENCE IS NOT. What an administrator needs to + * read here — that finishing the connector does not wait on them connecting — is true whichever + * way this app is connected, and a row that replaced the whole line took it away with the trip + * it was right to drop. + */ + const asked = `This app is connected with a key you already hold, not a trip to ${title}'s consent screen. Connect asks for it.`; + return disconnectedReassurance + ? `${asked} ${disconnectedReassurance}` + : asked; + } + + /* + * A consent app keeps the screen's own sentence, prefixed by what the connection rests on. What + * differs between an administrator checking their setup and a person checking who reads their + * mail is an argument, not a branch — see this file's opening comment. + */ + return account.connected + ? `Connected through ${title}'s consent screen. ${connectedDescription}` + : disconnectedDescription; +} + +/** + * The row itself, for a `PageRows` card on either screen. + * + * The `Item` and, for an app whose secret a person types, the dialog that takes it. Where the row + * sits in the card, and whether a `Separator` precedes it, is the screen's business and differs + * between the two; the dialog is portalled to the body and so sits nowhere at all. + */ +export function BrokeredAccountRow({ + account, + connectedDescription, + disconnectedDescription, + disconnectedReassurance, + title, +}: { + account: BrokeredAccount; + /** What being connected means on this screen, said beside the button rather than after it. */ + connectedDescription: string; + /** What connecting would do, in the voice of whoever is reading. */ + disconnectedDescription: string; + /** + * What stays true whether or not this person ever connects, in that same voice. + * + * Separate from `disconnectedDescription` because only part of a screen's line survives the kind + * that does not leave: the half describing the trip to a consent screen is wrong for an app whose + * key somebody types, and the half telling an administrator their setup is already complete is + * right for both. A screen with nothing of the second kind to say passes nothing. + */ + disconnectedReassurance?: string; + /** + * The app's own name, for the sentences that name it. + * + * Required, because the sentences it appears in are the ones whose whole job is to name a place: + * where a key still works after a disconnect, whose consent screen a connection rests on, which + * app needs no account at all. "Your key still works at the app" tells somebody nothing they can + * act on, so a screen that cannot name the app has no business drawing this row. + */ + title: string; +}) { + /* + * Whether the form is on screen, which is the whole of what this row holds. + * + * An app nobody consents to is connected by typing a key rather than by leaving for a consent + * screen, and the layout's answer to more than one value is a dialog rather than fields wedged + * into the row. What goes in those fields is asked for when this opens and is held by the form + * itself — see `connection-fields.tsx` — so closing this forgets it. + */ + const [asking, setAsking] = useState(false); + + /* + * A connection that landed takes its own form off the screen. + * + * The row behind redraws as connected on the refetch either way; without this the person is left + * reading the form they just submitted, over a row that says it worked. + */ + useEffect(() => { + if (account.connected) setAsking(false); + }, [account.connected]); + + return ( + <> + + + {/* Not "Connect your account": the row is also the connected state, and a title has to + read for both. */} + Your account + {/* Unclamped where the key is missing: that sentence is the only place the setting is + named, so it is the point rather than a hint. */} + + {accountSentence({ + account, + connectedDescription, + disconnectedDescription, + disconnectedReassurance, + title, + })} + + + {/* + * AN APP THAT NEEDS NO ACCOUNT HAS NOTHING HERE AT ALL — no Connect, and not a disabled one + * either. There is no account to make and none to end, so a button would offer an act with + * no effect and a greyed one would announce a step somebody is missing when they are not. + * The sentence above already says the app works as it is. + * + * Still drawn where the key is missing, because then nothing works, this app included. + */} + {account.configured && account.kind === "no-auth" ? null : ( + + {!account.configured ? ( + /* + * A value and nothing to press, which is the layout's read-only row: the deployment + * has no key, so Connect could only fail at the broker and Disconnect could only fail + * at it twice. A button that cannot work is worse than no button — it invites the + * second press that files a record of an act that did not happen. + */ + Key missing + ) : account.connected ? ( + <> + {/* Decorative: the word beside it already says which. */} + + )} + + + + + + {/* The title the row above cannot have: this exists only while the account is not + connected, so it is free to name the act rather than the subject. */} + Connect your account + + + {account.fields ? ( + + ) : ( +

+ {account.requestingFields + ? "Asking the app what it needs…" + : "That app could not be asked what it needs. Close this and try again."} +

+ )} + {/* + * THE REFUSAL WHERE THE PERSON IS LOOKING. It reaches the screen's banner too, and that + * banner is behind this dialog's own backdrop: a key Composio would not take said so in + * the vendor's words, at the top of a page nobody could see, while the form sat open as + * though nothing had been answered. + * + * Under the form rather than over it, beside the button that was just pressed, and the + * form stays up holding what was typed — a key is corrected, not retyped. + */} + {account.submissionError ? ( +

+ {account.submissionError} +

+ ) : null} +
+
+
+ + ); +} diff --git a/app/src/components/plugins/connection-fields.tsx b/app/src/components/plugins/connection-fields.tsx new file mode 100644 index 000000000..dd1e6c3db --- /dev/null +++ b/app/src/components/plugins/connection-fields.tsx @@ -0,0 +1,94 @@ +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + Item, + ItemContent, + ItemDescription, + ItemTitle, +} from "@/components/ui/item"; +import type { BrokerField } from "@/lib/plugins/mutations"; + +/** + * What one app asks for, drawn from what that app published. + * + * NOTHING HERE IS PER-APP KNOWLEDGE. The label, the help sentence, the masking and the pre-filled + * default all come off the field, which came off Composio. An app this deployment has never heard of + * draws correctly for the same reason Gmail does: Perplexity publishes one secret `generic_api_key` + * whose help sentence says to look for a value starting with `pplx-`; Shopify publishes a plain + * subdomain beside a secret admin token; Firecrawl publishes a base URL carrying a default most + * people keep. All three are this list, with different rows in it. + * + * SO THERE IS NOTHING TO SWITCH ON. Across forty sampled apps every required field is a plain string + * and the most any app asks for is three, and the server refuses anything that is not a string + * before it reaches here — so this is a list of text inputs and deliberately nothing more. A field + * type to branch on would be a second vocabulary to keep level with the vendor's, invented for a + * shape nobody publishes. + * + * THE VALUES ARE HELD IN THIS COMPONENT AND NOWHERE ELSE: no query cache, no router state, no local + * storage. They are somebody's own key. They go to the mutation on submit and are gone when the + * dialog closes, because keeping a credential we were only ever asked to forward is the one thing + * this form must not do. + */ +export function ConnectionFields({ + fields, + onSubmit, + busy, +}: { + fields: BrokerField[]; + onSubmit: (values: Record) => void; + /** Whether the submission is already in flight, so the button cannot start a second one. */ + busy: boolean; +}) { + /* Seeded from the defaults the app published, so a field most people keep is already filled in. */ + const [values, setValues] = useState>(() => + Object.fromEntries( + fields.map((field) => [field.name, field.default ?? ""]), + ), + ); + + return ( +
{ + event.preventDefault(); + onSubmit(values); + }} + > + {fields.map((field) => ( + /* `muted` rather than a card: `--card` and `--popover` are the same colour, so a + card-coloured row inside a dialog is no row at all. */ + + + + + + {/* Unclamped: the app's own instructions are the point of the row, not a hint under it. */} + {field.help ? ( + + {field.help} + + ) : null} + + setValues((held) => ({ + ...held, + [field.name]: event.target.value, + })) + } + required={field.required} + /* The app said which value is the secret; nothing here guesses from its name. */ + type={field.secret ? "password" : "text"} + value={values[field.name] ?? ""} + /> + + + ))} + +
+ ); +} diff --git a/app/src/lib/plugins/mutations.ts b/app/src/lib/plugins/mutations.ts index e8bee1ffa..8d7c96706 100644 --- a/app/src/lib/plugins/mutations.ts +++ b/app/src/lib/plugins/mutations.ts @@ -140,6 +140,25 @@ export function addCustomServerMutationOptions(queryClient: QueryClient) { }); } +/** + * Add a Composio app to the deployment, named by its slug. + * + * Its own endpoint rather than a curated key, because the catalogue is the vendor's rather than + * ours: the slug is all the server needs to look the app up and record the row. + */ +export function enableComposioAppMutationOptions(queryClient: QueryClient) { + return mutationOptions({ + mutationFn: async (input: { slug: string }) => { + await client("/api/plugins/composio/apps", { + method: "POST", + body: input, + fallback: "That app could not be added.", + }); + }, + onSuccess: () => invalidatePlugins(queryClient), + }); +} + /** Re-read a server's tool list, which is what makes a newly-added tool appear. */ export function refreshPluginServerMutationOptions(queryClient: QueryClient) { return mutationOptions({ @@ -252,6 +271,58 @@ export function connectAccountMutationOptions( }); } +/** + * Ask the vendor whether a brokered connection actually completed. + * + * Exists because the return trip from consent proves nothing. The callback is an ordinary redirect + * with nothing signed in it, so somebody arriving back on the page is not evidence that they + * finished the flow — or that the account they finished it with is the one the row claims. So the + * vendor is asked, and its answer is what the connected state is written from. + * + * Answers with the body rather than a bare success, because "asked, and told not connected" is a + * different thing for a screen to say than "could not ask". + * + * There is no start half here: beginning a brokered connect is the same write as any other consent + * flow, so callers use `connectAccountMutationOptions` above, which already reads the vendor's + * `authorizationUrl` off the connect route. + */ +export function confirmBrokeredConnectionMutationOptions( + queryClient: QueryClient, +) { + return mutationOptions({ + mutationFn: async (serverId: string): Promise<{ connected: boolean }> => { + const response = await client( + `/api/plugins/servers/${encodeURIComponent(serverId)}/connection/confirm`, + { method: "POST", fallback: "That connection could not be confirmed." }, + ); + return (await response.json()) as { connected: boolean }; + }, + onSuccess: () => invalidatePlugins(queryClient), + }); +} + +/** + * End the signed-in person's brokered connection. + * + * Ends the account at Composio rather than only here. Forgetting the row on our side would leave + * the vendor still holding a live grant on somebody's mailbox, which is not what the person who + * pressed disconnect was told would happen. + */ +export function disconnectBrokeredMutationOptions(queryClient: QueryClient) { + return mutationOptions({ + mutationFn: async (serverId: string) => { + await client( + `/api/plugins/servers/${encodeURIComponent(serverId)}/connection`, + { + method: "DELETE", + fallback: "That account could not be disconnected.", + }, + ); + }, + onSuccess: () => invalidatePlugins(queryClient), + }); +} + export function removeSkillMutationOptions(queryClient: QueryClient) { return mutationOptions({ mutationFn: async (slug: string) => { @@ -263,3 +334,133 @@ export function removeSkillMutationOptions(queryClient: QueryClient) { onSuccess: () => invalidatePlugins(queryClient), }); } + +/** + * One value Composio wants from the person connecting, as Composio itself describes it. + * + * Declared here rather than guessed at a form: the vendor publishes the list per app, `secret` says + * which one to mask, and `help` is written for the person filling it in. `name` goes back on the + * wire verbatim and is never shown. + */ +export type BrokerField = { + name: string; + label: string; + help: string; + required: boolean; + secret: boolean; + default?: string; +}; + +/** + * Ask what an app wants typed in, for the apps nobody consents to. + * + * Most Composio apps are not connected through a consent screen — the person holds an API key and + * types it in — so the same connect route answers a field list on the first press and takes the + * values on the second. Nothing is written by this half: it is a question about the app, not about + * anybody's account, which is why it refetches nothing. + */ +export function brokeredConnectionFieldsMutationOptions() { + return mutationOptions({ + mutationFn: (serverId: string): Promise => + client( + `/api/plugins/servers/${encodeURIComponent(serverId)}/connect`, + "fields", + { + method: "POST", + fallback: "That app could not be asked what it needs.", + }, + ), + }); +} + +/** + * Finish that connection with what the person typed. + * + * The values are passed to the mutation and held nowhere else — no query cache, no router state, no + * local storage. They are somebody's own key: the request body is the whole of their life in this + * app, and putting them anywhere a later render could read them back would be keeping a credential + * we were only ever asked to forward. + * + * Answers with the body rather than a bare success, because Composio does not check a submitted key. + * `connected` is only that the vendor accepted the row; `verified` is whether a real call was made + * with it, and a screen says different things about the two. + * + * AND `probe` IS WHAT SEPARATES THE TWO THINGS `verified: false` MEANS. A null probe is an app that + * publishes nothing safe to spend a key on, so nothing was tried and "accepted without being + * checked" is the truth. A NAMED probe beside that same false is the other state entirely: the + * action ran in this person's account, the vendor rejected the key, and the account could not be + * withdrawn — so the row exists and the key is bad. A screen reading the boolean alone cannot tell + * those apart, and would tell the second person that nothing had ever been checked. + */ +export function connectBrokeredWithFieldsMutationOptions( + queryClient: QueryClient, +) { + return mutationOptions({ + mutationFn: async (variables: { + serverId: string; + values: Record; + }): Promise<{ + connected: boolean; + verified: boolean; + probe: string | null; + }> => { + const response = await client( + `/api/plugins/servers/${encodeURIComponent(variables.serverId)}/connect`, + { + method: "POST", + body: { values: variables.values }, + fallback: "That account could not be connected.", + }, + ); + return (await response.json()) as { + connected: boolean; + verified: boolean; + probe: string | null; + }; + }, + onSuccess: () => invalidatePlugins(queryClient), + }); +} + +/** + * Spend one read-only call at the vendor to find out whether a key still works. + * + * A button rather than something a page does on its own. Verifying on every render would spend the + * person's own rate limit at the vendor to redraw a word, so the check happens when somebody asks + * for it and the answer is recorded with the time it was taken. + * + * `probe` TRAVELS WITH THE VERDICT, for the reason spelled out over + * {@link connectBrokeredWithFieldsMutationOptions}: the flag alone means three different things and + * a reader cannot tell them apart. On this route it also decides whether there is anything left to + * press — a null probe is an app publishing nothing safe to spend a key on, so the check that just + * ran is the last one there is to run, where a named probe is a check that can be made again as + * soon as somebody has corrected their key at the vendor. + * + * A CHECK THE VENDOR REFUSED DOES NOT ARRIVE HERE AT ALL. That path raises with Composio's own + * sentence in it, which the banner and the dialog draw; the only `verified: false` this answers with + * is the one carrying a null probe. + */ +export function recheckBrokeredConnectionMutationOptions( + queryClient: QueryClient, +) { + return mutationOptions({ + mutationFn: async ( + serverId: string, + ): Promise<{ + verified: boolean; + verifiedAt: string | null; + probe: string | null; + }> => { + const response = await client( + `/api/plugins/servers/${encodeURIComponent(serverId)}/connection/recheck`, + { method: "POST", fallback: "That connection could not be checked." }, + ); + return (await response.json()) as { + verified: boolean; + verifiedAt: string | null; + probe: string | null; + }; + }, + onSuccess: () => invalidatePlugins(queryClient), + }); +} diff --git a/app/src/lib/plugins/queries.ts b/app/src/lib/plugins/queries.ts index f74f2a273..e17ef349f 100644 --- a/app/src/lib/plugins/queries.ts +++ b/app/src/lib/plugins/queries.ts @@ -1,6 +1,14 @@ import { queryOptions } from "@tanstack/react-query"; import { client } from "@/lib/client"; +/** + * Re-exported, not re-declared. + * + * The field list is asked for by a POST, so it lives beside its only producer in `./mutations` — + * a second copy here would be free to drift from the shape that write actually returns. + */ +export type { BrokerField } from "./mutations"; + /** A tool one server offers, as the Plugins page sees it. */ export type PluginTool = { serverId: string; @@ -11,6 +19,15 @@ export type PluginTool = { ref: string; /** Whether it changes something. Anything not positively known to be a read is a write. */ effect: "read" | "write"; + /** + * Whether the vendor warns that it destroys something. + * + * Beside {@link effect} rather than folded into it: the rule engine judges reads and writes and + * gains nothing from a third value, while a person deciding whether to switch an action on is + * asking a different question. Recorded from the vendor's own labels, so false is an absence of a + * claim rather than a claim of safety. + */ + destructive: boolean; grantedTo: string[]; }; @@ -47,6 +64,19 @@ export type PluginServer = { * administrator to paste one in. */ dynamicClient: boolean; + /** + * How this server's authorization config was CREATED, for the brokered rows that have one. + * + * What was written down when somebody enabled the app, not what the catalogue publishes today. + * The catalogue is the vendor's, and an app it starts advertising under a different scheme has + * not moved the config this deployment already created — so a screen deciding what a live + * connection does reads this and never a fresh listing. The vocabulary is the vendor's own scheme + * literals (`OAUTH2`, `DCR_OAUTH`, `API_KEY`, `NO_AUTH`, and the rest), so it is a string rather + * than a union this page would have to keep level with Composio's. + * + * Null is not an older brokered row. It is a row that is not brokered at all. + */ + authScheme: string | null; tools: PluginTool[]; /** Empty for a healthy connector. See {@link WithdrawnGrant}. */ withdrawn: WithdrawnGrant[]; @@ -93,6 +123,18 @@ export type CatalogueItem = { perInstance: boolean; }; +/** One app in Composio's directory, as the picker shows it. */ +export type ComposioApp = { + slug: string; + name: string; + description: string; + logo: string | null; + categories: string[]; + /** The size of the decision, shown before enabling. */ + actionCount: number; + enabled: boolean; +}; + export type PluginsPage = { catalogue: CatalogueItem[]; servers: PluginServer[]; @@ -114,6 +156,14 @@ export type PluginsPage = { * consent flow at all. */ redirectUri: string | null; + /** + * Whether this deployment has Composio configured. + * + * A boolean about configuration and never the key: the page needs to know whether the directory + * can be browsed at all, and that question is answerable without the API key ever leaving the + * server. + */ + composioConfigured: boolean; }; /** What one Bot holds, which is all the runtime needs to offer it. */ @@ -137,14 +187,86 @@ export const pluginKeys = { page: () => ["plugins", "page"] as const, forAgent: (agentId: string) => ["plugins", "for-agent", agentId] as const, connections: () => ["plugins", "connections"] as const, + composioApps: (query: string) => + ["plugins", "composio", "apps", query] as const, }; -/** One account this person has connected, from their own point of view. */ +/** + * One account this person has connected, from their own point of view. + * + * DELIBERATELY NON-UNIFORM, because `/api/plugins/connections` concatenates two reads: connections + * held in this deployment's own vault, and brokered ones Composio keeps on our behalf. The fields a + * settings row draws from — the server id, the scope, the date — line up across both, which is why + * one type covers both. The pair below does not, so it is optional rather than required: making it + * required would be a lie the compiler then enforced on every held row. + */ export type PluginConnection = { serverId: string; - /** What the vendor actually granted, which is not always what was asked for. */ + /** What the vendor actually granted, which is not always what was asked for. Empty for brokered. */ scope: string; connectedAt: string; + /** + * Whether a real call was last made with this credential, present only on a BROKERED row. + * + * Only a brokered row has anything to re-check: this deployment holds no secret for it, only a + * note that Composio said yes, and that note can drift when somebody ends the connection in + * Composio's own dashboard. A held connection has no equivalent question, so its rows carry + * neither field, and the absence of the pair is what tells the two READS apart — nothing more. + * + * It is NOT how a reader learns how an app connects. That comes from {@link PluginServer.authScheme} + * on the server, and a page deriving it from a connection row instead would be a second answer to + * a question already carried. + */ + verified?: boolean; + /** + * When {@link PluginConnection.verified} was last earned, present only on a brokered row. + * + * Null is reachable and means never checked, which is why it stays null rather than collapsing + * the way `connectedAt` does. For the rows an older migration backfilled it is the moment of + * consent rather than of a probe, so it is not read as "this connection answered then". + */ + verifiedAt?: string | null; + /** + * Which action the last check of this key actually SPENT, present only on a brokered row. + * + * A RECORD READ OFF THE ROW, not a reading of what the app publishes now — which is why it + * survives a reload where the answer to a connect or a re-check cannot, and why nothing the + * catalogue does afterwards can move it. Read together with {@link PluginConnection.verified} it + * separates the three situations that share the one word `false`: no probe means nothing was + * tried, because at the time of the check the app published nothing safe to spend a key on; a + * probe with `verified` means the check ran and passed; and a probe WITHOUT it means the check + * ran and the vendor refused the key, over an account that is still standing. + * + * A PAST TENSE, AND ONLY THAT. It is what the SENTENCE beneath the row is drawn from. What it + * must never be asked is whether the key could be checked again — see + * {@link PluginConnection.checkable}, which is the present-tense answer and a different field + * because it is a different question. + * + * Optional for the same reason the pair above is: that endpoint concatenates two reads, and only + * the brokered one carries any of this. Undefined is the absence of the field and not a fourth + * state. + */ + probe?: string | null; + /** + * Whether the app has anything to check this key against TODAY, present only on a brokered row. + * + * ASKED OF THE APP AND NOT OF THE CONNECTION, out of the same chooser a real check would use: has + * this app published an action safe to spend somebody's key on — a vendor-labelled read, not + * destructive, needing no arguments, recorded at a version that can be called. It is what the + * Re-check button is drawn from, and nothing else here is. + * + * SEPARATE FROM {@link PluginConnection.probe} BECAUSE COLLAPSING THEM DEADLOCKS THE PAGE. While + * the button read the record, a key connected to an app with nothing to try recorded null, for + * good — and the button stayed withheld however much the app published later, though pressing it + * is the only thing that could ever put an action in the record. The past and the present are two + * questions; the row asks them separately and answers them separately. + * + * Optional for the same reason as the fields above, and false and absent mean the same thing to + * the only reader there is: nothing to press. That is why a screen may flatten this where it + * passes `probe` through unflattened — a missing verdict and a null verdict are different + * sentences, while a missing gate and a closed gate are the same gate. + */ + checkable?: boolean; }; export type PluginConnections = { @@ -182,6 +304,25 @@ export function pluginsPageQueryOptions() { }); } +/** + * Composio's app directory, narrowed by a search term. + * + * The term goes to our own endpoint because the vendor's client drops a search parameter and + * answers with an unfiltered page, so filtering has to happen somewhere that admits to doing it. + */ +export function composioAppsQueryOptions(query: string) { + return queryOptions({ + queryKey: pluginKeys.composioApps(query), + queryFn: async (): Promise<{ apps: ComposioApp[] }> => { + const response = await client( + `/api/plugins/composio/apps?q=${encodeURIComponent(query)}`, + { fallback: "Composio's app directory could not be read." }, + ); + return response.json(); + }, + }); +} + /** * Polled grant snapshot for what the active Bot should be offered; call-time checks still enforce. */ diff --git a/app/src/routeTree.gen.ts b/app/src/routeTree.gen.ts index ac2410814..bab75137e 100644 --- a/app/src/routeTree.gen.ts +++ b/app/src/routeTree.gen.ts @@ -36,10 +36,12 @@ import { Route as AuthedAdminComponentsIndexRouteImport } from './routes/_authed import { Route as AuthedAdminComponentsNameRouteImport } from './routes/_authed/admin/components/$name' import { Route as AuthedAdminPluginsIndexRouteImport } from './routes/_authed/admin/plugins/index' import { Route as AuthedAdminPluginsKeyRouteImport } from './routes/_authed/admin/plugins/$key' +import { Route as AuthedAdminPluginsComposioRouteImport } from './routes/_authed/admin/plugins/composio' import { Route as AuthedSettingsComponentsGalleryIndexRouteImport } from './routes/_authed/settings/components-gallery/index' import { Route as AuthedSettingsComponentsGalleryNameRouteImport } from './routes/_authed/settings/components-gallery/$name' import { Route as AuthedSettingsConnectedAccountsIndexRouteImport } from './routes/_authed/settings/connected-accounts/index' import { Route as AuthedSettingsConnectedAccountsKeyRouteImport } from './routes/_authed/settings/connected-accounts/$key' +import { Route as AuthedAdminPluginsKeyBotsAgentIdRouteImport } from './routes/_authed/admin/plugins/$key_.bots.$agentId' import { Route as AuthedAdminPluginsKeyToolsToolRouteImport } from './routes/_authed/admin/plugins/$key_.tools.$tool' const AuthedRoute = AuthedRouteImport.update({ @@ -179,6 +181,12 @@ const AuthedAdminPluginsKeyRoute = AuthedAdminPluginsKeyRouteImport.update({ path: '/plugins/$key', getParentRoute: () => AuthedAdminRouteRoute, } as any) +const AuthedAdminPluginsComposioRoute = + AuthedAdminPluginsComposioRouteImport.update({ + id: '/plugins/composio', + path: '/plugins/composio', + getParentRoute: () => AuthedAdminRouteRoute, + } as any) const AuthedSettingsComponentsGalleryIndexRoute = AuthedSettingsComponentsGalleryIndexRouteImport.update({ id: '/components-gallery/', @@ -203,6 +211,12 @@ const AuthedSettingsConnectedAccountsKeyRoute = path: '/connected-accounts/$key', getParentRoute: () => AuthedSettingsRouteRoute, } as any) +const AuthedAdminPluginsKeyBotsAgentIdRoute = + AuthedAdminPluginsKeyBotsAgentIdRouteImport.update({ + id: '/plugins/$key_/bots/$agentId', + path: '/plugins/$key/bots/$agentId', + getParentRoute: () => AuthedAdminRouteRoute, + } as any) const AuthedAdminPluginsKeyToolsToolRoute = AuthedAdminPluginsKeyToolsToolRouteImport.update({ id: '/plugins/$key_/tools/$tool', @@ -233,6 +247,7 @@ export interface FileRoutesByFullPath { '/channel/new': typeof AuthedAppChannelNewRoute '/admin/components/$name': typeof AuthedAdminComponentsNameRoute '/admin/plugins/$key': typeof AuthedAdminPluginsKeyRoute + '/admin/plugins/composio': typeof AuthedAdminPluginsComposioRoute '/settings/components-gallery/$name': typeof AuthedSettingsComponentsGalleryNameRoute '/settings/connected-accounts/$key': typeof AuthedSettingsConnectedAccountsKeyRoute '/agents/': typeof AuthedAppAgentsIndexRoute @@ -240,6 +255,7 @@ export interface FileRoutesByFullPath { '/admin/plugins/': typeof AuthedAdminPluginsIndexRoute '/settings/components-gallery/': typeof AuthedSettingsComponentsGalleryIndexRoute '/settings/connected-accounts/': typeof AuthedSettingsConnectedAccountsIndexRoute + '/admin/plugins/$key/bots/$agentId': typeof AuthedAdminPluginsKeyBotsAgentIdRoute '/admin/plugins/$key/tools/$tool': typeof AuthedAdminPluginsKeyToolsToolRoute } export interface FileRoutesByTo { @@ -263,6 +279,7 @@ export interface FileRoutesByTo { '/channel/new': typeof AuthedAppChannelNewRoute '/admin/components/$name': typeof AuthedAdminComponentsNameRoute '/admin/plugins/$key': typeof AuthedAdminPluginsKeyRoute + '/admin/plugins/composio': typeof AuthedAdminPluginsComposioRoute '/settings/components-gallery/$name': typeof AuthedSettingsComponentsGalleryNameRoute '/settings/connected-accounts/$key': typeof AuthedSettingsConnectedAccountsKeyRoute '/agents': typeof AuthedAppAgentsIndexRoute @@ -270,6 +287,7 @@ export interface FileRoutesByTo { '/admin/plugins': typeof AuthedAdminPluginsIndexRoute '/settings/components-gallery': typeof AuthedSettingsComponentsGalleryIndexRoute '/settings/connected-accounts': typeof AuthedSettingsConnectedAccountsIndexRoute + '/admin/plugins/$key/bots/$agentId': typeof AuthedAdminPluginsKeyBotsAgentIdRoute '/admin/plugins/$key/tools/$tool': typeof AuthedAdminPluginsKeyToolsToolRoute } export interface FileRoutesById { @@ -298,6 +316,7 @@ export interface FileRoutesById { '/_authed/_app/channel/new': typeof AuthedAppChannelNewRoute '/_authed/admin/components/$name': typeof AuthedAdminComponentsNameRoute '/_authed/admin/plugins/$key': typeof AuthedAdminPluginsKeyRoute + '/_authed/admin/plugins/composio': typeof AuthedAdminPluginsComposioRoute '/_authed/settings/components-gallery/$name': typeof AuthedSettingsComponentsGalleryNameRoute '/_authed/settings/connected-accounts/$key': typeof AuthedSettingsConnectedAccountsKeyRoute '/_authed/_app/agents/': typeof AuthedAppAgentsIndexRoute @@ -305,6 +324,7 @@ export interface FileRoutesById { '/_authed/admin/plugins/': typeof AuthedAdminPluginsIndexRoute '/_authed/settings/components-gallery/': typeof AuthedSettingsComponentsGalleryIndexRoute '/_authed/settings/connected-accounts/': typeof AuthedSettingsConnectedAccountsIndexRoute + '/_authed/admin/plugins/$key_/bots/$agentId': typeof AuthedAdminPluginsKeyBotsAgentIdRoute '/_authed/admin/plugins/$key_/tools/$tool': typeof AuthedAdminPluginsKeyToolsToolRoute } export interface FileRouteTypes { @@ -332,6 +352,7 @@ export interface FileRouteTypes { | '/channel/new' | '/admin/components/$name' | '/admin/plugins/$key' + | '/admin/plugins/composio' | '/settings/components-gallery/$name' | '/settings/connected-accounts/$key' | '/agents/' @@ -339,6 +360,7 @@ export interface FileRouteTypes { | '/admin/plugins/' | '/settings/components-gallery/' | '/settings/connected-accounts/' + | '/admin/plugins/$key/bots/$agentId' | '/admin/plugins/$key/tools/$tool' fileRoutesByTo: FileRoutesByTo to: @@ -362,6 +384,7 @@ export interface FileRouteTypes { | '/channel/new' | '/admin/components/$name' | '/admin/plugins/$key' + | '/admin/plugins/composio' | '/settings/components-gallery/$name' | '/settings/connected-accounts/$key' | '/agents' @@ -369,6 +392,7 @@ export interface FileRouteTypes { | '/admin/plugins' | '/settings/components-gallery' | '/settings/connected-accounts' + | '/admin/plugins/$key/bots/$agentId' | '/admin/plugins/$key/tools/$tool' id: | '__root__' @@ -396,6 +420,7 @@ export interface FileRouteTypes { | '/_authed/_app/channel/new' | '/_authed/admin/components/$name' | '/_authed/admin/plugins/$key' + | '/_authed/admin/plugins/composio' | '/_authed/settings/components-gallery/$name' | '/_authed/settings/connected-accounts/$key' | '/_authed/_app/agents/' @@ -403,6 +428,7 @@ export interface FileRouteTypes { | '/_authed/admin/plugins/' | '/_authed/settings/components-gallery/' | '/_authed/settings/connected-accounts/' + | '/_authed/admin/plugins/$key_/bots/$agentId' | '/_authed/admin/plugins/$key_/tools/$tool' fileRoutesById: FileRoutesById } @@ -602,6 +628,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthedAdminPluginsKeyRouteImport parentRoute: typeof AuthedAdminRouteRoute } + '/_authed/admin/plugins/composio': { + id: '/_authed/admin/plugins/composio' + path: '/plugins/composio' + fullPath: '/admin/plugins/composio' + preLoaderRoute: typeof AuthedAdminPluginsComposioRouteImport + parentRoute: typeof AuthedAdminRouteRoute + } '/_authed/settings/components-gallery/': { id: '/_authed/settings/components-gallery/' path: '/components-gallery' @@ -630,6 +663,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthedSettingsConnectedAccountsKeyRouteImport parentRoute: typeof AuthedSettingsRouteRoute } + '/_authed/admin/plugins/$key_/bots/$agentId': { + id: '/_authed/admin/plugins/$key_/bots/$agentId' + path: '/plugins/$key/bots/$agentId' + fullPath: '/admin/plugins/$key/bots/$agentId' + preLoaderRoute: typeof AuthedAdminPluginsKeyBotsAgentIdRouteImport + parentRoute: typeof AuthedAdminRouteRoute + } '/_authed/admin/plugins/$key_/tools/$tool': { id: '/_authed/admin/plugins/$key_/tools/$tool' path: '/plugins/$key/tools/$tool' @@ -652,8 +692,10 @@ interface AuthedAdminRouteRouteChildren { AuthedAdminIndexRoute: typeof AuthedAdminIndexRoute AuthedAdminComponentsNameRoute: typeof AuthedAdminComponentsNameRoute AuthedAdminPluginsKeyRoute: typeof AuthedAdminPluginsKeyRoute + AuthedAdminPluginsComposioRoute: typeof AuthedAdminPluginsComposioRoute AuthedAdminComponentsIndexRoute: typeof AuthedAdminComponentsIndexRoute AuthedAdminPluginsIndexRoute: typeof AuthedAdminPluginsIndexRoute + AuthedAdminPluginsKeyBotsAgentIdRoute: typeof AuthedAdminPluginsKeyBotsAgentIdRoute AuthedAdminPluginsKeyToolsToolRoute: typeof AuthedAdminPluginsKeyToolsToolRoute } @@ -669,8 +711,10 @@ const AuthedAdminRouteRouteChildren: AuthedAdminRouteRouteChildren = { AuthedAdminIndexRoute: AuthedAdminIndexRoute, AuthedAdminComponentsNameRoute: AuthedAdminComponentsNameRoute, AuthedAdminPluginsKeyRoute: AuthedAdminPluginsKeyRoute, + AuthedAdminPluginsComposioRoute: AuthedAdminPluginsComposioRoute, AuthedAdminComponentsIndexRoute: AuthedAdminComponentsIndexRoute, AuthedAdminPluginsIndexRoute: AuthedAdminPluginsIndexRoute, + AuthedAdminPluginsKeyBotsAgentIdRoute: AuthedAdminPluginsKeyBotsAgentIdRoute, AuthedAdminPluginsKeyToolsToolRoute: AuthedAdminPluginsKeyToolsToolRoute, } diff --git a/app/src/routes/_authed/admin/plugins/$key.tsx b/app/src/routes/_authed/admin/plugins/$key.tsx index 2c37ba95c..de184a96a 100644 --- a/app/src/routes/_authed/admin/plugins/$key.tsx +++ b/app/src/routes/_authed/admin/plugins/$key.tsx @@ -13,6 +13,10 @@ import { PageSection, PageShell, } from "@/components/layout/page-shell"; +import { + BrokeredAccountRow, + useBrokeredAccount, +} from "@/components/plugins/brokered-account-row"; import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; import { @@ -48,7 +52,9 @@ import { removePluginServerMutationOptions, } from "@/lib/plugins/mutations"; import { + type CatalogueItem, connectionsQueryOptions, + type PluginServer, pluginsPageQueryOptions, } from "@/lib/plugins/queries"; @@ -90,6 +96,43 @@ function grantSummary(held: number, total: number): string { return `${held} of ${total} Bots`; } +/** + * How much of this vendor one Bot holds, in the same voice as {@link grantSummary}. + * + * The same decision counted the other way round, for the rows that open a Bot's own page against + * this app. Named ends rather than a fraction, for the reason recorded above: "0/167" reads as a + * score, and the two answers worth recognising without reading are none of it and all of it. + */ +function heldSummary(held: number, total: number): string { + if (held === 0) return "No tools"; + if (held === total) return total === 1 ? "1 tool" : "Every tool"; + return `${held} of ${total} tools`; +} + +/** + * How this vendor is reached, from whichever record we have. + * + * The row's own provenance is asked first because it is a fact this deployment recorded when the + * app was enabled, not an inference: a brokered app is reached through Composio whatever the + * catalogue does or does not say about it. + * + * That order matters because the catalogue has nothing to say about a brokered app — it is not a + * curated entry — and what stood here was `entry?.auth ?? "deployment-bearer"`: a guess, and the + * one guess that is wrong for the one row shape that cannot hold a token. The page then offered + * "one credential, used for everybody" to the connector whose whole point is that each person + * connects their own account, and would have stored a token nothing ever reads. + * + * A server an administrator added by URL is still the fallback's case, and still correct there: + * nothing about it is reached as a person, so it gets the shared-token shape. + */ +export function connectionKindFor( + server: PluginServer | undefined, + auth: CatalogueItem["auth"] | undefined, +): CatalogueItem["auth"] | "brokered" { + if (server?.provenance === "composio") return "brokered"; + return auth ?? "deployment-bearer"; +} + function RouteComponent() { const { key } = useParams({ from: "/_authed/admin/plugins/$key" }); const queryClient = useQueryClient(); @@ -104,9 +147,15 @@ function RouteComponent() { */ const connections = useQuery(connectionsQueryOptions()); const { data: agents } = useQuery(agentListQueryOptions()); - const youConnected = (connections.data?.connections ?? []).some( + /* + * The row itself rather than whether there is one, because the brokered row below wants what the + * last re-check found and that is written on this same row. Asking a second time for it would be + * a second answer to a question this read already carried. + */ + const connection = (connections.data?.connections ?? []).find( (row) => row.serverId === key, ); + const youConnected = connection !== undefined; const nameFor = useBotNames(); const [error, setError] = useState(null); @@ -171,14 +220,54 @@ function RouteComponent() { name: nameFor(agent.id), })); - /** - * How this vendor is reached, from whichever record we have. + const auth = connectionKindFor(server, entry?.auth); + const title = entry?.title ?? server?.title ?? key; + + /* Bound once because the brokered row below is told it twice — once inside the whole + disconnected sentence and once on its own — and two copies of it would drift. */ + const reassurance = + "Setup is complete without it, and it reaches your documents only."; + + /* + * Everything the brokered row below reads and does, shared with the personal connected-accounts + * screen that draws the same row. See `brokered-account-row.tsx`. * - * A server added by URL has no catalogue entry, and nothing about it is reached as a person, so it - * falls back to the shared-token shape. + * `connectSelf` above stays: it is the `user-oauth` row's Connect, which is a different row with + * no Disconnect beside it and nothing brokered to confirm. */ - const auth = entry?.auth ?? "deployment-bearer"; - const title = entry?.title ?? server?.title ?? key; + const brokeredAccount = useBrokeredAccount({ + authScheme: server?.authScheme ?? null, + brokered: auth === "brokered", + configured: plugins.data?.composioConfigured ?? false, + recorded: youConnected, + report: setError, + // Back to this page afterwards, not to the personal settings screen. + returnTo: "admin", + serverId: key, + /* + * Absent where this person has never connected the app, and false rather than asserted: with no + * row there is nothing that could have been checked, which is exactly what the server's own + * columns default to. The three are optional on the type because that endpoint concatenates two + * reads and only a brokered row carries them. + */ + verified: connection?.verified ?? false, + verifiedAt: connection?.verifiedAt ?? null, + /* + * AND THIS ONE IS NOT FLATTENED, for the reason the personal screen gives: the two above fall + * back on the server's own column defaults, while a null here is the server's record that the + * last check spent nothing — not the absence of a record. The row draws a different sentence + * for each, so the difference has to reach it. + */ + probe: connection?.probe, + /* + * WHILE THIS ONE IS, for the reason that screen gives too: it is the Re-check button's gate and + * not a sentence. The record above says what was spent and this says whether the app has + * anything to spend today — asking the record the second question is what left a key nothing + * was tried on with no way to ever have anything tried on it. Absent and false are the same + * closed gate, so the fallback costs nothing here. + */ + checkable: connection?.checkable ?? false, + }); /** Adding is two writes when a token was typed: the credential, then the record pointing at it. */ const add = async () => { @@ -322,7 +411,10 @@ function RouteComponent() { ? "This vendor answers as whoever is asking. The deployment registers an OAuth client, and each person connects their own account, so a Bot only ever sees what that person can see." : auth === "builtin" ? "Built into this deployment. There is no vendor to reach and no credential to hold — a call runs as whoever asked." - : "What this deployment presents to the vendor. One credential, used for everybody." + : auth === "brokered" + ? /* The row below says how, at length. This says whose account it is, which is the part that decides what an administrator has to do here — which is nothing. */ + "Reached through Composio, which holds each person's own account." + : "What this deployment presents to the vendor. One credential, used for everybody." } title="Connection" > @@ -342,6 +434,12 @@ function RouteComponent() { * step rather than as the answer. The row states that plainly instead of leaving the * card empty — and being first, it also gives the docsUrl row below something other than * the card's own top border to sit its leading separator against. + * + * The third is the brokered row, for the same reason as the second and one more: an + * administrator looking at a Connection card with only somebody's personal account on it + * will reasonably look for the deployment's half of the arrangement. There is one, it is + * just not theirs to type, and saying so is the only way this card is not read as + * half-configured. */} {auth === "builtin" ? ( @@ -365,6 +463,31 @@ function RouteComponent() { ) : null} + {auth === "brokered" ? ( + /* + * Nothing to click. The deployment's half of a brokered app is one key held once, + * for Composio rather than for this vendor, and it is set where every brokered app + * reads it from — not here, per app, by hand. + */ + + + How this is reached + + This app is reached through Composio, which holds the + account. This deployment sends one key and the name of + whoever is asking, so each person connects their own and a + Bot sees only what that person can see. There is no token to + paste. + + + + + Through Composio + + + + ) : null} + {auth === "deployment-bearer" ? ( + + + + ) : null} + {auth === "user-oauth" && (server?.hasCredential || server?.dynamicClient) ? ( <> @@ -683,6 +838,56 @@ function RouteComponent() { ))} )} + + {/* + * The same grants, from the Bot's end. + * + * In this section rather than one of its own, because it is not a second subject: the + * list above is one action and every Bot, and these rows are one Bot and every action. + * Which way round somebody wants it depends on what they came here to do — write a rule + * about an action, or set a Bot up — and an app of a hundred and sixty-seven actions is + * only approachable from this end. + */} + {server.tools.length > 0 && bots.length > 0 ? ( + <> +

By Bot

+ + {bots.map((bot, index) => ( + + {/* A real link with no children: children passed to `render` replace the row's own. */} + + } + size="sm" + > + + {bot.name} + + Every action this app offers, switched one at a time. + + + + + {heldSummary( + server.tools.filter((tool) => + tool.grantedTo.includes(bot.id), + ).length, + server.tools.length, + )} + + + + + {index !== bots.length - 1 && } + + ))} + + + ) : null} ) : null} diff --git a/app/src/routes/_authed/admin/plugins/$key_.bots.$agentId.tsx b/app/src/routes/_authed/admin/plugins/$key_.bots.$agentId.tsx new file mode 100644 index 000000000..713723a77 --- /dev/null +++ b/app/src/routes/_authed/admin/plugins/$key_.bots.$agentId.tsx @@ -0,0 +1,389 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { createFileRoute, useParams } from "@tanstack/react-router"; +import * as React from "react"; +import { useState } from "react"; +import { + PageEmpty, + PageRows, + PageSection, + PageShell, +} from "@/components/layout/page-shell"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + Item, + ItemActions, + ItemContent, + ItemDescription, + ItemTitle, +} from "@/components/ui/item"; +import { Separator } from "@/components/ui/separator"; +import { Switch } from "@/components/ui/switch"; +import { useBotNames } from "@/lib/agents/bot-names"; +import { agentListQueryOptions } from "@/lib/agents/queries"; +import { + grantPlugin, + invalidatePlugins, + setPluginGrantMutationOptions, +} from "@/lib/plugins/mutations"; +import { + type PluginTool, + pluginsPageQueryOptions, +} from "@/lib/plugins/queries"; + +/** + * One Bot and one app: every action the app offers, and which of them this Bot holds. + * + * The sibling screen — `$key_.tools.$tool` — answers the same question from the other end, one + * action and every Bot, and that is the right shape when somebody is thinking about an action. It + * is the wrong shape for setting a Bot up against an app that offers a hundred and sixty-seven of + * them: that is one visit per action, and the decision being made ("what can this Bot do in Slack") + * is never on screen at once. + * + * So the list is searchable and split by what a boundary sees. A read and a write are not the same + * decision, and an app's actions arrive interleaved by name, which puts `delete_channel` two rows + * under `list_channels` with nothing between them but a word. + * + * `$key_` opts this route out of nesting under `$key.tsx`, so the connector page stays a page rather + * than becoming a layout with an outlet. + */ +export const Route = createFileRoute( + "/_authed/admin/plugins/$key_/bots/$agentId", +)({ component: RouteComponent }); + +/** + * The refs the bulk action would grant: every action that only reads. + * + * Exported as a function so the promise the button makes is assertable without a DOM, a router or + * a query client — see `tests/bot-app-grants.test.tsx`. The button says "read-only" and states a + * resulting count before it acts, and both of those are only true if this is exactly the reads. + * + * A destructive action can never appear here. `destructive` sits beside `effect` rather than inside + * it, and the effect a destructive action carries is `write` — anything not positively known to be + * a read is one — so filtering on the effect alone already excludes it. + * + * Every action, not the ones a search happens to be showing. The button names every read-only + * action and that is what it grants; narrowing it to the filtered rows would make the sentence + * beside it quietly wrong. + */ +export function readOnlyRefs(tools: PluginTool[]): string[] { + return tools.filter((tool) => tool.effect === "read").map((tool) => tool.ref); +} + +function RouteComponent() { + const { key, agentId } = useParams({ + from: "/_authed/admin/plugins/$key_/bots/$agentId", + }); + const queryClient = useQueryClient(); + const plugins = useQuery(pluginsPageQueryOptions()); + const agents = useQuery(agentListQueryOptions()); + const nameFor = useBotNames(); + const [error, setError] = useState(null); + /** What the list is narrowed to, over action names. Never over what is switched on. */ + const [search, setSearch] = useState(""); + /** + * How far through the bulk grant we are, or null when none is running. + * + * A count rather than a boolean, for the reason the connector page's own batch records: this is + * honestly N writes, each its own audit row, and a button that says only "Granting…" for the + * length of forty of them gives an administrator no way to tell a slow batch from a stuck one. + */ + const [granting, setGranting] = useState<{ + done: number; + total: number; + } | null>(null); + + const setGrant = useMutation({ + ...setPluginGrantMutationOptions(queryClient), + onError: (thrown: Error) => setError(thrown.message), + }); + + const server = plugins.data?.servers.find((row) => row.id === key); + const appTitle = + plugins.data?.catalogue.find((item) => item.key === key)?.title ?? + server?.title ?? + key; + const bot = agents.data?.find((one) => one.id === agentId); + + const back = { + label: appTitle, + linkProps: { + params: { key }, + to: "/admin/plugins/$key" as const, + }, + }; + + /* + * One write per grant, in list order, with one refetch at the end. + * + * Going through the single-grant mutation would invalidate every plugin query after each write + * and await it, so forty reads would be forty round trips interleaved with forty refetches of a + * list nobody can read while the button is still counting. A refusal stops the rest and says why; + * the ones before it landed, so the screen is refreshed either way. + */ + const grantEveryRead = async (refs: string[]) => { + setError(null); + setGranting({ done: 0, total: refs.length }); + let done = 0; + try { + for (const ref of refs) { + await grantPlugin({ agentId, kind: "mcp", ref }); + done += 1; + setGranting({ done, total: refs.length }); + } + } catch (thrown) { + setError((thrown as Error).message); + } finally { + await invalidatePlugins(queryClient); + setGranting(null); + } + }; + + /* Nothing rather than a placeholder, so no sentence asserts anything while a fetch is open. */ + if (plugins.isPending || agents.isPending) { + return {null}; + } + + /* + * Gated on the plugin list having ARRIVED, not on `server` being missing — the same guard, for + * the same reason, as the roster check below, and the two are meant to be read together. + * `isPending` goes false on a failed fetch exactly as it does on a successful one, so `!server` + * alone cannot tell "this deployment has not enabled that app" apart from "the plugin list could + * not be read". Only the first is a fact about this deployment, and a request that never came + * back is no evidence for it. + */ + if (plugins.data && !server) { + return ( + + There is nothing here to grant. + + ); + } + + /* + * Which leaves the other one: no server because the read itself failed. Nothing on this screen + * survives that — the app's title, its actions and this Bot's grants are all that one response — + * so it says the read failed and draws nothing else. Not the sentence above, which would be a + * claim; and not the list either, because a page of switches built from no actions reads as a + * Bot that holds none. Follows `admin/components/$name`, which states a failed read the same way. + */ + if (!server) { + return ( + +

+ Plugins could not be loaded. +

+
+ ); + } + + /* + * Gated on the roster having ARRIVED, not on `bot` being missing — the same guard as the app + * check above, over the other query. + * + * `isPending` goes false on a failed fetch exactly as it does on a successful one, so `!bot` + * alone cannot tell "this deployment has no such Bot" apart from "the roster could not be read" + * — and the first of those is a claim, made about a Bot that may be perfectly real, on the + * evidence of a request that never came back. See `tests/agent-roster-error.test.tsx`, which + * exists for the same mistake on the two screens that shipped it. With no roster the grants + * below are still the plugins query's own answer and still true; the name falls back to the id. + */ + if (agents.data && !bot) { + return ( + + + It may have been deleted since this page was opened. + + + ); + } + + const botName = nameFor(agentId); + const held = (tool: PluginTool) => tool.grantedTo.includes(agentId); + + /* + * What the search is over: the action's own name, which is what somebody arriving here already + * knows. An app of this size is not read top to bottom, and a field that searched descriptions + * too would answer a typed name with a screenful of rows that do not carry it. + */ + const needle = search.trim().toLowerCase(); + const matching = needle + ? server.tools.filter((tool) => tool.name.toLowerCase().includes(needle)) + : server.tools; + const reads = matching.filter((tool) => tool.effect === "read"); + const writes = matching.filter((tool) => tool.effect === "write"); + + /* + * What the Bot would hold afterwards, counted over every tool rather than the filtered ones: the + * button grants every read whatever the search is showing, and a count that moved while somebody + * typed would be describing a different action than the one the button takes. + */ + const everyRead = readOnlyRefs(server.tools); + const ungranted = everyRead.filter( + (ref) => !server.tools.some((tool) => tool.ref === ref && held(tool)), + ); + const wouldHold = server.tools.filter( + (tool) => held(tool) || tool.effect === "read", + ).length; + + /** One card of switchable rows. Both sections are the same row, so they are the same code. */ + const rows = (tools: PluginTool[]) => ( + + {tools.map((tool, index) => ( + + + + {tool.name} + + {tool.description || "This action came with no description."} + + + + {/* + * Drawn only for an action the vendor warns about, and nothing at all beside one it + * does not. `destructive` is false when the vendor made no claim, which is not a + * claim of safety — so an absence stays an absence here rather than becoming a green + * word that says more than anybody knows. + * + * The destructive colour rather than the amber this app's tool list gives writes: the + * heading over these rows already says they change things, and the one thing left to + * say about this row is that what it changes does not come back. + */} + {tool.destructive ? ( + + destroys things + + ) : null} + {/* + * Binary and immediate, which is what a Switch is for: it takes effect when switched + * and there is no save. It is on for a grant that exists and off otherwise — nothing + * here is switched on by default, and nothing is proposed pre-switched. Disabled only + * while its own write is in flight, so switching one action does not freeze the list. + */} + { + setError(null); + setGrant.mutate({ + agentId, + granted: next, + kind: "mcp", + ref: tool.ref, + }); + }} + /> + + + {index !== tools.length - 1 && } + + ))} + + ); + + return ( + + {error ? ( +

+ {error} +

+ ) : null} + + {server.tools.length === 0 ? ( + + {server.lastError ?? + `${appTitle} lists no actions. Refresh its tools to ask again.`} + + ) : ( + <> + setSearch(event.target.value)} + placeholder="Search by action name" + value={search} + /> + + {/* + * The one bulk action on this screen, and the count comes first: it says what the Bot + * would hold afterwards, before anybody presses anything, because the alternative is a + * button whose result is only visible once it has happened forty times. + * + * Hidden when there is nothing left for it to do. A button promising a state the Bot is + * already in can only be pressed to find that out. + */} + {ungranted.length > 0 ? ( +
+

+ {`This Bot would then hold ${wouldHold} ${ + wouldHold === 1 ? "tool" : "tools" + } from this app.`} +

+ +
+ ) : null} + + + {reads.length === 0 ? ( + + {needle + ? `Nothing that only reads matches "${search.trim()}".` + : `${appTitle} offers nothing that only reads.`} + + ) : ( + rows(reads) + )} + + + + {writes.length === 0 ? ( + + {needle + ? `Nothing that changes things matches "${search.trim()}".` + : `${appTitle} offers nothing that changes anything.`} + + ) : ( + rows(writes) + )} + + + )} +
+ ); +} diff --git a/app/src/routes/_authed/admin/plugins/composio.tsx b/app/src/routes/_authed/admin/plugins/composio.tsx new file mode 100644 index 000000000..11f711d19 --- /dev/null +++ b/app/src/routes/_authed/admin/plugins/composio.tsx @@ -0,0 +1,183 @@ +import { IconPlug } from "@tabler/icons-react"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { createFileRoute } from "@tanstack/react-router"; +import * as React from "react"; +import { + PageEmpty, + PageRows, + PageSection, + PageShell, +} from "@/components/layout/page-shell"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + Item, + ItemActions, + ItemContent, + ItemDescription, + ItemMedia, + ItemTitle, +} from "@/components/ui/item"; +import { Separator } from "@/components/ui/separator"; +import { enableComposioAppMutationOptions } from "@/lib/plugins/mutations"; +import { + type ComposioApp, + composioAppsQueryOptions, +} from "@/lib/plugins/queries"; +import { queryClient } from "@/query-client"; + +/** + * Composio's directory, searched rather than listed. + * + * The catalogue on the previous screen is a reviewed handful, and every entry there is a decision + * somebody made about a vendor. This is a few hundred apps that nobody here has reviewed, which is + * why it is a search field and not a list: the only sensible entry point into a directory that size + * is the name of the app you already came looking for. + * + * Adding one is account-wide, and it still reads nothing. Every app here is reached as whoever is + * asking, so a person's own connection is what makes their own mail or messages readable — which is + * made on their settings page, not here. + */ +export const Route = createFileRoute("/_authed/admin/plugins/composio")({ + component: RouteComponent, +}); + +/** + * The apps to draw, in the order to draw them. + * + * A function rather than a `.sort()` inside the map, so the two things a row is decided from — the + * order, and the marker saying an app is already here — can be asserted without a DOM, a router and + * a query client. See `tests/composio-picker.test.tsx`. + * + * A copy, because the array belongs to TanStack Query's cache and is the same object on every + * render; sorting it in place would rewrite what the cache holds. By name rather than by the + * vendor's own order, which is popularity — useful for a landing page, useless for finding the one + * app somebody typed half the name of. + */ +export function matchingApps(apps: ComposioApp[]): ComposioApp[] { + return [...apps].sort((left, right) => left.name.localeCompare(right.name)); +} + +function RouteComponent() { + const [search, setSearch] = React.useState(""); + /* + * Debounced, as on People, and for a stronger reason: every distinct term is a cache key of its + * own and a request to the vendor, so typing a name un-debounced is a round trip per keystroke + * against somebody else's rate limit. + */ + const [query, setQuery] = React.useState(""); + React.useEffect(() => { + const timer = setTimeout(() => setQuery(search), 250); + return () => clearTimeout(timer); + }, [search]); + + const apps = useQuery(composioAppsQueryOptions(query)); + const enable = useMutation(enableComposioAppMutationOptions(queryClient)); + const listed = matchingApps(apps.data?.apps ?? []); + + return ( + + + {enable.error ? ( +

+ {enable.error.message} +

+ ) : null} + + setSearch(event.target.value)} + placeholder="Search by app name" + value={search} + /> + + {/* Pending, error, empty, rows — pending first, so no sentence asserts anything mid-fetch. */} + {apps.isPending ? null : apps.error ? ( +

+ {/* + * The server's own sentence, as the enable failure above already does. A keyless + * deployment is refused with one naming COMPOSIO_API_KEY, and that name is the single + * thing an operator who reached this URL needs to read; the hardcoded line stays as + * the fallback for a failure that arrived carrying no message of its own. + */} + {apps.error.message || + "Composio's app directory could not be read."} +

+ ) : listed.length === 0 ? ( + + {query + ? `Nothing in Composio's directory matches "${query}".` + : "Composio returned no apps."} + + ) : ( + + {listed.map((entry, index) => { + /* + * One mutation serves every row, so the row being added is the one whose slug the + * mutation is carrying. Without that, pressing Add on one app would put every other + * row into Adding… at the same time. + */ + const adding = + enable.isPending && enable.variables?.slug === entry.slug; + + return ( + + + {/* The vendor's own mark where Composio supplies one, and a plug where it does + not, so a list of third parties still has a fixed left edge. */} + + {entry.logo ? ( + + ) : ( + + )} + + + {entry.name} + {entry.description} + + + {/* The size of the decision, stated before it is made: an app is not one + tool, and "167 actions" is what says so. */} + + {entry.actionCount} actions + + {/* + * An app this deployment already has says so rather than offering Add again. + * A second Add would ask the server to record a connector it already holds, + * and there is nothing on the row to suggest that would be harmless. + */} + {entry.enabled ? ( + + Added + + ) : ( + + )} + + + {index !== listed.length - 1 && } + + ); + })} + + )} +
+
+ ); +} diff --git a/app/src/routes/_authed/admin/plugins/index.tsx b/app/src/routes/_authed/admin/plugins/index.tsx index 17d6db1dd..c008db613 100644 --- a/app/src/routes/_authed/admin/plugins/index.tsx +++ b/app/src/routes/_authed/admin/plugins/index.tsx @@ -231,6 +231,57 @@ function RouteComponent() { )} + + + + {/* + * The row states the setting rather than hiding the feature. A deployment with no + * Composio key still says that Composio is a thing this build can do and names the + * variable that turns it on — a section that simply vanished would leave an + * administrator with nothing to search for, and nothing to tell them the handful of + * connectors above is not the whole story. + */} + {plugins.data?.composioConfigured ? ( + } + size="sm" + > + + + + + Browse Composio + + A few hundred apps, reached as whoever is asking. Each + person connects their own account. + + + + + + + ) : ( + /* No chevron and no link: the row goes nowhere, because there is nowhere to go + until the key is set. */ + + + + + + Composio + + Add your Composio key to enable a catalogue of tools. Set + COMPOSIO_API_KEY on this deployment. + + + + )} + + )} diff --git a/app/src/routes/_authed/settings/connected-accounts/$key.tsx b/app/src/routes/_authed/settings/connected-accounts/$key.tsx index 19f8b1c94..89d3073bf 100644 --- a/app/src/routes/_authed/settings/connected-accounts/$key.tsx +++ b/app/src/routes/_authed/settings/connected-accounts/$key.tsx @@ -8,6 +8,10 @@ import { PageSection, PageShell, } from "@/components/layout/page-shell"; +import { + BrokeredAccountRow, + useBrokeredAccount, +} from "@/components/plugins/brokered-account-row"; import { Button } from "@/components/ui/button"; import { DropdownMenu, @@ -62,10 +66,53 @@ function RouteComponent() { }); const entry = plugins.data?.catalogue.find((item) => item.key === key); - const enabled = (plugins.data?.servers ?? []).some((s) => s.id === key); + const server = (plugins.data?.servers ?? []).find((s) => s.id === key); + const enabled = server !== undefined; const connection = (connections.data?.connections ?? []).find( (row) => row.serverId === key, ); + /* + * Asked of the row rather than the catalogue, because a brokered app has no catalogue entry at + * all: the deployment recorded how it is reached when the app was enabled, and that record is the + * only thing here that knows. + */ + const brokered = server?.provenance === "composio"; + + /* Everything the brokered row below reads and does. See `brokered-account-row.tsx`. */ + const brokeredAccount = useBrokeredAccount({ + authScheme: server?.authScheme ?? null, + brokered, + configured: plugins.data?.composioConfigured ?? false, + recorded: connection !== undefined, + report: setNotice, + returnTo: "settings", + serverId: key, + /* + * Off the same row `recorded` is read from, and absent where you have never connected this app: + * with no row there is nothing that could have been checked, which is what the server's own + * columns default to. The three are optional on the type because that endpoint concatenates two + * reads and only a brokered row carries them. + */ + verified: connection?.verified ?? false, + verifiedAt: connection?.verifiedAt ?? null, + /* + * PASSED THROUGH UNFLATTENED, unlike the two above. Their fallbacks are the server's own column + * defaults, so an absent field and a recorded one mean the same thing; this one's null is the + * server saying the last check of this key spent nothing, which is a different fact from having + * been told nothing. Collapsing the two would hand the row a verdict on every page load that + * has no record behind it. + */ + probe: connection?.probe, + /* + * AND THIS ONE IS FLATTENED AGAIN, because it is a gate and not a verdict. `probe` is the + * record of what the last check spent and this is whether the app has anything to check with + * today — two questions, which is why they are two fields: gating the Re-check button on the + * record left a key nothing was ever spent on unable to ever have anything spent on it. A + * missing gate and a closed gate are the same gate, so absent collapses to false here where a + * missing verdict above may not collapse to a null one. + */ + checkable: connection?.checkable ?? false, + }); if (plugins.isPending) { return {null}; @@ -76,6 +123,53 @@ function RouteComponent() { linkProps: { to: "/settings/connected-accounts" as const }, }; + /* + * A brokered app, before the catalogue is consulted at all. + * + * It has no catalogue entry, so the branch below would find no `entry`, decide the deployment has + * no connector by that name, and say so under the raw id — about the one connector the list on the + * way in demonstrably drew a row for. Falling through to the `user-oauth` branch instead is no + * better: its title and summary are the catalogue's, and there is none. + */ + if (brokered && server) { + /* Bound once because the row below is told it twice — once inside the whole disconnected + sentence and once on its own — and two copies of it would drift. */ + const reassurance = "No Bot can read this as you."; + + return ( + + {notice ? ( +

+ {notice} +

+ ) : null} + + {/* One decision, so no heading: it would only repeat the row's own title. */} + + + + + +
+ ); + } + /* * A vendor that is not reached as a person has nothing here for anybody to decide, and one an * administrator has not enabled cannot be consented to — there is no OAuth client behind it. Both diff --git a/app/src/routes/_authed/settings/connected-accounts/index.tsx b/app/src/routes/_authed/settings/connected-accounts/index.tsx index 04a7f96b2..b2c99766e 100644 --- a/app/src/routes/_authed/settings/connected-accounts/index.tsx +++ b/app/src/routes/_authed/settings/connected-accounts/index.tsx @@ -79,6 +79,17 @@ function RouteComponent() { (entry) => entry.auth === "user-oauth" && added.has(entry.key), ); + /* + * Brokered apps belong here for the same reason the OAuth ones do: they answer as you. + * + * The filter above names the catalogue's `user-oauth` kind, which a brokered row cannot have + * because it has no catalogue entry at all — so the one connector that is nothing but per-person + * accounts was the one this page never listed. + */ + const brokered = (plugins.data?.servers ?? []).filter( + (server) => server.provenance === "composio", + ); + return ( ) : ( - {yours.length === 0 ? ( + {yours.length === 0 && brokered.length === 0 ? ( /* * Says whose move it is. "Nothing here" on its own reads as though you failed to do * something, when what is missing is an administrator enabling a connector. @@ -160,7 +171,64 @@ function RouteComponent() { - {index !== yours.length - 1 && } + {(index !== yours.length - 1 || brokered.length > 0) && ( + + )} + + ); + })} + {brokered.map((server, index) => { + const Mark = markFor(server.id); + return ( + + + } + size="sm" + > + + + + + {server.title} + {/* Written here rather than read off the row: a brokered app has no + catalogue entry, so the summary the server sends back is empty. */} + + Reached through Composio, which holds the account, so + a Bot sees only what you can see. + + + + {/* + * The same dot and the same two words as the rows above, read out of the + * same set. The connections endpoint now answers out of both tables, so a + * brokered app this person has connected is in `connected` under the id of + * its server row — which is the id this row is drawn from. The state was + * never a different kind of fact here, only an unanswerable one. + */} + + + {index !== brokered.length - 1 && } ); })} diff --git a/app/tests/bot-app-grants-screen.test.tsx b/app/tests/bot-app-grants-screen.test.tsx new file mode 100644 index 000000000..ca8e9b522 --- /dev/null +++ b/app/tests/bot-app-grants-screen.test.tsx @@ -0,0 +1,361 @@ +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, waitFor } from "@testing-library/react"; +import { + type PluginServer, + type PluginsPage, + type PluginTool, + pluginKeys, +} from "@/lib/plugins/queries"; +import { Route as BotAppRoute } from "@/routes/_authed/admin/plugins/$key_.bots.$agentId"; + +/** + * What the per-Bot grant screen draws: which of its three pages a failed read returns, and the one + * mark a row carries beyond the action's own name. + * + * Most of it is about the failure. Same mistake, same shape, as `agent-roster-error.test.tsx`: the screen branched on the app being + * absent from `plugins.data`, and `isPending` goes false on a failed fetch exactly as it does on a + * successful one — so a request that never came back was rendered as "this deployment has not + * enabled an app by that name", about an app that may be enabled and granted right now. The Bot + * half of the same function always got this right (`agents.data && !bot`), which is what made the + * app half visible. + * + * The last case is no failure at all: the danger mark on a destructive action. It is here because + * this is where the harness that draws this screen lives, and it needs both halves — the marked row + * and the unmarked one — since `destructive: false` is the vendor making no claim rather than a + * claim of safety, and a row that read as reassuring on that evidence would say more than anybody + * knows. It draws off the same seeded page as the failed-refetch case above. + * + * `bot-app-grants.test.tsx` is the other half of this screen's coverage and deliberately renders + * nothing — it asserts the bulk button's set through the exported `readOnlyRefs`. This file has to + * draw, because what is under test is what reaches the page, so it is its own file rather than a + * DOM smuggled into that one. + * + * THE HARNESS IS THIS REPOSITORY'S, copied from `agent-roster-error.test.tsx` for the reasons + * recorded there: `GlobalRegistrator` in `beforeAll`/`afterAll`, `cleanup` in `afterEach`, queries + * off `render()`'s own return, and a `QueryClient` with `retry: false` so a failing query settles + * in one attempt. + */ + +beforeAll(() => GlobalRegistrator.register()); +afterEach(cleanup); +afterAll(() => GlobalRegistrator.unregister()); + +const originalFetch = global.fetch; + +beforeEach(() => { + // Every read in this app goes through `client()` in `lib/client.ts`, which throws once the + // response is not `ok`. A 500 with no body is the shape a broken server actually sends. + global.fetch = (async () => + new Response(null, { status: 500 })) as typeof fetch; +}); + +afterEach(() => { + global.fetch = originalFetch; +}); + +const APP_KEY = "slack"; +const BOT_ID = "bot-1"; + +/** A client the failing queries settle on in one attempt, so no test waits on a retry. */ +function failingQueryClient() { + return new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); +} + +/** + * A client already holding a good plugins page under the exact key `pluginsPageQueryOptions()` + * reads, built on `failingQueryClient()`. + * + * With this file's always-failing `fetch`, mounting against it reproduces a failed BACKGROUND + * refetch: `refetchOnMount` fires because `staleTime` is 0, that fetch 500s, and `isError` goes + * true while `data` stays exactly this seeded page. That is the state the screen must keep + * rendering from — see `agent-roster-error.test.tsx`, which documents the query-core behaviour. + */ +function stalePluginsClient(page: PluginsPage) { + const queryClient = failingQueryClient(); + queryClient.setQueryData(pluginKeys.page(), page); + return queryClient; +} + +/** A minimal but complete `PluginTool`, overridable per case. */ +function tool(overrides: Partial & { name: string }): PluginTool { + return { + serverId: APP_KEY, + description: "Does something.", + inputSchema: {}, + ref: `${APP_KEY}/${overrides.name}`, + effect: "read", + destructive: false, + grantedTo: [], + ...overrides, + }; +} + +/** A minimal but complete `PluginServer`, overridable per case. */ +function server(overrides: Partial & { id: string }) { + return { + title: "Slack", + vendor: "Slack", + url: "https://example.invalid/mcp", + summary: "Chat.", + docsUrl: "https://example.invalid/docs", + provenance: "first-party", + hasCredential: true, + toolsRefreshedAt: null, + lastError: null, + addedBy: null, + dynamicClient: false, + tools: [], + withdrawn: [], + ...overrides, + } satisfies PluginServer; +} + +/** A minimal but complete `PluginsPage`, overridable per case. */ +function pluginsPage(overrides: Partial = {}): PluginsPage { + return { + catalogue: [], + servers: [], + skills: [], + botsMayCallBack: true, + redirectUri: null, + composioConfigured: false, + ...overrides, + }; +} + +/** Waits for the seeded query to have actually failed its background refetch, rather than trusting + * that the seeded data alone (which would render identically before any fetch ran) proves it. */ +async function waitForFailedRefetch(queryClient: QueryClient) { + await waitFor(() => { + expect(queryClient.getQueryState(pluginKeys.page())?.status).toBe("error"); + }); +} + +/* + * This screen's component calls `useParams({ from: "/_authed/admin/plugins/$key_/bots/$agentId" })`, + * which resolves that id against the router's matched routes, so the tree has to produce exactly + * that id — decoy pathless/static parents are enough, as `agent-roster-error.test.tsx` establishes, + * and the real ancestors (a session check, the admin shell) are not what this file is about. + * + * `routeTree.gen.ts` fixes both halves: id `/plugins/$key_/bots/$agentId` under `/_authed/admin`, + * path `/plugins/$key/bots/$agentId` — the trailing underscore opts the route out of nesting and + * never appears in a URL. + * + * Capture and restore of the exported `Route` singleton is that file's scheme verbatim, and for its + * reason: `.update()` merges into the live object and `createRouter()` derives `_id`/`parentRoute` + * off it, so a render here would otherwise leave the real router (`router.test.ts` builds one in + * this same bun process) pointed at a decoy parent. + */ +function captureRouteState(route: object): Record { + return { ...route, options: { ...(route as { options: object }).options } }; +} + +function restoreRouteState( + route: object, + snapshot: Record, +): void { + for (const key of Object.keys(route)) { + if (!(key in snapshot)) { + delete (route as Record)[key]; + } + } + Object.assign(route, snapshot); +} + +/** Captured once, at module scope, before any `test()` body in this file has run — the state this + * file must hand back, whatever it happens to be. See `agent-roster-error.test.tsx`. */ +const pristineBotAppRouteState = captureRouteState(BotAppRoute); + +let botAppRouteSnapshot: Record; + +beforeEach(() => { + botAppRouteSnapshot = captureRouteState(pristineBotAppRouteState); +}); + +afterEach(() => { + restoreRouteState(BotAppRoute, botAppRouteSnapshot); +}); + +function renderScreen(queryClient: QueryClient) { + const rootRoute = createRootRoute({ component: Outlet }); + const authedRoute = createRoute({ + id: "/_authed", + getParentRoute: () => rootRoute, + component: Outlet, + }); + const adminRoute = createRoute({ + path: "/admin", + getParentRoute: () => authedRoute, + component: Outlet, + }); + // The two Back links this screen can draw. Registered so `Link` has a real route to build an + // href from; neither is navigated to here. + const pluginsRoute = createRoute({ + path: "/plugins/", + getParentRoute: () => adminRoute, + component: () => null, + }); + const appRoute = createRoute({ + path: "/plugins/$key", + getParentRoute: () => adminRoute, + component: () => null, + }); + const wired = ( + BotAppRoute as unknown as { + update: (options: unknown) => typeof BotAppRoute; + } + ).update({ + id: "/plugins/$key_/bots/$agentId", + path: "/plugins/$key/bots/$agentId", + getParentRoute: () => adminRoute, + }); + const tree = rootRoute.addChildren([ + authedRoute.addChildren([ + adminRoute.addChildren([pluginsRoute, appRoute, wired]), + ]), + ]); + const router = createRouter({ + routeTree: tree, + history: createMemoryHistory({ + initialEntries: [`/admin/plugins/${APP_KEY}/bots/${BOT_ID}`], + }), + }); + return render( + + + , + ); +} + +test("a failed plugin read reports the failure instead of claiming the app is not enabled", async () => { + const view = renderScreen(failingQueryClient()); + + expect(await view.findByText("Plugins could not be loaded.")).toBeTruthy(); + + // The whole point: an app that may be enabled, with grants on it right now, must never be + // reported as absent on the evidence of a request that never came back. + expect( + view.queryByText("This deployment has not enabled an app by that name."), + ).toBeNull(); + expect(view.queryByText("There is nothing here to grant.")).toBeNull(); +}); + +test("a failed plugin read draws no switches, so nothing reads as a Bot holding nothing", async () => { + const view = renderScreen(failingQueryClient()); + + await view.findByText("Plugins could not be loaded."); + + expect(view.container.querySelectorAll('[role="switch"]').length).toBe(0); + expect(view.queryByText("Turn on every read-only action")).toBeNull(); + // The two section headings the grant list is drawn under: neither belongs on a page built from + // an answer that never arrived. + expect(view.queryByText("Reads")).toBeNull(); + expect(view.queryByText("Changes things")).toBeNull(); +}); + +test("an answer that genuinely lacks the app still says the app is not enabled", async () => { + // Seeded, so `plugins.data` is a real answer; the refetch on mount still fails, which is what + // separates "the list came back without it" from "the list never came back". + const queryClient = stalePluginsClient(pluginsPage()); + + const view = renderScreen(queryClient); + await waitForFailedRefetch(queryClient); + + expect( + await view.findByText( + "This deployment has not enabled an app by that name.", + ), + ).toBeTruthy(); + expect(view.queryByText("Plugins could not be loaded.")).toBeNull(); +}); + +test("a failed REFETCH keeps the grant list it already had, not the error", async () => { + const queryClient = stalePluginsClient( + pluginsPage({ + servers: [ + server({ + id: APP_KEY, + tools: [tool({ name: "list_channels" })], + }), + ], + }), + ); + + const view = renderScreen(queryClient); + await waitForFailedRefetch(queryClient); + + expect(await view.findByText("list_channels")).toBeTruthy(); + expect(view.queryByText("Plugins could not be loaded.")).toBeNull(); + expect( + view.queryByText("This deployment has not enabled an app by that name."), + ).toBeNull(); +}); + +/** + * The actions end of one action's row: its switch, and whatever is drawn beside it. + * + * Reached through the switch's own label because a row carries no test id, and the label is the + * only thing on it that names the action uniquely. The roster never comes back in this file, so the + * Bot's name in that label falls back to its id, which is what the screen itself does. + */ +function rowActions(view: ReturnType, name: string) { + const actions = view + .getByLabelText(`Let ${BOT_ID} call ${name}`) + .closest('[data-slot="item-actions"]'); + if (!actions) throw new Error(`No row drawn for ${name}.`); + return actions; +} + +test("an action the vendor calls destructive is marked, and a write it says nothing about is not", async () => { + const queryClient = stalePluginsClient( + pluginsPage({ + servers: [ + server({ + id: APP_KEY, + tools: [ + tool({ effect: "write", name: "post_message" }), + tool({ + destructive: true, + effect: "write", + name: "delete_channel", + }), + ], + }), + ], + }), + ); + + const view = renderScreen(queryClient); + await waitForFailedRefetch(queryClient); + await view.findByText("delete_channel"); + + // The mark itself, on the row that earned it: the heading already says these rows change things, + // and this is the one saying what this row changes does not come back. + expect(rowActions(view, "delete_channel").textContent).toContain( + "destroys things", + ); + + // The other half, which matters as much. `destructive: false` is the vendor having made no claim, + // not a claim that nothing is lost — so nothing at all goes beside this switch. A word here, of + // any colour, would be the page vouching for an action on evidence it does not have. + expect(rowActions(view, "post_message").textContent).toBe(""); +}); diff --git a/app/tests/bot-app-grants.test.tsx b/app/tests/bot-app-grants.test.tsx new file mode 100644 index 000000000..3a9f3de77 --- /dev/null +++ b/app/tests/bot-app-grants.test.tsx @@ -0,0 +1,73 @@ +import { expect, test } from "bun:test"; +import type { PluginTool } from "@/lib/plugins/queries"; +import { readOnlyRefs } from "@/routes/_authed/admin/plugins/$key_.bots.$agentId"; + +/** + * What one button on the per-Bot grant screen promises, checked without drawing anything. + * + * The screen lists every action an app offers with a switch each, and offers one bulk action: + * "Turn on every read-only action". That button is the only control on the page that grants more + * than one thing at a time, so the only thing worth pinning about it is the set it acts on — every + * read, and nothing that changes anything, however the vendor labels it. + * + * A `.tsx` file because it imports a route module, which is JSX; the precedent is + * `agent-roster-error.test.tsx`. Nothing here renders, though — `readOnlyRefs` is exported as a + * function rather than left inline for exactly that reason, following `composio-picker.test.tsx`: + * the promise the button makes can be asserted without a DOM, a router or a query client. + */ + +/** A minimal but complete `PluginTool`, overridable per case. */ +function tool(overrides: Partial & { name: string }): PluginTool { + return { + serverId: "slack", + description: "Does something.", + inputSchema: {}, + ref: `slack/${overrides.name}`, + effect: "read", + destructive: false, + grantedTo: [], + ...overrides, + }; +} + +const LIST_CHANNELS = tool({ name: "list_channels" }); +const SEARCH_MESSAGES = tool({ name: "search_messages" }); +const SEND_MESSAGE = tool({ name: "send_message", effect: "write" }); +const DELETE_CHANNEL = tool({ + name: "delete_channel", + effect: "write", + destructive: true, +}); + +test("the bulk action covers every read, and nothing that changes anything", () => { + const refs = readOnlyRefs([ + LIST_CHANNELS, + SEND_MESSAGE, + SEARCH_MESSAGES, + DELETE_CHANNEL, + ]); + + // Every read, so the button's own sentence about what the Bot would then hold is true. + expect(refs).toEqual(["slack/list_channels", "slack/search_messages"]); + // And nothing else: a bulk grant that quietly swept a write in would be the one mistake this + // button must never make, because nobody switched that write on. + expect(refs).not.toContain("slack/send_message"); +}); + +test("a destructive action never appears in the bulk action", () => { + const tools = [LIST_CHANNELS, SEND_MESSAGE, DELETE_CHANNEL]; + const refs = readOnlyRefs(tools); + + // Read back through the tools rather than naming the ref: this asserts the property — nothing + // the vendor warns about destroying anything comes out — rather than one hard-coded absence. + const granted = tools.filter((entry) => refs.includes(entry.ref)); + expect(granted.every((entry) => !entry.destructive)).toBe(true); + expect(granted.every((entry) => entry.effect === "read")).toBe(true); +}); + +test("an app that offers nothing to read grants nothing", () => { + // The button is hidden in this case, and would still be harmless if it were not: an empty + // promise is kept by doing nothing, not by falling back to the whole list. + expect(readOnlyRefs([SEND_MESSAGE, DELETE_CHANNEL])).toEqual([]); + expect(readOnlyRefs([])).toEqual([]); +}); diff --git a/app/tests/brokered-account-row.test.tsx b/app/tests/brokered-account-row.test.tsx new file mode 100644 index 000000000..e977a664f --- /dev/null +++ b/app/tests/brokered-account-row.test.tsx @@ -0,0 +1,1255 @@ +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, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { + type BrokeredAccount, + BrokeredAccountRow, +} from "@/components/plugins/brokered-account-row"; +import type { BrokerField } from "@/lib/plugins/mutations"; +import type { PluginServer, PluginsPage } from "@/lib/plugins/queries"; +import { Route as AdminAppRoute } from "@/routes/_authed/admin/plugins/$key"; +import { Route as ConnectedAccountRoute } from "@/routes/_authed/settings/connected-accounts/$key"; + +/** + * The brokered account row, on both screens that draw it. + * + * Neither screen had a test, which is how two defects survived a wave review and a final one. + * + * THE FIRST is that a successful disconnect went on reading "Connected". The row's state is + * `confirmBrokered.data?.connected ?? `, and a mutation's `data` is not query + * state: disconnecting invalidated the queries, the recorded row went away, and the vendor's old + * answer — set by the confirm-on-mount, whose dependencies had not changed — kept winning. The + * person read that as a failure and pressed Disconnect again, and the second DELETE reached a store + * method with no row-existence check, which revoked nothing and filed a second + * `mcp.account_disconnected` entry about an account that was already gone. + * + * THE SECOND is a deployment with no Composio key. The confirm answers 503, both screens swallow + * it by design, and the state fell back to the stale local row — so every reader was told + * "Connected" on a deployment that cannot reach the broker at all, with no sign anything was wrong + * until a Bot call refused. `docs/plugins/composio.md` has always claimed the page says the key is + * missing; nothing implemented it. + * + * THE HARNESS IS THIS REPOSITORY'S, from `agent-roster-error.test.tsx` and + * `bot-app-grants-screen.test.tsx`: `GlobalRegistrator` in `beforeAll`/`afterAll`, `cleanup` in + * `afterEach`, queries off `render()`'s own return, a `QueryClient` with `retry: false`, and the + * capture-and-restore of each exported `Route` singleton those files document at length — bun walks + * every file into one process, and `.update()` merges into the live object rather than replacing it. + * + * What is NOT copied from them is the always-failing `fetch`. What is under test here is a sequence + * — confirmed live, then disconnected, then read again — so the stub below is a small in-memory + * deployment that answers each endpoint from state a test can set and a DELETE can change, rather + * than one canned response. It also counts the DELETEs, which is the only way to assert the second + * press cannot happen rather than merely that the word changed. + */ + +beforeAll(() => GlobalRegistrator.register()); +afterEach(cleanup); +afterAll(() => GlobalRegistrator.unregister()); + +const APP_KEY = "gmail"; + +/** When the key was last known to work, as this deployment wrote it down. */ +const CHECKED_AT = "2026-09-10T09:00:00.000Z"; + +/** When a re-check pressed during a test finds out again. A different day, so the two read apart. */ +const RECHECKED_AT = "2026-09-13T09:00:00.000Z"; + +/** + * The action Composio publishes for this app, as the server names it in an answer. + * + * A real name rather than a flag, because the name is the whole of what separates the two things + * `verified: false` means: a null probe is an app with nothing safe to spend a key on, and this + * beside the same false is a key the vendor looked at and refused. + */ +const PROBE = "GMAIL_FETCH_EMAILS"; + +/** The same day, spelled the way the row spells it — the reader's own locale, not this file's. */ +function asDay(iso: string): string { + return new Date(iso).toLocaleDateString(); +} + +/** A minimal but complete brokered `PluginServer` — the row shape only Composio produces. */ +function brokeredServer(authScheme: string): PluginServer { + return { + authScheme, + id: APP_KEY, + title: "Gmail", + vendor: "Google", + url: "https://example.invalid/composio", + summary: "Mail.", + docsUrl: "", + provenance: "composio", + hasCredential: false, + toolsRefreshedAt: null, + lastError: null, + addedBy: null, + dynamicClient: false, + tools: [], + withdrawn: [], + }; +} + +function pluginsPage( + composioConfigured: boolean, + authScheme: string, +): PluginsPage { + return { + catalogue: [], + servers: [brokeredServer(authScheme)], + skills: [], + botsMayCallBack: true, + redirectUri: null, + composioConfigured, + }; +} + +/** + * The deployment these tests render against, as a handful of facts a test sets up front. + * + * `recorded` is this deployment's own row — written from the unproven return trip from consent — + * and `confirms` is what the broker answers when asked about the account behind it. They are + * separate on purpose: every case worth testing here is one where the two disagree. + */ +type Deployment = { + composioConfigured: boolean; + recorded: boolean; + confirms: boolean; + /** + * How this app's authorization config was created, as the vendor's own scheme literal. Defaults + * to the consent scheme, which is what every test written before there was a second kind meant. + */ + authScheme?: string; + /** What the app publishes as the things a person types in, for the `API_KEY` schemes. */ + fields?: BrokerField[]; + /** Whether a real call was ever made with this key and worked, as this deployment recorded it. */ + verified?: boolean; + /** When that happened. Null wherever `verified` is false — a check that failed records no time. */ + verifiedAt?: string | null; + /** + * Which action this deployment WOULD check the key with, as the connections read now derives it. + * + * Left off by default, because that is what a row out of the held-connection half of that + * endpoint looks like and what every test written before the field existed meant: the key was + * taken and nothing here knows what, if anything, tried it. A name or a null is the read saying + * which of the three states the row is really in, and it survives a reload where an answer to a + * mutation cannot. + */ + probe?: string | null; + /** + * Whether the app has anything to check a key against today, as the connections read answers. + * + * A SECOND FIELD BECAUSE IT IS A SECOND QUESTION. `probe` above is the record of what the last + * check SPENT; this is what the app publishes NOW, and it is what the Re-check button is drawn + * from. They agree until an app starts publishing something it did not publish when the key was + * taken — which is the state a deployment reaches by an administrator pressing Refresh, and the + * one the button was unreachable in while it read the record. + * + * Left off by default for the same reason `probe` is, and false where it is left off: a held row + * carries neither, and a closed gate is what a row nothing has said about should draw. + */ + checkable?: boolean; + /** + * What a re-check answers when somebody presses for one, as the route's whole body. + * + * `probe` travels with the verdict because the verdict alone is not an answer: the only + * `verified: false` that arrives here as a 200 is the one carrying a null probe, and a re-check + * the vendor refused is raised rather than answered. + */ + recheckAnswer?: { + verified: boolean; + verifiedAt: string | null; + probe: string | null; + }; + /** What Composio refuses a submitted key with, where this deployment refuses it at all. */ + rejects?: string; +}; + +type Server = { + /** How many DELETEs reached the connection endpoint. */ + deletes: number; +}; + +/** + * A stub `fetch` answering the four endpoints these two screens read and write, from state the + * DELETE actually changes. + * + * A canned response per endpoint would not do: the defect under test is a screen that keeps + * rendering an answer the vendor gave BEFORE an act that invalidated it, so the disconnect has to + * really take effect somewhere for a later read to be able to disagree with it. + */ +function installDeployment(deployment: Deployment): Server { + const state = { + authScheme: "OAUTH2", + fields: [] as BrokerField[], + verified: false, + verifiedAt: null as string | null, + rejects: undefined as string | undefined, + probe: undefined as string | null | undefined, + checkable: false, + recheckAnswer: { verified: true, verifiedAt: RECHECKED_AT, probe: PROBE }, + ...deployment, + }; + const server: Server = { deletes: 0 }; + + global.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const path = typeof input === "string" ? input : String(input); + const method = init?.method ?? "GET"; + const json = (body: unknown) => + new Response(JSON.stringify(body), { + headers: { "content-type": "application/json" }, + }); + + if (path.startsWith("/api/plugins/connections")) { + return json({ + connections: state.recorded + ? [ + { + serverId: APP_KEY, + scope: "", + connectedAt: "2026-09-10T00:00:00.000Z", + verified: state.verified, + verifiedAt: state.verifiedAt, + /* + * The action the last check recorded, so it is on every brokered row a page load + * reads and not only on the answer to a write. Undefined here is the field being + * absent from the JSON, which is what a held connection's row looks like. + */ + probe: state.probe, + /* + * And what the app publishes today, which is the other question and the one the + * Re-check button asks. A read that sent only the record left the button gated on + * what a past check spent, so a key nothing was spent on could never have anything + * spent on it. + */ + checkable: state.checkable, + }, + ] + : [], + redirectUri: null, + }); + } + if (path.endsWith("/connection/confirm") && method === "POST") { + // What the route answers with no key at all: nothing to ask, and no answer invented. + if (!state.composioConfigured) { + return new Response( + JSON.stringify({ error: "Composio is not set up" }), + { + headers: { "content-type": "application/json" }, + status: 503, + }, + ); + } + return json({ connected: state.confirms }); + } + /* + * The first press on an app nobody consents to: what does it want typed in? The consent half of + * this same route carries `?returnTo=`, so the two are told apart by the query rather than by + * the method they share. + */ + if (path.endsWith("/connect") && method === "POST") { + /* + * The second press carries the values on it, and is the one that writes. Told apart by the + * body rather than by a second path, because the route itself is one route: the first press + * asks the app what it wants and the second hands it over. + */ + if (typeof init?.body === "string" && init.body.includes("values")) { + /* + * The vendor's own refusal, carried on the envelope `client` unwraps. It is the sentence + * this whole path exists to preserve, and the only thing that tells somebody their key was + * mistyped rather than their deployment broken. + */ + if (state.rejects) { + return new Response(JSON.stringify({ error: state.rejects }), { + headers: { "content-type": "application/json" }, + status: 400, + }); + } + state.recorded = true; + state.confirms = true; + // A fresh key, and the store writes every one of those unverified: nothing has been spent + // on it, whatever the app does or does not publish to spend. + state.verified = false; + state.verifiedAt = null; + /* + * The route's whole body, `probe` included. This deployment's app publishes nothing safe to + * spend a key on, which is what a null probe beside an unverified key says — and a stub that + * left the field off would answer `undefined`, a state the server has no way to send. + */ + return json({ connected: true, verified: false, probe: null }); + } + return json({ fields: state.fields }); + } + if (path.endsWith("/connection/recheck") && method === "POST") { + state.verified = state.recheckAnswer.verified; + state.verifiedAt = state.recheckAnswer.verifiedAt; + return json(state.recheckAnswer); + } + if (path.endsWith("/connection") && method === "DELETE") { + server.deletes += 1; + state.recorded = false; + state.confirms = false; + // The row is gone, and so is everything that was ever checked about it. + state.verified = false; + state.verifiedAt = null; + return json({ ok: true }); + } + if (path.startsWith("/api/agents")) return json({ agents: [] }); + if (path.startsWith("/api/plugins")) { + return json(pluginsPage(state.composioConfigured, state.authScheme)); + } + return new Response(null, { status: 404 }); + }) as typeof fetch; + + return server; +} + +const originalFetch = global.fetch; + +afterEach(() => { + global.fetch = originalFetch; +}); + +/** A client that settles in one attempt, so no test waits on a retry. */ +function queryClient() { + return new QueryClient({ defaultOptions: { queries: { retry: false } } }); +} + +/* + * Capture and restore of each exported `Route` singleton, verbatim from + * `agent-roster-error.test.tsx` and for the reason recorded there: `.update()` merges into the live + * object, `createRouter()` derives `_id`/`parentRoute` off it, and nothing re-runs `init()` on a + * replay — so a render here would otherwise leave the real router (`router.test.ts` builds one in + * this same bun process) pointed at a decoy parent. + */ +function captureRouteState(route: object): Record { + return { ...route, options: { ...(route as { options: object }).options } }; +} + +function restoreRouteState( + route: object, + snapshot: Record, +): void { + for (const key of Object.keys(route)) { + if (!(key in snapshot)) { + delete (route as Record)[key]; + } + } + Object.assign(route, snapshot); +} + +/** Captured once, at module scope, before any `test()` body here has run — the state this file must + * hand back, whatever it happens to be. See `agent-roster-error.test.tsx`. */ +const pristineAccountRouteState = captureRouteState(ConnectedAccountRoute); +const pristineAdminRouteState = captureRouteState(AdminAppRoute); + +let accountRouteSnapshot: Record; +let adminRouteSnapshot: Record; + +beforeEach(() => { + accountRouteSnapshot = captureRouteState(pristineAccountRouteState); + adminRouteSnapshot = captureRouteState(pristineAdminRouteState); +}); + +afterEach(() => { + restoreRouteState(ConnectedAccountRoute, accountRouteSnapshot); + restoreRouteState(AdminAppRoute, adminRouteSnapshot); +}); + +/** + * The personal screen, at its real id. + * + * `routeTree.gen.ts` fixes both halves: id `/connected-accounts/$key` under `/_authed/settings`, + * path `/connected-accounts/$key`. Decoy pathless/static parents are enough to make the join land + * on the id `useParams({ from: … })` resolves against; the real ancestors check a session and mount + * the settings shell, neither of which this file is about. The index route is registered only so + * the Back link has something to build an href from. + */ +function renderAccountScreen(client: QueryClient) { + 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 wired = ( + ConnectedAccountRoute as unknown as { + update: (options: unknown) => typeof ConnectedAccountRoute; + } + ).update({ + id: "/connected-accounts/$key", + path: "/connected-accounts/$key", + getParentRoute: () => settingsRoute, + }); + const tree = rootRoute.addChildren([ + authedRoute.addChildren([settingsRoute.addChildren([indexRoute, wired])]), + ]); + const router = createRouter({ + routeTree: tree, + history: createMemoryHistory({ + initialEntries: [`/settings/connected-accounts/${APP_KEY}`], + }), + }); + return render( + + + , + ); +} + +/** The administrator's connector page, the same way: id `/plugins/$key` under `/_authed/admin`. */ +function renderAdminScreen(client: QueryClient) { + const rootRoute = createRootRoute({ component: Outlet }); + const authedRoute = createRoute({ + id: "/_authed", + getParentRoute: () => rootRoute, + component: Outlet, + }); + const adminRoute = createRoute({ + path: "/admin", + getParentRoute: () => authedRoute, + component: Outlet, + }); + const pluginsRoute = createRoute({ + path: "/plugins/", + getParentRoute: () => adminRoute, + component: () => null, + }); + const wired = ( + AdminAppRoute as unknown as { + update: (options: unknown) => typeof AdminAppRoute; + } + ).update({ + id: "/plugins/$key", + path: "/plugins/$key", + getParentRoute: () => adminRoute, + }); + const tree = rootRoute.addChildren([ + authedRoute.addChildren([adminRoute.addChildren([pluginsRoute, wired])]), + ]); + const router = createRouter({ + routeTree: tree, + history: createMemoryHistory({ + initialEntries: [`/admin/plugins/${APP_KEY}`], + }), + }); + return render( + + + , + ); +} + +/** The sentence the row carries where the deployment has no Composio key, matched on the part that + * names the setting — the whole point of the row in that state. */ +const NAMES_THE_SETTING = /Set COMPOSIO_API_KEY on this deployment/; + +test("a disconnect that lands stops reading Connected and offers Connect again", async () => { + const server = installDeployment({ + composioConfigured: true, + recorded: true, + confirms: true, + }); + + const view = renderAccountScreen(queryClient()); + + // Connected first, on the vendor's own answer — otherwise the assertion below proves nothing. + const disconnect = await view.findByRole("button", { name: "Disconnect" }); + expect(view.queryByText("Connected")).toBeTruthy(); + + await userEvent.click(disconnect); + + await waitFor(() => + expect(view.queryByRole("button", { name: "Connect" })).toBeTruthy(), + ); + expect(view.queryByText("Connected")).toBeNull(); + expect(view.queryByRole("button", { name: "Disconnect" })).toBeNull(); + // The whole reason this matters: with Disconnect still on screen the person presses it again, + // and the second DELETE files a row claiming an account was disconnected when there was none. + expect(server.deletes).toBe(1); +}); + +test("a deployment with no Composio key names the setting 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 + // exact state that used to read "Connected" on a deployment that cannot reach the broker. + recorded: true, + confirms: true, + }); + + const view = renderAccountScreen(queryClient()); + + expect(await view.findByText(NAMES_THE_SETTING)).toBeTruthy(); + expect(view.queryByText("Key missing")).toBeTruthy(); + expect(view.queryByText("Connected")).toBeNull(); + expect(view.queryByRole("button", { name: "Disconnect" })).toBeNull(); + expect(view.queryByRole("button", { name: "Connect" })).toBeNull(); +}); + +test("somebody who abandoned consent reads as not connected, not as an error", async () => { + installDeployment({ + composioConfigured: true, + // The callback wrote our row on an ordinary redirect with nothing signed in it. The vendor is + // the only thing that knows the consent was never finished, and it says so. + recorded: true, + confirms: false, + }); + + const view = renderAccountScreen(queryClient()); + + expect(await view.findByRole("button", { name: "Connect" })).toBeTruthy(); + expect(view.queryByText("Connected")).toBeNull(); + // Not a failure of this page: it asked a question and got an answer. A red sentence across the + // top would report the page's own question as this person's problem. + expect(view.queryByRole("alert")).toBeNull(); + expect(view.queryByText(NAMES_THE_SETTING)).toBeNull(); +}); + +test("the connector's admin page draws the same row, and its disconnect clears too", async () => { + const server = installDeployment({ + composioConfigured: true, + recorded: true, + confirms: true, + }); + + const view = renderAdminScreen(queryClient()); + + const disconnect = await view.findByRole("button", { name: "Disconnect" }); + expect(view.queryByText("Connected")).toBeTruthy(); + + await userEvent.click(disconnect); + + await waitFor(() => + expect(view.queryByRole("button", { name: "Connect" })).toBeTruthy(), + ); + expect(view.queryByText("Connected")).toBeNull(); + expect(view.queryByRole("button", { name: "Disconnect" })).toBeNull(); + expect(server.deletes).toBe(1); +}); + +test("the admin page says the key is missing too, rather than offering an action", async () => { + installDeployment({ + composioConfigured: false, + recorded: true, + confirms: true, + }); + + const view = renderAdminScreen(queryClient()); + + expect(await view.findByText(NAMES_THE_SETTING)).toBeTruthy(); + expect(view.queryByText("Key missing")).toBeTruthy(); + expect(view.queryByText("Connected")).toBeNull(); + expect(view.queryByRole("button", { name: "Disconnect" })).toBeNull(); + // The app is still enabled and still says how it is reached — the row is honest about what is + // still true, rather than reading as a connector that has gone away. + expect(view.queryByText("How this is reached")).toBeTruthy(); +}); + +/** + * One real field, as Composio publishes it for Perplexity. + * + * Kept verbatim rather than trimmed to a label: the help sentence is the app's own, and the point of + * the test below is that this deployment reproduces a sentence it has never been taught. + */ +const PERPLEXITY_KEY: BrokerField = { + name: "generic_api_key", + label: "API Key", + help: "Your secret Perplexity API key, starting with 'pplx-'. Create one at console.perplexity.ai under API Keys — it's shown only once, so copy it immediately.", + required: true, + secret: true, +}; + +test("a key app asks for what the app asked for, with its own help text", async () => { + installDeployment({ + authScheme: "API_KEY", + composioConfigured: true, + confirms: false, + fields: [PERPLEXITY_KEY], + recorded: false, + }); + + const view = renderAccountScreen(queryClient()); + + await userEvent.click(await view.findByRole("button", { name: "Connect" })); + + // Labelled by what the app called it, which is how a person finds the box the vendor's own + // instructions are about. + const input = await view.findByLabelText("API Key"); + // The app said which value is the secret. Nothing here guessed it from the name. + expect(input.getAttribute("type")).toBe("password"); + expect(view.queryByText(/starting with 'pplx-'/)).toBeTruthy(); +}); + +test("a key app nobody has connected says it will ask for a key, not send you off", async () => { + installDeployment({ + authScheme: "API_KEY", + composioConfigured: true, + confirms: false, + fields: [PERPLEXITY_KEY], + recorded: false, + }); + + const view = renderAccountScreen(queryClient()); + + expect( + await view.findByText( + /connected with a key you already hold, not a trip to Gmail's consent screen/, + ), + ).toBeTruthy(); + /* + * The screen's own not-connected sentence is written for the kind that leaves, and pressing + * Connect on this app opens a form instead. Promising a trip to the vendor here is not a vaguer + * sentence than the truth; it is a different act from the one about to happen. + */ + expect( + view.queryByText(/takes you to Composio and then to the vendor to consent/), + ).toBeNull(); +}); + +test("a key app nobody has connected keeps the screen's own reassurance", async () => { + installDeployment({ + authScheme: "API_KEY", + composioConfigured: true, + confirms: false, + fields: [PERPLEXITY_KEY], + recorded: false, + }); + + const view = renderAdminScreen(queryClient()); + + // The row's own sentence, which is the one the screen's cannot be: pressing Connect here opens a + // form rather than leaving for a consent screen. + expect(await view.findByText(/Connect asks for it\./)).toBeTruthy(); + /* + * And the half of the screen's line that survives it. Replacing the whole line took away the one + * thing an administrator reading this row needs to know — that the connector is finished whether + * or not they ever connect themselves — and left them looking at a step they do not have to take. + */ + expect( + view.getByText( + /Setup is complete without it, and it reaches your documents only/, + ), + ).toBeTruthy(); +}); + +/** Composio's own words for a key it would not take, which is the sentence worth carrying. */ +const REFUSED = "Composio rejected that key: invalid API key for perplexityai."; + +test("a key the broker refuses says so inside the dialog, not only behind it", async () => { + installDeployment({ + authScheme: "API_KEY", + composioConfigured: true, + confirms: false, + fields: [PERPLEXITY_KEY], + recorded: false, + rejects: REFUSED, + }); + + const view = renderAccountScreen(queryClient()); + + await userEvent.click(await view.findByRole("button", { name: "Connect" })); + const dialog = await view.findByRole("dialog"); + await userEvent.type(await view.findByLabelText("API Key"), "pplx-mistyped"); + await userEvent.click( + within(dialog).getByRole("button", { name: "Connect" }), + ); + + /* + * WHERE THE PERSON IS LOOKING. The screen's banner is behind this dialog's backdrop, so a + * refusal that lands only there lands nowhere: the form sits open over it as though nothing had + * been answered, and the one sentence that says "you mistyped it" rather than "we are broken" is + * unreadable until somebody closes the thing they were trying to finish. + */ + await waitFor(() => expect(within(dialog).queryByText(REFUSED)).toBeTruthy()); + // Reported to the screen as well, not instead: the dialog is closable and the reason outlives it. + expect(view.getAllByText(REFUSED).length).toBe(2); + // And the form stays up holding what was typed. A mistyped key is corrected, not retyped. + expect(within(dialog).queryByLabelText("API Key")).toBeTruthy(); +}); + +test("the row names the app rather than calling it the app", async () => { + installDeployment({ + authScheme: "API_KEY", + composioConfigured: true, + confirms: true, + fields: [PERPLEXITY_KEY], + recorded: true, + }); + + const view = renderAdminScreen(queryClient()); + + /* + * The point of every sentence that names the vendor is that it names a place somebody has to go: + * the console where a key is rotated, the consent screen a connection rests on. A screen that + * drew this row without handing over the title left them all saying "the app", which names + * nowhere at all. + */ + expect( + await view.findByText(/accepted without being checked against Gmail/), + ).toBeTruthy(); + expect( + view.queryByText(/accepted without being checked against the app/), + ).toBeNull(); +}); + +/** + * A `BrokeredAccount` standing on its own, for the cases that are about what the row SAYS. + * + * The hook is exercised through the two screens above, which is where its own defects live. These + * cases differ only in the three facts the row branches on — `kind`, `connected`, `verified` — and + * a deployment built for each would be testing the stub rather than the sentence. + * + * Every field of `BrokeredAccount` has to be kept here by hand: `app/tsconfig.json` covers `src` + * and not `app/tests`, and `bun test` does not typecheck, so a field added to the type and missed + * here is `undefined` at render time and nothing says so. + */ +function accountState(overrides: Partial): BrokeredAccount { + return { + configured: true, + connect: () => {}, + connected: false, + connecting: false, + disconnect: () => {}, + disconnected: false, + disconnecting: false, + fields: null, + kind: "consent", + /* + * UNDEFINED IS THE DEFAULT BECAUSE IT IS THE COMMON STATE, not because the field is optional to + * fill in: a row drawn from a page load has been told nothing about a probe, and the cases below + * that are about the three the server DOES send say `null` or a name for themselves. + */ + probe: undefined, + /* + * FALSE IS THE DEFAULT BECAUSE IT IS THE CLOSED GATE, and the cases below that are about the + * Re-check button say so for themselves. It is the app's own question — is there anything to + * check a key against today — and not the record `probe` above carries. + */ + checkable: false, + recheck: () => {}, + rechecking: false, + requestFields: () => {}, + requestingFields: false, + submissionError: null, + submitFields: () => {}, + submittingFields: false, + verified: false, + verifiedAt: null, + ...overrides, + }; +} + +/** The row with both screens' arguments filled in, so only the account differs between cases. */ +function renderRow(account: BrokeredAccount) { + return render( + , + ); +} + +test("both kinds say Connected, and the line beneath says how", () => { + const consent = renderRow(accountState({ connected: true, kind: "consent" })); + + expect(consent.getByText("Connected")).toBeTruthy(); + expect(consent.getByText(/through Gmail's consent screen/)).toBeTruthy(); + + cleanup(); + + const key = renderRow( + accountState({ + connected: true, + kind: "fields", + verified: true, + verifiedAt: CHECKED_AT, + }), + ); + + // The same word, deliberately: what differs between a consent screen and a key somebody typed is + // not whether the account is live, and a second word for it would invite a distinction there is + // no fact behind. + expect(key.getByText("Connected")).toBeTruthy(); + expect( + key.getByText( + `Connected with a key you provided, last checked ${asDay(CHECKED_AT)}.`, + ), + ).toBeTruthy(); +}); + +test("an app needing no account offers nothing to press", () => { + const view = renderRow(accountState({ kind: "no-auth" })); + + // Not a disabled Connect, and not a Connect that would make an account nobody needs: there is no + // account here to make, so there is no control. + expect(view.queryByRole("button")).toBeNull(); + expect(view.getByText(/Gmail needs no account/)).toBeTruthy(); +}); + +test("Re-check appears only where a check is possible, and asks when pressed", async () => { + let checks = 0; + const checkable = renderRow( + accountState({ + // The app has something to spend the key on, which is the button's whole condition and is + // asked of the app rather than of anything a past check recorded. + checkable: true, + connected: true, + kind: "fields", + recheck: () => { + checks += 1; + }, + verified: true, + verifiedAt: CHECKED_AT, + }), + ); + + await userEvent.click(checkable.getByRole("button", { name: "Re-check" })); + expect(checks).toBe(1); + + cleanup(); + + /* + * AN APP WITH NOTHING TO CHECK A KEY AGAINST, which is what the server answers `checkable: false` + * for: no action it could safely spend the key on, so there is no check to make and nothing to + * offer. Not the same as a key nothing has checked YET — a null RECORD keeps its button, because + * the person who has just fixed their key is exactly who reaches for it, and because the record + * is the only place an action can come from. + */ + const unchecked = renderRow( + accountState({ + checkable: false, + connected: true, + kind: "fields", + probe: null, + }), + ); + + expect(unchecked.queryByRole("button", { name: "Re-check" })).toBeNull(); + expect( + unchecked.getByText(/accepted without being checked against Gmail/), + ).toBeTruthy(); +}); + +test("a connected consent app offers no Re-check at all", () => { + /* + * WHAT A CONSENT ROW ACTUALLY LOOKS LIKE, and what every one of them was backfilled to by + * migration 0030: connected and verified, with no probe anywhere behind the flag. Gating the + * button on `verified` alone drew Re-check on all of them, and pressing it reached an endpoint + * this deployment does not serve — a red banner, guaranteed, on the one kind that works today. + */ + const view = renderRow( + accountState({ + connected: true, + kind: "consent", + verified: true, + verifiedAt: CHECKED_AT, + }), + ); + + expect(view.queryByRole("button", { name: "Re-check" })).toBeNull(); + // The row is otherwise itself: a live account somebody can still end. + expect(view.getByRole("button", { name: "Disconnect" })).toBeTruthy(); + expect(view.getByText(/through Gmail's consent screen/)).toBeTruthy(); +}); + +/** + * THE THREE THINGS A KEY CONNECTION'S VERIFICATION CAN MEAN, one test apiece. + * + * The row used to collapse all three into "It was accepted without being checked against Gmail", + * which is vague for two of them and FALSE for the third: there the key was checked, the vendor + * refused it, and the account that check ran in is still standing — so the one person whose key is + * definitely bad, and whose account is definitely live at Composio, was told nothing had ever been + * tried. `probe` is what tells them apart, and these are the three shapes it arrives in. + */ + +test("an app with nothing to check a key against says that about the app", () => { + /* + * STATE ONE: a null probe beside an unverified key. When the key was taken the app published no + * action this deployment could safely spend it on, so nothing was tried — a fact about what the + * app published then, which is why the sentence has to say so, in that tense, rather than leave a + * person reading suspicion of their own key into it. + */ + const view = renderRow( + accountState({ connected: true, kind: "fields", probe: null }), + ); + + expect( + view.getByText(/accepted without being checked against Gmail/), + ).toBeTruthy(); + expect( + view.getByText(/published nothing safe to try a key on at the time/), + ).toBeTruthy(); + expect(view.getByText(/about the app, not about your key/)).toBeTruthy(); + // The one thing this state must never read as: a verdict on the key. + expect(view.queryByText(/rejected/)).toBeNull(); +}); + +test("a key that passed its check says when it passed", () => { + /* + * STATE TWO: a named probe and a verdict that it answered. The action ran in this person's own + * account and the vendor took the key — and because Composio never re-checks a key once it has + * taken it, the sentence names the moment rather than asserting a present tense. + */ + const view = renderRow( + accountState({ + connected: true, + kind: "fields", + probe: PROBE, + verified: true, + verifiedAt: CHECKED_AT, + }), + ); + + expect( + view.getByText( + `Connected with a key you provided, last checked ${asDay(CHECKED_AT)}.`, + ), + ).toBeTruthy(); + expect( + view.queryByText(/accepted without being checked against Gmail/), + ).toBeNull(); +}); + +test("a key the vendor rejected says so, and that the account still stands", () => { + /* + * STATE THREE, AND THE WHOLE REASON `probe` TRAVELS. It is reachable on the worst path from + * either producer: the check ran, the vendor refused the key, and the account it ran in is still + * standing — a connect whose withdrawal failed, or a re-check that never withdraws one. All three + * facts are the person's to act on: the key is bad, an account of theirs is live at Composio, and + * the row says which button ends which. WHY it stands is the one thing the sentence must not + * assert, because the two paths stand for different reasons. + */ + const view = renderRow( + accountState({ + connected: true, + kind: "fields", + probe: PROBE, + verified: false, + verifiedAt: null, + }), + ); + + expect( + view.getByText( + /was checked against Gmail and rejected, and the account it was checked in still stands at Composio/, + ), + ).toBeTruthy(); + // And never a cause for it: a failed re-check leaves the account standing without trying to take + // it back, so a sentence blaming a failed withdrawal would be false on that path. + expect(view.queryByText(/could not withdraw it/)).toBeNull(); + /* + * AND NOT THE OTHER SENTENCE. This is the state that sentence was false in: saying nothing had + * been checked, to the one person whose key has definitely been checked and definitely refused. + */ + expect( + view.queryByText(/accepted without being checked against Gmail/), + ).toBeNull(); +}); + +test("Re-check is offered where the key is bad and withheld where there is nothing to check", async () => { + /* + * THE BUTTON BELONGS TO THE APP'S PROBE, NOT TO A CHECK THAT HAS ALREADY PASSED. Gating it on + * `verified` hid it in state three, which is precisely where somebody stands after correcting the + * key at the vendor and wanting to try it again — and the row that hid it also told them nothing + * had ever been tried. + */ + let checks = 0; + const rejected = renderRow( + accountState({ + // The app still publishes what the refused check was spent on, which is the ordinary shape of + // this state and what puts the button within reach of somebody who has fixed their key. + checkable: true, + connected: true, + kind: "fields", + probe: PROBE, + recheck: () => { + checks += 1; + }, + verified: false, + verifiedAt: null, + }), + ); + + await userEvent.click(rejected.getByRole("button", { name: "Re-check" })); + expect(checks).toBe(1); + + cleanup(); + + /* + * And withheld where the app has nothing to check with. Pressing it there could only spend a + * request to be told the same nothing again. + */ + const nothingToCheck = renderRow( + accountState({ + checkable: false, + connected: true, + kind: "fields", + probe: null, + }), + ); + + expect(nothingToCheck.queryByRole("button", { name: "Re-check" })).toBeNull(); + // Still a live account somebody can end: the missing button is about checking, not about acting. + expect( + nothingToCheck.getByRole("button", { name: "Disconnect" }), + ).toBeTruthy(); +}); + +test("disconnecting a key names the step this deployment cannot take", () => { + const view = renderRow( + accountState({ connected: false, disconnected: true, kind: "fields" }), + ); + + // The account ends at Composio and the key does not end anywhere. Saying "disconnected" and + // stopping would leave somebody believing they had ended access they still have live. + expect(view.getByText(/Removed from Composio/)).toBeTruthy(); + expect( + view.getByText(/Your key still works at Gmail — rotate it there/), + ).toBeTruthy(); +}); + +test("a key re-checked and then disconnected stops claiming it was checked", async () => { + installDeployment({ + authScheme: "API_KEY", + // The app publishes something to check the key against, so the button this test presses exists. + checkable: true, + composioConfigured: true, + confirms: true, + fields: [PERPLEXITY_KEY], + recorded: true, + verified: true, + verifiedAt: CHECKED_AT, + }); + + const view = renderAccountScreen(queryClient()); + + expect( + await view.findByText( + new RegExp(`last checked ${asDay(CHECKED_AT)}`.replace(/\//g, "\\/")), + ), + ).toBeTruthy(); + + await userEvent.click(view.getByRole("button", { name: "Re-check" })); + await waitFor(() => + expect( + view.queryByText( + new RegExp(`last checked ${asDay(RECHECKED_AT)}`.replace(/\//g, "\\/")), + ), + ).toBeTruthy(), + ); + + await userEvent.click(view.getByRole("button", { name: "Disconnect" })); + + await waitFor(() => + expect(view.queryByText(/Removed from Composio/)).toBeTruthy(), + ); + // The account is gone; the answer the re-check gave was about it and must go with it. + expect(view.queryByText(/last checked/)).toBeNull(); + expect(view.queryByRole("button", { name: "Re-check" })).toBeNull(); + + /* + * AND THE ANSWER MUST NOT COME BACK WITH THE NEXT KEY. A mutation's `data` is not query state: + * without it being thrown away, connecting again leaves the re-check's old verdict standing, and + * the row reads "last checked" about a key entered seconds ago that nothing has ever tried. + */ + await userEvent.click(view.getByRole("button", { name: "Connect" })); + const dialog = await view.findByRole("dialog"); + await userEvent.type( + await view.findByLabelText("API Key"), + "pplx-a-fresh-one", + ); + await userEvent.click( + within(dialog).getByRole("button", { name: "Connect" }), + ); + + await waitFor(() => expect(view.queryByText("Connected")).toBeTruthy()); + expect( + view.queryByText(/accepted without being checked against Gmail/), + ).toBeTruthy(); + expect(view.queryByText(/last checked/)).toBeNull(); +}); + +test("a rejected key still says so on a page that has only read, and still offers Re-check", async () => { + /* + * THE RELOAD, WHICH IS THE STATE THIS WHOLE FIELD WAS MISSING FROM. Nothing has been pressed + * here: no key has just been handed over and no re-check has been made, so the hook holds no + * mutation answer at all and everything the row knows came out of the connections read. That read + * now carries `probe` as the record of what the check spent, written down when it was spent, which + * is what lets the three states behind one `verified: false` survive a refresh. + * + * Before it did, this exact page said "accepted without being checked" — to the one person whose + * key HAS been checked and refused, and whose account is standing at Composio. The button they + * would reach for was withheld at the same time, on the only render where they would look for it. + */ + installDeployment({ + authScheme: "API_KEY", + // Still publishing what the refused check was spent on, which is what offers the way back. + checkable: true, + composioConfigured: true, + confirms: true, + fields: [PERPLEXITY_KEY], + recorded: true, + verified: false, + verifiedAt: null, + probe: PROBE, + }); + + const view = renderAccountScreen(queryClient()); + + expect( + await view.findByText(/was checked against Gmail and rejected/), + ).toBeTruthy(); + expect(view.getByText(/still stands at Composio/)).toBeTruthy(); + // And never the sentence that is false here. + expect( + view.queryByText(/accepted without being checked against Gmail/), + ).toBeNull(); + // The way back: a key corrected at the vendor is worth a second check, not a second connection. + expect(await view.findByRole("button", { name: "Re-check" })).toBeTruthy(); + + cleanup(); + + /* + * AND THE SAME ON THE ADMINISTRATOR'S PAGE, which draws the same row from its own call. The two + * screens wire the hook up separately, so a field carried into one of them and not the other is a + * defect neither screen's other tests can see. + */ + installDeployment({ + authScheme: "API_KEY", + // Still publishing what the refused check was spent on, which is what offers the way back. + checkable: true, + composioConfigured: true, + confirms: true, + fields: [PERPLEXITY_KEY], + recorded: true, + verified: false, + verifiedAt: null, + probe: PROBE, + }); + + const admin = renderAdminScreen(queryClient()); + + expect( + await admin.findByText(/was checked against Gmail and rejected/), + ).toBeTruthy(); + expect(await admin.findByRole("button", { name: "Re-check" })).toBeTruthy(); +}); + +test("a key nothing was tried on offers Re-check once the app has something to try, and still says nothing was tried", async () => { + /* + * THE DEADLOCK, AND ITS GUARD, IN ONE ROW. The server records what a check SPENT and answers + * separately whether the app has anything to spend TODAY, and this row is the state where those + * two part company: a key accepted against an app that published nothing, under an app that + * publishes something now. + * + * While the button read the record, this row had no way out. The check spent nothing, so the + * record is null for good; the button was withheld on a null; and pressing that button is the + * only thing in the product that could ever put an action in the record. Withholding it was the + * safe direction for a question about the app and the wrong answer to it. + * + * AND THE SENTENCE MUST NOT MOVE WITH IT. What the row SAYS is drawn from the record, so it goes + * on saying the key was taken and never tried — which is what happened, and stays what happened + * however much the app has published since. A screen that let the button's question write the + * sentence would be the accusation this record exists to prevent, arriving by the other door. + */ + const row = renderRow( + accountState({ + checkable: true, + connected: true, + kind: "fields", + probe: null, + }), + ); + + expect(row.getByRole("button", { name: "Re-check" })).toBeTruthy(); + expect( + row.getByText(/accepted without being checked against Gmail/), + ).toBeTruthy(); + /* + * AND IN THE PAST TENSE, which is what keeps the line and the button from contradicting each + * other. The app publishes something NOW — that is why the button is there — so a clause claiming + * it publishes nothing would be read off the same row as the offer to check, and one of the two + * would have to be wrong. The clause is about the moment of the check, and says so. + */ + expect( + row.getByText(/published nothing safe to try a key on at the time/), + ).toBeTruthy(); + // And never the sentence written for a key the vendor refused: nothing was refused here. + expect(row.queryByText(/and rejected/)).toBeNull(); + + cleanup(); + + /* + * AND THE SAME OFF A PAGE THAT HAS ONLY READ, on both screens. Nothing is pressed here, so every + * field the row branches on came out of the connections read — which is the only place the second + * answer can come from, and the two screens wire the hook up separately. + */ + installDeployment({ + authScheme: "API_KEY", + checkable: true, + composioConfigured: true, + confirms: true, + fields: [PERPLEXITY_KEY], + recorded: true, + verified: false, + verifiedAt: null, + probe: null, + }); + + const view = renderAccountScreen(queryClient()); + + expect( + await view.findByText(/accepted without being checked against Gmail/), + ).toBeTruthy(); + expect(await view.findByRole("button", { name: "Re-check" })).toBeTruthy(); + + cleanup(); + + installDeployment({ + authScheme: "API_KEY", + checkable: true, + composioConfigured: true, + confirms: true, + fields: [PERPLEXITY_KEY], + recorded: true, + verified: false, + verifiedAt: null, + probe: null, + }); + + const admin = renderAdminScreen(queryClient()); + + expect( + await admin.findByText(/accepted without being checked against Gmail/), + ).toBeTruthy(); + expect(await admin.findByRole("button", { name: "Re-check" })).toBeTruthy(); +}); diff --git a/app/tests/brokered-connection-card.test.tsx b/app/tests/brokered-connection-card.test.tsx new file mode 100644 index 000000000..36f6e67f4 --- /dev/null +++ b/app/tests/brokered-connection-card.test.tsx @@ -0,0 +1,28 @@ +import { expect, test } from "bun:test"; +import { connectionKindFor } from "@/routes/_authed/admin/plugins/$key"; + +/** + * Which shape the connector page draws, decided from the row rather than guessed. + * + * The page used to fall back to `deployment-bearer` whenever the catalogue had nothing to say, and + * a brokered row is exactly the case with nothing to say: it is not a curated entry, so there is no + * `auth` to read. The guess then drew the one shape a brokered row cannot hold — a shared token + * pasted once for everybody — over the connector whose whole point is that each person connects + * their own account. + * + * `.tsx` because this imports a route module, which is JSX; see `agent-roster-error.test.tsx` for + * the same reason. Nothing here renders: the decision is a pure function precisely so that the one + * thing worth pinning can be pinned without a router, a query client or a document. + */ + +test("a Composio-provenance row is brokered, whatever the catalogue says", () => { + expect( + connectionKindFor({ provenance: "composio" } as never, undefined), + ).toBe("brokered"); +}); + +test("a server added by URL still falls back to the shared-token shape", () => { + expect(connectionKindFor({ provenance: "custom" } as never, undefined)).toBe( + "deployment-bearer", + ); +}); diff --git a/app/tests/composio-picker.test.tsx b/app/tests/composio-picker.test.tsx new file mode 100644 index 000000000..d61747b05 --- /dev/null +++ b/app/tests/composio-picker.test.tsx @@ -0,0 +1,79 @@ +import { expect, test } from "bun:test"; +import type { ComposioApp } from "@/lib/plugins/queries"; +import { matchingApps } from "@/routes/_authed/admin/plugins/composio"; + +/** + * What the Composio picker lists, decided without drawing anything. + * + * The screen reads a directory of a few hundred apps, some of which this deployment already has. + * Two facts about that list are worth pinning: the order it is read in, and that the two fields a + * decision actually rests on — whether the app is already here, and how much it brings with it — + * arrive at the row intact. + * + * A `.tsx` file because it imports a route module, which is JSX; the precedent is + * `agent-roster-error.test.tsx`. Nothing here renders, though — `matchingApps` is exported as a + * function rather than left inline in the map for exactly that reason, so the ordering and the + * already-added marker can be asserted without a DOM, a router or a query client. + */ + +/** A minimal but complete `ComposioApp`, overridable per case. */ +function app(overrides: Partial & { slug: string }): ComposioApp { + return { + name: "App", + description: "Does something.", + logo: null, + categories: [], + actionCount: 1, + enabled: false, + ...overrides, + }; +} + +const SLACK = app({ + slug: "slack", + name: "Slack", + description: "Messages, channels and files.", + actionCount: 167, + enabled: true, +}); + +const GMAIL = app({ + slug: "gmail", + name: "Gmail", + description: "Mail, threads and labels.", + actionCount: 42, +}); + +test("an app this deployment already has is offered as added, not as one more thing to add", () => { + const listed = matchingApps([SLACK, GMAIL]); + + // Once, and carrying the flag the row branches on. An app that came back enabled and lost it + // would draw a second Add button for something already here, and pressing it would ask the + // server to add a duplicate. + const slack = listed.filter((entry) => entry.slug === "slack"); + expect(slack).toHaveLength(1); + expect(slack[0]?.enabled).toBe(true); + + // The other side of the same claim: an app that is genuinely not here stays addable. + expect(listed.find((entry) => entry.slug === "gmail")?.enabled).toBe(false); +}); + +test("the size of the decision survives, however large the app", () => { + const listed = matchingApps([SLACK, GMAIL]); + + // 167 is the real count on Slack, and it is the whole reason the row states one: an app is not + // a small thing to switch on, and the number is what says so before anybody presses Add. + expect(listed.find((entry) => entry.slug === "slack")?.actionCount).toBe(167); + expect(listed.find((entry) => entry.slug === "gmail")?.actionCount).toBe(42); +}); + +test("the directory is listed by name, and the vendor's own order is left alone", () => { + const apps = [SLACK, GMAIL]; + const listed = matchingApps(apps); + + expect(listed.map((entry) => entry.name)).toEqual(["Gmail", "Slack"]); + // A copy: the query's cached array is the same object every render, and sorting it in place + // would rewrite what TanStack Query holds. + expect(apps.map((entry) => entry.name)).toEqual(["Slack", "Gmail"]); + expect(listed).not.toBe(apps); +}); diff --git a/app/tests/preload.ts b/app/tests/preload.ts new file mode 100644 index 000000000..4825c03b7 --- /dev/null +++ b/app/tests/preload.ts @@ -0,0 +1,28 @@ +/** + * Loaded before any test file, so a component that portals can be rendered at all. + * + * Base UI decides ONCE, while its module is first evaluated, whether its isomorphic layout effect + * is `useLayoutEffect` or a no-op: + * + * export const useIsoLayoutEffect = typeof document !== 'undefined' ? useLayoutEffect : noop; + * + * Every DOM test here registers happy-dom in its own `beforeAll`, which is far too late: bun walks + * all the test files into one process, so whichever of them imports a route first pulls Base UI in + * while `document` is still undefined, and the no-op is what every later file gets. A portal needs + * that effect to resolve its container — so `Dialog` mounts nothing, forever, and a test that opens + * one is left asserting against an empty `` with no error to explain it. + * + * Worse, it decided that by import order. The same test passed on its own and failed in the suite, + * which is the failure `test-preload.ts` exists to stop in the other direction. + * + * So the DOM is registered here, that one module is evaluated against it, and the DOM is taken away + * again. Nothing else is left holding browser globals: server and worker tests run exactly as + * before, and each DOM test file still registers and unregisters its own happy-dom. + */ + +import { GlobalRegistrator } from "@happy-dom/global-registrator"; + +GlobalRegistrator.register(); +/* `@base-ui/utils` is not a dependency of this package; the react package that owns it is. */ +await import("@base-ui/react/dialog"); +GlobalRegistrator.unregister(); diff --git a/bun.lock b/bun.lock index b116c2a30..bf28de4f6 100644 --- a/bun.lock +++ b/bun.lock @@ -66,6 +66,7 @@ "@ag-ui/client": "0.0.59", "@better-auth/drizzle-adapter": "^1.7.1", "@better-auth/sso": "^1.7.1", + "@composio/core": "^0.18.1", "@copilotkit/runtime": "1.70.1", "@modelcontextprotocol/sdk": "^1.30.0", "better-auth": "^1.7.1", @@ -264,6 +265,12 @@ "@chevrotain/utils": ["@chevrotain/utils@11.0.3", "", {}, "sha512-YslZMgtJUyuMbZ+aKvfF3x1f5liK4mWNxghFRv7jqRR9C3R3fAOGTTKvxXDa2Y1s9zSbcpuO0cAxDYsc9SrXoQ=="], + "@composio/client": ["@composio/client@0.1.0-alpha.76", "", {}, "sha512-MXC5JGRVdiQ4EgLricy9o/mqBa1+1T7wHFZ6Q4ZJkrjzZqOMvxTgy21Zlb5J/1oGkB2bg9UDzpH8PkXCp/D4zA=="], + + "@composio/core": ["@composio/core@0.18.1", "", { "dependencies": { "@composio/client": "0.1.0-alpha.76", "@composio/json-schema-to-zod": "0.3.2", "@types/json-schema": "^7.0.15", "is-fs-case-sensitive": "^2.0.0", "openai": "^7.2.0", "picocolors": "^1.1.1", "pusher-js": "^8.6.0", "semver": "^7.8.5", "undici": "^7.29.0", "zod-to-json-schema": "^3.25.2" }, "peerDependencies": { "zod": ">=3.25.76 <5" } }, "sha512-VEg6F92cMG/4uhRfX6O4SRd+0Pnd4L67tF+Az/EXQ/+aPfDasW5GMTzkiSUSArGDyiMfEsLUYaTYizfnEw2usw=="], + + "@composio/json-schema-to-zod": ["@composio/json-schema-to-zod@0.3.2", "", { "dependencies": { "@cfworker/json-schema": "^4.1.1", "dequal": "^2.0.3" }, "peerDependencies": { "zod": ">=3.25.76 <5" } }, "sha512-TUUzu4uQH6esokVd6LD0SK4oIz8WaYRtU0cnuj0rmxbiHS0VbhN1qdvj2gIvrIWDdNyvQUIalO63YT/lNx46Gw=="], + "@copilotkit/a2ui-renderer": ["@copilotkit/a2ui-renderer@1.70.1", "", { "dependencies": { "@a2ui/web_core": "0.10.4", "clsx": "^2.1.1", "lit": "^3.3.2", "zod": "^3.25.75", "zod-to-json-schema": "^3.24.1" }, "peerDependencies": { "react": "^18 || ^19 || ^19.0.0-rc", "react-dom": "^18 || ^19 || ^19.0.0-rc" }, "optionalPeers": ["react", "react-dom"] }, "sha512-YLNNst0ll2A5zjjZHgPSWqLxKOR2b8e5j4IZDnN0lwtJVgRV1NBd2Rzp7XGb+uU2+Kq1jOF9rCGfKBPuw9JtGQ=="], "@copilotkit/aimock": ["@copilotkit/aimock@1.39.0", "", { "peerDependencies": { "jest": ">=29", "vitest": ">=3" }, "optionalPeers": ["jest", "vitest"], "bin": { "aimock": "dist/aimock-cli.js", "llmock": "dist/cli.js" } }, "sha512-AWw4vmW2hBchHoggh0G4McWGmGZD6wtXAehL6K5ncWF5lVIjlv++bPmxmRwrpQCi/K4/xK10N9Zp9srJYipEJw=="], @@ -1428,6 +1435,8 @@ "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], + "is-fs-case-sensitive": ["is-fs-case-sensitive@2.0.0", "", {}, "sha512-JoCsyGITdYPM+pUbeMQ4IiEuQ4wjPdeWORlG7n644isewbcxiQIr+9gmF5k7UabTP/jLBRkbgydEamR7JZBKHA=="], + "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], "is-hexadecimal": ["is-hexadecimal@2.0.1", "", {}, "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg=="], @@ -1872,6 +1881,8 @@ "pump": ["pump@3.0.4", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA=="], + "pusher-js": ["pusher-js@8.6.0", "", { "dependencies": { "tweetnacl": "^1.0.3" } }, "sha512-wShJPfCS/kYkCBVzVW67wa9cnQIgHTszEK2XHNrFkOgGruuGw081aERAxfRjfdFU+WcIt8x6dvbwkTW4iZuQ8Q=="], + "qs": ["qs@6.15.3", "", { "dependencies": { "es-define-property": "^1.0.1", "side-channel": "^1.1.1" } }, "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A=="], "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], @@ -1988,7 +1999,7 @@ "secure-json-parse": ["secure-json-parse@2.7.0", "", {}, "sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw=="], - "semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], "send": ["send@0.19.2", "", { "dependencies": { "debug": "2.6.9", "depd": "2.0.0", "destroy": "1.2.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", "fresh": "~0.5.2", "http-errors": "~2.0.1", "mime": "1.6.0", "ms": "2.1.3", "on-finished": "~2.4.1", "range-parser": "~1.2.1", "statuses": "~2.0.2" } }, "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg=="], @@ -2118,6 +2129,8 @@ "tw-animate-css": ["tw-animate-css@1.4.0", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="], + "tweetnacl": ["tweetnacl@1.0.3", "", {}, "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw=="], + "type-graphql": ["type-graphql@2.0.0-rc.1", "", { "dependencies": { "@graphql-yoga/subscription": "^5.0.0", "@types/node": "*", "@types/semver": "^7.5.6", "graphql-query-complexity": "^0.12.0", "semver": "^7.5.4", "tslib": "^2.6.2" }, "peerDependencies": { "class-validator": ">=0.14.0", "graphql": "^16.8.1", "graphql-scalars": "^1.22.4" }, "optionalPeers": ["class-validator"] }, "sha512-HCu4j3jR0tZvAAoO7DMBT3MRmah0DFRe5APymm9lXUghXA0sbhiMf6SLRafRYfk0R0KiUQYRduuGP3ap1RnF1Q=="], "type-is": ["type-is@1.6.18", "", { "dependencies": { "media-typer": "0.3.0", "mime-types": "~2.1.24" } }, "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g=="], @@ -2266,8 +2279,14 @@ "@authenio/xml-encryption/xpath": ["xpath@0.0.32", "", {}, "sha512-rxMJhSIoiO8vXcWvSifKqhvV96GjiD5wYb8/QHdoRyQvraTpp4IEv944nhGausZZ3u7dhQXteZuZbaqfpB7uYw=="], + "@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], + "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "@babel/helper-create-class-features-plugin/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "@copilotkit/a2ui-renderer/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], "@copilotkit/channels-slack/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], @@ -2366,8 +2385,6 @@ "conf/json-schema-typed": ["json-schema-typed@7.0.3", "", {}, "sha512-7DE8mpG+/fVw+dTpjbxnx47TaMnDfOI1jwft9g1VybltZCduyRQPJPvc+zzKY9WPHxhPWczyFuYa6I8Mw4iU5A=="], - "conf/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], - "cross-spawn/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], "cytoscape-fcose/cose-base": ["cose-base@2.2.0", "", { "dependencies": { "layout-base": "^2.0.0" } }, "sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g=="], @@ -2452,8 +2469,6 @@ "is-inside-container/is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="], - "jsonwebtoken/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], - "katex/commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="], "log-symbols/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], @@ -2544,8 +2559,6 @@ "style-to-js/style-to-object": ["style-to-object@1.0.14", "", { "dependencies": { "inline-style-parser": "0.2.7" } }, "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw=="], - "type-graphql/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], - "type-is/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], "unified/@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], diff --git a/bunfig.toml b/bunfig.toml index b599e7d8d..749ce9cfd 100644 --- a/bunfig.toml +++ b/bunfig.toml @@ -1,4 +1,6 @@ [test] -# See server/scripts/test-preload.ts: makes the module graph deterministic so a test file cannot fail to -# import depending on the order the suite happened to be walked. -preload = ["./server/scripts/test-preload.ts"] +# Both of these make the module graph deterministic, so a test file cannot pass or fail depending on +# the order the suite happened to be walked. See server/scripts/test-preload.ts, which evaluates an +# ESM-only dependency before anything requires it, and app/tests/preload.ts, which evaluates Base UI +# while a DOM exists so its layout effect is not a no-op for every file that follows. +preload = ["./server/scripts/test-preload.ts", "./app/tests/preload.ts"] diff --git a/docs/README.md b/docs/README.md index 6b7c66687..bc726bc9a 100644 --- a/docs/README.md +++ b/docs/README.md @@ -8,6 +8,7 @@ Start with the root [README](../README.md), then use these references: - [Coworkers](coworkers.md): durable Bot profiles, channels, visibility, deletion, and external AG-UI registration. - [Routines](routines.md): standing instructions a Bot runs on a schedule, the worker that fires them, and who they run as. - Plugins, one connector per page — what an administrator registers, what each person consents to, and what the failures mean: + - [Composio](plugins/composio.md): the broker, and so the one page here that is a catalogue of apps rather than a single connector. - [Google Drive](plugins/google-drive.md) - [Notion](plugins/notion.md) - [Deployment](deployment.md): the container, what is in the image, minimum sizes, and the platform notes. diff --git a/docs/configuration.md b/docs/configuration.md index f7053e3f3..bd14a428d 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -59,6 +59,7 @@ at `agent-langgraph` on a laptop. | `AUDIT_RETENTION_DAYS` | unset | Whole number of days to keep audit rows; older ones are removed. Unset keeps the trail forever. | | `WORKER_SHARED_SECRET` | unset; `start.sh` uses a fixed local default | The secret the routines worker presents to fire a due routine. Without it the server refuses every handoff, whether or not a worker exists to send one. | | `OPENBOT_GENERATIVE_UI` | unset (capability off) | `true` or `1` lets a Bot answer with an interface it wrote itself. | +| `COMPOSIO_API_KEY` | unset | One key for the whole deployment, for the broker that holds people's accounts for a few hundred apps. Unset, there is nothing to connect, nothing to grant and no Composio tool for a Bot to call; what remains is one row that goes nowhere, under **More apps** on the admin Plugins page, naming this variable. See [Composio](plugins/composio.md). | **`OPENBOT_GENERATIVE_UI`** turns on generated interfaces. Set it, and a Bot may answer by writing the markup, styles and script for an interface and streaming it into the transcript, where it renders @@ -225,6 +226,8 @@ where `` is `google`, `microsoft` or `okta`. `OPENBOT_APP_URL` is where the callback sends the person afterwards. It is a separate setting because the app and the API are separate addresses: locally the app is Vite on `3010` and the API is `3001`, so a relative redirect would land on the API, which serves no pages. A deployment serving both from one origin can leave it unset. +A [Composio](plugins/composio.md) app needs `OPENBOT_APP_URL` and nothing else of the two: the consent lives at the broker, so no redirect URI of ours is registered anywhere, but the address Composio returns somebody to has to be absolute and this is where it comes from. Connecting a brokered account refuses where it resolves to nothing, rather than sending somebody to a consent screen with no way back. + ## One Bot handing work to another | Variable | Meaning | diff --git a/docs/plugins/composio.md b/docs/plugins/composio.md new file mode 100644 index 000000000..c50bf69e5 --- /dev/null +++ b/docs/plugins/composio.md @@ -0,0 +1,511 @@ +# Composio + +Composio is a broker. It holds people's accounts for a few hundred apps — Slack, Linear, Gmail, +HubSpot and the long tail behind them — and publishes each app's actions as tools this deployment +can call on somebody's behalf. There is no OAuth client to register, no secret to paste and no +redirect URI to match character for character, because the account does not live here: the person +consents to Composio, and Composio holds what comes back. A Bot with a brokered action granted +reaches the app **as the person asking**, the same as every other per-person connector here, so two +people asking the same question get the answers their own accounts can see. + +Setting it up takes three hands, and none of them can do another's: + +| Who | Does | Where | +| ---------------- | -------------------------------------- | ---------------------------------------------------------------- | +| Whoever deploys | Sets `COMPOSIO_API_KEY` | The deployment's environment. There is no screen for it | +| An administrator | Enables an app | `/admin/plugins/composio` | +| 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` | + +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 +`/admin/plugins/composio` is where a key that is already set gets used rather than where one is set. + +There is deliberately no endpoint for an administrator to connect an account on somebody's behalf. + +## The key + +`COMPOSIO_API_KEY` is one key for the whole deployment, and it is optional. It is the only Composio +setting there is — nothing per app, nothing per person. One key does not mean one shared account: +Composio keeps people apart by a user id sent with every call, so one person's connections are never +reachable from another's. + +Nothing validates the key at startup. There is no shape to check it against and no call worth making +at boot to find out, so the first real request is what says whether it works. `composio:smoke` below +is how an operator asks that question deliberately rather than by watching somebody else fail. + +Unset is a supported, fully described state rather than a degraded one: it is what every deployment +is today. What is on screen with no key is exactly one row, on `/admin/plugins` under the **More +apps** heading, titled *Composio* and reading *Add your Composio key to enable a catalogue of tools. +Set COMPOSIO_API_KEY on this deployment.* It has no chevron and it is not a link, because there is +nowhere to go until the key is set. It names the setting rather than hiding the feature, so an +administrator who has heard of Composio can find out what it wants — and because the heading above +it stays too, the handful of reviewed connectors does not read as the whole story. + +That row is the whole of it. There is no directory to browse, no picker, no brokered app on +anybody's connected-accounts page, and no Composio tool for a Bot to call. + +An app enabled while a key was set and then left without one keeps its row and its grants. Its page +says the key is missing, and its calls refuse with a sentence saying the same — distinguished, as +everywhere else here, from "the app advertises nothing". + +### Asking what this key can see + +``` +COMPOSIO_API_KEY=... bun run composio:smoke -- --user [--call] +``` + +`scripts/composio-smoke.ts` is the one command to run when Composio misbehaves and nothing on screen +says why. It ships inside the container image, so it is run where the key is — in the deployment, +not on a laptop — and it answers the question no page can: whether *this* key opens *this* project. A +key with no project behind it, a project with no authorization config for the app, and a person who +never finished the consent page all leave a product that looks configured and answers nothing. + +**It separates the key from the person, which is two of those three and not all of them.** The +catalogue read answers for the key: a key Composio rejects, and a project whose catalogue does not +carry the app, both stop the run with the vendor's own sentence. The connection read answers for the +person. What it does not separate is the authorization config — an app no administrator has enabled +here has no config for anybody to connect against, so it reads exactly like a person who never +finished consenting, and the no-connection line says so rather than blaming the person. Check the +app's page under `/admin/plugins` to tell those two apart; enabling an app is what creates the +config. Making the script itself distinguish them would need a read-only auth-config listing on the +broker seam, which does not exist yet — the seam has `ensureAuthConfig` and `deleteAuthConfig`, both +of which write, and a read-only diagnostic must not create the object it was asked to look for. + +**It names every app's resolved connection kind, and tallies the kinds, because a silent failure +lives exactly there.** One line per app carries the slug, the kind this deployment resolved it to +and its action count — and for a `fields` app the scheme too, `fields: API_KEY`, because which of +API_KEY, BASIC, BEARER_TOKEN or BASIC_WITH_JWT an app resolved to decides what the connect form +asks somebody to type. The kind is read off the catalogue row rather than derived a second time +here, so a diagnostic can never disagree with the product about how an app connects. Under the +lines is a tally: `Kinds: consent 121, self-registering 86, fields 1243, no auth 34, unsupported +56.` Every known kind is seeded at zero so the zeros print, which is the whole point of it — +resolution reads a malformed `auth_schemes` as an empty list, so a vendor renaming or reshaping +that field resolves *every* app to `unsupported`, and the picker hides unsupported apps. From +every other angle that failure says nothing: the catalogue lists its usual four figures, the +directory answers 200, not one app is offered. `Kinds: consent 0, self-registering 0, fields 0, +no auth 0, unsupported 1540` is that state, and it reads as one without anybody scrolling. The +per-app lines are what to grep afterwards for the app the report was run about. + +It is all reads. It mints no connect link and starts no session, because a link is a bearer +capability and a diagnostic that printed one would leave somebody's mailbox in a terminal scrollback. +The key is never printed either: every line goes out through a redactor, including the vendor's own +sentences, which are the only lines carrying text nobody here wrote. `--user` takes the id this +deployment sends Composio as the person a call is for — the same id `composio_connections` records. +`--call` additionally runs one read-only action, `GMAIL_GET_PROFILE`, and only after checking +Composio's own behaviour label at call time rather than trusting the name. + +**Which stream a line goes to is decided by the exit code it explains.** A line that explains a +non-zero exit is written to stderr; every other line is written to stdout. So `… > report.txt` keeps +a report of what the key can see while every reason the command failed is still on the terminal +beside it — and an action that ran and failed puts its outcome and its log id on stderr, because +those two lines are the whole explanation of the `1` it exits with. The exit codes are `0` for a run +that finished, `1` for a run that stopped, and `2` for a missing `--user`. + +## What an administrator does + +### 1. Find the app, and read its action count + +At `/admin/plugins/composio`, search Composio's directory. The directory is read to its end — page +by page, following Composio's own cursor — and searched in this process rather than at the vendor, +so what is on screen is a whole listing and not a page of one. + +**It is the whole CONNECTABLE listing, which is not the whole catalogue.** The apps that want an +OAuth application registered by whoever runs this deployment are filtered out before the search +runs: 56 of the 1540 apps Composio published on 2026-09-13, hidden because there is nowhere here to +put a client id and secret, and an **Add** button that could only ever meet a refusal is worse than +an app that is honestly absent. Every other kind is offered. What each of those kinds asks, and of +whom, is the section after this one. + +Each row carries the app's **action count**, and that number is worth reading before pressing +**Add**. Slack publishes 167 actions, 73 of them reads — several times more than a model handles +well. There is no cap: grants remain the only ceiling, so a large app is possible and merely never +accidental. + +### 2. Enable the app + +**Add** takes the slug, which has to be one the directory itself answered with — a slug that arrived +from a caller and was written into a row's url would become the app every future call runs in. + +**The authorization config is created here, not on somebody's first click, and it is created +first.** The SDK's own one-call shortcut would have made one on demand, at Composio's managed +defaults and under a name of its choosing, the first time any person pressed Connect. Creating it at +enable time, named for this deployment, makes it an object an operator can see in their Composio +dashboard from the moment the app exists — and tighten there, without a code change. It comes before +anything is written here, so a failure leaves no row behind and pressing the button again is the +whole recovery. Which KIND of config is created depends on how the app connects, and the app that +needs no authentication gets none at all, because Composio refuses to hold one for it — that is the +next section. + +An account is then attached **against that config**, whether by a link minted for a consent screen +or by a key somebody types, which is why nothing mints a config later: an app whose config was +deleted at the dashboard refuses at Connect, naming the administrator's step, rather than quietly +acquiring a second one that nobody here named or can find. + +Then one ordinary `mcp_servers` row — id `composio-`, url `composio://`, provenance +`composio`, vendor Composio, title from the directory, and no credential of any kind — and then the +app's actions, recorded with each one's effect, destructive marker and version, so a bad key is +reported to the administrator who just pressed the button rather than the first time a Bot calls +something. The audit row is the existing `configuration.changed` / `mcp_server_added`, marked +`provenance: "composio"`. + +Nothing arrives switched on. Enabling an app names no Bot, and a switch drawn in the on position for +a grant nobody made is the one thing this codebase is most consistently careful about. + +### 3. Grant actions to a Bot + +Enabling the app gives no Bot access to it. From the app's page at `/admin/plugins/composio-`, +open one Bot to get a screen listing every action with a switch each — searchable, split into reads +and writes, with *turn on every read-only action* as the one bulk action, which says how many tools +that Bot will then carry before it does it. Every call then checks the grant, evaluates the action +policy, and writes an audit row. + +A destructive action renders as danger. Nothing renders as reassurance: an action that does not +claim to be destructive is not claiming to be safe, so the absence of the marker is drawn plain, +never as green. + +## The four ways an app connects + +Composio's catalogue is not one flow wearing one name. Some apps end at a consent screen the person +has seen a hundred times; most end at a box asking for an API key they have to go and find; a few +ask nothing of anybody at all. Measured against the live catalogue on 2026-09-13, 1540 apps: + +| Apps | How it connects | What it asks, and of whom | +| ---: | ----------------------------------------------- | -------------------------------------------------------------- | +| 121 | Composio's own consent screen | Nothing of anybody here. Composio holds the credentials | +| 86 | OAuth that registers itself | Nothing of anybody at all. A client is minted during consent | +| 1243 | A secret the person already holds | One to three boxes, typed by the person connecting | +| 34 | No authentication at all | Nothing, ever. There is no account to make | +| 56 | An OAuth application registered by the operator | Not offered here at all — see the last section below | + +**Only the first row of that table used to work.** Every authorization config was created as +`use_composio_managed_auth`, which is the right object for the apps Composio itself holds developer +credentials with and the wrong one for everything else. For a self-registering app the failure was +loud — Composio has no client of its own to manage, so it answered 404 and the app simply could not +be added. An app that needs no authentication failed in the vendor's own words, because Composio +will not hold a config for one at all. And for a key app it was quiet and worse: the config was +accepted, and every person enabled onto it was then sent to a consent screen that had nothing to ask +them for. + +**Which kind an app is, is derived once and then recorded.** The derivation reads what the catalogue +already publishes — `no_auth`, `composio_managed_auth_schemes`, `auth_schemes` — and the picker and +the connect screen both read that one answer rather than each guessing for themselves. The order is +`no_auth` first, and that is not a preference: Composio refuses outright to hold an authorization +config for an app that needs none, so nothing else an app publishes beside it can be acted on. Then +managed OAuth, because it asks the person for nothing; then self-registering OAuth, which asks +nobody for anything; then a scheme whose secret the person already holds. Linear publishes managed +OAuth *and* an API key, and resolves to the consent flow for exactly that reason. + +The resolved answer is written onto the app's row (`mcp_servers.auth_scheme`) when an administrator +enables it, and every later step — the form, the connect call, the disconnect sentence, the call +gate — reads the row rather than the catalogue. A connection is a lasting attachment to the +authorization config it was made against, so a vendor that starts publishing a new scheme for an app +next month must not move live connections onto a different flow. Pressing **Add** again therefore +does not rewrite the scheme, with one exception: where nobody has connected there is nothing to +strand, so the rewrite happens and is how an operator picks up a vendor's change without removing +the app. + +### Composio's consent screen, and an OAuth app that registers itself + +These two are one flow from here, which is why the earlier sections describe them without +distinguishing them: a link is minted, the person leaves for a page at Composio, and they come back +to a page this deployment chose. Nobody types anything and nobody registers anything. + +They differ only in the object created at enable time. A managed app gets +`use_composio_managed_auth` and rides on the developer app Composio registered with the vendor. A +self-registering app gets `use_custom_auth` with `DCR_OAUTH` **and no credentials at all**, because +there are none to hold: the client is registered with the vendor at the moment somebody consents. +The 86 apps in that row need credentials from nobody — not from Composio, not from whoever runs this +deployment — and they were unreachable here purely because the wrong kind of config was being asked +for. + +### A secret the person already holds + +This is most of the catalogue, and it is the kind that has no consent screen in it. **Connect** on a +key app does not leave OpenBot: it asks Composio what the app wants, draws those boxes, and the +press after that carries what was typed in them. + +The boxes are the app's own. Their names, labels, help text, defaults and which of them are secret +are published per app by Composio and used verbatim — one key for most apps, a key and a workspace +subdomain for Shopify, two values for Firecrawl — and the names are sent back exactly as they came, +because a name renamed on the way through is a box somebody filled in that no app ever reads. A +field Composio marks as not user-visible is not drawn. A field of any type other than text, or one +with no name at all, is a refusal rather than a box drawn blind: somebody typing a path into a box +labelled *Certificate* and being told they are connected is the failure that guard exists to stop. +A submitted name the app does not publish is refused too, rather than dropped — a stale form +connected with the half that still matches is an account every screen here draws as working. + +**What is typed in is held by Composio and never by this deployment.** The values arrive on one +request, travel to Composio in the next call, and are gone when the handler returns. They are not +written to a table — `composio_connections` goes on being a row that names an app and a person and +nothing else — not to a log line, not into an error body, and not into the audit row, which records +the field *names* that were filled and never a value. The one rule this connector reverses for them +is its own: everywhere else a vendor's thrown object is carried along as `cause` because it holds +the request it was made for, and on this one call that request is somebody's key, so the adapter +reads the vendor's sentence and drops the object entirely. What that costs is the diagnostic trail +on the flow people most often mistype, and the cost is taken knowingly: what an operator gets is +Composio's own sentence and the request id inside it, which is what Composio's dashboard searches +on. + +### A key is checked once, and the page says when + +**Composio does not grade a submitted key.** A connection created with an obviously wrong value +comes back `ACTIVE`, and stays `ACTIVE` forever after. Left there, "connected" would mean "typed +something", and the first failure would arrive hours later inside a Bot's answer to somebody. + +So a key connection is followed by exactly one call: a read-only, argument-less action the app +itself publishes, chosen by this deployment from the metadata recorded when the app was enabled and +never from anything a request said. Both conditions are load-bearing and neither implies the other. +Read, because a probe must not change anything — and "read" here means Composio labelled the action +`readOnlyHint`, since everything unlabelled is recorded as a write. Argument-less, because at that +moment nothing is known about the account beyond the key, so any required argument would have to be +invented, and an invented one turns *is this key good* into *does this identifier exist*. The two +together are not belt and braces: the first argument-less action on Stripe's own list is +`STRIPE_CREATE_BILLING_METER_EVENT_SESSION`, so a probe chosen on "takes no arguments" alone would +write to somebody's account to find out whether their key works. An identity-shaped name is +preferred where the app publishes one, and most apps publish some other safe read instead. + +It is the only call in this deployment that reaches a vendor without a Bot, a grant check, a policy +evaluation, content inspection or an `mcp.call_*` row, because there is no Bot to check a grant for. +What keeps it narrow is structure rather than care. It has exactly two callers — the connect step, +and a person pressing **Re-check** on their own connection — and neither reads a person out of a +request body: the account probed is the session's own. The action is chosen by this deployment from +recorded metadata and never from anything a request named, and it carries no arguments, which is +also what leaves content inspection nothing to inspect. A probe that ran leaves an +`mcp.connection_verified` row naming the action and the verdict, so the calls that happened are +readable; the single exception is a bad key on a first connection, which is undone completely — +no account, no row, nothing for anybody to do — and files nothing, so that the rows which do exist +keep meaning *something is still standing here*. The consequence to accept knowingly is that this is +the first vendor call in this deployment attributable to a person rather than to a Bot, and queries +over that trail were written assuming otherwise. + +What the page then says is the moment it last looked, never a present tense: *Connected with a key +you provided, last checked 13 Sep.* Three outcomes are possible and the page keeps them apart. The +probe ran and answered, and the row is verified as of that instant. The probe ran and the vendor +refused, and the account this connect just made is deleted at Composio **by its id** — not by app, +because ending every account somebody holds for an app is what disconnect means and the intent here +is only to undo what just happened — and nothing is recorded. Or there was nothing to try — the app +publishes no action that passes both conditions, or none at a version this deployment recorded — in +which case the key is kept and the page says it was accepted without being checked, which is the +honest sentence and not an apology for one. A person with a perfectly good key must not be told the +vendor rejected it because an app's listing was thin. + +There is one state worse than those, and it has a sentence of its own for that reason: a key the +vendor refused, over an account that is still standing at Composio. Two paths arrive at it and they +arrive for different reasons. A connect whose probe failed tried to withdraw the account it had just +made and Composio would not take it back — and leaving no row then would not mean nothing was left +behind, it would mean a live account nothing on any screen names and the person cannot disconnect, +because disconnect works off the row. A re-check the vendor refuses never tried to withdraw +anything, deliberately: that account predates the press and is the person's own, so taking it away +in order to report a bad key would destroy the thing they came to repair. + +Either way the row is written unverified and the person is told all three facts rather than left to +conclude their key might be fine: *Your key was checked against Perplexity and rejected, and the +account it was checked in still stands at Composio, so disconnect it here, or fix the key at +Perplexity and press Re-check.* What the sentence does not do is say **why** the account stands, +and the omission is deliberate rather than vague. Blaming a failed withdrawal is true on the connect +path and false on the re-check, where nothing ever attempted a removal; the standing account is the +actionable half on both, and which path wrote the row is not recoverable from it afterwards. Both +ways out are named because neither is obvious from a row that still reads "Connected": the account +ends with the button beside the line, and a key corrected at the vendor is worth a second check +rather than a second connection. An audit row naming the action that was tried records the same +state for whoever reads the trail a week later, because a sentence one person read once outlives +nothing. + +**Which of the three a row is in is a fact about the action a check actually spent, and the page +keeps it across a reload.** The flag alone cannot say: `false` is both *this app published nothing +safe to try a key on* and *your key was checked and rejected and the account is still standing*. +So the connections read carries two fields beside the flag and the date, and they answer two +different questions. `probe` is the action the last check SPENT, written down by that check and read +back off the row — a fact about the past, and what the row's sentence is drawn from. `checkable` is +whether the app publishes anything safe to spend a key on TODAY, asked of the app and kept as a yes +or no — a fact about the present, and what the Re-check button is drawn from. Neither is a weaker +spelling of the other, and a screen that asks either of them the other's question breaks in the way +the other field exists to prevent. A re-check or a key just handed over names the action it was +actually spent on, and that answer wins over the recorded one, because an answer is a newer record +of the same thing and a check that has just run must not be overruled by a read taken before it. A +re-check the vendor refuses is not an answer at all: it is raised, and Composio's own sentence for +it reaches the person as a refusal rather than as a row that quietly changed its wording. + +While the name was derived on every read, an administrator's Refresh could rewrite what a check had +found. Somebody connects a key to an app that publishes nothing safe to try it on, and the row +honestly says the key was accepted unchecked. Then an administrator presses Refresh, which is the +very press this transport tells them to make when an action appears or gains the version that makes +it callable. The derivation names an action, and from that page load on the row draws the sentence +written for a REFUSED key: checked and rejected, the account it was checked in still standing, so +disconnect it. Every clause of that is false for somebody whose key nobody had touched, it tells +them to take down a connection that works, and it persists until they press Re-check. The recorded +column is what puts that state out of reach: what a check spent is written by the check, and no +later reading of today's metadata can move it. + +**Nothing re-checks on page load.** That call is spent on the person's own account and against their +own rate limit at the vendor, so verifying on every render would burn somebody's quota at Linear to +redraw one word on a page they were passing through. **Re-check** is a button, and it runs the same +probe against the account that already exists: it creates nothing, and a key the vendor rejects +leaves their account alone — it is the key that is wrong, and taking the account away would destroy +the thing they are trying to repair. It is drawn on a key connection and on nothing else, and the +one state it is withheld in is an app with nothing to check a key against today, where pressing it +could only ask for the same answer again. That question is asked of the app and never of the record: +a key accepted when the app published nothing records no action, permanently and correctly, and a +button withheld on that record stays withheld even after a Refresh has given the app something to +try — while pressing that button is the only thing in the product that could ever put an action into +the record. A key the vendor has just rejected keeps its button, because that is the person most +likely to have gone and fixed something. + +### Disconnecting a key does not end it at the vendor + +Disconnect promises that an account ends at Composio and not only here, and for a consent app that +is the whole truth: the grant dies at the provider. For a key app there is nothing upstream to end. +The account goes at Composio, and the key is still live at the vendor and still works for anyone +holding it — including whoever else it was already pasted into. So the sentence differs and names +the step this deployment cannot take: *Removed from Composio. Your key still works at Perplexity — +rotate it there if you meant to end its access.* + +The audit row says the same thing in its own half of the sentence. `vendorRevocationRequested` is +`false` for a key connection by construction rather than by what the vendor found, because +`revoke_on_delete` asks a *provider* to end a grant and there is no grant behind a key to withdraw. +Recording otherwise would be the one thing that field exists not to do: claim a withdrawal nobody +asked for and nobody could have made. + +### An app that needs no authentication has no account, and the gate has to know + +Thirty-four apps need no authentication at all, and Composio refuses to hold an authorization config +for one of them — *"Cannot create an auth config for toolkit hackernews because it does not require +authentication."* Enabling such an app is therefore the `mcp_servers` row and its actions and +nothing else: no config, no consent, no account. Its row on a connected-accounts page says the app +needs none and carries no button, not even a disabled one, because there is no act to offer and a +greyed control announces a step somebody is missing when they are not. + +**The call gate had to learn about this, and the alternative was worse than it looks.** A brokered +call is decided on one row: `(toolkit, user_id)` in `composio_connections` is the whole of the +permission. A no-auth app can never have one, so the gate reads the app's recorded scheme beside the +row it already loads and lets a `NO_AUTH` app through with none. Writing a row anyway would have +been the easy fix and would have poisoned the table: every row in it means *this person granted this +deployment access to their account at this app*, and that is how offboarding, the audit trail and +disconnect all read it. Rows where nobody consented and no account exists are indistinguishable, a +year later, from rows where somebody did. + +### The 56 that are not offered + +Fifty-six apps want an OAuth application registered by whoever runs this deployment — a client id +and a client secret, obtained from each vendor in turn, per app. They are filtered out of the +picker, so an administrator never meets an **Add** button that cannot work; where one is reached +anyway, the refusal comes before anything is written and names what the app is asking for. + +They are hidden here rather than at the vendor, because the filter is a fact about what this +deployment can drive and not about what Composio publishes. **A screen to hold a client id and +secret for a brokered app is a deliberate non-goal, and this sentence exists so that its absence +does not read as an oversight.** The whole argument of this connector is that there is no OAuth +client to register and no secret to paste, and an app that requires both is asking for the thing the +broker was adopted to avoid. One app in thirteen was connectable before this change; these 56 are +what is left out now, and they are named here so that an operator who goes looking for a Slack-like +app and does not find it knows which question they are asking. + +## What each person does + +At `/settings/connected-accounts`, a brokered app appears beside the OAuth connectors once an +administrator has enabled it. Open it and press **Connect**. On a consent app — and on one whose +OAuth client registers itself, which is the same trip from here — that leaves OpenBot for Composio's +consent screen and returns to the same page, or, for an administrator who started from the app's own +page under `/admin/plugins`, back to that page, because leaving a page mid-task and being returned +to a different one is the round trip this exists to remove. On an app whose secret the person +already holds, **Connect** goes nowhere: it opens a form and asks for it. The rest of this section +is about the trip; the section above is about the form. + +**The address they come back to is built here, and a caller has no say in it.** It is this +deployment's `OPENBOT_APP_URL` plus one of two known pages, so what a request can choose is which +page and never which site: an address taken from a body or a query would be an open redirect with a +consent screen in front of it, which is the same reason this deployment's own OAuth flow narrows its +`returnTo` to a name. A deployment with no app URL configured has no absolute address to hand over +— the consent screen is on Composio's origin, so a relative one resolves against theirs — and +**Connect** refuses there, naming the setting, rather than minting a link that would strand somebody +on Composio's page having just granted access to their mailbox. + +**The link is yours alone.** It is minted for the session's own id and can be asked for on nobody +else's behalf. It is a bearer capability — whoever opens it binds *their* account to the id it was +minted for — so it is handed to the browser that asked and is never stored, logged, audited, or put +anywhere a second person could read it. Do not forward it. + +Coming back does not by itself grant anything. The return trip is an ordinary redirect with nothing +signed in it, so the row that lets calls through is written only after Composio confirms the account +is live. The page asks on return, and asks again on load, so a row that drifted heals. Composio is +the source of truth and the row here is a cache of it. + +**A failure at Composio arrives as Composio's own sentence.** A wrong key, a revoked one, an app +whose authorization config was deleted at the dashboard: each of those comes back as the one +sentence the vendor wrote — *Invalid API key provided.* — with everything that travelled beside it +left where it was. The key never appears anywhere, nor the connect link, nor the vendor's thrown +object, which is an entire HTTP response including headers and trace ids. Where the vendor reported +a failure and said nothing about it, the sentence is this deployment's own and names the step to +take rather than echoing a placeholder. + +**One account per person per app, and the second is refused.** Composio would happily hold several +accounts for one person and one app, but the call that runs an action names the person and not the +account — so with two Gmail accounts connected, which mailbox a Bot reads would be Composio's +choice, and neither the table here nor the audit row could say which one it was. Connect therefore +refuses while a live connection for that app already exists, and names the way to switch: *You +already have an account connected to Slack. Disconnect it first if you want to connect a different +one.* The app's own title, never the row's id. It is per app and nothing more — Gmail and Linear and Notion connected alongside each other are untouched. + +### Disconnecting + +**Disconnect**, on the same page, revokes at Composio first and deletes the row second. The account +ends at Composio, not just here — which, for an app whose secret somebody typed, is all it can end, +and the page says so rather than letting *disconnected* be read as *revoked*. Revoke-then-delete is +the ordering everywhere in this connector, so a failure between the two leaves access dead rather +than live and unreachable; pressing Disconnect again is the whole recovery. Audited as +`mcp.account_disconnected`. + +## 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 +while there was nothing to revoke with. Both now reach the broker. + +**Removing the app** revokes every person's connection to it at Composio, clears the rows, and then +deletes the authorization config that enabling created. For an app whose secret people typed, that +withdrawal again reaches only as far as Composio: their keys stay live at the vendor, and nobody is +told to rotate one, because nobody here is looking at the screen. Re-adding the app afterwards starts empty +rather than silently restoring everybody who had connected before. + +**Removing the person** revokes each of their brokered connections at Composio before clearing the +rows, and reports what was revoked. The `composio_connections` table is keyed on +`(toolkit, user_id)` and outlives the user record for exactly this reason — so offboarding can still +find the connection after the person is gone. + +## What the grants narrow, and what they do not + +The authorization config keeps Composio's default scopes. Narrowing them properly would mean +choosing scopes before anybody has been granted anything, which is backwards, so it is not done +pre-emptively. Stated plainly: + +> **The vendor-side grant is as wide as Composio's own app asks for, and this deployment's grants +> are the entire narrowing.** + +This page is where that is stated. The app's own screen does not repeat it — worth knowing, because +an operator who reads only the screen will not meet it. + +This is the position Notion is already in. What keeps a Bot's reach small is which actions are +switched on for it, and nothing at the vendor stands behind that. + +## Blast radius + +One vendor ends up holding every person's connection to every app — which is the deal any broker +offers, and should be chosen rather than discovered. + +## Not built yet + +**No approval step before a destructive action.** The destructive marker is recorded and now +visible, but it gates nothing: a Bot granted a destructive action performs it without anybody being +asked. That is the same position every other connector is in — but it is now a position reachable +through the UI rather than only through a database insert, which is a real change in exposure. + +**No way to give a Bot a whole large app to search.** A Bot carries the actions somebody switched on +for it, one at a time. There is no search-and-run path for an app too large to tick through, which +is why an app's action count is worth reading before it is enabled rather than afterwards. + +## See also + +- [Architecture](../architecture.md) — where plugins, grants, policy and audit sit. +- [Configuration](../configuration.md) — `COMPOSIO_API_KEY`, and that it is optional. +- [Notion](notion.md) and [Google Drive](google-drive.md) — the same per-person shape, with the + OAuth client registered here instead of held by a broker. diff --git a/package.json b/package.json index c365827c4..6022a63a6 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,9 @@ "pretest": "bun run generate:app-config", "test:smoke": "OPENBOT_SMOKE=1 bun test tests/smoke", "test:live-screen": "OPENBOT_LIVE_SCREEN=1 bun test agent-computer/tests/live-screen.test.ts agent-computer/tests/browser-close-announcement.test.ts", + "test:live-composio": "bun -e \"if (!process.env.COMPOSIO_API_KEY?.trim()) { console.error('COMPOSIO_API_KEY is empty or unset, so every case in this suite would skip and this command would still exit 0. Run it as COMPOSIO_API_KEY=... bun run test:live-composio.'); process.exit(1) }\" && OPENBOT_LIVE_COMPOSIO=1 bun test server/tests/composio-live.test.ts", "diagram": "bun scripts/architecture-diagram.ts", + "composio:smoke": "bun scripts/composio-smoke.ts", "mock:knowledge": "bun scripts/mock-knowledge-mcp.ts" }, "devDependencies": { diff --git a/scripts/composio-smoke.ts b/scripts/composio-smoke.ts new file mode 100644 index 000000000..0bda1920c --- /dev/null +++ b/scripts/composio-smoke.ts @@ -0,0 +1,566 @@ +/** + * Does this deployment's Composio key actually open Composio? + * + * `server/tests/composio-live.test.ts` asks what the vendor does; this asks what THIS KEY can see. + * They are different questions and only the second one is about an operator's own account: a key + * with no project behind it, a project that cannot see the app, or a person who never finished the + * consent page all produce a product that looks configured and answers nothing. So everything below + * is a read, and every read is one an operator would otherwise make by clicking around Composio's + * dashboard. + * + * IT SEPARATES TWO STATES AND NOT THREE, WHICH IS A CORRECTION TO WHAT THIS DOCBLOCK USED TO CLAIM. + * The promise was that a key with no project, an app with no authorization config, and an unfinished + * consent were told apart. Two of those are: the catalogue read answers for the KEY, and the + * connection read answers for the PERSON. The third was never delivered — nothing here reads an auth + * config, so an app this deployment has no config for and a person who never consented both print + * the same "no live connection" line, and the line now names both rather than asserting the second. + * + * IT IS NOT READ BECAUSE THERE IS NOTHING HERE TO READ IT WITH, AND THAT IS THE HONEST REASON. + * `server/src/plugins/broker.ts` gives this script `ensureAuthConfig` and `deleteAuthConfig`, both of + * which WRITE, and no listing at all; a diagnostic that called the first would create the very object + * it was asked whether anybody had created, and a read-only diagnostic may not do that. Making the + * distinction real therefore means a new read on that seam and in its adapter — a change to + * `server/src`, not to this file, and one nobody should infer from a docblock. Until it exists, an + * operator separates the two on the app's page under `/admin/plugins`, which is where enabling an app + * creates the config. + * + * COMPOSIO_API_KEY=... bun run composio:smoke -- --user [--call] + * + * IT MINTS NO CONNECT LINK AND CREATES NO SESSION. `broker.authorize` answers a url that attaches + * an account to whoever opens it, so a diagnostic that printed one would be leaving somebody's + * mailbox in a terminal scrollback and in whatever captured it; this script never calls it, and a + * person connects in their own browser through the product instead. Sessions are the other half: + * `createComposioClient` builds a plain per-call client — see the boundary written down at the top + * of `server/src/plugins/composio-adapter.ts` — and nothing here reaches past it. + * + * THE KEY IS NEVER PRINTED. It is read once, handed to {@link createComposioClient}, and after that + * every line this script writes goes through {@link redact}, which takes it back out. That is belt + * and braces on purpose: the adapter promises not to quote the key, but a vendor exception is a + * foreign object and "this SDK does not put the key in an error" is not a promise this file is in a + * position to make on the SDK's behalf. Which is why NO vendor call below is made bare — an + * unhandled rejection, or a synchronous throw nothing caught, is printed by the runtime rather than + * by this file, and that is the one way out past the redactor. {@link ask} closes it for the reads, + * which are awaited, and {@link build} closes it for the client constructor, which is not: it was + * the single call still outside the guarantee, and "every vendor call except one" is not a promise + * worth making. + * + * WHICH STREAM A LINE GOES TO IS DECIDED BY THE EXIT CODE IT EXPLAINS, and that is the whole rule: + * + * A line that explains a non-zero exit is written to STDERR. Every other line is written to + * STDOUT. + * + * So `smoke > report.txt` keeps a report of what this key can see, and every reason the command + * failed is still on the terminal beside it. {@link say} is stdout and {@link stop} is stderr, and + * because {@link stop} exits, no line can be on the wrong one by accident. The rule decides the last + * pair too: an action that ran and failed puts its own outcome and its log id on stderr, because + * those two lines are the whole explanation of the 1 this command exits with. The usage and the + * missing-key refusal are the same rule reached before there is a key to redact, which is why they + * are the only two that write to the stream directly. + */ +import type { BrokerConnection } from "../server/src/plugins/broker"; +import { + type ComposioResult, + effectOf, + LISTING_LIMIT, + unexplained, + VENDOR_PLACEHOLDER, + vendorSentence, +} from "../server/src/plugins/composio"; +import { createComposioClient } from "../server/src/plugins/composio-adapter"; + +/** + * The app the numbers below are about. + * + * One app rather than all of them, because the question is whether a real connection works and a + * person only ever has one app connected at a time when they are debugging this. Gmail because it + * is the app this deployment's Composio work has been written against throughout, and because its + * action count is large enough that a truncated or "important"-filtered listing shows up as an + * obviously wrong number rather than as a plausible one. + */ +const APP = "gmail"; + +/** + * The one action `--call` runs, and why it is safe to run against somebody's real account. + * + * A profile read: it answers the address and the message counts, and it touches no message. The + * slug is named rather than discovered so that what a `--call` does is readable here instead of + * depending on whatever Composio happens to list first — but the name is not the guarantee. The + * guarantee is the {@link effectOf} check below, which reads the vendor's own behaviour label at + * call time and refuses anything that is not marked read-only. + */ +const READ_ACTION = "GMAIL_GET_PROFILE"; + +const args = process.argv.slice(2); +const userFlag = args.indexOf("--user"); +const given = userFlag === -1 ? undefined : args[userFlag + 1]; +/* + * A value that is itself a flag is a missing id rather than a strange one: `--user --call` reads + * as somebody who forgot the id, and taking `--call` as the user would ask Composio about a person + * who does not exist and report "no connection" as though that were a finding about them. + */ +const user = given?.startsWith("--") ? undefined : given; +const call = args.includes("--call"); + +/* + * The two refusals below are the file's stream rule reached before there is a key to redact, which + * is the only reason they write to stderr themselves instead of through {@link stop}. Both explain + * a non-zero exit, so both belong there; neither can be carrying a credential, because one is a + * constant and the other is only reached when the variable is empty. + */ +if (!user) { + console.error( + "Usage: COMPOSIO_API_KEY=... bun run composio:smoke -- --user [--call]\n\n" + + "The user id is the one this deployment sends Composio as the person a call is for — the\n" + + "same id `composio_connections` records. --call additionally runs one read-only action as\n" + + "that person, which only works once they have connected the app in their own browser.", + ); + process.exit(2); +} + +const configured = process.env.COMPOSIO_API_KEY?.trim(); +if (!configured) { + console.error( + "COMPOSIO_API_KEY is empty or unset, so there is nothing to ask Composio with and every answer below would be an absence rather than a finding. Run it as COMPOSIO_API_KEY=... bun run composio:smoke -- --user .", + ); + process.exit(1); +} +/* + * Rebound so that {@link redact}, which is a closure and therefore outside the narrowing above, holds + * a `string` by declaration rather than by a cast. A cast would be the wrong tool twice over: it + * asserts what the refusal above already proved, and this is the one variable in the file where + * silencing the type checker is least welcome. + */ +const key: string = configured; + +/** + * Every line this script writes, with the key taken back out of it. + * + * A plain `split`/`join` rather than a regular expression, because a key is an arbitrary string and + * building a pattern out of one is how a `+` or a `.` in a credential turns a redaction into a + * mismatch. Applied to the vendor's words as well as to this file's own: the only lines that carry + * text nobody here wrote are the failure lines, which are exactly the ones worth guarding. + */ +function redact(line: string): string { + return line.split(key).join(""); +} + +/** A finding: something this key can see. Stdout, per the rule at the top of this file. */ +function say(line: string): void { + console.info(redact(line)); +} + +/** + * The last line of a run that is ending unhappily, on stderr, with the code it is ending with. + * + * ONE FUNCTION SO THE RULE CANNOT DRIFT. The stream and the exit used to be chosen separately at + * every place a run can stop, and they disagreed: the usage and the missing key wrote to stderr and + * every other stopping point wrote to stdout, with nothing written down anywhere saying which the + * next one should pick. Tying the two together makes the rule at the top of this file true by + * construction rather than by everybody remembering it. + * + * `never` so the type checker knows the run is over here, which is what lets the callers below stop + * without a redundant `return` that would read as though the line were only advisory. + */ +function stop(line: string, code: number): never { + console.error(redact(line)); + process.exit(code); +} + +/** + * One failure, as the line this script prints about it. + * + * The MESSAGE, never the object. A caught value from an SDK carries a request, a config and + * whatever else the vendor attached to it, and `console.error(error)` prints all of it — which is + * the path by which a key ends up in a terminal and in whatever captured it. So the shape is + * discarded here and the one human sentence is kept, and even that goes out through {@link redact}. + * + * WHICH MESSAGE, THOUGH, IS NOT THE OUTER ONE. `error.message` on a Composio throw is "Error + * executing the tool GMAIL_GET_PROFILE" — the placeholder `./composio` documents as the sentence + * never worth passing on, and on a diagnostic it is worse than useless: it names the thing the + * reader just asked for and says nothing about why the key could not do it. The actionable + * sentence — "API Key is not valid", "No connected account found for user ID …" — is nested two + * levels inside `cause`, beside the whole HTTP response. {@link vendorSentence} is the reach that + * takes that sentence and nothing else, and it is imported rather than rewritten here so that a + * vendor changing the nesting breaks one place. + * + * AND THE PLACEHOLDER IS REFUSED HERE TOO, which is the half this file was missing. The fallback + * used to be `error.message` unconditionally — so on the one path where the placeholder is what + * `error.message` holds, a docblock saying the sentence is never worth passing on sat directly + * above the code that passed it on. {@link VENDOR_PLACEHOLDER} is the same guard the transport uses + * at the same fork, imported rather than re-spelled so this file cannot drift from `callTool`'s + * reading of a failure Composio declined to explain. + * + * WHAT TO SAY IN THAT SILENCE IS THE CALLER'S TO DECIDE, WHICH IS WHY IT IS A PARAMETER. It used to + * be {@link unexplained} for everybody, and that function's sentence is about a person's connection + * — correct for the one caller that hands it an action's name, wrong for every caller that hands it + * a step. A single fallback could only be right for one of the two, so the fork that already exists + * at the call sites decides it: {@link ask} passes {@link unexplainedRead} and the action call + * passes {@link unexplained}. Named rather than inlined so each sentence keeps a docblock saying who + * it is for. + * + * The thrown message is still the fallback where it says anything at all, because a failure that is + * not a Composio throw — DNS, a proxy, a TLS refusal — carries its whole diagnosis there. + */ +function failed( + subject: string, + error: unknown, + silence: (subject: string) => string, +): string { + const vendor = vendorSentence(error); + if (vendor !== null) return `${subject} failed: ${vendor}`; + const thrown = + error instanceof Error ? error.message.trim() : String(error).trim(); + return thrown === "" || VENDOR_PLACEHOLDER.test(thrown) + ? silence(subject) + : `${subject} failed: ${thrown}`; +} + +/** + * What to say when one of this script's READS failed and Composio explained nothing. + * + * NOT {@link unexplained}, AND THAT IS THE CORRECTION. That sentence ends "Check that this app is + * still connected on its Plugins page", which is the right advice for what it was written for — a + * named ACTION that failed, where a lapsed connection is the likeliest cause by a wide margin and + * the reader fixes it in two clicks. Every {@link ask} below was handing it a STEP instead, so + * "Listing the apps this key can see" and "Listing gmail's actions" both answered with advice about + * one person's connection. A catalogue that will not list and a key Composio has stopped accepting + * are faults in the deployment, and the person whose connection that sentence sends the reader to + * inspect is the one party who cannot do anything about either. + * + * SO IT NAMES THE TWO THINGS ACTUALLY IN QUESTION AT THIS STAGE, in the order worth checking: the + * key this deployment sent, then Composio itself. By the time any read here runs, nothing about + * anybody's connection has been established or is implicated — the catalogue read does not involve a + * person at all. + */ +function unexplainedRead(step: string): string { + return `${step} failed and Composio did not say why. At this stage that is a fault in the key this deployment sent or at Composio, and not in anybody's connection: check COMPOSIO_API_KEY on this deployment, then Composio's status page.`; +} + +/** + * One vendor read, with a thrown failure reported rather than raised. + * + * WITHOUT THIS THE READS BELOW GO ROUND THE REDACTOR, which is the one rule this file has. A + * top-level await that rejects is an unhandled rejection, and the runtime prints the thrown value + * itself: for a bad key that is the vendor's error object with the 401 body, every response header + * and two stack traces, none of it through {@link say}. A bad key is also the FIRST thing this + * script exists to diagnose — so the crash was reserved for exactly the case the script was written + * for, and the guarantee at the top of this file held only while nothing went wrong. + * + * IT EXITS RATHER THAN ANSWERING A SENTINEL, because each read below is a precondition for the ones + * after it: a catalogue that could not be read makes "no connection" a statement about nothing. + */ +async function ask(attempt: string, read: () => Promise): Promise { + try { + return await read(); + } catch (error) { + stop(failed(attempt, error, unexplainedRead), 1); + } +} + +/** + * {@link ask} for something that is not awaited, which at present is exactly one call. + * + * THE CLIENT CONSTRUCTOR WAS THE ONE VENDOR CALL OUTSIDE THE REDACTOR. Everything else in this file + * goes through {@link ask}, whose whole purpose is that no thrown vendor object is printed by the + * runtime instead of by this file — and `createComposioClient` sat bare above it, so a throw there + * went straight past the guarantee this file's own docblock makes about never printing the key. + * `ask` could not cover it because `ask` awaits and this does not, so the shape is repeated + * synchronously rather than the call being made to look asynchronous. + * + * WHAT IT CAN ACTUALLY THROW WAS CHECKED BEFORE THIS WAS ADDED, AND THE ANSWER IS "ONE THING, NOT + * REACHABLE FROM HERE". `new Composio(...)` validates nothing but the key: `getSDKConfig` raises + * `ComposioNoAPIKeyError` when the key is empty after falling back to the environment and the user + * config file, and everything after that is object construction (`@composio/core` 0.18.1, + * `src/composio.ts`). The refusal above means this file never hands it an empty one, and that error's + * message quotes no key even when it is raised. A malformed `COMPOSIO_BASE_URL` does NOT throw here + * either — it is carried to the first request and fails there, inside {@link ask} already. + * + * SO THIS IS BELT AND BRACES, AND DELIBERATELY SO. The file's guarantee is that the key cannot reach + * a terminal, and the docblock at the top says why it will not rest that guarantee on the SDK's + * behaviour: a vendor exception is a foreign object, the constructor runs their telemetry + * instrumentation and their provider's constructor, and the audit above is true of 0.18.1 rather + * than of the next version. A guard that costs four lines makes the promise structural instead of + * something a reader has to re-derive from the vendor's source every bump. + */ +function build(attempt: string, make: () => T): T { + try { + return make(); + } catch (error) { + stop(failed(attempt, error, unexplainedRead), 1); + } +} + +/** + * One app's resolved connection kind, as the word this script prints for it. + * + * READ OFF THE CATALOGUE ROW RATHER THAN DERIVED AGAIN. `connectionOf` in + * `server/src/plugins/composio-adapter.ts` has already made this decision for every row this + * listing carries, and it is the decision the directory, the enable path and the connect screen all + * act on. A second reading of the vendor's schemes here would be a second answer, and a diagnostic + * whose kinds disagree with the product's is worse than one that prints none. + * + * THE SCHEME IS NAMED FOR A `fields` APP BECAUSE IT IS THE HALF THAT MOVES. Which of API_KEY, + * BASIC, BEARER_TOKEN or BASIC_WITH_JWT an app resolved to decides what the connect form asks a + * person to type, and an app that has quietly changed scheme is a form drawn for the wrong secret — + * which "fields" on its own would not show. + * + * THE REASON ON AN `unsupported` APP IS NOT PRINTED. It is a paragraph written for one app's page, + * and there is one per app in a listing that runs to four figures; the tally below is what makes a + * catalogue full of them legible, and the app's own page is where its sentence is worth reading. + * + * AN UNKNOWN KIND PRINTS ITSELF rather than being collapsed into a chosen word, which is why this + * is not a `switch` with a fallback arm: a kind added to {@link BrokerConnection} after this was + * written is exactly the thing an operator reading these lines needs to see by name. + */ +function kindOf(connection: BrokerConnection): string { + return connection.kind === "fields" + ? `fields: ${connection.authScheme}` + : kindLabel(connection.kind); +} + +/** + * The kind's own literal, hyphen taken out of the one that reads as two words. + * + * ONE VOCABULARY FOR BOTH THE LINES AND THE TALLY, which is the whole reason it is a function: the + * per-app line and the count are about the same fact, and two spellings of it would read as two + * different findings on a page an operator is scanning rather than reading. + */ +function kindLabel(kind: BrokerConnection["kind"]): string { + return kind === "no-auth" ? "no auth" : kind; +} + +/** The width a column has to be for the widest thing going in it to fit. */ +function widest(values: string[]): number { + return values.reduce((width, value) => Math.max(width, value.length), 0); +} + +const { actions, broker } = build("Opening Composio with this key", () => + createComposioClient(key), +); + +const apps = await ask("Listing the apps this key can see", () => + broker.listApps(), +); +const app = apps.find((candidate) => candidate.slug === APP); +/* + * THE TRUNCATED CATALOGUE IS NOT CHECKED FOR HERE, BECAUSE IT CANNOT ARRIVE HERE. + * + * There used to be a branch below reporting that the catalogue had come back full at + * {@link LISTING_LIMIT}, and it could never run: `broker.listApps` refuses that answer at its own + * ceiling and throws, for the reason written down beside the throw — at the ceiling a whole + * catalogue and a cut-off one are the same array, so a partial directory is not shown at all. So + * every value of `apps` that reaches this line is shorter than the limit, and the one thing the + * branch promised to report was the one thing it could never see. + * + * WHICH DOES NOT LOSE THE REPORT, and that is why the branch went rather than the refusal being + * worked around. The refusal's own sentence says the catalogue came back at the largest page this + * deployment can ask for, and {@link ask} prints it: a run against a truncated catalogue stops on + * that sentence instead of continuing under a warning. It is the stronger of the two, because the + * branch would have gone on to report "gmail was not among them" about a listing it had just said + * it could not trust. + * + * The action listing further down keeps its own full-page check, which is NOT the same case: + * `actions.listActions` is a pass-through with no ceiling refusal in it, so there a full page + * really can arrive and really does need saying. + */ +say(`Composio listed ${apps.length} apps for this key.`); +/* + * EVERY APP AND THE KIND IT RESOLVED TO, WHICH IS THE ONE READING THAT CATCHES A CATALOGUE GONE + * FLAT. + * + * `connectionOf` reads a malformed `auth_schemes` as an empty list, so a vendor renaming or + * reshaping that field resolves EVERY app to `unsupported` — and the directory route hides + * unsupported apps. From every other angle that failure is silent: the catalogue lists its usual + * four figures, the route answers 200, not one app is offered, and no sentence anywhere says so. + * The count printed above is unchanged by it, which is exactly why the count is not enough. + * + * THE TALLY IS THE LINE THAT MAKES THE SHAPE VISIBLE AT A GLANCE, and it prints the zeros rather + * than only the kinds that occurred: "consent 0, self-registering 0, fields 0, no auth 0, + * unsupported 1540" is a catalogue that has stopped resolving, and it reads as one without anybody + * scrolling the per-app lines. A tally built only from what was seen would print one cheerful line + * in that state. The per-app lines are what an operator greps afterwards for the app they came + * about. + * + * SEEDED IN THE ORDER {@link BrokerConnection} DECLARES, AND OPEN AT THE END. The five known kinds + * are seeded so their zeros are printed; a kind this script has never heard of increments a key + * that was not seeded and is appended by `Map` where it cannot be missed, rather than being + * silently dropped by a fixed list of five. The seed is checked against the type rather than + * spelled as loose strings, so a kind RENAMED there is a compile error here instead of a zero that + * goes on printing for ever beside the new name. + * + * THROUGH {@link say}, LIKE EVERYTHING ELSE. These lines explain a run that got this far rather + * than a non-zero exit, so the rule at the top of this file puts them on stdout, and going through + * `say` is what keeps them inside {@link redact}. Nothing of the vendor's object reaches them: a + * checked slug, a word chosen here, and a number. + */ +const rows = apps.map((candidate) => ({ + slug: candidate.slug, + kind: kindOf(candidate.connection), + count: String(candidate.actionCount), +})); +const slugColumn = widest(rows.map((row) => row.slug)); +const kindColumn = widest(rows.map((row) => row.kind)); +const countColumn = widest(rows.map((row) => row.count)); +for (const row of rows) { + say( + `${row.slug.padEnd(slugColumn)} ${row.kind.padEnd(kindColumn)} ${row.count.padStart(countColumn)} actions`, + ); +} +const KINDS = [ + "consent", + "self-registering", + "fields", + "no-auth", + "unsupported", +] satisfies BrokerConnection["kind"][]; +const tally = new Map( + KINDS.map((kind): [string, number] => [kindLabel(kind), 0]), +); +for (const candidate of apps) { + const kind = kindLabel(candidate.connection.kind); + tally.set(kind, (tally.get(kind) ?? 0) + 1); +} +say( + `Kinds: ${[...tally].map(([kind, count]) => `${kind} ${count}`).join(", ")}.`, +); +if (!app) { + /* + * Stated rather than shrugged at. A catalogue that does not contain Gmail is a key pointed at + * something other than what this script assumes, and reporting "0 actions" for it would read as + * an empty app rather than as a listing that never included it. + */ + stop( + `${APP} was not among them, so the action count and the connection below are about an app this key cannot see.`, + 1, + ); +} +say(`${app.name} publishes ${app.actionCount} actions.`); + +const connected = await ask( + `Asking whether ${user} has a ${APP} connection`, + () => broker.isConnected({ userId: user, toolkit: APP }), +); +/* + * BOTH STATES ARE NAMED BECAUSE THIS READ CANNOT TELL THEM APART. `isConnected` is a count of this + * person's ACTIVE accounts for the app, so it answers `false` for somebody who never consented and + * equally for an app this deployment has no authorization config for — in the second case there is + * nothing for a consent to have been made against, and nobody could have connected even if they + * tried. Sending the reader to the person in that case is sending them to the one party who cannot + * fix it. See the docblock at the top for why the config is not read here and what reading it would + * take. + */ +say( + connected + ? `${user} has a live ${APP} connection.` + : `${user} has no live ${APP} connection, and this script cannot say which of two reasons it is. Either no administrator has enabled ${APP} on this deployment, so there is no authorization config for anybody to connect against — check the app's page under /admin/plugins — or the app is enabled and ${user} never finished the consent page, which they do in their own browser; nothing here can do it for them.`, +); + +if (!call) { + say("Nothing was called. Pass --call to run one read-only action."); + process.exit(0); +} + +if (!connected) { + stop( + `--call was passed, but there is no connection to call through, so nothing was sent. Connect ${APP} for ${user} first.`, + 1, + ); +} + +/* + * The action is looked up in a real listing rather than called from the constant alone, for the two + * things only the listing carries: the concrete version, which the SDK refuses to execute without, + * and the behaviour labels, which are what makes the claim "read-only" checkable instead of + * asserted in a comment. + */ +const listed = await ask(`Listing ${APP}'s actions`, () => + actions.listActions(APP, { limit: LISTING_LIMIT }), +); +/* + * The count, because it is the number {@link APP} was chosen for: Gmail publishes enough actions + * that a page truncated at the ceiling, or narrowed to the vendor's "important" subset, reads as an + * obviously wrong number beside the count the catalogue published — and neither one announces + * itself. Printing only the catalogue's figure left the comparison this file promises impossible to + * make. + */ +say( + listed.length >= LISTING_LIMIT + ? `Composio answered with ${listed.length} ${APP} actions, which is the whole page it will answer with (${LISTING_LIMIT}), so that listing came back full and is likely cut off.` + : `Composio listed ${listed.length} of the ${app.actionCount} actions ${app.name} publishes.`, +); +const action = listed.find((candidate) => candidate.slug === READ_ACTION); +if (!action) { + stop( + `Composio does not list ${READ_ACTION} for ${APP}, so nothing was called. Pick another read-only action rather than calling one of the writes.`, + 1, + ); +} +const { effect, destructive } = effectOf(action.tags); +if (effect !== "read" || destructive) { + /* + * The vendor's label decides, and a disagreement stops the run. `effectOf` treats anything + * unlabelled as a write, so this also covers the case where Composio stops publishing labels + * altogether — which would otherwise turn a smoke test into an unreviewed write. + */ + stop( + `Composio no longer marks ${READ_ACTION} as read-only, so nothing was called. This script only ever runs a read.`, + 1, + ); +} +if (!action.version) { + stop( + `Composio listed ${READ_ACTION} with no version, so no versioned call could be made and nothing was sent.`, + 1, + ); +} + +let result: ComposioResult; +try { + result = await actions.execute( + { + toolkit: APP, + slug: action.slug, + userId: user, + version: action.version, + }, + /* + * No arguments. A profile read is about the connected account itself, and the one parameter it + * takes defaults to it — so an empty bag is both the smallest request and the one that cannot + * accidentally name somebody else's mailbox. + */ + {}, + ); +} catch (error) { + /* + * {@link unexplained} rather than {@link unexplainedRead}, and this is the one call site it was + * written for: the subject is an action's name, and a connection that lapsed between the listing + * above and this call really is the likeliest reason a run that got this far fails here. + */ + stop(failed(READ_ACTION, error, unexplained), 1); +} + +/* + * A resolution is not a success. Composio reports most failures by answering with `successful: + * false` rather than by throwing, and a smoke test that only watched for exceptions would report a + * working key on top of a call that failed. + * + * THE REPORTED SENTENCE GOES THROUGH THE SAME TWO GUARDS A THROWN ONE DOES. `result.error` is the + * vendor's field and it carries the vendor's placeholder as readily as an exception message does — + * `callTool` refuses it there for exactly this reason — so "failed: Error executing the tool + * GMAIL_GET_PROFILE" is a line this script could otherwise print while its own docblock says that + * sentence is never worth passing on. {@link unexplained} is what is said instead, which is the + * wording the product uses for the same silence. + */ +const reported = result.error?.trim() ?? ""; +const outcome = result.successful + ? `${READ_ACTION} succeeded.` + : reported === "" || VENDOR_PLACEHOLDER.test(reported) + ? unexplained(READ_ACTION) + : `${READ_ACTION} failed: ${reported}`; +const log = `Log id: ${result.logId ?? "none was returned."}`; + +/* The rule at the top of this file: these two lines are the whole explanation of a non-zero exit. */ +if (!result.successful) stop(`${outcome}\n${log}`, 1); +say(outcome); +say(log); +process.exit(0); diff --git a/server/drizzle/0029_composio.sql b/server/drizzle/0029_composio.sql new file mode 100644 index 000000000..2189a2648 --- /dev/null +++ b/server/drizzle/0029_composio.sql @@ -0,0 +1,12 @@ +CREATE TABLE "composio_connections" ( + "toolkit" text NOT NULL, + "user_id" text NOT NULL, + "connected_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "composio_connections_toolkit_user_id_pk" PRIMARY KEY("toolkit","user_id") +); +--> statement-breakpoint +ALTER TABLE "mcp_tools" ADD COLUMN "effect" text;--> statement-breakpoint +ALTER TABLE "mcp_tools" ADD COLUMN "destructive" boolean DEFAULT false NOT NULL;--> statement-breakpoint +ALTER TABLE "mcp_tools" ADD COLUMN "version" text;--> statement-breakpoint +CREATE INDEX "composio_connections_user_idx" ON "composio_connections" USING btree ("user_id"); \ No newline at end of file diff --git a/server/drizzle/0030_composio_schemes.sql b/server/drizzle/0030_composio_schemes.sql new file mode 100644 index 000000000..2f447e05e --- /dev/null +++ b/server/drizzle/0030_composio_schemes.sql @@ -0,0 +1,5 @@ +ALTER TABLE "mcp_servers" ADD COLUMN "auth_scheme" text;--> statement-breakpoint +UPDATE "mcp_servers" SET "auth_scheme" = 'OAUTH2' WHERE "provenance" = 'composio' AND "auth_scheme" IS NULL;--> statement-breakpoint +ALTER TABLE "composio_connections" ADD COLUMN "verified" boolean DEFAULT false NOT NULL;--> statement-breakpoint +ALTER TABLE "composio_connections" ADD COLUMN "verified_at" timestamp with time zone;--> statement-breakpoint +UPDATE "composio_connections" SET "verified" = true, "verified_at" = "connected_at"; diff --git a/server/drizzle/0031_composio_probe_action.sql b/server/drizzle/0031_composio_probe_action.sql new file mode 100644 index 000000000..aa0ed2f96 --- /dev/null +++ b/server/drizzle/0031_composio_probe_action.sql @@ -0,0 +1 @@ +ALTER TABLE "composio_connections" ADD COLUMN "probe_action" text; \ No newline at end of file diff --git a/server/drizzle/meta/0029_snapshot.json b/server/drizzle/meta/0029_snapshot.json new file mode 100644 index 000000000..b7c9a03e0 --- /dev/null +++ b/server/drizzle/meta/0029_snapshot.json @@ -0,0 +1,3223 @@ +{ + "id": "9038560c-f1da-4f48-bb9a-f489ec39409f", + "prevId": "2dc825f1-9abb-47e2-9b47-9c6c941c557a", + "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.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_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 + }, + "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 + }, + "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 + } + }, + "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 + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "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.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 + }, + "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.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()" + }, + "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 + }, + "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 + }, + "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 + }, + "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": {}, + "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 + }, + "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.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 + } + }, + "enums": { + "public.agent_type": { + "name": "agent_type", + "schema": "public", + "values": [ + "built_in", + "remote_ag_ui" + ] + }, + "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" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/server/drizzle/meta/0030_snapshot.json b/server/drizzle/meta/0030_snapshot.json new file mode 100644 index 000000000..28d697159 --- /dev/null +++ b/server/drizzle/meta/0030_snapshot.json @@ -0,0 +1,3242 @@ +{ + "id": "e2ed6458-1ca9-4358-bff7-8663f0352146", + "prevId": "9038560c-f1da-4f48-bb9a-f489ec39409f", + "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.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_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 + }, + "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 + }, + "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 + } + }, + "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 + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "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.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 + }, + "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.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 + }, + "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 + }, + "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 + }, + "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 + }, + "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": {}, + "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 + }, + "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.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 + } + }, + "enums": { + "public.agent_type": { + "name": "agent_type", + "schema": "public", + "values": [ + "built_in", + "remote_ag_ui" + ] + }, + "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" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/server/drizzle/meta/0031_snapshot.json b/server/drizzle/meta/0031_snapshot.json new file mode 100644 index 000000000..31a215406 --- /dev/null +++ b/server/drizzle/meta/0031_snapshot.json @@ -0,0 +1,3248 @@ +{ + "id": "2baf8eb9-b155-4af4-b886-01d17d1f489f", + "prevId": "e2ed6458-1ca9-4358-bff7-8663f0352146", + "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.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_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 + }, + "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 + }, + "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 + } + }, + "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 + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "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.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 + }, + "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.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 + }, + "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 + }, + "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 + }, + "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": {}, + "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 + }, + "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.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 + } + }, + "enums": { + "public.agent_type": { + "name": "agent_type", + "schema": "public", + "values": [ + "built_in", + "remote_ag_ui" + ] + }, + "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" + ] + } + }, + "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 040924c34..884234823 100644 --- a/server/drizzle/meta/_journal.json +++ b/server/drizzle/meta/_journal.json @@ -204,6 +204,27 @@ "when": 1788548093782, "tag": "0028_audit_initiator", "breakpoints": true + }, + { + "idx": 29, + "version": "7", + "when": 1788968143912, + "tag": "0029_composio", + "breakpoints": true + }, + { + "idx": 30, + "version": "7", + "when": 1789298766378, + "tag": "0030_composio_schemes", + "breakpoints": true + }, + { + "idx": 31, + "version": "7", + "when": 1789314631385, + "tag": "0031_composio_probe_action", + "breakpoints": true } ] -} +} \ No newline at end of file diff --git a/server/package.json b/server/package.json index 02eb051bc..75153261f 100644 --- a/server/package.json +++ b/server/package.json @@ -15,6 +15,7 @@ "@ag-ui/client": "0.0.59", "@better-auth/drizzle-adapter": "^1.7.1", "@better-auth/sso": "^1.7.1", + "@composio/core": "^0.18.1", "@copilotkit/runtime": "1.70.1", "@modelcontextprotocol/sdk": "^1.30.0", "better-auth": "^1.7.1", diff --git a/server/src/app.ts b/server/src/app.ts index 8f07911a4..7f6e2b417 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -7,9 +7,9 @@ import type { AgentProfileStore } from "./agents/profile-store"; import { createAgentRoutes } from "./agents/routes"; import { type AuditEventType, + AuditQueryError, type AuditReader, type AuditStore, - AuditQueryError, auditQueryFromUrl, DEPLOYMENT_INITIATOR, recordAuditEvent, @@ -41,6 +41,7 @@ import type { CredentialAdminService, CredentialInput } from "./credentials"; import { createIntelligenceClient } from "./intelligence-client"; import type { OnboardingStore } from "./people/onboarding"; import type { PeopleStore } from "./people/store"; +import type { ComposioBroker } from "./plugins/broker"; import { createPluginRoutes } from "./plugins/routes"; import type { PluginStore } from "./plugins/store"; import { REFUSAL_MARKER } from "./plugins/tools"; @@ -221,6 +222,19 @@ export function createApp( * shown an empty box, and the obvious thing to do with an empty box is fill it in again. */ userInstructions?: UserInstructionsStore, + /** + * The broker behind apps a person connects through Composio rather than an administrator + * registering an MCP server. + * + * Appended last, like everything above it: these are positional, so inserting one anywhere else + * silently shifts every existing call site's arguments by one. + * + * Passed in already built, like the copilot handler and the intent router, so this module never + * imports the vendor's package. Absent leaves the plugin surface reporting that no broker is + * configured, which is the correct degraded behaviour: a deployment with no Composio API key has + * no app directory to offer, rather than one that lists apps nobody can connect. + */ + composio?: { broker: ComposioBroker }, ) { const app = new Hono<{ Variables: AppVariables }>(); @@ -1061,33 +1075,39 @@ export function createApp( if (pluginStore) { app.route( "/api/plugins", - createPluginRoutes(pluginStore, requireUser, canUseBot, { - encryptionKey: config.keyEncryptionKey, - /* - * Whether the person a consent was started for still has access, asked when the callback - * lands rather than when the flow began. - * - * The callback carries no session — identity comes from the state — so this is where the - * question gets asked at all. `find` answers both halves of it: no row means a user id that - * names nobody, and `revoked` means an administrator removed them while they were away at - * the vendor. Either way there is no live person for a fresh refresh token to belong to. - * - * No people store means this deployment cannot answer the question, so it refuses rather - * than assuming yes. It also cannot remove anybody, which is exactly why guessing here - * would be a hole nothing else closes. - */ - personHasAccess: async (userId) => { - if (!peopleStore) return false; - const person = await peopleStore.find(userId); - return person !== undefined && !person.revoked; + createPluginRoutes( + pluginStore, + requireUser, + canUseBot, + { + encryptionKey: config.keyEncryptionKey, + /* + * Whether the person a consent was started for still has access, asked when the callback + * lands rather than when the flow began. + * + * The callback carries no session — identity comes from the state — so this is where the + * question gets asked at all. `find` answers both halves of it: no row means a user id that + * names nobody, and `revoked` means an administrator removed them while they were away at + * the vendor. Either way there is no live person for a fresh refresh token to belong to. + * + * No people store means this deployment cannot answer the question, so it refuses rather + * than assuming yes. It also cannot remove anybody, which is exactly why guessing here + * would be a hole nothing else closes. + */ + personHasAccess: async (userId) => { + if (!peopleStore) return false; + const person = await peopleStore.find(userId); + return person !== undefined && !person.revoked; + }, + // The deployment-wide fallback a Bot may present, as a yes or no. The secret itself stays + // in config and is checked in `/api/agent-tools/call`; the surface only needs to know + // whether a Bot without its own credential has any way to call back. + botsMayCallBack: Boolean(config.agentToolToken), + publicUrl: config.publicUrl, + appUrl: config.appUrl, }, - // The deployment-wide fallback a Bot may present, as a yes or no. The secret itself stays - // in config and is checked in `/api/agent-tools/call`; the surface only needs to know - // whether a Bot without its own credential has any way to call back. - botsMayCallBack: Boolean(config.agentToolToken), - publicUrl: config.publicUrl, - appUrl: config.appUrl, - }), + composio, + ), ); } diff --git a/server/src/audit.ts b/server/src/audit.ts index ada81df10..a4f8693a7 100644 --- a/server/src/audit.ts +++ b/server/src/audit.ts @@ -166,6 +166,36 @@ export const auditEventTypes = [ * was current when it was made. The client id, never the secret. */ "mcp.oauth_client_registered", + /* + * One person's brokered account was exercised with a real call, to see whether it is really there. + * + * THE ONE EXECUTION OF AN APP'S OWN ACTION THAT HAPPENS OUTSIDE `callTool`, which is why it is + * written down on its own instead of joining the `mcp.call_*` family. Everything that surrounds a + * vendor call on the ordinary path is absent from this one: no grant is consulted, no policy is + * evaluated, no arguments are inspected, and no `mcp.call_succeeded`, `mcp.call_rejected` or + * `mcp.call_failed` row is left behind it. An action ran at a vendor and the trail's tool-call + * family says nothing about it, so this row is the whole of what is recorded. + * + * WHAT MAKES THAT SAFE IS THE SHAPE OF THE CALL RATHER THAN ANYBODY'S CARE. The action is fixed: + * the adapter picks it out of the metadata this deployment already recorded for the app, not out + * of anything a caller sent; it is sent with no arguments at all, so there is nothing for content + * inspection to have missed; and the only two callers are the connect step and a person + * re-checking their own connection. Those three properties are the protection. A later change + * that let the action, the arguments or the callers vary would not be loosening a check that is + * merely skipped here — it would be removing the reason the check can be skipped at all, and the + * one call in this deployment that reaches a vendor unexamined would start taking instructions. + * + * THE ROW NAMES A PERSON AND HAS NO BOT IN IT, which is not new on this trail and must not be + * read as such. `mcp.callback_refused` deliberately names none, and `mcp.account_connected` and + * `mcp.account_disconnected` are both filed against the person whose account it was, so code that + * reads a Bot out of every `mcp.*` payload was already wrong before this row existed. What IS new + * is a vendor action with no `mcp.call_*` row beside it: anybody reconciling this trail against + * an app's own logs has a call here to account for that the tool-call rows will never mention. + * Borrowing a Bot to make the row look uniform with its neighbours would answer that by lying — + * a call somebody made about their own account, filed against a Bot that never ran, is the + * confidently wrong kind of entry that `mcp.call_failed` exists to keep off this trail. + */ + "mcp.connection_verified", /* * One person connected their own account to one server. * @@ -181,10 +211,15 @@ export const auditEventTypes = [ * their access". `reason` distinguishes somebody disconnecting their own account from an * administrator removing them, because those are the same effect and very different events. * - * `vendorRevoked` says whether the grant at the vendor was withdrawn as well, and is currently - * false: removing somebody stops this deployment holding a usable secret, and the grant at Google - * outlives it until it is revoked there. Recorded rather than glossed, because a row that implied - * otherwise would be worse than no row. + * `vendorRevocationRequested` says whether the grant at the vendor was asked to be withdrawn as + * well. It is false for every credential this deployment holds in its own vault: removing + * somebody stops us holding a usable secret, and the grant at Google outlives it until it is + * revoked there, which nothing on that path asks for. It is true for a brokered account whose + * withdrawal Composio accepted — accepted rather than completed, because the upstream revocation + * runs as a background job with no supported way to poll it, which is why the field is named for + * the ask. Recorded rather than glossed, because a row that implied otherwise would be worse + * than no row, and this field once did exactly that: it was called `vendorRevoked` and said true + * while the grant at Google stood untouched. */ "mcp.account_disconnected", // Every action a Bot takes on its computer, allowed or refused. Both, always: a trail that records diff --git a/server/src/config.ts b/server/src/config.ts index 162dc36bf..e85328a28 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -171,6 +171,22 @@ export type DeploymentConfig = { * packages but not a copy of one running alongside the original. See channels/thread-identity.ts. */ deploymentId: string | undefined; + /** + * The key this deployment talks to Composio with, the broker that holds people's accounts for a + * few hundred apps so a Bot can act in Gmail or Slack without an OAuth client of this + * deployment's own registered with each of them. + * + * Optional, and undefined is the ordinary state rather than a degraded one. A deployment that has + * not bought Composio is not a deployment missing something: there is nothing to connect, nothing + * to grant and no Composio tool for a Bot to call, what remains on screen is one row that goes + * nowhere under More apps on the admin Plugins page naming this variable, and nothing else it does + * is any worse for that. + * + * Nothing here validates the key. There is no shape to check it against and no call worth making + * at boot to find out, so the first real request is what says whether it works — which is also + * where a key that was revoked last week would have surfaced regardless. + */ + composioApiKey: string | undefined; /** * Where this deployment is reached from outside, with no trailing slash. * @@ -975,6 +991,7 @@ export function loadConfig( ...(managedAgent ? { managedAgent } : {}), agentEndpointAllowedHosts: agentEndpointAllowedHosts(environment), deploymentId: optional(environment, "DEPLOYMENT_ID"), + composioApiKey: optional(environment, "COMPOSIO_API_KEY"), publicUrl: ( optional(environment, "OPENBOT_PUBLIC_URL") ?? auth?.baseUrl )?.replace(/\/+$/, ""), diff --git a/server/src/db/schema/plugins.ts b/server/src/db/schema/plugins.ts index 2c887bbd2..3e37fa798 100644 --- a/server/src/db/schema/plugins.ts +++ b/server/src/db/schema/plugins.ts @@ -76,6 +76,22 @@ export const mcpServers = pgTable("mcp_servers", { credentialId: uuid("credential_id").references(() => credentials.id, { onDelete: "restrict", }), + /** + * How this app connects, as it was resolved when somebody enabled it. + * + * Recorded rather than re-derived, because the catalogue is somebody else's and a vendor that + * starts publishing a new scheme for an app must not move live connections onto a different + * flow underneath them. + * + * THE VENDOR'S OWN SCHEME LITERAL, NOT A {@link BrokerConnection} KIND — `OAUTH2`, `DCR_OAUTH`, + * `API_KEY`, `BASIC`, `BEARER_TOKEN`, `BASIC_WITH_JWT`, `NO_AUTH`. Those two vocabularies name one + * fact, and this column is where a reader comes to find out which of them is written down, so it + * says: somebody looking here for `consent` or `fields` is reading the other one. Migration 0030 + * backfilled every row whose provenance is `composio` to `OAUTH2`, because managed OAuth was the + * only config this deployment ever created and `addBrokeredApp` writes the row only after that + * config stands. A null is therefore not an older brokered row — it is a row that is not brokered. + */ + authScheme: text("auth_scheme"), /** What the deployment last heard back from it. `null` until the first successful listing. */ toolsRefreshedAt: timestamp("tools_refreshed_at", { withTimezone: true }), /** The last failure, kept so the Plugins page can say why a server has no tools. */ @@ -105,11 +121,182 @@ export const mcpTools = pgTable( description: text("description").notNull().default(""), /** The tool's own JSON Schema, passed to the model unchanged. */ inputSchema: jsonb("input_schema").notNull().default({}), + /** + * What this action does, as the vendor itself described it, or null when nothing said. + * + * Recorded here rather than derived per call because the source is the listing: Composio labels + * every action, and those labels arrive with the tool list and nowhere else. A hand-written write + * list per app — which is what {@link CatalogueEntry.writeTools} is — cannot be kept for a + * catalogue of several hundred apps that changes weekly, and a list naming only the actions + * somebody thought of reads as a guard while behaving like a gap. + * + * PLAIN TEXT RATHER THAN AN ENUM, deliberately. The value is somebody else's vocabulary, so a + * database enum would need a migration every time a vendor invents a label, and the migration + * would be the thing standing between a refresh and a correct classification. `classifyTool` + * defends instead: only the exact string `read` produces a read, so an unrecognised value fails + * closed. Same reasoning as `mcp_servers.provenance`, which is text for the same reason. + * + * NULLABLE, AND NOT DEFAULTED TO "write". Every row that already exists was listed before this + * column did, and a default would reclassify every Notion read as a write when the migration ran. + * Null means "nothing said", and the classifier decides that means write. + */ + effect: text("effect"), + /** + * Whether the vendor marked this action as destroying something. + * + * Separate from {@link mcpTools.effect} rather than a third value in it, so the rule engine keeps + * the two values every existing policy is written against and nobody's rules need migrating. It + * is recorded now because the confirmation card is what needs it, and re-listing every app later + * to backfill a column is worse than carrying it from the start. + * + * `false` for an action nothing said about — the same fail-closed direction as `effect` without + * claiming a vendor said something it did not. An unclassified action is already gated as a + * write; marking it destructive as well would paint every ordinary write as dangerous and teach + * an approver to click through the colour. + */ + destructive: boolean("destructive").notNull().default(false), + /** + * The vendor's version for this action, as the listing gave it — `20260903_00` and the like. + * + * NOT OPTIONAL BOOKKEEPING. Composio refuses to execute an action without a specific version, + * and refuses the word `latest` too, so this column is what makes a call possible at all. It is + * stored rather than fetched per call because it arrives free with the listing and fetching it + * would be a second round trip on every single call. + * + * Null for every other transport, which publishes no such thing, and for rows listed before this + * column existed. The Composio transport treats a missing version as a reason to refuse rather + * than a reason to guess — a guessed version is a call against an action's other behaviour. + */ + version: text("version"), createdAt: createdAt(), }, (table) => [primaryKey({ columns: [table.serverId, table.name] })], ); +/** + * One person's Composio connection to one app. + * + * WHY THIS IS NOT `mcp_user_credentials`. That table's whole guarantee is that a row means real held + * access: it points at a vault row, not-null, and the vault is what offboarding scans. Composio holds + * the account, so there is no secret to point at and none to scan for — and `retireConnectionsFor` + * deliberately reads the VAULT rather than the join table, because the join row is deleted along with + * the person while the vault row survives. Putting a Composio connection there would mean removing + * somebody deletes the only record of it, leaving their mailbox connected at Composio with nothing + * left to revoke it by, while an administrator has been told they removed it. + * + * So `user_id` is plain text with NO foreign key and no cascade. The row outliving the person is the + * point, not an oversight: it is the only thing that lets offboarding say "this person had Gmail + * connected, tell Composio to drop it". A scope column would be a lie — Composio returns no scope we + * see, and the column on the other table exists precisely to record what the vendor said it granted — + * so there is none. + * + * A CACHE, NOT THE TRUTH. Composio is authoritative about whether a connection is live; this row + * exists so the settings page can be drawn without a network call per row, and so offboarding has + * something to iterate. A call against an app the person never connected fails at Composio, and that + * refusal is the answer rather than this table's absence. + */ +export const composioConnections = pgTable( + "composio_connections", + { + /** The Composio app slug, lower case, as their directory spells it: `gmail`, `slack`. */ + toolkit: text("toolkit").notNull(), + /** + * The person, as `users.id`. + * + * The same value sent to Composio as the identity a call runs under, so the two cannot drift: + * what this row says somebody connected is what a call will act as. + */ + userId: text("user_id").notNull(), + /** When they connected, shown on their own settings page. */ + connectedAt: timestamp("connected_at", { withTimezone: true }) + .notNull() + .defaultNow(), + /** + * Whether a real call was made with this connection and answered. See the verify path. + * + * TRUE ON EVERY ROW THAT PREDATES THIS COLUMN WITHOUT A PROBE BEHIND IT. Migration 0030 + * backfilled them to true with `verified_at = connected_at`, and no call was made to earn it: + * every one of the rows it touched is a consent connection, which is verified by construction, + * because the only way it exists at all is that the vendor's own screen sent the person back + * connected. So on a backfilled row the timestamp is the moment of consent, not the moment of a + * check, and a reader treating every `verified_at` as "this connection answered then" would be + * wrong about exactly the rows that were here first. + * + * AND THE SAME NOW HOLDS OF EVERY CONSENT ROW AND NOT ONLY THE BACKFILLED ONES, because the + * writer that records a connection sets `verified` itself: `recordBrokeredConnection` is the + * one place a row is written, and `confirmBrokeredConnection` calls it with `verified: true` on + * the vendor's yes. So a consent connection made today carries the same true migration 0030 + * wrote and earns it the same way — the vendor answered that the account is attached — and its + * `verified_at` is the moment of that answer: the consent itself on the first confirm, and the + * vendor's yes again on every later one, because a confirm really does go and ask. What it used + * to do was insert on the defaults, and until it stopped, every consent connection made since + * the backfill read `false` beside a null `verified_at` — the very pair a key connection nobody + * has ever checked reads — so nothing could tell the two apart, and the rows the migration + * touched were the only ones in the table saying anything true. + * + * WHAT THE PAIR SEPARATES IS A CHECKED CONNECTION FROM AN UNCHECKED ONE, and never one KIND of + * connection from another. Which kind a row is comes from the app's own `auth_scheme`, which + * the settings page branches on first; this column says only that somebody established the + * account is live, and `verified_at` when that was last done. + */ + verified: boolean("verified").notNull().default(false), + /** When that check last passed, which is what the page reports instead of a present tense. */ + verifiedAt: timestamp("verified_at", { withTimezone: true }), + /** + * The action the last check SPENT on this connection, and null where it spent none. + * + * A RECORD OF WHAT HAPPENED, NOT A QUESTION ASKED OF TODAY'S METADATA — which is the whole + * reason it is a column at all. `verified` is a fact about a check made against the app's action + * listing as it stood THEN; this used to be derived on read from the listing as it stands NOW, + * and the argument that the two agreed held only for as long as nothing changed in between. + * Something can: `POST /servers/:id/refresh` is a generic administrator's route keyed on a + * server id and `composio-` is one, so an ordinary press of Refresh re-lists a brokered + * app's actions — the very press the Composio transport tells an operator to make when an + * action gains the version that makes it callable. The moment it did, a row that truthfully + * said "the key was accepted without being checked, because this app publishes nothing safe to + * try one on" began reading as a NAMED probe beside `verified: false`, which the settings page + * draws as "your key was checked and rejected, and the account still stands — disconnect it". + * Every clause of that is false for somebody whose key was never tried, and it persisted: it is + * what every page load said until they pressed Re-check. + * + * SO THE WRITER RECORDS IT, and the writer is the one place that cannot be wrong about it. + * `recordBrokeredConnection` is the single writer of this row, every caller knows what it spent + * — a probe's name, or nothing — and it is passed in beside `verified` because the two are one + * fact: what was checked, and how it went. + * + * NULL MEANS NO ACTION WAS SPENT, WHICH IS NOT THE SAME AS UNCHECKED. Read beside `verified` it + * says which: + * + * null, not verified — nothing was tried. The app published nothing safe to spend a key on + * at the moment of the check. A fact about the app, not about the key. + * null, verified — a CONSENT connection. The vendor's own yes at the end of its own + * screen is the evidence, and no call was ever made against the + * account, so there is no action to name and there never will be. + * a name, verified — it ran in this person's account and the vendor took the key. + * a name, not verified — it ran and the vendor refused the key, and the account it ran in is + * still standing. A live account with a bad key behind it. + * + * AND NULL ON A ROW WRITTEN BEFORE THIS COLUMN EXISTED, which is the same null and deliberately + * so. No backfill is possible or wanted: what a check spent in March is not recoverable, and + * today's chooser answering for it is exactly the inference this column retires. The rows + * migration 0030 touched are consent rows, where null is permanently right; a key row that + * predates it reads as unchecked, the mildest of the four states and the only safe direction to + * be uncertain in — a name invented for it would be the false accusation above, written down. + * + * WHAT A CALLER MUST NOT READ IT AS is "the action this app could be checked with now". That is + * a different question, asked of `probeActionFor`, and the two answers diverge exactly when + * the app's listing has moved since the check. + */ + probeAction: text("probe_action"), + updatedAt: updatedAt(), + }, + (table) => [ + primaryKey({ columns: [table.toolkit, table.userId] }), + // "What has this person connected" is the settings page's only query, and offboarding's. + index("composio_connections_user_idx").on(table.userId), + ], +); + /** * One person's grant on one MCP server: the row that makes a Bot answer as the asker. * diff --git a/server/src/index.ts b/server/src/index.ts index 260091eb6..2465b56fd 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -77,6 +77,8 @@ import { intelligenceChannelMappings } from "./db/schema"; import { createOnboardingStore } from "./people/onboarding"; import { createPeopleStore } from "./people/store"; import { useRoutineTools } from "./plugins/builtin-routines"; +import { useComposioClient } from "./plugins/composio"; +import { createComposioClient } from "./plugins/composio-adapter"; import { redirectUriFor } from "./plugins/oauth"; import { createPluginStore } from "./plugins/store"; import { grantedSkills, grantedTools } from "./plugins/tools"; @@ -315,6 +317,28 @@ const computerGateway = computerProvider */ const sandboxedStore = createSandboxedStore(database, bootAuditStore); +/** + * Composio, built ONCE: the client the transport calls through and the broker behind the app + * directory are the same client, and the store below and the routes further down share it. + * + * ONE CLIENT, TWO SEAMS, INSTALLED TWO DIFFERENT WAYS, because the two are reached two different + * ways. The transport is reached as a MODULE — `transportFor` maps a kind to one, exactly as the + * builtin routines transport above is reached — so there is no constructor to hand a client to and + * the registry is built at IMPORT TIME, long before there is configuration to read. That is why the + * actions seam is installed globally, from here, the one place that has the key. The broker has no + * such problem: it is an ordinary argument, passed to the store and to `createApp`. + * + * A DEPLOYMENT WITH NO KEY INSTALLS NEITHER, which is the state the transport is written for rather + * than an edge of it. The seam stays null and every Composio listing and call refuses saying the + * connector is not configured here; the store gets no broker and the app directory says the same. + * Installing a client built from an absent key would turn all of that into a vendor error at first + * use, which sends an operator looking for a broken Composio instead of at their own configuration. + */ +const composio = config.composioApiKey + ? createComposioClient(config.composioApiKey) + : null; +if (composio) useComposioClient(composio.actions); + const pluginStore = createPluginStore({ database, auditStore: bootAuditStore, @@ -330,6 +354,14 @@ const pluginStore = createPluginStore({ * registering. */ redirectUri: config.publicUrl ? redirectUriFor(config.publicUrl) : undefined, + /* + * The same client the transport seam above was installed with, never a second one. Enabling an + * app writes the row here and creates the auth config at the vendor, and a store holding a + * different client from the one the call goes out through is two deployments' worth of state + * behind one screen. Undefined without a key, which leaves enabling an app refused rather than + * attempted. + */ + broker: composio?.broker, }); /** @@ -1157,6 +1189,10 @@ const app = createApp( // The same store every run reads through `loadInstructionsForActor`, so the screen a person edits // and the prompt their coworker is built from can never be two different pieces of text. userInstructionsStore, + // The app directory, behind the same client the plugin store and the transport already share. + // Absent without a key, which leaves the routes reporting no broker rather than listing apps + // nobody could connect. + composio ? { broker: composio.broker } : undefined, ); /** diff --git a/server/src/plugins/access.ts b/server/src/plugins/access.ts new file mode 100644 index 000000000..825b5acad --- /dev/null +++ b/server/src/plugins/access.ts @@ -0,0 +1,256 @@ +import type { CatalogueEntry } from "./catalogue"; +import { toolkitOf } from "./composio"; +import type { TransportKind } from "./transport"; + +/** + * How one server row is reached: which protocol, whose credential, which app at a broker, and whose + * name the trail records. + * + * WHY THIS EXISTS AS ONE THING. These three questions were asked separately, in three places, each + * deriving its own answer from whichever field was nearest. That was complete while every server + * either had a frozen catalogue entry or was somebody's MCP endpoint. A Composio app is neither: it + * is a row an operator enabled, with no entry to carry a transport field and no OAuth kind to read, + * so all three questions answered wrongly by default and each failed silently in its own direction. + * + * The fourth question arrived the same way. Which app a brokered row is was read from the row id by + * the gate that checks whether a person has connected it, from the url by the transport that dials + * it, and described as a third thing by the schema column that records the connection — with nothing + * comparing the three, so a row whose id and url slug differed was checked against one app and run + * against another. + * + * Resolved once, here, and read as a field everywhere else. A fourth kind of server cannot be added + * without filling in this function, and the test beside it enumerates every row shape that exists — + * which is the exhaustiveness the previous arrangement could not offer, since nothing connects three + * independent string comparisons. + */ + +/** Whose credential a call goes out on. */ +export type CredentialSource = + /** One token the deployment holds, used for everybody. */ + | "deployment-token" + /** The asking person's own OAuth grant, exchanged per call. */ + | "person-oauth" + /** One key the deployment holds, with the broker keeping people apart by an id we send. */ + | "brokered" + /** None at all, because the call never leaves this process. */ + | "none"; + +export type ServerAccess = { + transport: TransportKind; + credential: CredentialSource; + /** + * Whose account the call reached, as the audit row names it. + * + * `person` is the asking person's id, and it is only correct where the call actually landed + * somewhere that person alone can see: their own OAuth grant, their own mailbox behind a broker — + * where the deployment holds the key but the call runs in one person's mailbox, which is the whole + * point of the connector and therefore the only useful thing the trail can say about it — or this + * deployment's own tables read as them. `deployment` means the opposite: not any one person's + * account. That covers a shared token, a server an administrator added by URL, and a public + * endpoint reached with no credential at all, where every person's call sees the same data and + * naming the asker would assert an attribution that does not exist. + */ + reachedAs: "person" | "deployment"; + /** + * Which app at the broker this row is, and null for a row that is not brokered at all. + * + * Read from the URL, because the URL is what the transport dials — so the app a person is checked + * against is the same app the call runs in, by construction rather than by two spellings agreeing. + * The row id is a display key: it is what an operator sees and what a grant names, and nothing + * keeps it equal to the slug in the URL. Deriving the app from it meant a row could pass the "has + * this person connected this app" gate on one spelling and run against another. + * + * Null everywhere else, because there is no app: an MCP endpoint and a per-person OAuth vendor are + * reached at an address, not at a broker, and a caller that finds null where it needs a toolkit is + * looking at a row it should not be brokering. + */ + toolkit: string | null; +}; + +const CREDENTIAL_BY_AUTH: Record< + CatalogueEntry["auth"]["kind"], + CredentialSource +> = { + none: "none", + "deployment-bearer": "deployment-token", + "user-oauth": "person-oauth", + builtin: "none", +}; + +/** + * Whose account each auth kind reaches. Keyed on the auth kind, NOT on the credential source above. + * + * `none` and `builtin` collapse to the same credential source — there is no credential either way — + * and they do not share an answer. A public endpoint touches nobody's account, so the trail says + * `deployment`, the same thing it says for a server added by URL. The builtin one runs against this + * deployment's own tables as the person whose turn it is, so the trail says `person`. Deriving this + * from `CREDENTIAL_BY_AUTH` made the two indistinguishable at exactly the point they differ, and + * answered `person` for both. + * + * A second table rather than a branch, so the compiler forces the question to be answered for any + * auth kind added later — which is what this module claims above and could not deliver while this + * field was inferred from something coarser than the thing it depends on. + */ +const REACHED_AS_BY_AUTH: Record< + CatalogueEntry["auth"]["kind"], + ServerAccess["reachedAs"] +> = { + none: "deployment", + "deployment-bearer": "deployment", + "user-oauth": "person", + builtin: "person", +}; + +/** + * The shelf both refusals below sit on: this deployment cannot say how to reach a row, and will + * not guess. + * + * CRITERION. Nothing on this shelf is a vendor's doing, a credential's doing, or anything the + * person asking can act on. An operator gets the sentence, because it names two of our own columns + * and what to do about them; every other audience — a model's context, a person's browser — gets + * the fact that the call did not happen, and none of the sentence. + * + * REASON. `store.ts` draws exactly this line already, between {@link PluginRefusedError} — a + * refusal somebody CAN act on, and the one class the codebase relays verbatim — and + * `PluginInvariantError`, a state this deployment's own code says cannot exist. These two are that + * second kind, found one step earlier: in resolving the row rather than in querying against it. + * + * A BASE CLASS RATHER THAN A LIST AT EACH AUDIENCE. `ServerRowAmbiguousError` shipped with no + * `catch` anywhere, so the refresh route rethrew it into the default handler — an administrator + * got a 500 with no body and a page that said "That did not work" — and `grantedTools` copied its + * message into a model's context, offering an end user's Bot a sentence about correcting a + * provenance column. A third contradiction added here has to be refused everywhere without a + * second edit, so the audiences ask one question: `isDeploymentFault` in `store.ts`. + */ +export abstract class ServerUnresolvableError extends Error {} + +/** + * A row that claims to be two servers at once, which makes it neither. + * + * CRITERION. A row whose provenance says `composio` and whose id is a curated catalogue slug is + * refused, not resolved — in either direction. + * + * REASON. {@link accessFor} holds two facts and no third: the row, and the entry that row's id + * looked up. A curated row whose provenance column was edited to `composio` and a genuinely + * brokered app that happens to be named `notion` arrive here identically, so every answer is right + * about one of them and wrong about the other. Entry-wins picked the first reading and therefore + * dialled the second as MCP at the curated vendor's pinned host, spending the deployment's own + * grant instead of the asking person's brokered connection — the wrong vendor on the wrong + * credential, recorded in the trail as an ordinary call to a reviewed server. + * + * THE SAME COLLISION IS ALREADY REFUSED AT THE OTHER END. `addCustomServer` will not let a row take + * a curated slug, because the slug prefixes tool names and is what a grant and a policy rule are + * written against. There is no `addComposioServer` to copy that guard into — nothing in the shipped + * product writes a `composio` row at all — so a colliding row arrives only by hand edit or restore, + * and only a check at resolution sees one. + * + * NOBODY ASKED FOR THIS REFUSAL, so it is not a person's to act on mid-call: it is two of our own + * columns contradicting each other, the same shelf `PluginInvariantError` sits on. Declared here + * rather than imported from `store.ts` because this module is a leaf — `store.ts` imports it, and + * it imports nothing back. + */ +export class ServerRowAmbiguousError extends ServerUnresolvableError { + constructor(message: string) { + super(message); + this.name = "ServerRowAmbiguousError"; + } +} + +/** + * A reviewed entry that names a transport no entry can be reached over. + * + * CRITERION. An entry declaring `transport: "composio"` is refused at resolution, and no answer is + * produced for it. + * + * REASON. {@link CuratedTransportKind} already keeps the value out of the catalogue at compile + * time, which is where it belongs — nothing writes an entry at runtime. This is what stands behind + * a cast, a JSON fixture in a test, or a future loader that reads entries from somewhere: what the + * unrefused answer WAS is a Composio dial with `toolkit: null` and a `reachedAs` copied from the + * entry's auth kind, so both store gates that keep one person's brokered account out of another's + * were skipped and the trail said the wrong thing about whose account was reached. Fail-closed + * costs one comparison; the alternative is a hole that opens the first time the type is bypassed. + */ +export class CatalogueTransportUnroutableError extends ServerUnresolvableError { + constructor(message: string) { + super(message); + this.name = "CatalogueTransportUnroutableError"; + } +} + +/** + * Whether a kind is the broker's, asked through a function so the question survives being answered. + * + * CRITERION. This comparison must stay live even though {@link CuratedTransportKind} makes it + * unreachable from the catalogue as the catalogue stands today. + * + * REASON. Written inline against `entry.transport`, the compiler narrows the operand to the three + * curated kinds and rejects the comparison as pointless — correctly, and only while nothing + * bypasses the type. A cast, a test fixture, or a loader that ever reads entries from outside the + * build would each produce the state this refuses, and each arrives at runtime where a type says + * nothing. Widening to {@link TransportKind} at a parameter costs one call and keeps both the + * compile-time door and the runtime one shut, rather than trading the second for the first. + */ +function isBrokerTransport(kind: TransportKind): boolean { + return kind === "composio"; +} + +/** + * A reviewed entry decides for itself; otherwise the row decides — and a row that claims both is + * refused rather than resolved. + * + * THE ENTRY WINS, AND THAT ORDER IS THE SECURITY PROPERTY. A curated slug's behaviour comes from code + * that was reviewed, so a row whose provenance column says something else — edited by hand, restored + * from an old backup, written by a bug — cannot turn a reviewed vendor into a brokered one and start + * sending its calls somewhere else. The row only ever answers where the catalogue is silent. + * + * IT CUTS BOTH WAYS, WHICH IS WHY `composio` IS REFUSED RATHER THAN OVERRULED. Only one direction + * was considered when that order was written: a brokered row whose id collides with a curated slug + * was quietly answered as the curated vendor. Nothing in these two arguments tells that row apart + * from a tampered curated one, so the only answer that is not wrong in one of the two worlds is no + * answer. See {@link ServerRowAmbiguousError}. Every other provenance value still loses to the + * entry, because none of them proposes a different vendor to reach. + * + * MCP stays the fallback, which is still right for a server an administrator added by URL: that is + * somebody else's MCP endpoint by definition, reached on the one token the deployment holds for it. + */ +export function accessFor( + row: { provenance: string; url: string }, + entry: CatalogueEntry | null, +): ServerAccess { + if (entry && row.provenance === "composio") { + throw new ServerRowAmbiguousError( + `${entry.key} is a server this deployment ships an entry for, and a row with that id says its provenance is composio. Nothing can tell an edited column from a brokered app that took the name, so this row is not resolved at all: rename it, or correct its provenance.`, + ); + } + + if (entry) { + if (isBrokerTransport(entry.transport ?? "mcp")) { + throw new CatalogueTransportUnroutableError( + `${entry.key} is a catalogue entry declaring the composio transport, which is reached from a row's provenance and the app slug in its url — neither of which an entry has. There is no app to broker to and no connection to check, so this entry is not resolved at all: give it the transport it is actually reached over.`, + ); + } + + return { + transport: entry.transport ?? "mcp", + credential: CREDENTIAL_BY_AUTH[entry.auth.kind], + reachedAs: REACHED_AS_BY_AUTH[entry.auth.kind], + toolkit: null, + }; + } + + if (row.provenance === "composio") { + return { + transport: "composio", + credential: "brokered", + reachedAs: "person", + toolkit: toolkitOf(row.url), + }; + } + + return { + transport: "mcp", + credential: "deployment-token", + reachedAs: "deployment", + toolkit: null, + }; +} diff --git a/server/src/plugins/broker.ts b/server/src/plugins/broker.ts new file mode 100644 index 000000000..5b857228c --- /dev/null +++ b/server/src/plugins/broker.ts @@ -0,0 +1,471 @@ +/** + * What openbot needs of Composio the BROKER, as against Composio the transport. + * + * `./composio` is about calling an action once an app is connected. This file is about everything + * that has to be true before that: which apps exist to choose from, which of them this deployment + * has an auth config for, whose account is attached to one, and how a person attaches or detaches + * theirs. Two different questions, so two different projections rather than one wide client. + * + * IT IMPORTS NOTHING, AND THAT IS THE POINT OF IT. Not `@composio/core`, not a type from elsewhere + * in this tree. Everything below is a name for a shape, so the vendor's package stays confined to + * the adapter that implements {@link ComposioBroker} — one file, replaceable, and the only place a + * version bump can reach. A module that named the vendor's types here would put their package on + * the import graph of every test that touches enablement. + */ + +/** + * The schemes whose secret a PERSON holds and types in, rather than one anybody registers. + * + * WRITTEN ONCE AND READ TWICE, BECAUSE A SECOND DECLARATION IS A WAY FOR THE TWO TO DISAGREE. The + * list is the fact and {@link FieldScheme} is derived from it, so the names the guard tests and the + * names the type admits cannot come apart. A hand-written union beside a hand-written array is the + * same set stated twice, and a member added to one of them and not the other typechecks perfectly. + * + * AND THE DISAGREEMENT FAILS OPEN, which is why it is worth a derivation rather than a comment + * asking the next person to keep both in step. {@link isFieldScheme} would answer false for a scheme + * the type calls valid, and an app whose secret a person types would be read as one nobody types — + * sent down the consent path, to a vendor screen that has nothing to ask them for. + */ +const FIELD_SCHEME_NAMES = [ + "API_KEY", + "BASIC", + "BEARER_TOKEN", + "BASIC_WITH_JWT", +] as const; + +export type FieldScheme = (typeof FIELD_SCHEME_NAMES)[number]; + +/** + * Whether a scheme recorded on a row is one whose secret a person types. + * + * Takes the column's own type — `string | null` — rather than a `FieldScheme`, because every + * caller is asking ABOUT a recorded value, and a signature demanding the answer first would push + * the same `includes` into four call sites. + */ +export function isFieldScheme(scheme: string | null): scheme is FieldScheme { + return ( + scheme !== null && + (FIELD_SCHEME_NAMES as readonly string[]).includes(scheme) + ); +} + +/** + * One value Composio wants from the person connecting, as Composio itself describes it. + * + * Every field here is published per app rather than guessed: `is_secret` says which one to mask, + * and the description is written for the person filling it in ("Your Firecrawl API key, a token + * starting with fc-"). `name` is sent back on the wire verbatim and is never shown. + */ +export type BrokerField = { + name: string; + label: string; + help: string; + required: boolean; + secret: boolean; + default?: string; +}; + +/** + * How this deployment would connect somebody to an app — the one fact everything else reads. + * + * Derived once from the catalogue, recorded on the app's row at enable time, and read by the + * picker (which hides `unsupported`), by the enable path (which creates the config this names, or + * none) and by the connect screen (which draws a link or a form). Deriving it twice is how a + * single hard-coded choice came to leak into every app in the first place. + */ +export type BrokerConnection = + | { kind: "consent" } + | { kind: "self-registering" } + | { kind: "fields"; authScheme: FieldScheme } + | { kind: "no-auth" } + | { kind: "unsupported"; reason: string }; + +/** + * One app in the catalogue, as much of Composio's toolkit listing as anything here reads. + * + * `logo` is nullable because the vendor publishes none for some toolkits, and an administrator + * picking from a list of a few hundred apps is better served by a missing image than by a broken + * one. + */ +export type BrokerApp = { + slug: string; + name: string; + description: string; + logo: string | null; + categories: string[]; + /** + * How many actions the app publishes, shown BEFORE anybody enables it. + * + * Because the size is the decision. Enabling an app writes every one of its actions into + * `mcp_tools` and puts them in front of a model, so the difference between an app with six + * actions and one with sixty-three is the difference between a small addition and a rewrite of + * what the model sees. An administrator who learns the number only after enabling has already + * made the choice this field exists to inform. + */ + actionCount: number; + /** How somebody would connect to it. See {@link BrokerConnection}. */ + connection: BrokerConnection; +}; + +/** + * What this deployment needs of Composio's broker, and nothing more. + * + * A NARROW PROJECTION RATHER THAN THEIR CLIENT, for the reason the transport's `ComposioActions` is + * one: nine methods is a shape a test satisfies with an object literal, so every test about + * enablement, connection and revocation is a test about this deployment's logic and none of them + * reaches the network. The vendor's client would drag its constructor, its retries and its schemas + * into each of those tests, and the first thing every one of them would do is find a way not to + * dial. + */ +export type ComposioBroker = { + /** Every app the catalogue offers, which is what an administrator chooses from. */ + listApps(): Promise; + /** + * Make sure this deployment has an auth config for the app, and say nothing if it already did. + * + * IDEMPOTENT, AND CALLED AT ENABLE TIME. An auth config is per-deployment rather than per-person — + * it is the thing a person's connection is then created against — so the natural moment to create + * it is when an administrator enables the app, and the natural number of times that moment + * happens is "more than once": an app can be enabled, removed and enabled again, and two + * administrators can press the button together. An implementation that created a second config + * on the second call would leave a person's existing connections pointing at the first one. + */ + ensureAuthConfig(config: { + toolkit: string; + name: string; + /** + * Which flow this app was resolved to, which decides what is created and whether anything is. + * + * Passed in rather than read here, because the caller has already resolved it from the + * catalogue row the administrator chose, and a second derivation is a second answer. + */ + connection: BrokerConnection; + }): Promise; + /** + * Drop this deployment's own auth configs for the app, which is what removing an app has to do. + * + * ITS OWN, WHICH IS A NARROWER PROMISE THAN "THE APP'S". An auth config lives in an operator's + * Composio dashboard beside any they made by hand there, and removing an app from these pages is + * not a mandate to delete somebody's dashboard work. An implementation has to be able to tell the + * two apart before it deletes anything, and to leave anything it cannot claim. + */ + deleteAuthConfig(toolkit: string): Promise; + /** + * Begin one person's connection to one app, answering the url they have to visit. + * + * THE URL IS A BEARER CAPABILITY. Whoever opens it attaches an account to this person's + * connection, so it is neither stored nor logged nor put in an audit row: it is handed to the + * browser that asked for it and then forgotten. A redirect url in a log is somebody else's + * mailbox for as long as it stays valid. + */ + authorize(request: { + userId: string; + toolkit: string; + /** + * Where the vendor sends this person once the consent screen is done with them. + * + * REQUIRED, BECAUSE A CONSENT WITH NO RETURN LEG STRANDS SOMEBODY. Without it the flow ends on + * Composio's own hosted page: the person has consented, nothing here knows it, and the only + * way back is for them to find this deployment again by hand. An optional field would have + * made that the default for whichever call site forgot to pass one, which is exactly the state + * this parameter exists to end. + * + * IT IS AN ADDRESS THIS DEPLOYMENT BUILT AND NEVER ONE A CALLER CHOSE. Whoever names it names + * where a person lands holding a just-completed consent, so a value taken from a request body, + * a query or a header would be an open redirect with a consent screen in front of it. The one + * caller builds it from the deployment's configured app URL and narrows the page within it to + * a known name, the same way this repository's own OAuth `returnTo` is narrowed. + * + * AND `string` IS THE WHOLE OF WHAT THE TYPE CAN PROMISE, WHICH IS WHY + * {@link brokerReturnUrl} EXISTS. "Required" above means "not optional", and `""`, `" "` and + * `openbot.example.com/settings/...` are all required values: they satisfy this field and + * reach the vendor as a callback nobody returns through. Nor can a narrower type fix it — the + * address is assembled at run time from an environment variable, so the caller holds a + * `string` and every type an ordinary `string` is assignable to admits the empty one too. The + * promise this comment makes is therefore kept by the guard below, and a caller hands its + * address through that before it hands it here. + */ + returnUrl: string; + }): Promise<{ redirectUrl: string }>; + /** Whether this person currently has an account attached to this app at the vendor. */ + isConnected(request: { userId: string; toolkit: string }): Promise; + /** + * Ask the vendor to withdraw this person's grant, answering WHAT WAS ACTUALLY ASKED. + * + * True where this deployment found at least one account and asked the vendor to revoke it, false + * where there was none to withdraw — not "the call did not throw". The audit trail records that + * answer as `mcp.account_disconnected`'s `vendorRevocationRequested`, and the whole value of that + * field is that a reader can tell an account this deployment acted on from one that outlives it + * somewhere else. A boolean that always said true would make the row a worse record than no row. + * + * "REQUESTED" IS AS FAR AS ANY IMPLEMENTATION CAN HONESTLY GO, and the name of the field says so + * because the first one did not. This boolean used to be called `vendorRevoked` and was written + * by an adapter that soft-deleted the account and asked for no revocation at all, so a trail that + * said a grant had been withdrawn recorded one that was still live at Google. What a broker can + * promise synchronously is that the account is gone at the broker — nothing here can call with it + * again — and that the upstream withdrawal was asked for; whether the provider honoured it + * happens afterwards, out of sight of the call that asked. A field that claimed the stronger + * thing would be the one row in the trail nobody could rely on. + * + * A PARTIAL ASK IS A FAILURE RATHER THAN A TRUE. One person can hold more than one account for + * one app, and an implementation that ended some of them and could not end the rest has not + * disconnected anybody: their app still answers. It must throw, so that the row this deployment + * holds — the only thing that names which app to try again against — is still standing when they + * press disconnect a second time. + */ + revoke(request: { userId: string; toolkit: string }): Promise; + /** + * What this app asks a person to type, as Composio publishes it for the scheme. + * + * ASKED OF THE VENDOR RATHER THAN WRITTEN DOWN HERE, which is the whole reason it is a call and + * not a constant. The fields are per app and they move: a form built from one hard-coded "API + * key" box is right for Firecrawl and wrong for the app that also wants a workspace subdomain, + * and wrong quietly — the person fills in what they were shown, a connection is created without + * the value nobody asked them for, and the first tool call is what discovers it. The `help` on + * {@link BrokerField} is the vendor's own sentence written for the person filling the box in, and + * it is worth more than anything this deployment could invent about somebody else's console. + * + * THE SCHEME IS PASSED IN RATHER THAN RESOLVED HERE, for the reason {@link + * ComposioBroker.ensureAuthConfig} takes a connection rather than deriving one: the caller + * already holds the scheme recorded on the app's row at enable time, and a second derivation is a + * second answer — a form drawn for `BASIC` in front of a config created for `API_KEY`, whose + * fields the person cannot fill in because they are not the ones their app has. + */ + connectionFields(request: { + toolkit: string; + authScheme: FieldScheme; + }): Promise; + /** + * Connect this person with the secret they typed, answering the account it made. + * + * THE ONE CALL WHOSE IMPLEMENTATION MUST RETHROW WITH NO `cause`, WHICH IS AN INVERSION OF THE + * STANDING RULE AND SAYS SO ON PURPOSE. The rule `composio-adapter.ts` states and every path here + * keeps is that a vendor error is never logged and always carried as `cause`, precisely because + * the object holds the request it was made for and whoever is reading a log rather than a page + * deserves it. On every other call that request is a link mint or a delete. On this one it is + * somebody's API key. So this is the single place the rule reverses: read the vendor's sentence + * through the existing door, then drop the object entirely rather than attach it. + * + * WHAT THAT COSTS IS THE DIAGNOSTIC TRAIL ON THE FLOW PEOPLE MOST OFTEN MISTYPE, AND THE COST IS + * ACCEPTED KNOWINGLY. A key pasted with a newline, a token from the wrong workspace, a secret for + * the staging tenant — these are the ordinary failures here, and they are the ones this leaves + * nothing behind about. What an operator gets instead is Composio's own sentence and the request + * id inside it, which is enough to ask the vendor about that attempt and not enough to rebuild it + * here. A `cause` that made the next mistyped key easier to explain would put every correctly + * typed one in a log for as long as the log is kept. + * + * THE `accountId` IS ANSWERED SO A CALLER CAN UNDO EXACTLY THIS ACCOUNT. A connection made from + * typed fields is verified before it is kept, and a verification has to be able to take back the + * thing it just made and nothing else. {@link ComposioBroker.revoke} is the wrong instrument for + * that — it ends every account this person holds for the app, which is right for somebody ending + * their access and wrong for a step undoing its own work. The two differ exactly when the local + * row and Composio have drifted apart: the person already had a connection that works, this + * attempt made a second one, the verification failed — and a sweep there takes down the + * connection that was working. See {@link ComposioBroker.revokeAccount}. + */ + connectWithFields(request: { + userId: string; + toolkit: string; + authScheme: FieldScheme; + /** + * What the person typed, keyed by the `name` {@link BrokerField} was published under. + * + * THE ONLY SECRET THAT CROSSES THIS SEAM, which is what the no-`cause` rule above is about. The + * names are sent back on the wire verbatim and are never shown; the values are the person's own + * credential and belong in no message, no log and no audit row, for the reason the connect url + * does not. + */ + values: Record; + }): Promise<{ accountId: string }>; + /** + * End ONE account by id, and ask for the grant behind it to be withdrawn too. + * + * ONE, WHICH IS THE WHOLE DIFFERENCE FROM {@link ComposioBroker.revoke}. That method sweeps every + * account a person holds for an app, because what it serves is a person ending their access to + * it. This serves a caller undoing an account it just made, and the id is the whole of what it + * names — nothing is listed, nothing is matched, and no account this call was not handed can be + * reached by it. The narrower instrument exists because the wider one is destructive in precisely + * the case a verification runs into: a working connection standing beside a failed second + * attempt. + * + * WITH `revoke_on_delete`, WHICH IS WHAT MAKES IT A WITHDRAWAL RATHER THAN A RECORD-KEEPING + * SOFT-DELETE. Without that flag the account stops being visible to this deployment and the + * credential at the far end stands — which is the exact state {@link ComposioBroker.revoke} + * records this deployment once claiming as a revocation, and it is worse here than there: the + * secret left live is one a person typed minutes ago into a form that then told them the + * connection had not been kept. + * + * NO BOOLEAN, BECAUSE THERE IS NOTHING TO COUNT. `revoke` answers what it found because it + * searches for it; this is handed the id of an account created moments earlier by the call that + * answered it, so "there was nothing there" is not an outcome a caller chooses between — it is a + * failure, and it throws like any other. + */ + revokeAccount(accountId: string): Promise; +}; + +/** + * A refusal this deployment authored, whose own message is the whole explanation. + * + * THE ROUTE CANNOT TELL AN AUTHORED REFUSAL FROM A VENDOR OUTAGE WITHOUT A TYPE, which is the only + * reason this class exists. `routes.ts` answers a thrown broker error by reaching into it for the + * vendor's own sentence and, finding none, saying what a vendor failure deserves to be told — + * "Composio said nothing about why, check the key, check their status". That advice is wrong twice + * over for a sentence this deployment wrote itself: Composio was reachable, answered, and the thing + * that has to change is here rather than there. Every refusal raised below this line is written for + * the person who will read it and names the step that fixes it, so the one correct thing a route + * can do with it is pass it through. + * + * WHICH MAKES THE CLASS A PROMISE ABOUT THE MESSAGE rather than a category of failure. Nothing is + * raised as one of these unless its sentence is safe to show anybody who could have made the + * request — no url that is a bearer capability, no key, no vendor object — because that is exactly + * what raising it asks the route to do. A failure this file cannot explain stays a plain `Error`, + * so the route keeps reaching for the vendor's own words instead of inventing better ones. + */ +export class BrokerRefusalError extends Error { + constructor(message: string, options?: ErrorOptions) { + super(message, options); + this.name = "BrokerRefusalError"; + } +} + +/** + * The state a deployment with no Composio key is in, raised rather than returned. + * + * A STATE, NOT A FAULT, in the same sense `./composio`'s unconfigured listing is one: unset + * `COMPOSIO_API_KEY` is the documented default, and where it is unset there is nothing to connect, + * nothing to grant and no brokered tool for a Bot to call — what is left on screen is one row that + * goes nowhere, under More apps on the admin Plugins page, naming the setting rather than hiding + * the feature. It is a thrown class rather than a null answer because the broker's methods + * answer apps, booleans and urls, and there is no value in any of those shapes that means "nobody + * was asked" — an empty app list is indistinguishable from a catalogue outage, and `false` from + * {@link ComposioBroker.isConnected} is a positive claim about somebody's account. + * + * The setting is named in the message because the message is usually the whole remedy: an operator + * reading it needs the name of the variable to set, and the one other place this deployment names + * it is that row on the admin Plugins page, which somebody meeting this error through the API may + * never have seen. + * + * A {@link BrokerRefusalError} BECAUSE IT IS THE ORIGINAL ONE. It was the only authored refusal a + * route could recognise when this file had one class, and it is a refusal of exactly that kind: a + * sentence written here, naming the step that fixes it. Keeping its own name is what lets a caller + * ask for this one state in particular — `store.ts` raises it by name, and `routes.ts` sends its + * message where no call was made at all. + */ +export class BrokerUnconfiguredError extends BrokerRefusalError { + constructor() { + super( + "Composio is not configured for this deployment, so nothing was asked. Set COMPOSIO_API_KEY to make the brokered apps available; until it is set there is nothing to connect, nothing to grant and no Composio tool for a Bot to call, and the admin Plugins page shows one row under More apps that goes nowhere.", + ); + this.name = "BrokerUnconfiguredError"; + } +} + +/** + * A return address that could not bring anybody back, refused before a consent is spent on it. + * + * ITS OWN CLASS BECAUSE ITS REMEDY IS ITS OWN. Every other refusal in this file is about Composio — + * a key that is not set, a config this deployment never made, a consent the vendor answered with no + * page. This one is about this deployment's own address for itself, and the person who can act on it + * is an operator with `OPENBOT_APP_URL` in front of them. A caller that could not tell the two apart + * would send somebody to check a Composio key that is perfectly fine. + * + * A {@link BrokerRefusalError} because it keeps that class's promise about the message: the sentence + * is written here, names the step that fixes it, and carries no url. Which matters more than usual + * for this one — the value it is refusing is the thing a bad message would be tempted to quote, and + * an address is the half of a connect link that says which deployment and which person it is for. + */ +export class BrokerReturnUrlError extends BrokerRefusalError { + constructor(message: string) { + super(message); + this.name = "BrokerReturnUrlError"; + } +} + +/** + * The address a person comes back to, checked to be one, or a refusal instead of a link. + * + * BEFORE THE CONSENT RATHER THAN AFTER IT, which is the entire value of doing this at all. Past this + * point the next thing that happens is a vendor page and somebody granting a third party access to + * their mailbox; a callback that is not a callback is only discovered once they have, by which time + * the thing that would tell them what went wrong is on the deployment they can no longer reach. So a + * caller that has no usable address gets a refusal in place of a link, and nobody spends a consent. + * + * TWO REFUSALS, BECAUSE THEY ARE TWO DIFFERENT MISTAKES. An empty address is a caller that built + * none — the guard in front of this one did not run, or ran against the wrong value. An address that + * is not a web page is a configured one that cannot work: `OPENBOT_APP_URL` set to + * `openbot.example.com`, which no browser can resolve from Composio's origin, or to `localhost:3001`, + * where `localhost:` is read as the scheme. Both are reachable from the settings this deployment + * actually ships — the variable is an environment string and nothing between it and the vendor looks + * at it — and both end with the same person on the same hosted page with nowhere to go. + * + * IT NAMES THE SETTING AND NOT THE VALUE. The setting is the remedy, and it is the same one whether + * the address arrived empty or malformed; the value is a page address for one person's connection + * and belongs in no message, no log and no audit row, for the reason the connect url does not. + * + * WHAT COMES BACK IS THE ADDRESS THAT WAS CHECKED, WHICH IS NOT ALWAYS THE STRING THAT WENT IN. The + * check reads a parsed address: the emptiness test trims, and parsing drops the spaces and control + * characters a URL cannot contain — so ` https://openbot.test/…`, an address ending in a newline and + * one with a tab inside its host all satisfy this guard while denoting something else entirely. A + * version that approved the parsed address and returned the raw one approved nothing: the padding + * travelled on to Composio as part of the callback, which is the person stranded on a vendor page + * that this function exists to prevent. Returning what was read is what makes the reading binding. + * + * THAT IS A READING OF THE ADDRESS AND NOT A CHOICE ABOUT IT. Where somebody lands holding a + * just-completed consent is a decision this seam keeps in one place, so the destination is still + * the caller's; a parsed address names the same place a browser handed the original would have gone + * — the padding was never part of the destination, only of the string. What this cannot do is guess + * at an address that means nothing, which is why the branch above refuses rather than repairs: a + * missing scheme is a setting to fix and not whitespace to drop. + */ +export function brokerReturnUrl(returnUrl: string): string { + if (returnUrl.trim() === "") { + throw new BrokerReturnUrlError( + "This deployment built no address for Composio to send you back to, so the connection was not begun rather than begun with nowhere to land. Set OPENBOT_APP_URL to the address this deployment's pages are served from, and connecting an app will have a return leg.", + ); + } + const address = webAddress(returnUrl); + if (address === null) { + throw new BrokerReturnUrlError( + "The address Composio would send you back to is not a web address, so a consent granted there would end on Composio's own page with no way back here. Set OPENBOT_APP_URL to this deployment's own origin including the scheme — https://openbot.example.com rather than openbot.example.com.", + ); + } + return address.href; +} + +/** + * The address a browser on somebody else's origin could follow — absolute, and http or https — and + * null for anything else. + * + * It answers with the parsed address rather than a boolean so that the one caller can hand back what + * was actually examined. A predicate would leave the caller holding only the string it was given and + * no way to tell it apart from the address that string denotes. + */ +function webAddress(value: string): URL | null { + let parsed: URL; + try { + parsed = new URL(value); + } catch { + return null; + } + return parsed.protocol === "https:" || parsed.protocol === "http:" + ? parsed + : null; +} + +/** + * The broker failures worth explaining to a reader, and null for every other one. + * + * NULL RATHER THAN A FALLBACK SENTENCE, which is the whole reason this is a function instead of an + * `error.message` read at each call site. The only failures whose message this module can vouch for + * are the ones raised as a {@link BrokerRefusalError}, which is a promise its subclasses make about + * what they say; a socket that hung up, a 500 from the catalogue and a rate limit are all failures + * it knows nothing about. A function that answered those with `error.message` would be putting a + * vendor's object, and whatever it happens to carry, in front of whoever asked. + * + * So the caller chooses what to say about a failure it actually has, and this decides only the + * cases it can decide. The raised error's own message is returned rather than a copy, so there is + * one wording of each remedy and it lives beside the code that raises it. + */ +export function brokerSentence(error: unknown): string | null { + return error instanceof BrokerRefusalError ? error.message : null; +} diff --git a/server/src/plugins/catalogue.ts b/server/src/plugins/catalogue.ts index b8445bfce..afd265821 100644 --- a/server/src/plugins/catalogue.ts +++ b/server/src/plugins/catalogue.ts @@ -30,7 +30,7 @@ // cloud credentials. `target.ts` imports nothing itself, so asking it here adds no dependency. import { isNeverAllowedHostname } from "../computer/target"; // Type-only, so naming the transport here creates no import cycle with the registry that resolves it. -import type { TransportKind } from "./transport"; +import type { CuratedTransportKind } from "./transport"; export type CatalogueAuth = /** Answers without any credential at all. */ @@ -116,8 +116,13 @@ export type CatalogueEntry = { * unknown tool as a write rather than as a read: a tool the server never advertised, so nothing * here could have named it, is safe to over-scrutinize as a write. The opposite direction is the * one that matters for this list: a tool the server DOES advertise but that is missing from here - * classifies as a read, so an incomplete list is the failure mode, not a safe default — this list - * has to lean over-inclusive. + * classifies as a read unless the vendor itself recorded an effect for it, so for a vendor that + * records nothing — which is every one here today — an incomplete list is the failure mode rather + * than a safe default, and this list has to lean over-inclusive. + * + * Nothing outside review can shorten it, either. A name this list holds is a write no matter what + * a vendor recorded for that action, because this list is what a person read and the recorded + * effect is whatever was last written into an unconstrained column. */ writeTools: readonly string[]; /** @@ -127,8 +132,12 @@ export type CatalogueEntry = { * serves Drive over both an MCP endpoint and an ordinary REST API, and which one this deployment * uses is a decision about availability and risk rather than a property of the vendor. Naming it * here keeps that decision beside the host it applies to, and makes reversing it a one-line diff. + * + * NOT EVERY KIND, and the narrowing is the point: see {@link CuratedTransportKind}. The broker's + * transport is reached from a row's provenance and its url, never from an entry, and an entry + * naming it resolves to a Composio dial with no app and no brokered gate. */ - transport?: TransportKind; + transport?: CuratedTransportKind; docsUrl: string; }; @@ -244,9 +253,11 @@ export const CATALOGUE: readonly CatalogueEntry[] = Object.freeze([ * The writing tools as the hosted server advertises them today. The hosted server advertises * its tools, so a name here that does not match an advertised tool is not the risk — an * advertised tool that is missing from this list is: {@link classifyTool} reads an unlisted - * but advertised name as a read, never as a write. That makes under-inclusion the failure - * mode, so this list has to lean over-inclusive rather than minimal, and reconciling it - * against the live tool list on the first Refresh tools is required, not cosmetic. + * but advertised name as a read, never as a write. Notion's MCP listing carries no per-action + * effect, so this list is the only thing that can say otherwise and nothing backstops it. That + * makes under-inclusion the failure mode, so this list has to lean over-inclusive rather than + * minimal, and reconciling it against the live tool list on the first Refresh tools is + * required, not cosmetic. */ writeTools: Object.freeze([ "notion-convert-page-to-skill", @@ -361,25 +372,68 @@ export function resolveServerUrl( /** * What this tool does, in the only two categories a policy author cares about. * - * Unknown counts as a write. A tool named in {@link CatalogueEntry.writeTools} is a write. A tool - * the server never advertised at all is a write, because the only thing that produced the name was - * a model. A server with no catalogue entry behind it is a write throughout, because nothing - * reviewed says any tool of theirs only reads. + * A RECORDED EFFECT MAY NARROW WHAT A BOT MAY DO AND MAY NEVER WIDEN IT. That is the criterion the + * order below is built from, and it is why the reviewed write list is consulted FIRST. Both sources + * are trusted to make an action a write; only the reviewed one is trusted to make an action a read + * where the other says write. `writeTools` was read by a person before it shipped. A recorded effect + * arrives from a vendor listing into a plain `text` column with no check constraint, so it is + * whatever was last written there, by a refresh or by a hand on a psql prompt. A value in that column + * therefore cannot take an action off the reviewed list. + * + * THREE SOURCES, CONSULTED IN THIS ORDER. Whether the server advertised the name at all: it did not, + * the name came from a model and the answer is write, and nothing later overrides that. Then the + * reviewed write list, which settles a name it holds as a write. Then what the vendor recorded, where + * exactly `read` is a read and every other value it holds — a recorded write, an unrecognised label, + * a different case, the empty string — is a write. Where the column holds nothing at all the reviewed + * list finishes the job: an advertised name it declines to call a write is a read. * - * Only a tool the server itself listed AND that is absent from the write list is treated as a read. - * That is the one case where both sources agree, and it is the only one where guessing permissively - * is recoverable. + * Unknown counts as a write throughout. A server with no catalogue entry behind it is a write unless + * the vendor recorded a read for that action — there is no reviewed list to consult, so an unlabelled + * action of theirs has nothing saying it is safe. + * + * So there are two ways to earn a read, and both require somebody to have said so. Either the vendor + * labelled the action a read and no reviewed list contradicts them, or the server advertised it and a + * reviewed list declined to call it a write. Guessing permissively is recoverable only in those two + * cases; everywhere else the answer is a write. */ export function classifyTool( entry: CatalogueEntry | null, toolName: string, advertised: boolean, + /** + * What the vendor said about this action when it was listed, or null when nothing did. + * + * Consulted AFTER the entry's write list, so it can only agree with review or add to it. It is the + * only source that can exist for a broker's catalogue — Composio labels every one of Gmail's + * sixty-three actions, and no reviewed list here could keep pace with several hundred apps that + * change weekly — so where review said nothing it is the whole answer. + * + * PRESENCE, NOT TRUTHINESS, is what makes it consulted, and only the exact string `read` produces a + * read. A recorded write, an unrecognised value, a different case and the EMPTY STRING are all + * writes: the empty string is a value the column holds rather than a silence, and reading it as + * "nothing was recorded" would send an advertised action no reviewed list names down the read + * branch. Null and undefined are the column saying nothing, and fall through to the reviewed list. + * So a column somebody typed into by hand, or a label a vendor adds later that this code has never + * heard of, cannot widen what a Bot may do unasked. + */ + recorded?: string | null, ): "read" | "write" { + // A name the server never listed came from a model, and nothing reviewed says it only reads — + // checked first, so neither later source can rescue a name that was never advertised. + if (!advertised) return "write"; + // The reviewed list outranks the column, and only in this direction: a name a person reviewed as a + // write stays a write whatever the listing recorded about it. + if (entry?.writeTools.includes(toolName)) return "write"; + // Anything the column holds settles the rest. `typeof` rather than truthiness so the empty string + // is treated as the value it is instead of as an absence of one. + if (typeof recorded === "string") + return recorded === "read" ? "read" : "write"; // A server an administrator added by URL has no reviewed tool catalogue behind it, so nothing here // can say a tool of theirs only reads. Everything it offers is a write. if (!entry) return "write"; - if (!advertised) return "write"; - return entry.writeTools.includes(toolName) ? "write" : "read"; + // Advertised, not on the reviewed write list, and nothing recorded. Two sources had the chance to + // call it a write and neither did. + return "read"; } /** diff --git a/server/src/plugins/composio-adapter.ts b/server/src/plugins/composio-adapter.ts new file mode 100644 index 000000000..2a733e8e8 --- /dev/null +++ b/server/src/plugins/composio-adapter.ts @@ -0,0 +1,3789 @@ +import { Composio } from "@composio/core"; +import { + type BrokerApp, + type BrokerConnection, + type BrokerField, + BrokerRefusalError, + type ComposioBroker, + type FieldScheme, + isFieldScheme, +} from "./broker"; +import { + type ComposioAction, + type ComposioActions, + type ComposioResult, + LISTING_LIMIT, + vendorSentence, +} from "./composio"; + +/** + * The one file in `server/src` that imports `@composio/core`, and what it owes the rest of them. + * + * `./composio` describes calling an action and `./broker` describes everything that has to be true + * before one can be called; both are written as narrow projections that name no vendor type, so + * that the SDK's shape — its constructor, its retries, its zod schemas and whatever the next + * version renames — is confined here. This module is the adapter that satisfies both from one + * client. A second importer of `@composio/core` under `server/src` would undo that, because the + * point of a single import site is that a version bump has exactly one file to be read against. + * + * NO SESSION IS EVER CREATED, AND THAT IS A SECURITY BOUNDARY RATHER THAN A PREFERENCE. The SDK's + * `composio.create(...)` and `sessions.create(...)` open a Composio tool-router session, and a + * session brings Composio's own hosted surface with it — a remote shell and a Python sandbox that + * this deployment neither asked for, cannot see into, and could not audit if a model reached them. + * Everything below is a plain per-call request carrying a user id. A `create` of a session + * anywhere in this file is a defect, not an optimisation, and it will not look like one: the + * session API is the shortest path to most of what this file does the long way. + * + * EVERY LISTING PASSES AN EXPLICIT LIMIT AND EVERY LISTING IS READ TO THE END OF ITS CURSOR. + * Composio's default page is 20, which is smaller than the number of actions Gmail alone publishes, + * and through the SDK's tool wrapper the default did a second thing as well: `getRawComposioTools` + * set `important=true` whenever a toolkit query arrived with no limit, no tags and no search + * (`@composio/core` 0.18.1, `src/models/Tools.ts:505-515`), so an omitted limit silently narrowed + * the answer to the vendor's own "important" subset and nothing in the result said a filter had + * been applied. {@link LISTING_LIMIT} is the documented page ceiling and therefore the fewest round + * trips a listing can be read in. + * + * AND THE LIMIT IS A PAGE RATHER THAN THE ANSWER, WHICH IS WHAT CHANGED. Two of the four listings + * here used to meet a full page and refuse it, because the SDK wrapper around them could not ask + * for a second — one takes no cursor, the other drops the response's. That was never true of the + * vendor: both raw endpoints carry `cursor` and `next_cursor`, and Composio publishes more than + * {@link LISTING_LIMIT} toolkits, so the catalogue refusal fired on every call and the app picker + * showed an operator nothing at all. All four now go through {@link everyRowOf}, which follows the + * cursor until the vendor stops offering one and refuses rather than truncates when it cannot. + * + * THE API KEY NEVER LEAVES THIS FILE. It arrives as {@link createComposioClient}'s only argument, + * goes straight into the vendor's constructor, and is held from there on by the vendor's client + * inside a closure. Nothing below logs it, no thrown message quotes it, and neither of the two + * returned objects carries a field that could be read back to it — they expose eight methods and + * no state. A key in a log line is a key in a log aggregator, and a key in an error message is a + * key in an audit row and in a model's context. + */ + +/** + * One tool as the SDK's SINGLE-TOOL call hands it over, in as much detail as anything here reads. + * + * Declared structurally rather than imported as `Tool`, for the same reason the seams it feeds are + * structural: a field this file does not read is a field a vendor rename cannot break. `toolkit` + * is optional because the SDK spells it optional — see {@link ComposioActions.execute} below for + * what is done when it is in fact missing. + * + * THIS IS THE ONE ROW THE SDK STILL VALIDATES, AND IT IS NO LONGER TWO. `Tools.transformToolCases` + * ends in `ToolSchema.parse(...)` (`@composio/core` 0.18.1, `src/models/Tools.ts:193`), a throwing + * parse rather than the warn-only `transform()` every other answer here goes through, and + * `getRawComposioToolBySlug` runs it (`:719`). The LISTING used to as well (`:561`) and does not + * any more: it reads {@link VendorToolRow} off the raw client, because the wrapper that ran the + * parse is also the wrapper that could not be paged. So `ToolkitSchema` spelling that inner `slug` + * required (`src/types/tool.types.ts:12-16`) is a guarantee this declaration may still rest on, + * and it is a guarantee about exactly one call. + * + * WHAT THIS FILE HANDS ON IS STILL `unknown`, WHICH IS WHERE THAT ARGUMENT ALWAYS STOPPED BEING + * TRUE. A parse is a fact about one method of one version of one package, and what these + * declarations govern is {@link ComposioVendor} — the seam a test satisfies with a literal and the + * shape the next version will be read against. Running it shows the gap is not academic: a + * `description` of 42 crosses into `./composio` as the `string` this said it was and reaches + * `.replaceAll` in `./store` as a bare `TypeError`. A declaration is an assertion and not a check. + */ +type VendorTool = { + slug: string; + description?: unknown; + inputParameters?: unknown; + tags?: string[]; + version?: unknown; + toolkit?: { slug: string }; +}; + +/** + * One tool as the LISTING hands it over, which is the wire's own spelling and nobody's parse. + * + * SNAKE_CASE BECAUSE THIS IS COMPOSIO'S ANSWER RATHER THAN THE SDK'S RESTATEMENT OF IT. The listing + * reads `client.tools.list` directly — see {@link ComposioVendor} for why it has to — so nothing + * renames `input_parameters` on the way here and nothing runs `ToolSchema` over it. Both halves of + * that are deliberate. The rename was never a service: `transformToolCases` re-spelled the field + * and `ToolSchema.parse` then STRIPPED every schema key its `ParametersSchema` did not name — + * `if`, `then`, `else`, `examples`, every `x-` extension at the root, and `deprecated` and + * `contentEncoding` per property (`@composio/core` 0.18.1, `src/types/tool.types.ts:77-174`) — + * before any caller could see them. What a model is shown is now what Composio published; see + * {@link ComposioAction.inputParameters}, where that loss was written down as unavoidable. + * + * AND EVERY FIELD IS DECLARED AT WHAT THE WIRE CAN HOLD, because with the parse gone there is + * nothing between Composio and {@link actionOf} at all. `slug` widens for exactly that reason: + * `ToolSchema` required it and nothing does now, and `actionOf` was already checking it anyway. + * `tags` stays narrow on the same argument it always stood on, which never involved the parse — + * `./composio` refuses a `tags` that is not a list of labels where it reads them, container and + * contents both, and a check on both sides of one seam is a check nobody maintains. + */ +type VendorToolRow = { + slug?: unknown; + description?: unknown; + input_parameters?: unknown; + tags?: string[]; + version?: unknown; +}; + +/** + * One catalogue row as the vendor hands it over, which is the wire's own spelling and nobody's map. + * + * `meta` is where all of it lives and every field of it is optional, which is not the SDK being + * cautious: Composio genuinely publishes toolkits with no logo, no description and no category. + * See {@link ComposioBroker.listApps} below for what each absence becomes. + * + * SNAKE_CASE AND `unknown` THROUGHOUT, BECAUSE THE TRANSFORMER THIS WAS WRITTEN AGAINST IS GONE. + * The catalogue reads `client.toolkits.list` directly — see {@link ComposioVendor} for why it has + * to — so `transformToolkitListResponse` no longer stands between Composio and {@link appOf}, and + * three things it was doing have to be accounted for rather than assumed. + * + * IT RENAMED, so the count is `tools_count` here and the category's own word is `name` + * (`@composio/client` 0.1.0-alpha.76, `resources/toolkits.d.ts:405-435`). That rename was the one + * thing in this projection easiest to get silently wrong — a count read off the wrong key is a + * plausible zero rather than an error — which is why {@link appOf} is where it is read and why a + * test asserts the figure rather than the field. + * + * IT REBUILT `meta` AND THE `categories` LIST, spreading each into a fresh literal and mapping + * every entry (`@composio/core` 0.18.1, `src/utils/transformers/toolkits.ts:21-34`). Those were + * this file's two structural guarantees and they were real: a `meta` of null and a `categories` of + * "crm" each raised a `TypeError` from that line when 0.18.1 was run, which is why neither was + * declared `unknown` and neither was checked. Nothing raises now. A `meta` that is a string reads + * as an app with no description, no logo, no categories and no count, and a `categories` that is a + * string reads as an app in no category — two silent, plausible answers about a real app. Both are + * declared at what the wire can hold and both are refused in {@link appOf}. + * + * AND IT NEVER VALIDATED, which is the part that does not change. `transform()` checks with + * `safeParse` and, where that fails, logs a warning and returns the unvalidated object anyway + * (`src/utils/transform.ts:26-36`), so `ToolKitItemSchema` spelling `name` required and the count a + * number always described the answer Composio MEANS to send rather than the one that arrived. Every + * wire-valued field was already `unknown` on that argument and stays so: the slug, which is the + * only name this deployment has for an app; the name, which is the only thing to show a person + * choosing between apps; the description, the logo, each category's word, and the count. + */ +type VendorToolkit = { + slug?: unknown; + name?: unknown; + meta?: unknown; + no_auth?: unknown; + auth_schemes?: unknown; + composio_managed_auth_schemes?: unknown; +}; + +/** + * One toolkit read on its own, which this file wants for exactly one thing: what it asks a person. + * + * ONE FIELD, BECAUSE ONE FIELD IS WHAT IS READ. The per-app retrieve answers everything the + * catalogue row does and a good deal more, and naming any of it here would be this file declaring + * knowledge of a shape nothing below opens. + * + * `unknown` FOR THE REASON EVERY OTHER VENDOR TYPE IN THIS FILE SAYS SO. What hangs off + * `auth_config_details` is a list of modes, each carrying the fields its scheme wants, and every + * one of those is copied across verbatim — so the declaration would be an assertion about the wire + * rather than a fact about it. {@link ComposioBroker.connectionFields} reads it a step at a time + * and shows a person only what it could actually read. + */ +type VendorToolkitDetail = { + auth_config_details?: unknown; +}; + +/** + * One auth config as the vendor hands it over, which is three fields because all three decide. + * + * `name` IS THE ONLY PROVENANCE THERE IS. Composio publishes no field saying which client created a + * config, and the listing is scoped to the project rather than to this deployment, so a config an + * operator made by hand in the dashboard comes back beside the ones made here and is otherwise + * identical. The name is the one field this deployment chooses, which is why {@link CONFIG_SUFFIX} + * is written into it and why every decision below reads it. + * + * `status` because a DISABLED config is still a config: it answers the listing, it satisfies the + * "does one exist" question, and a connect link minted against it does not work. The two facts have + * to be separable or an app with a disabled config reads as an app that is ready. + * + * BOTH OF THOSE ARE DECLARED AS THE WIRE CAN SEND THEM RATHER THAN AS THE SDK SPELLS THEM, for the + * reason the toolkit row above gives at length: `transformAuthConfigRetrieveResponse` copies + * `name` and `status` across verbatim inside a warn-only `transform()` + * (`@composio/core` 0.18.1, `src/utils/transformers/authConfigs.ts:29-58`), so + * `AuthConfigRetrieveResponseSchema` requiring a string name and an `ENABLED`/`DISABLED` enum is + * not something this file can rest on. A null name reaches {@link madeHere}, and a status the + * enum does not contain reaches the choice of config to connect against — where "not ENABLED" and + * "disabled" are different facts and only one of them is worth telling an operator. + * + * `id` IS DECLARED THE SAME WAY, AND THE GUARD IT WAS WAITING FOR IS WRITTEN. {@link readableConfigs} makes + * that check, so the declaration no longer claims more than the wire promises. It is the one of the + * three whose absence sends a request: a delete named with `undefined` asks Composio to remove + * whatever it cares to, and this deployment then records that the app was withdrawn. + * + * THE ROW BEING AN OBJECT AT ALL IS THE ONE THING THAT IS NOT IN DOUBT. + * `transformAuthConfigRetrieveResponse` reads `authConfig.toolkit.logo` while building every row + * (`@composio/core` 0.18.1, `src/utils/transformers/authConfigs.ts:41`), so a row that is not an + * object raises a `TypeError` inside the vendor's own code and never arrives. All three fields + * below are `unknown` for the opposite reason: the same function copies them across verbatim. + */ +type VendorAuthConfig = { + id?: unknown; + name?: unknown; + status?: unknown; +}; + +/** + * The connected-account statuses this file knows how to ask for, as the literals the SDK admits. + * + * The whole enum is named rather than the two or three in use, because the point of the two lists + * below is that they are CHOICES: a reader comparing them can see which statuses each question + * leaves out, and a status added by a vendor version shows up here as a name nothing mentions + * rather than as an answer that quietly got narrower. Written as literals for the reason the + * previous `"ACTIVE"[]` was: the vendor's parameter is an enum and a widened `string[]` does not + * satisfy it. + */ +type VendorAccountStatus = + | "INITIALIZING" + | "INITIATED" + | "ACTIVE" + | "FAILED" + | "EXPIRED" + | "INACTIVE" + | "REVOKED"; + +/* + * WHERE THE LINE BETWEEN "READ AT ITS TYPE" AND "READ OUT OF `unknown`" IS DRAWN, AND WHY IT MOVED. + * + * Everything below used to be read out of `unknown` — containers and fields alike — on one argument: + * `transform()` validates with `safeParse`, logs a warning where it fails, and returns the + * unvalidated object anyway (`@composio/core` 0.18.1, `src/utils/transform.ts:26-36`), so a + * TypeScript declaration over a wire value is an assertion and not a check. + * + * THAT ARGUMENT IS TRUE OF THE FIELDS AND FALSE OF THE SHAPES AROUND THEM, which a round of running + * the SDK against malformed answers established rather than reasoned about. `transform()` returns + * whatever its TRANSFORMER built, and every one of these transformers builds its result by + * dereferencing the raw answer — `response.items.map(...)`, `item.meta.categories`, + * `authConfig.toolkit.logo`, `response.auth_config.id`. So the containers and the rows inside them + * are the vendor's own construction and cannot arrive malformed; only the values copied ACROSS + * those lines can, and those are exactly the fields declared `unknown` above. Guards were written + * for both halves, and the ones covering the shapes were branches no answer could reach, standing + * where the next reader would take them for what was keeping them safe. + * + * WHAT KEEPS THEM SAFE IS `askVendor`. A shape the SDK could not read raises inside the SDK, and + * the `TypeError` row in {@link vendorRefusal} turns that into a sentence — one guard, at the layer + * where the fault actually surfaces, covering every shape rather than the four somebody listed. + * + * REFUSING RATHER THAN FILLING IN, WHICH IS THE WHOLE OF THE ARGUMENT FOR THE FIELDS. A `?? ""`, a + * `String(x)` or a cast does not make a malformed answer safe; it converts a fault this deployment + * could have reported into an answer it gives wrongly, and the wrong answers are not small ones — + * an app with no name in an administrator's picker, an action with no slug put in front of a model, + * and the one that was actually happening: a delete sent with `undefined` where an account id belongs, + * answered by Composio however it likes, after which the audit trail records that a person's access + * was withdrawn and nothing had been. Every reader below therefore answers null on anything it + * cannot read, and every caller turns that null into a sentence naming what Composio sent. + */ + +/** + * The remedy every shape refusal here ends with, because it is the same act in every one of them. + * + * None of these is a misconfiguration. The key is right, the request is right and the answer + * arrived; what changed is the shape of it, which is a thing nobody operating this deployment can + * correct from any page it has. Saying so is the difference between an operator reading their own + * settings for an hour and an operator upgrading a package. + */ +const VENDOR_SHAPE_REMEDY = + "That is a change in what Composio answers rather than a setting an operator can correct, so upgrading this deployment's @composio/core is what fixes it."; + +/** + * WHAT COMPOSIO PUT SOMEWHERE, NAMED IN A SENTENCE A READER CAN ACT ON. + * + * Every refusal below says what arrived where something else belonged, because "Composio's answer + * was not a shape this deployment reads" sends whoever is holding the page looking through a vendor + * dashboard with nothing to look for. + * + * THE VALUE IS NEVER QUOTED, AND THAT IS THE POINT OF THE FUNCTION RATHER THAN AN INTERPOLATION. A + * listing row carries a person's mailbox address, an account handle and whatever else the vendor + * chose to put on it, and a refusal from here is read off an admin page, written into an app's + * `lastError` and put in front of a model. The shape is the part that is safe to say and is also + * the only part that helps: a reader who knows a list arrived where an object belongs knows which + * vendor change they are looking at. + * + * AND THE EMPTINESS IT REPORTS IS THE ONE THE CALLER JUDGED, WHICH IT WAS NOT. Every caller reaches + * this function on the branch {@link textOf} sent it down, and `textOf` decides on the TRIMMED + * value while this tested `value === ""` — so a padded blank, which is the shape a wire value + * actually arrives in, was refused for being empty and then described as "a string". "Composio sent + * a string where the id belongs" is a sentence with no finding in it: a string is what an id IS, so + * the reader is told the field was right and the call refused anyway. The two branches now agree on + * what blank means, and they say which of the two blanks arrived, because an id that is three + * spaces and an id that is absent are different things to go looking at in a dashboard. + */ +function sent(value: unknown): string { + if (value === undefined) return "nothing"; + if (value === null) return "null"; + if (Array.isArray(value)) return "a list"; + if (typeof value === "string") { + if (value === "") return "an empty string"; + return value.trim() === "" ? "a string of blank space" : "a string"; + } + if (typeof value === "object") return "an object"; + return `a ${typeof value}`; +} + +/** + * A vendor status named as itself, because an enum value is the one wire value worth quoting. + * + * {@link sent} withholds what it is given for a reason that does not reach here: an auth config's + * status is one of a closed set of vendor enum names, carries nobody's data, and IS the finding — + * "Composio called it PENDING" is something an operator can search their dashboard and the vendor's + * changelog for, where "Composio sent a string" is something they can only shrug at. + * + * AND THE ARGUMENT ONLY HOLDS WHILE THE VALUE IS ACTUALLY ONE OF THOSE NAMES, which is the hole + * this closes. Every caller reaches this function on the branch taken precisely BECAUSE the value + * is not one of the words the code expects — so what arrives is not "an enum name Composio has + * added", it is whatever came off the wire: a gateway's HTML error page, a stack trace, a sentence + * carrying a person's mailbox address, a megabyte of it. That string was interpolated whole into a + * refusal that is read off an admin page, written into an app's `lastError` and put in front of a + * model. + * + * SO THE SHAPE OF AN ENUM NAME IS THE TEST, and anything that is not one is described by + * {@link sent} like every other wire value in this file. A vendor enum name is a short run of + * letters, digits and underscores; nothing that fails that is a word an operator could search a + * changelog for, which was the entire argument for quoting it. + */ +const VENDOR_ENUM_NAME = /^[A-Za-z0-9_]{1,40}$/; + +function named(value: unknown): string { + const text = typeof value === "string" ? value.trim() : ""; + return VENDOR_ENUM_NAME.test(text) ? `"${text}"` : sent(value); +} + +/* + * THE CONTAINER OF A LISTING IS THE ONE THING BELOW THAT IS NOT READ OUT OF `unknown`, AND THE + * REASON IS THAT THE SDK PROVES IT. + * + * There used to be a `hasFields` predicate and an `itemsOf` reader here, and every listing passed + * its answer through them before touching a field — on the argument the row types give at length, + * that a TypeScript interface over a wire value is an assertion rather than a check. That argument + * is correct about the FIELDS and wrong about the CONTAINER, which running `@composio/core` 0.18.1 + * settles rather than reasons about. Every list transformer dereferences the answer before + * returning it: `response.items.map(...)` in `transformAuthConfigListResponse` + * (`src/utils/transformers/authConfigs.ts:79`), in `transformConnectedAccountListResponse` + * (`connectedAccounts.ts:113`). So a container of the wrong shape — null, a bare list where an + * envelope belongs, an `items` that is a string — dies inside the vendor's code and NEVER arrives + * here. Each of those guards was therefore a branch no input could reach, sitting where the next + * reader would take it for the thing keeping them safe. + * + * WHAT KEEPS THEM SAFE IS ONE LAYER DOWN NOW. The vendor's crash is a bare `TypeError`, and + * {@link vendorRefusal} translates it into a sentence naming what did not happen and the one act + * that changes it — which catches every malformed container, including the shapes nobody here + * thought to enumerate. + * + * AND IT IS TWO OF THE FOUR LISTINGS NOW RATHER THAN ALL OF THEM, WHICH IS THE COST OF PAGING THE + * OTHER TWO. The catalogue and the action listing read `@composio/client` directly — a generated + * client that parses the body and returns it — so nothing dereferences their answers before this + * file does. The same two shapes therefore reach this code rather than dying in the vendor's, and + * {@link pageOf} answers them at exactly those two call sites. The argument above still holds + * everywhere it is made: a guard is written where an input can reach it and nowhere else. + * + * SO THE DECLARATIONS BELOW ARE READ AT THEIR TYPES, and each one says which vendor line makes it + * true. The fields inside them stay `unknown`, because the warn-only `transform()` really does copy + * those across whatever they turn out to be. + */ + +/** + * One field as the non-empty string it has to be, or null where the vendor sent anything else. + * + * EMPTY COUNTS AS ABSENT because every caller of this reads an identifier — a slug, an id, a name + * this file matches a suffix against — and an empty identifier is unusable in exactly the way a + * missing one is, while being the one that reads as present at every glance. + * + * AND THE STRING THAT COMES BACK IS THE ONE THAT WAS JUDGED, which it was not. This decided + * emptiness on the TRIMMED value and answered the PADDED one, so " " was correctly refused while + * " ac_1 " was accepted and handed on with its spaces — a guard that checked one thing and passed + * along another. What that reached is the whole of this file: a padded id is what an auth-config + * delete and an account withdrawal NAME, so Composio is asked to remove an object nobody has; + * a padded slug is what an enabled app records into its url and what an action list writes into + * `mcp_tools`; and a padded app slug on the vendor's own answer compares unequal to the app the + * caller was gated on, refusing a call that was about the right app the whole time. Trimming is + * not tidying here — it is answering with the identifier rather than with the identifier plus + * whatever the wire wrapped it in. + */ +function textOf(value: unknown): string | null { + if (typeof value !== "string") return null; + const text = value.trim(); + return text === "" ? null : text; +} + +/** + * How many pages of one listing this deployment will read before it stops and says so. + * + * THE BOUND IS AGAINST A VENDOR THAT NEVER STOPS, not against a large answer. What it guards is a + * cursor that keeps being handed back, which without a ceiling is a request that never returns: a + * person waiting on a page they pressed disconnect from, and a process holding every row it has + * read so far. + * + * REACHING IT IS A REFUSAL AND NEVER A TRUNCATION, which is the property the whole guard exists for + * — see {@link everyRowOf}. A ceiling that answered with what it had would be the page ceiling + * again, one order of magnitude further out and harder to notice. + * + * IT WAS 50, AND 50 WAS ARGUED FROM TWO NARROW LISTINGS THAT ARE NO LONGER THE ONLY ONES. The + * number was justified by one app's authorization configs and one person's accounts for one app: + * at {@link LISTING_LIMIT} rows a page a second page is extraordinary there and a fiftieth is not + * a data set. The app CATALOGUE is not that listing. Composio publishes more than + * {@link LISTING_LIMIT} toolkits today, so it is a listing whose SECOND page is the ordinary case, + * and a ceiling reasoned about from the narrow two would be sitting on top of a healthy answer + * rather than above it. + * + * 200 IS CHOSEN AGAINST THE PAGE THE VENDOR MIGHT ACTUALLY SEND rather than the one asked for, and + * that is the whole of the arithmetic. Asking for {@link LISTING_LIMIT} does not oblige Composio to + * answer with it — the cursor is documented as "a base64 encoded string of the page and limit" + * (`@composio/client` 0.1.0-alpha.76, `resources/toolkits.d.ts:469-478`), so the page size is the + * vendor's to settle. At the page asked for, 200 is 200,000 rows, two orders of magnitude past any + * catalogue Composio has published. At the vendor's OWN default page of twenty it is 4,000 rows, + * which still clears today's catalogue with room — where 50 pages of twenty is 1,000, which is + * today's catalogue exactly, and a ceiling that lands on the real answer is a healthy vendor turned + * into a refusal. And 200 sequential requests is still a request that ends. + * + * ONE NUMBER FOR ALL FOUR LISTINGS, because the two narrow ones lose nothing by it: they refuse a + * runaway cursor after 200 pages instead of 50, and there is no state in which a real answer to + * either of those questions is even a second page. A per-listing ceiling would be a second number + * to reason about in exchange for tightening a bound that nothing genuine approaches. + * + * AND IT IS THE NUMBER OF PAGES THAT ARE READ, WHICH IS NOT WHAT IT USED TO BE. The test stood + * ahead of the line recording the page it was counting, so the set held one fewer than had + * arrived and the refusal fired on the two-hundred-FIRST page while telling its reader two hundred. + * A ceiling is a number somebody reasons about; stating one and doing another makes it the one + * number here nobody can check. + */ +const PAGE_CEILING = 200; + +/** + * The listing a refusal is about, as the two clauses every sentence below is built from. + * + * WRITTEN AT THE CALL SITE for the same reason {@link VendorCall}'s outcome is: what could not be + * told is a fact about the question being asked — "whether this person is connected" is not + * something {@link everyRowOf} can know — and composing it beside the call keeps it true. + */ +type Listing = { + /** The listing as a noun phrase: "this person's gmail accounts". */ + noun: string; + /** What could not be told, as a clause following "so": "whether one exists could not be read". */ + consequence: string; +}; + +/** + * EVERY ROW OF A LISTING THE VENDOR PAGES, or a refusal rather than a fragment read as the whole. + * + * EVERY LISTING IN THIS FILE COMES THROUGH HERE NOW, AND TWO OF THEM USED TO REFUSE INSTEAD. This + * paragraph said that `fetchDirectory` met a full page and refused, that `./composio` did the same + * with a full action listing, and that both were right to: "the SDK offers no cursor to ask for a + * second page with, so an answer at the ceiling and an answer past it are indistinguishable and no + * second request could tell them apart". The reasoning was sound and the premise was wrong. It was + * a fact about the WRAPPER — `ToolListParamsSchema` names no cursor and + * `transformToolkitListResponse` drops the response's — and never about the request, which the raw + * client has always been able to compose: both list params carry `cursor` and both responses carry + * `next_cursor` (`@composio/client` 0.1.0-alpha.76, `resources/toolkits.d.ts:467-478` and + * `:322-326`, `resources/tools.d.ts:421-432` and `:200-204`). The cost of the mistake was not + * theoretical: Composio publishes more than {@link LISTING_LIMIT} toolkits, so the catalogue + * refusal fired on the first call every time and the app picker showed an operator nothing at all. + * See {@link ComposioVendor}, where both of those listings now name the raw client. + * + * THE OTHER TWO WERE ALWAYS EXPRESSIBLE THROUGH THE WRAPPER: `AuthConfigListParamsSchema` and + * `ConnectedAccountListParamsSchema` both name a `cursor` (`@composio/core` 0.18.1, + * `src/types/authConfigs.types.ts:124-131`, `src/types/connectedAccounts.types.ts:259-266`), both + * models forward it (`src/models/AuthConfigs.ts:95`, `src/models/ConnectedAccounts.ts:118`) and + * both transformers fill `nextCursor` in from the response's `next_cursor` + * (`src/utils/transformers/authConfigs.ts:80`, `connectedAccounts.ts:116`). + * + * AND THE CALLER THAT DECIDES IT IS `revoke`. Refusing a truncated listing would be honest and + * would also mean that the person it happened to could never disconnect: every attempt would meet + * the same page and the same refusal, with their grants standing the whole time. The removal of an + * app is the same shape one level up. Reading the rest is the answer that finishes the job, and + * refusing is what is left for the cases where reading the rest is not possible — which is what the + * three refusals below are, and why none of them can be reached by a caller carrying a partial + * answer that reports itself complete. + * + * THE FIRST REQUEST CARRIES NO CURSOR FIELD AT ALL rather than an undefined one, which is what + * {@link everyRowOf}'s callers spread for: a `cursor: undefined` would reach the vendor's `parse` + * as a key, and an explicit undefined is not something this file needs to make the SDK have an + * opinion about. + * + * THERE WAS A FOURTH REFUSAL HERE AND IT MOVED RATHER THAN DIED, which is the correction the raw + * client forces. It stood at the top of this loop for a page that is not an envelope at all, and + * for the two SDK listings no answer can reach it: both transformers begin `response.items.map(...)` + * (`src/utils/transformers/authConfigs.ts:79`, `connectedAccounts.ts:113`), so a null, a bare list + * and an `items` that is not one all raise a `TypeError` inside the vendor's own code, answered + * where it happens — see the `TypeError` row in {@link vendorRefusal}. Nothing dereferences the + * answer on the two RAW listings, so for those the same shapes reach this loop, and + * `rows.push(...answered.items)` over a string is "string is not iterable" with nothing in it a + * person can act on. {@link pageOf} is where that is answered, at the two call sites that need it, + * rather than as a branch every listing pays for and two of them cannot reach. + * + * What is left below are the three faults every one of the four can hand over, all of them about + * the cursor, because the cursor is the one field nothing on any of these paths checks. + * + * AND A QUESTION ALREADY ANSWERED STOPS HERE, WHICH IS WHAT `enough` IS FOR. Paging made three of + * this file's answers complete and made one of them FAILABLE: {@link ComposioBroker.isConnected} + * returns a boolean, and the first page carrying a single row has settled it — no cursor Composio + * could send next, and no fiftieth page, can turn that `true` into anything else. Reading on + * anyway put the two cursor refusals and the page ceiling in front of a person whose account had + * already been found, so a fault on a page nobody needed decided the answer to a question nobody + * still had. The default reads every page, because that is what a withdrawal and a config listing + * genuinely need; a caller that says when it has enough is a caller that cannot be failed after it + * has its answer. + * + * CHECKED WHERE THE ROWS LAND AND BEFORE THE CURSOR IS LOOKED AT, deliberately. Reading the cursor + * first and stopping afterwards would keep every one of the three refusals reachable on the page + * that already answered the question, which is the whole of what this closes. + */ +async function everyRowOf( + listing: Listing, + page: ( + cursor: string | undefined, + ) => Promise<{ items: Row[]; nextCursor?: unknown }>, + enough: (rows: Row[]) => boolean = () => false, +): Promise { + const rows: Row[] = []; + const followed = new Set(); + let cursor: string | undefined; + + for (;;) { + const answered = await page(cursor); + rows.push(...answered.items); + if (enough(rows)) return rows; + + /* + * ABSENT AND NULL BOTH MEAN THE END. The auth-config schema spells the field nullable and the + * connected-account one spells it nullish, and a transformer that met no `next_cursor` writes + * `response.next_cursor ?? null`. + * + * AND SO DOES A CURSOR WITH NOTHING IN IT, WHICH IS THE CORRECTION AND WAS THE COSTLIEST + * REFUSAL IN THIS FILE. The claim above used to be that absent and null are "the two the vendor + * actually sends" — which is not something this deployment can know, and the installed types + * say otherwise: all four list responses declare `next_cursor?: string | null` + * (`@composio/client` 0.1.0-alpha.76, `resources/auth-configs.d.ts:248`, + * `connected-accounts.d.ts:4987`, `toolkits.d.ts:326`, `tools.d.ts:204`), so `""` is type-legal + * on the wire, and `?? null` does not catch it. It therefore arrived here, failed + * {@link textOf}, and became a refusal — one that kills `revoke`, `authorize`, + * `ensureAuthConfig`, `deleteAuthConfig` and `isConnected` for EVERY app at once, permanently, + * over a field whose whole content is that there is nothing in it. + * + * A CURSOR NAMES A POSITION, AND THE BLANK ONE NAMES NONE. It is exactly what this loop sends + * when it has no position — the first request omits the field — so following it would ask for + * page one again, and the repeat guard below would then answer the vendor's empty string with + * a sentence accusing it of sending the same page twice. There is no reading of `""` under + * which a second request could reach anything the first did not. So it is the end of the + * listing, which is the same rule {@link textOf} already applies to every other identifier + * here, applied to the one field that had been left out of it. + * + * WHICH IS NOT COERCION, AND THE DIFFERENCE IS THE TEST BELOW IT. A cursor that is a number, an + * object or a list is a position this deployment cannot express and CANNOT rule out being real, + * so it is still the refusal it always was: one page read as the whole answer is the mistake + * this function exists to prevent. What changed is only the string that says nothing. + */ + const next = answered.nextCursor; + if (next === undefined || next === null) return rows; + + const follow = textOf(next); + if (follow === null) { + if (typeof next === "string") return rows; + throw new BrokerRefusalError( + `Composio sent ${sent(next)} where the cursor to the next page of ${listing.noun} belongs, so ${listing.consequence}: there are more of them than arrived and no cursor this deployment can ask for the rest with. ${VENDOR_SHAPE_REMEDY}`, + ); + } + + /* + * A CURSOR ALREADY FOLLOWED IS A LOOP AND NOT A PAGE. Nothing here can tell a vendor bug from a + * proxy answering from a cache, and both end the same way: the same rows for ever. Refusing on + * the second sight of one is what keeps a person's disconnect a request that returns. + */ + if (followed.has(follow)) { + throw new BrokerRefusalError( + `Composio answered the same page of ${listing.noun} twice, so ${listing.consequence}: following its cursor did not advance, so the rest of them cannot be reached. ${VENDOR_SHAPE_REMEDY}`, + ); + } + followed.add(follow); + + /* + * COUNTED AFTER THE PAGE IS RECORDED, so the number in the sentence is the number of pages that + * happened — see {@link PAGE_CEILING} for the off-by-one this order closes. + * + * AND THE ROWS ARE THE ONES THAT ARRIVED. The sentence asserted `at ${LISTING_LIMIT} rows each`, + * which is the page this deployment ASKED FOR and not one thing it measured: Composio is free + * to answer fifty pages of one row, and a reader told the listing was fifty thousand rows long + * would be reading a figure nothing had counted. What is said now is what was collected. + */ + if (followed.size >= PAGE_CEILING) { + throw new BrokerRefusalError( + `Composio has answered ${PAGE_CEILING} pages of ${listing.noun}, ${rows.length} rows in all, and is still offering another, so ${listing.consequence}: this deployment stops there rather than read on, because what is left cannot be told from a listing that never ends. ${VENDOR_SHAPE_REMEDY}`, + ); + } + cursor = follow; + } +} + +/** + * ONE PAGE OFF THE RAW CLIENT, CHECKED FOR BEING A PAGE, in the shape {@link everyRowOf} reads. + * + * THE TWO SDK LISTINGS DO NOT NEED THIS AND THE TWO RAW ONES CANNOT DO WITHOUT IT. Every answer the + * wrapper hands over has already been dereferenced inside the vendor's package — both transformers + * open with `response.items.map(...)` — so a malformed envelope there is a `TypeError` raised + * inside `@composio/core` and translated by {@link vendorRefusal} with a sentence about a package + * upgrade. `@composio/client` is a generated client: it parses the body and returns it. Nothing + * looks at `items` before this file does. + * + * SO THE TWO SHAPES THE WRAPPER USED TO CATCH ARE CAUGHT HERE, and they are the same two: + * an answer that is not an envelope, and an `items` that is not a list of rows. Both would + * otherwise reach `rows.push(...answered.items)` as a bare `TypeError` naming a vendor field — + * the crash-wearing-a-refusal's-clothes that every sentence in this file exists not to be. + * + * OUTSIDE {@link askVendor} RATHER THAN INSIDE IT, deliberately, and it is the reason this is a + * function rather than four lines at each call site. That wrapper goes around the `await vendor.*` + * AND NOTHING ELSE, which is what makes "a throw reaching `vendorRefusal` came out of the vendor's + * code" true by construction; a refusal composed here is this file's own reading, and it already + * carries an authored sentence. + * + * THE CURSOR IS RENAMED AND NOT READ. `next_cursor` is the wire's spelling and `nextCursor` is what + * {@link everyRowOf} looks for, and it is carried across as `unknown` — every check on it belongs + * there, where the three faults it can carry are enumerated and answered together for all four + * listings. + */ +async function pageOf( + listing: Listing, + ask: () => Promise<{ items?: unknown; next_cursor?: unknown } | null>, +): Promise<{ items: Row[]; nextCursor?: unknown }> { + const answered = await ask(); + if (answered === null || typeof answered !== "object") { + throw new BrokerRefusalError( + `Composio sent ${sent(answered)} where a page of ${listing.noun} belongs, so ${listing.consequence}: what came back is not a listing at all. ${VENDOR_SHAPE_REMEDY}`, + ); + } + if (!Array.isArray(answered.items)) { + throw new BrokerRefusalError( + `Composio sent ${sent(answered.items)} where the rows of ${listing.noun} belong, so ${listing.consequence}: what came back is not a listing at all. ${VENDOR_SHAPE_REMEDY}`, + ); + } + return { items: answered.items as Row[], nextCursor: answered.next_cursor }; +} + +/** + * Where each field scheme sits in the order a person would rather meet them. + * + * A RECORD KEYED BY {@link FieldScheme} RATHER THAN A LIST OF THEM, because the compiler counts the + * keys of a record and counts nothing at all about a list. This was a `readonly FieldScheme[]` + * spelling the same four names a second time, which is precisely what the comment on + * {@link FIELD_SCHEME_NAMES} argues against: the same set stated twice, where a member added to one + * of them and not the other typechecks perfectly. An element type refuses a name that is not a + * scheme and says nothing whatever about a scheme left out. + * + * AND THE ONE LEFT OUT FAILS OPEN, MORE QUIETLY HERE THAN THERE. A fifth scheme added to the names + * and not given a rank here would match nothing in the pick below, so every app publishing it would + * fall through to `unsupported` — and the directory route hides an unsupported app, so those apps + * would simply leave the picker: no refusal for anyone to read, no operator sentence, and no failing + * test. `Record` does not compile until the new scheme has a place, which is + * the whole of the protection, and the reason the pick goes through {@link isFieldScheme} rather + * than through a second list of names. + * + * THE ORDER ITSELF IS THE PERSON'S AND NOT THE WIRE'S, and it is unchanged: a plain key first, + * because it is the one they are likeliest to already hold, then the other three ways of spelling a + * secret they have to go and assemble. + */ +const FIELD_SCHEME_ORDER: Record = { + API_KEY: 0, + BEARER_TOKEN: 1, + BASIC: 2, + BASIC_WITH_JWT: 3, +}; + +/** The strings out of an `unknown`, which is all a vendor list promises. */ +function labelsOf(value: unknown): string[] { + return Array.isArray(value) + ? value.filter((entry): entry is string => typeof entry === "string") + : []; +} + +/** + * Which flow an app gets, and why that order. + * + * `no_auth` FIRST, AND IT IS NOT A PREFERENCE. Composio refuses an auth config for such a toolkit + * outright — "Cannot create an auth config for toolkit hackernews because it does not require + * authentication" — so an app flagged this way has no other reading available, whatever else it + * publishes beside it. + * + * Then managed OAuth, because it asks the person for nothing at all. Then self-registering OAuth, + * which asks nobody for anything: the client registers itself at consent time. Then a scheme whose + * secret the person already holds. What is left wants an OAuth client registered by whoever runs + * this deployment, and there is nowhere here to put one, so it is named rather than attempted. + */ +export function connectionOf(row: VendorToolkit): BrokerConnection { + if (row.no_auth === true) return { kind: "no-auth" }; + + const offered = labelsOf(row.auth_schemes); + const managed = labelsOf(row.composio_managed_auth_schemes); + if (managed.length > 0) return { kind: "consent" }; + if (offered.includes("DCR_OAUTH")) return { kind: "self-registering" }; + + const field = offered + .filter((scheme) => isFieldScheme(scheme)) + .sort( + (left, right) => FIELD_SCHEME_ORDER[left] - FIELD_SCHEME_ORDER[right], + )[0]; + if (field) return { kind: "fields", authScheme: field }; + + return { + kind: "unsupported", + reason: offered.length + ? `${offered.join(", ")} needs an OAuth application registered by whoever runs this deployment, and this deployment holds no place to put its own OAuth client for a brokered app.` + : "Composio published no authentication scheme for this app, so there is no flow this deployment could run, and its own OAuth client is not something this deployment can register.", + }; +} + +/** + * One catalogue row checked into the app an administrator picks from, or a refusal saying why not. + * + * A ROW THIS FILE CANNOT READ STOPS THE WHOLE CATALOGUE, for the reason the full-page guard in + * {@link buildComposioClient} gives at length: the directory is held for ten minutes and both the + * picker and the enable route read the held copy, so a row quietly dropped is an app missing from a + * search and an app whose Add button reports that Composio does not publish it. One refusal an + * operator can act on is worth more than several hundred rows, one of which is a guess. + * + * THE ABSENCES THAT ARE REAL ANSWERS ARE STILL ANSWERS. Composio genuinely publishes toolkits with + * no description, no logo, no category and no count, and each of those is a fact about the app + * rather than a fault in the answer — so an absent one becomes the value that reads honestly on a + * screen, exactly as it did before. What is refused is the other thing: a field that is PRESENT and + * is not what it is declared to be. A count that arrived as the string "63" is not a count, and + * `Number(x)` over it would turn a vendor change into a plausible figure nobody would question. + * + * THE ROW'S SHAPE IS CHECKED AGAIN, AND THAT IS THE RAW CLIENT RATHER THAN A CHANGE OF MIND. Two + * refusals used to open this function — one for a row that is not an object, one for a row with no + * meta — and they were deleted because neither could be reached: `transformToolkitListResponse` + * read `item.meta.categories` while building each row (`@composio/core` 0.18.1, + * `src/utils/transformers/toolkits.ts:27`) and `Toolkits.getToolkits` rethrew everything as + * `ComposioToolkitFetchError` (`src/models/Toolkits.ts:70-82`), so a catalogue this deployment + * could not read never became a row here at all. The catalogue does not go through that function + * any more — see {@link ComposioVendor} — and both shapes now arrive. + * + * BOTH ARE BACK RATHER THAN LEFT TO THE FIELD READS, and the reason is the category guard three + * screens down, which was deleted on the same reasoning and restored after it was RUN. `("gmail") + * .slug` is `undefined` and not a throw, so a row that is a bare string would be refused with + * "Composio sent nothing where the slug of row 1 of Composio's app catalogue belongs" — a sentence + * that sends an operator looking in a dashboard for an app with a missing slug, about an answer + * that had no app in it. A guard whose absence is argued from a dereference that does not + * dereference is not a guard anything should rest on. + */ +function appOf(row: VendorToolkit, position: number): BrokerApp { + const at = `row ${position + 1} of Composio's app catalogue`; + + if (typeof row !== "object" || row === null || Array.isArray(row)) { + throw new BrokerRefusalError( + `Composio sent ${sent(row)} where ${at} belongs, and a catalogue row is an object carrying an app's slug and name. ${VENDOR_SHAPE_REMEDY}`, + ); + } + + const slug = textOf(row.slug); + if (slug === null) { + throw new BrokerRefusalError( + `Composio sent ${sent(row.slug)} where the slug of ${at} belongs. The slug is the only name this deployment has for an app — it is what enabling one records and what every later call names — so the directory was not shown, rather than shown with an app nothing could be done with. ${VENDOR_SHAPE_REMEDY}`, + ); + } + + const name = textOf(row.name); + if (name === null) { + throw new BrokerRefusalError( + `Composio sent ${sent(row.name)} where the name of ${at} belongs, and ${slug} is a slug rather than a title, so there is nothing to show an administrator choosing between apps. ${VENDOR_SHAPE_REMEDY}`, + ); + } + + /* + * `meta` IS CHECKED FOR BEING AN OBJECT, WHICH IT DID NOT USED TO BE AND NOW HAS TO BE. + * + * The argument for taking it on trust was `transformToolkitListResponse`: it read + * `item.meta.categories` while building each row and then spread the result into a fresh literal + * (`@composio/core` 0.18.1, `src/utils/transformers/toolkits.ts:24-38`), so a meta that was + * absent or null raised there and every meta that did arrive was an object whatever the wire had + * sent. That was a guarantee about the transformer, it was written down as one, and the catalogue + * no longer goes through it — see {@link ComposioVendor}, where the toolkit listing names the raw + * client because the wrapper could not be paged. + * + * WHAT THE ABSENT GUARD WOULD COST IS FOUR SILENT ABSENCES RATHER THAN A CRASH, which is the + * worse of the two. `meta.description` off the string "productivity" is `undefined`, not a throw, + * and so are the logo, the categories and the count — so a row this file cannot read would show + * on an administrator's screen as a real app that publishes nothing, indistinguishable from the + * many that genuinely publish little. Absent IS an answer here, which is exactly why a meta that + * is present and is not a meta cannot be allowed to look like one. + */ + const rawMeta = row.meta; + if ( + typeof rawMeta !== "object" || + rawMeta === null || + Array.isArray(rawMeta) + ) { + throw new BrokerRefusalError( + `Composio sent ${sent(row.meta)} where ${slug}'s description, logo, categories and action count belong. Every one of those is a thing an app is allowed to publish none of, so a row whose metadata is not readable at all would show as a real app that publishes nothing rather than as the answer this deployment could not read. ${VENDOR_SHAPE_REMEDY}`, + ); + } + const meta = rawMeta as { + description?: unknown; + logo?: unknown; + categories?: unknown; + tools_count?: unknown; + }; + + const description = meta.description ?? ""; + if (typeof description !== "string") { + throw new BrokerRefusalError( + `Composio sent ${sent(meta.description)} where ${slug}'s description belongs. An app that publishes none is ordinary and reads as a gap on the page; an app whose description is not text is an answer this deployment cannot show. ${VENDOR_SHAPE_REMEDY}`, + ); + } + + const logo = meta.logo ?? null; + if (typeof logo !== "string" && logo !== null) { + throw new BrokerRefusalError( + `Composio sent ${sent(meta.logo)} where ${slug}'s logo belongs, and this deployment puts that value in an image address. ${VENDOR_SHAPE_REMEDY}`, + ); + } + + /* + * NEITHER THE LIST NOR THE ENTRIES IN IT ARE ANYBODY'S CONSTRUCTION NOW, so both are checked. + * + * The list used to be the transformer's: `item.meta.categories?.map(category => ({ slug: + * category.id, name: category.name }))` (`@composio/core` 0.18.1, + * `src/utils/transformers/toolkits.ts:27-30`) has no `.map` for a `categories` that is not a + * list, and that half genuinely held when it was run. It does not hold through the raw client, + * where nothing maps this field at all — a `categories` of "productivity" would simply read as an + * app in no category, which is a state real apps are in. + * + * THE ENTRY GUARD WAS ALREADY BACK, AND FOR A REASON WORTH KEEPING IN VIEW. It had been deleted + * on the ground that "a category that is null throws on `.id`, and everything that survives is an + * object". Only the first clause was ever true: `("crm").id` is `undefined`, not a throw, and so + * is `(7).id` — so a primitive survived the map as `{ slug: undefined, name: undefined }`, and + * the whole catalogue was refused with "Composio sent nothing where the name of gmail's category + * 1 belongs" about a value that was the string "crm". An operator reading that goes looking in a + * dashboard for a category with a missing name, and there is no such category. + * + * SO THE SHAPE IS TESTED BEFORE THE FIELD IS READ, twice over, and the three faults get the three + * sentences they are. The vendor's own word is `name` on both spellings of this row + * (`@composio/client` 0.1.0-alpha.76, `resources/toolkits.d.ts:425-435`), which is why that is + * the one field read. + */ + const listed = meta.categories ?? []; + if (!Array.isArray(listed)) { + throw new BrokerRefusalError( + `Composio sent ${sent(meta.categories)} where ${slug}'s categories belong, and the catalogue shows an app's categories as the words a person chooses by. An app in no category is ordinary; a list of them that is not a list is an answer this deployment cannot show. ${VENDOR_SHAPE_REMEDY}`, + ); + } + const categories = listed.map((entry: unknown, index: number) => { + const at = `${slug}'s category ${index + 1}`; + if (typeof entry !== "object" || entry === null || Array.isArray(entry)) { + throw new BrokerRefusalError( + `Composio sent ${sent(entry)} where ${at} belongs, and a category is an object carrying the word a person picks an app by. ${VENDOR_SHAPE_REMEDY}`, + ); + } + const label = textOf((entry as { name?: unknown }).name); + if (label === null) { + throw new BrokerRefusalError( + `Composio sent ${sent((entry as { name?: unknown }).name)} where the name of ${at} belongs. The catalogue shows an app's categories as the words a person chooses by, so a category with no name is a blank one of those. ${VENDOR_SHAPE_REMEDY}`, + ); + } + return label; + }); + + /* + * `tools_count` IS THE WIRE'S OWN KEY AND `toolsCount` WAS THE WRAPPER'S RESTATEMENT OF IT + * (`@composio/client` 0.1.0-alpha.76, `resources/toolkits.d.ts:405-408`; + * `@composio/core` 0.18.1, `src/utils/transformers/toolkits.ts:34`). Reading the old key off the + * new answer is the one mistake in this function that would not look like one: every count would + * be `undefined`, every count would default to zero, and every app in the picker would say it + * publishes no actions — a plausible figure on a page whose whole job is to show one. Which is + * why it is a number a test asserts rather than a field a type checks. + */ + const actionCount = meta.tools_count ?? 0; + if (typeof actionCount !== "number" || !Number.isFinite(actionCount)) { + throw new BrokerRefusalError( + `Composio sent ${sent(meta.tools_count)} where ${slug}'s action count belongs. The count is shown BEFORE anybody enables an app, because it is the difference between a small addition and a rewrite of what a model sees, so a figure derived from a value that is not a number is the one number here nobody would think to question. ${VENDOR_SHAPE_REMEDY}`, + ); + } + + return { + slug, + name, + description, + logo, + categories, + actionCount, + connection: connectionOf(row), + }; +} + +/** + * One auth config with the two fields every decision here turns on, checked. + * + * `status` IS CARRIED ACROSS UNCHECKED ON PURPOSE, which is the one field of the three this reader + * does not settle. {@link ComposioBroker.authorize} is its only reader and it has three answers to + * give rather than two — enabled, disabled, and a word the vendor has invented since — so a check + * here could only collapse the third into one of the first two, which is the exact defect being + * closed. It is left as `unknown` so the reader has to say what it does with it. + */ +type CheckedAuthConfig = { + id: string; + name: string; + status: unknown; +}; + +/** + * One app's auth configs split into the ones a decision can be made about and the ones it cannot. + * + * EVERY ROW IS CHECKED AND NOT ONLY THE ONES THAT TURN OUT TO BE OURS, because which ones are ours + * is precisely what the name decides. A row whose name cannot be read cannot be sorted into "made + * here" or "somebody's dashboard work", and both of the guesses are damaging in opposite + * directions: read as somebody else's, {@link ComposioBroker.ensureAuthConfig} creates a second + * config beside it and splits one app's connections in two; read as ours, + * {@link ComposioBroker.deleteAuthConfig} deletes an object nobody here chose and every account + * anybody had connected against it. + * + * PARTITIONED RATHER THAN THROWN, WHICH IS THE SAME CORRECTION {@link withdrawableAccounts} + * ALREADY CARRIES ONE LEVEL DOWN, ARRIVING HERE A WAVE LATE. This checked every row on the way out + * of the listing and threw on the first one it could not read, so a single unreadable config row + * was a permanent block on everything behind it: a person pressing disconnect got the same throw + * every time, for ever, because the row will be exactly as unreadable on the next attempt and + * nothing they can reach changes it. That is not the safe end of the trade — it is the SAME defect + * as a false success, pointing the other way, which is the finding the accounts path was corrected + * for and this one was left standing on. + * + * WHAT A CALLER IS OWED IS BOTH HALVES: every config this file CAN decide about, decided about, and + * a refusal counting the ones it cannot. Which half matters differs per caller and is therefore + * settled at each of the four rather than here — a withdrawal acts on what it can name and reports + * the rest, a creation must not put a second config beside a row that might already be ours, and a + * connect link minted against a readable config of ours is right whatever else the listing held. + * + * AND ONE ID IS ONE CONFIG, HOWEVER MANY TIMES THE LISTING NAMED IT — the other half + * {@link withdrawableAccounts} had and this did not. The paging loop guards against a repeated + * CURSOR and not a repeated ROW, and a page boundary crossed while a config is being created, or a + * proxy stitching two overlapping pages together, hands one id over twice with the cursor + * advancing normally each time. The second delete of one config then meets Composio's "there is no + * such auth config", which arrives as a refusal — so {@link ComposioBroker.deleteAuthConfig} + * counted a removal that had in fact completed as a partial one and refused to finish removing the + * app, every time, over a duplicate that is still there on the retry. It also inflates the count + * both that method and {@link ComposioBroker.authorize} put in front of an operator, and sends the + * same id twice in the withdrawal's `authConfigIds` filter. + * + * THE FIRST SIGHTING KEEPS ITS PLACE, exactly as it does for accounts; the caller sorts on the id + * afterwards, so the order two callers see is the same one either way. + */ +function readableConfigs( + rows: VendorAuthConfig[], + toolkit: string, +): { configs: CheckedAuthConfig[]; unreadable: BrokerRefusalError[] } { + const configs: CheckedAuthConfig[] = []; + const alreadyRead = new Set(); + const unreadable: BrokerRefusalError[] = []; + + rows.forEach((row, position) => { + const at = `row ${position + 1} of Composio's authorization configs for ${toolkit}`; + + const id = textOf(row.id); + if (id === null) { + unreadable.push( + new BrokerRefusalError( + `Composio sent ${sent(row.id)} where the id of ${at} belongs, and the id is the whole of what a deletion names. Nothing was sent for that row, because a delete without one asks Composio to remove whatever it cares to while this deployment records that the app was withdrawn. ${VENDOR_SHAPE_REMEDY}`, + ), + ); + return; + } + + const name = textOf(row.name); + if (name === null) { + unreadable.push( + new BrokerRefusalError( + `Composio sent ${sent(row.name)} where the name of ${at} belongs, and the name is the only thing that says whether this deployment made a config or an operator built it by hand in Composio's dashboard. Neither guess is safe: one splits this app's connections across two configs, and the other deletes a config nobody here chose along with every account connected against it. ${VENDOR_SHAPE_REMEDY}`, + ), + ); + return; + } + + if (alreadyRead.has(id)) return; + alreadyRead.add(id); + configs.push({ id, name, status: row.status }); + }); + + return { configs, unreadable }; +} + +/** + * This person's accounts split into the ones a withdrawal can name and the ones it cannot. + * + * THE ID IS THE WHOLE OF WHAT A WITHDRAWAL NAMES, which is why it is read at all. + * {@link ComposioBroker.revoke} deletes by id and then answers `true`, and `store.ts` writes that + * answer into the audit trail as this person's access having been withdrawn before deleting the one + * row in this deployment naming which app they had connected. An id-less account reaching the + * delete is a request to withdraw `undefined` — which the vendor is free to read as anything at all + * — followed by a `true`, a trail entry, and a live grant with nothing left pointing at it. + * + * PARTITIONED RATHER THAN THROWN, AND THAT IS THE CORRECTION. This reader used to refuse, and the + * caller mapped EVERY row through it before sending a single delete — so one row whose id Composio + * omitted threw ahead of the first withdrawal, and the next attempt met the same row and threw in + * the same place. A person with three grants and one unreadable row could not withdraw any of them, + * ever, while being told to try again. That replaced a false success with a permanent block, which + * is the same defect pointing the other way. + * + * WHAT A PERSON IS OWED IS BOTH HALVES: every grant this deployment CAN name withdrawn, and a + * sentence counting the ones it cannot. The second half is not a thing they can retry — the row + * will be unreadable next time too — so the refusal names the dashboard rather than the button + * they just pressed. + * + * A ROW THAT IS NOT AN OBJECT IS NOT A CASE HERE, and that is the SDK's doing rather than an + * omission. `transformConnectedAccountResponse` reads `response.auth_config.id` while building + * every row (`@composio/core` 0.18.1, `src/utils/transformers/connectedAccounts.ts:60`), so a + * non-object row raises a `TypeError` inside the vendor's own code and never reaches this function + * — see the `TypeError` row in {@link vendorRefusal}, which is where that answer is now given. + * + * ONE ID IS ONE WITHDRAWAL, HOWEVER MANY TIMES THE LISTING NAMED IT. The paging loop above guards + * against a vendor repeating a CURSOR and not against it repeating a ROW, and those are different + * faults: a page boundary crossed while an account is created or deleted, or a proxy stitching two + * overlapping pages together, hands the same account id over twice with a cursor that advanced + * normally every time. The second delete of one account then meets Composio's "there is no such + * account", which arrives here as a refusal — so {@link ComposioBroker.revoke} counted a + * withdrawal that had in fact completed as a partial one, threw over it, and left the person's + * connection row standing to be pressed again. Every retry meets the same duplicate. Deduplicating + * is not tidying the listing: it is the difference between one account and two. + * + * THE FIRST SIGHTING KEEPS ITS PLACE, so the order the deletes go out in is still the listing's, + * which is the order {@link buildComposioClient}'s sort makes stable. + */ +function withdrawableAccounts( + rows: { id?: unknown }[], + toolkit: string, +): { ids: string[]; nameless: BrokerRefusalError[] } { + const ids: string[] = []; + const alreadyNamed = new Set(); + const nameless: BrokerRefusalError[] = []; + + rows.forEach((row, position) => { + const id = textOf(row.id); + if (id === null) { + nameless.push( + new BrokerRefusalError( + `Composio sent ${sent(row.id)} where the id of row ${position + 1} of its ${toolkit} accounts for this person belongs, and the id is the whole of what a withdrawal names. Nothing was sent for that account, because a delete without one is a request this deployment cannot describe, after which the audit trail would record that this person's access had ended while their grant stood. ${VENDOR_SHAPE_REMEDY}`, + ), + ); + return; + } + if (alreadyNamed.has(id)) return; + alreadyNamed.add(id); + ids.push(id); + }); + + return { ids, nameless }; +} + +/** + * Composio's own verdict on one withdrawal, as a refusal wherever it is not a yes. + * + * THE VENDOR TELLS US WHEN THE DELETE DID NOT HAPPEN AND NOTHING WAS LOOKING. `success` is a + * required field of `ConnectedAccountDeleteResponse` — see the declaration on + * {@link ComposioVendor}'s `connectedAccounts.delete` — and the answer used to be thrown away + * unread. A 200 whose body says `success: false` therefore became a withdrawn account in + * {@link ComposioBroker.revoke}'s count, a `true` out of that method, and a + * `vendorRevocationRequested: true` in the audit trail, over a grant Composio had just said it had + * not touched. That is the same class of lie as the unflagged delete before it and the one-page + * listing before that, arriving through the one door left unwatched: the reply. + * + * WHICH DOES NOT BLUR "ASKED" INTO "DONE", and the distinction is worth being exact about because + * the whole audit field rests on it. `success: true` still claims no more than it ever did — the + * account is gone at the broker and the revocation job was started — and whether Google honoured it + * happens afterwards, out of sight, with no supported way to poll. What `success: false` adds is + * the other end: Composio did not delete the account, so there is no job and nothing was asked of + * the provider at all. Reading it narrows the set of things `true` can be covering up rather than + * widening what `true` means. + * + * TWO SENTENCES, BECAUSE THEY ARE TWO DIFFERENT FACTS ABOUT TWO DIFFERENT THINGS. "Composio said + * no" is a fact about this account: the account is still there, a second press reaches it, and the + * caller's own count already says to press again. "Composio answered with something where its + * verdict belongs" is a fact about the package — nobody holding an admin page can correct the shape + * of a reply, and pressing disconnect again would be answered identically — so it carries + * {@link VENDOR_SHAPE_REMEDY} instead. Collapsing the two would send an operator to press a button + * for a condition no button changes. + * + * NULLABLE IN THE PARAMETER THOUGH THE DECLARATION SAYS OTHERWISE. The generated client parses the + * body and returns it, and there are two answers for which it hands over no body at all. + * + * AND NO BODY AT ALL IS NOT A REFUSAL, WHICH IS THE CORRECTION AND THE OPPOSITE MISTAKE TO THE ONE + * ABOVE. `defaultParseResponse` resolves a 204 to `null` — "fetch refuses to read the body when the + * status code is 204" — and a JSON reply carrying `content-length: 0` to `undefined` + * (`@composio/client` 0.1.0-alpha.76, `src/internal/parse.ts:16-42`). Neither of those ever reaches + * a non-2xx: the client throws `APIError` for every `!response.ok` before parsing + * (`src/client.ts:539`), so an answer arriving here at all is Composio having accepted the request. + * A 204 is therefore the vendor saying it did the delete and has nothing to add, and the guard + * added for `success: false` read it as the one shape it could not tell apart from a failure — + * turning a withdrawal that HAPPENED into a partial-withdrawal refusal, over a grant that was in + * fact ended. That is the same lie as the one this function exists to stop, pointing the other way. + * + * WHICH IS NOT THE SAME AS A BODY THAT ARRIVED WITHOUT THE FIELD. `{}` is Composio answering with a + * document whose verdict is missing, and a document this deployment cannot read is a fact about the + * package rather than an outcome — so it stays in the unreadable branch below. What is exempted + * here is the narrower thing the client documents: no document. + */ +function withdrawalDeclined( + answer: { success?: unknown } | null | undefined, + toolkit: string, +): BrokerRefusalError | null { + if (answer === null || answer === undefined) return null; + const verdict = answer.success; + if (verdict === true) return null; + if (verdict === false) { + return new BrokerRefusalError( + `Composio answered the withdrawal of one of this person's ${toolkit} accounts with success: false, so it did not delete the account and started no revocation of the grant behind it. Nothing was asked of the provider for that account, whatever this deployment would otherwise have recorded. Disconnecting again asks Composio for it a second time.`, + ); + } + return new BrokerRefusalError( + `Composio sent ${sent(verdict)} where its verdict on the withdrawal of one of this person's ${toolkit} accounts belongs, and that field is the only thing in the reply that says whether the account was deleted at all. This deployment cannot tell a withdrawal that happened from one that did not, so the account is reported as still standing rather than counted as ended. ${VENDOR_SHAPE_REMEDY}`, + ); +} + +/** + * One tool row as the action this deployment holds, with the one check the SDK's schema leaves open. + * + * THE VENDOR USED TO VALIDATE THIS ROW AND NO LONGER DOES, WHICH IS WHY THE GUARDS ARE ALL HERE. + * `transformToolCases` ends in `ToolSchema.parse(...)` — a THROWING parse rather than the + * warn-only `transform()` every other listing goes through (`@composio/core` 0.18.1, + * `src/models/Tools.ts:193`) — and the listing used to run through it (`:561`). It does not any + * more: `getRawComposioTools` is the one method of the vendor's tool model that cannot be paged, + * so the listing reads `client.tools.list` and {@link VendorToolRow} is the wire's own shape. Five + * refusals once stood here for five shapes that parse caught — a description that is a number, an + * input schema that is a string of JSON, tags that are not all labels, a version that is a number, + * and a row that is not an object at all — and each was deleted as unreachable. Four of the five + * had already come back, on the argument below. The fifth, the container, comes back with this + * change, because there is nothing at all between Composio and this function now. + * + * THE ARGUMENT THAT BROUGHT THE OTHER FOUR BACK IS THE SAME MISTAKE AS THE DELETED CATEGORY GUARD + * IN {@link appOf}, CORRECTED THE SAME WAY. "The SDK validates this" is a fact about one method of + * one version, and it is not a fact about {@link ComposioVendor}, which is the seam this function + * actually sits on and the shape a test satisfies with a literal. Running it settles what the + * difference costs: a `description` of 42 and an input schema of "not-a-schema" both travel through + * here untouched into {@link ComposioAction}, whose declared types say they cannot. What they reach + * is not a refusal. `storableTools` writes + * `(tool.description ?? "").replaceAll(NUL, "")` and `tool.version?.replaceAll(NUL, "")` + * (`./store`), so a description or a version that is not a string is a bare + * "42.replaceAll is not a function" thrown from outside every vendor `try` in this file — a crash + * wearing a refusal's clothes, which is the one outcome none of this file's sentences may become. + * An `inputParameters` that is not an object is quieter and worse: it is stored as the app's input + * schema and then shown to a model as Composio's own. + * + * SO THE THREE WIRE VALUES THIS FILE HANDS ON ARE CHECKED HERE, BESIDE THE SLUG, and `tags` is not + * — `./composio` already refuses a `tags` that is not a list where it reads them, and a check on + * both sides of one seam is a check nobody maintains. + * + * WHAT THE SCHEMA DOES NOT SETTLE IS THE FOURTH. `slug: z.string()` is satisfied by the + * empty string, and an action's slug is not a label: it becomes `mcp_tools.name`, which is NOT NULL + * and half that table's primary key, it is what a grant points at, and it is what a later call + * sends back to Composio. An empty one is a row that cannot be written and a call that names + * nothing, so it is refused here. + * + * A PLAIN `Error` RATHER THAN A `BrokerRefusalError`, because this listing's failures are not + * answered to a route. `./composio` records them in an app's `lastError` for an administrator to + * read on its Plugins page, and `listingSentence` passes an authored message through untouched. + */ +function actionOf( + row: VendorToolRow, + position: number, + toolkit: string, +): ComposioAction { + const at = `row ${position + 1} of Composio's action list for ${toolkit}`; + + /* + * THE CONTAINER, FOR THE REASON {@link appOf}'S ROW GUARD IS BACK ONE FUNCTION UP. `ToolSchema` + * refused a row that is not an object and nothing does now. Read without it, `("GMAIL").slug` is + * `undefined` rather than a throw, so a listing of bare strings would be refused with "Composio + * sent nothing where the slug of row 1 ... belongs" — an administrator sent to look for an action + * with a missing name, about an answer that had no action in it. + */ + if (typeof row !== "object" || row === null || Array.isArray(row)) { + throw new Error( + `Composio sent ${sent(row)} where ${at} belongs, and an action is an object carrying the name a call to it uses, so the list was not refreshed and the tools already held are untouched. ${VENDOR_SHAPE_REMEDY}`, + ); + } + + const slug = textOf(row.slug); + if (slug === null) { + throw new Error( + `Composio sent ${sent(row.slug)} where the slug of ${at} belongs, and the slug is what calling the action names, so the list was not refreshed and the tools already held are untouched. ${VENDOR_SHAPE_REMEDY}`, + ); + } + + /* + * ABSENT IS AN ANSWER AND PRESENT-AND-WRONG IS NOT, which is the same split {@link appOf} makes + * over a catalogue row. Composio genuinely publishes actions with no description and no version, + * and one that publishes no parameters at all arrives with none — the SDK normalizes a `{}` to + * absent before parsing. Each of those reaches `./composio` as the absence it is and is defaulted + * where a column has a default. What is refused is the other thing: a field that is THERE and is + * not what this file has told `./composio` it is. + */ + const description = row.description; + if (description !== undefined && typeof description !== "string") { + throw new Error( + `Composio sent ${sent(description)} where the description of ${at} belongs, and that value is written into this app's tools and read back as text, so the list was not refreshed and the tools already held are untouched. ${VENDOR_SHAPE_REMEDY}`, + ); + } + + /* + * `input_parameters` IS THE WIRE'S KEY AND `inputParameters` WAS THE WRAPPER'S — see + * {@link VendorToolRow}. Reading the old one off the new answer would put no schema on any + * action, which `./composio` treats as the ordinary case of an action that publishes none: every + * tool would reach a model with an open schema and nothing would report a fault. + */ + const inputParameters = row.input_parameters; + if ( + inputParameters !== undefined && + (typeof inputParameters !== "object" || + inputParameters === null || + Array.isArray(inputParameters)) + ) { + throw new Error( + `Composio sent ${sent(inputParameters)} where the input schema of ${at} belongs, and this deployment stores that value as the action's schema and shows it to a model as Composio's own. An action offered with a schema that is not one is a call nothing can get right, so the list was not refreshed and the tools already held are untouched. ${VENDOR_SHAPE_REMEDY}`, + ); + } + + const version = row.version; + if (version !== undefined && typeof version !== "string") { + throw new Error( + `Composio sent ${sent(version)} where the version of ${at} belongs, and the version is what a later call to this action asks Composio for, so the list was not refreshed and the tools already held are untouched. ${VENDOR_SHAPE_REMEDY}`, + ); + } + + /* + * Mapped field by field rather than spread, so what crosses the seam is the four things + * `./composio` documents and not whatever else the vendor's tool object happens to carry. + */ + return { + slug, + description, + inputParameters: inputParameters as Record | undefined, + tags: row.tags, + version, + }; +} + +/** + * WHAT THIS DEPLOYMENT WAS DOING WHEN A VENDOR CALL REFUSED, so a translated refusal can say. + * + * A CONDITION AND AN OUTCOME ARE TWO DIFFERENT HALVES OF A SENTENCE, and only one of them is the + * vendor's. "This person already holds an account for gmail" is what Composio determined; "so their + * connection to gmail was not begun" is what this deployment did about it, and a reader needs both + * — the first to know why asking again will not help, the second to know what state they are in + * now. The vendor cannot supply the second, because it does not know which of this file's ten calls + * it was answering. + * + * WRITTEN AT THE CALL SITE AS A FINISHED CLAUSE, in the past tense, so that it reads after "so" + * without any sentence below having to conjugate it. That is also what keeps the outcomes honest: + * each one is composed beside the call it describes, where whether anything was sent is a fact + * rather than a guess. + */ +type VendorCall = { + /** What did not happen, as a clause following "so": "the app catalogue was not read". */ + outcome: string; + /** The app the call is about, or null where the question names no app at all. */ + app: string | null; +}; + +/** + * The vendor's own name for the condition it raised, or null where it raised something anonymous. + * + * READ OFF `name` RATHER THAN ASKED WITH `instanceof`, which is a deliberate choice and not a + * shortcut. Every error class in `@composio/core` 0.18.1 ends its constructor by assigning its own + * `name` (`src/errors/*.ts`), so the name is the vendor's published discriminator and is stable + * across the package boundary; `instanceof` is not, because it is identity on a constructor and + * therefore hostage to a second copy of the package anywhere in the tree. The SDK makes the same + * judgement about its own classes — `isRequestAbortError` falls back to `constructor.name` and + * `name` and says why: "dual-package-hazard cases" (`src/errors/SDKErrors.ts`). `./composio`'s + * `isSchemaMismatch` reaches for a shape rather than a class for the same reason. + * + * AND IT IS WHAT LETS THE TABLE BE A TABLE. Naming eight classes as values would mean importing + * eight symbols from the vendor into the one file whose whole argument is that the vendor's surface + * is confined — and a version that renames one would then be a compile error in a switch that is + * meant to degrade to "not a condition this file knows" rather than to fail the build. + */ +function conditionOf(error: unknown): string | null { + const name = (error as { name?: unknown } | null | undefined)?.name; + return typeof name === "string" && name.trim() !== "" ? name : null; +} + +/** + * A VENDOR CONDITION AS A SENTENCE THIS DEPLOYMENT WROTE, or null where there is none to write. + * + * THE DEFECT THIS CLOSES IS THAT ALMOST NOTHING WAS TRANSLATED. `routes.ts` answers a thrown broker + * error by reaching for the vendor's own sentence and, finding none, telling the reader that + * Composio said nothing about why and that an administrator should check this deployment's Composio + * key. That is exactly right about a socket that hung up. It is wrong twice over about a condition + * `@composio/core` raised by name: the key is fine — the call that failed usually went out through + * a listing that had just succeeded on the same key — and several of these states are settled at + * the vendor, so the "and try again" half of the advice is an instruction to repeat something that + * will answer identically for ever. The worst of them is the first row below: a person who already + * has an account for an app was told to check an API key and retry. + * + * ONE SENTENCE PER CONDITION AND NO SENTENCE SHARED, which is the property its test asserts in both + * directions. A translation that gave two conditions one wording would be worse than leaving both + * alone: the reader would be handed a remedy that is right for somebody else's failure and would + * have no way to tell, where an untranslated failure at least says plainly that nothing is known. + * + * NULL WHERE COMPOSIO'S OWN SERVER EXPLAINED ITSELF, WHICH IS THE LIMIT ON DOING THIS AT ALL. + * `routes.ts` reads `brokerSentence` first and `vendorSentence` second, so a refusal authored here + * HIDES the vendor's message rather than joining it. Several of the SDK's classes are wrappers + * around whatever the API returned — `ComposioFailedToCreateConnectedAccountLink` keeps the + * `BadRequestError` as its `cause` (`src/models/ConnectedAccounts.ts`), and `vendorSentence` reaches + * through exactly that nesting — so translating one of those unconditionally would replace a + * specific server sentence with this deployment's general one. Where the vendor said something a + * reader can use, the error goes on untouched and the vendor gets the last word. + * + * THE ORIGINAL IS KEPT AS `cause` on every refusal, for whoever is reading a log rather than a page. + * It is never quoted into the message: the file's promise about {@link BrokerRefusalError} is that + * its sentence is safe to show anybody who could have made the request, and a vendor error object + * out of `connectedAccounts.link` carries the request that was being minted. + * + * WHAT IS DELIBERATELY NOT HERE, because the route's default answer is the correct one for it: + * `ComposioToolkitFetchError`, which `Toolkits.getToolkits` wraps around EVERY catalogue failure + * including its own validation one, and whose message is the bare "Failed to fetch toolkits" — the + * key and the status page genuinely are the remedy; and `ComposioToolExecutionError`, the same + * wrapper one call further on, whose `cause` carries the server's own words for `vendorSentence` to + * find and whose own message `./composio`'s {@link VENDOR_PLACEHOLDER} already refuses to pass on. + */ +function vendorRefusal( + error: unknown, + call: VendorCall, +): BrokerRefusalError | null { + if (vendorSentence(error) !== null) return null; + + const app = call.app ?? "the app"; + const outcome = call.outcome; + const refusal = (message: string): BrokerRefusalError => + new BrokerRefusalError(message, { cause: error }); + + switch (conditionOf(error)) { + /* + * THE ONE THAT WAS DOING THE MOST DAMAGE. `connectedAccounts.link` lists this person's active + * accounts for the config before it mints anything and refuses where it finds one + * (`@composio/core` 0.18.1, `src/models/ConnectedAccounts.ts`), which is this deployment's own + * rule met one layer down — one person holds one account per app, because the call that runs an + * action names the person and not the account. So it is a settled fact rather than a moment, + * and "check the key and try again" is advice that cannot ever come true. + */ + case "ComposioMultipleConnectedAccountsError": + return refusal( + `Composio answered that this person already holds a connected account for ${app}, so ${outcome}. That is a settled state at Composio rather than a moment that passes — asking again meets the same answer — and what clears it is disconnecting the account they already hold, on this deployment's Connected accounts page, before another is attached.`, + ); + + /* + * ACCESS RULES ARE NOT SOMETHING THIS FILE SENDS, which is the whole of why this one is worth a + * sentence. The SDK raises it when the server rejects ACL fields on an account that is not + * shared, and nothing here asks for sharing — so the reader must not go looking through this + * deployment's settings for a field it does not have. The config in Composio's dashboard is + * where the sharing is decided and where an operator can change it. + */ + case "ComposioAclOnlyForSharedError": + return refusal( + `Composio refused account-sharing rules on an account that is not a shared one, so ${outcome}. This deployment attaches every account to one person and asks for no sharing, so the rules are on the authorization config rather than on anything sent from here: an operator changing how ${app} is shared in Composio's own dashboard is what clears it.`, + ); + + /* + * REACHED ONLY WHERE THE SERVER SAID NOTHING, by the guard at the top of this function. What is + * left when it is reached is still worth far more than the route's default, because of what has + * already happened by the time this call is made: the auth configs were listed through the same + * key moments earlier and one of them was found enabled. So the two things the default sends an + * operator to check are both already proven, and the two things a person needs to know — that + * they were not sent anywhere, and that their consent is unspent — are facts about this + * particular call that no general sentence carries. + */ + case "ComposioFailedToCreateConnectedAccountLink": + return refusal( + `Composio would not mint a connect link for ${app} and said nothing about why, so ${outcome}. Nobody was sent to a consent screen and no consent was spent. This deployment's key and its authorization config for the app were both read through successfully moments earlier, so neither of those is what to check; Composio's status page is.`, + ); + + /* + * THE SDK'S OWN SCHEMA REFUSING, ON EITHER SIDE OF THE WIRE. `ValidationError` is raised by + * nearly every model here — the auth-config create, the connected-account listing and link, the + * tool listing and the execute all `safeParse` what they are handed and what comes back — and in + * both directions it means the same thing: this deployment's copy of `@composio/core` and + * Composio's API no longer agree. It is the one condition below whose remedy is a package + * rather than a page, which is why it shares its wording with the shape refusals above. + */ + case "ValidationError": + return refusal( + `This deployment's @composio/core refused the request or Composio's answer against its own schema, so ${outcome} — either before Composio was asked or after it had replied. Nothing an operator can set corrects that and the key is not what to check: upgrading this deployment's @composio/core is what fixes it.`, + ); + + /* + * NOTHING IS WRONG AT COMPOSIO, WHICH IS THE ENTIRE MESSAGE. A cancelled request is a caller's + * own abort, so sending somebody to a key or a status page is sending them to look at two things + * that are working. The one honest thing to add is the ambiguity: a call cancelled in flight may + * or may not have been acted on at the vendor, and this deployment cannot tell which. + */ + case "ComposioRequestCancelledError": + return refusal( + `The request was cancelled before Composio answered, so ${outcome} as far as this deployment can tell — and how far it had got when the cancellation landed is exactly what it cannot tell. Nothing is wrong at Composio and nothing needs setting here; asking for it again is what settles which of the two it was.`, + ); + + /* + * THE ACCOUNT IS GONE AT THE VENDOR, WHICH THIS DEPLOYMENT'S ROWS DO NOT KNOW. `tools.execute` + * maps API error code 1803 onto this class (`src/errors/ToolErrors.ts`), and what it reports is + * a person whose `composio_connections` row still stands over an account Composio no longer + * holds — a grant withdrawn at Google, or an account removed in the dashboard. Retrying reaches + * neither; connecting again is what puts an account back under the row. + */ + case "ComposioConnectedAccountNotFoundError": + return refusal( + `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.`, + ); + + /* + * THE ACTION COULD NOT BE FETCHED, WHICH IS NOT THE SAME CLAIM AS THE ONE THIS USED TO MAKE. + * + * The sentence here read "Composio no longer publishes that action", on the strength of the + * class's name. The name does not carry that: `getRawComposioToolBySlug` wraps its whole + * retrieve in a try whose catch rethrows EVERYTHING except a cancellation as this class — + * `throw new ComposioToolNotFoundError(\`Unable to retrieve tool with slug ${"${slug}"}\`, { cause: error })` + * (`@composio/core` 0.18.1, `src/models/Tools.ts:709-721`) — and `tools.execute` resolves + * through that same method (`:1163`). So a 500, a 429, a refused key, a socket that hung up and + * an action genuinely withdrawn all arrive under one name, and nothing on the error tells them + * apart. An outage was being reported to an administrator as a catalogue change, with an + * instruction to press Refresh at a vendor that was not answering. + * + * WHAT CAN HONESTLY BE SAID IS THAT IT COULD NOT BE FETCHED, AND WHICH TWO READINGS THAT HAS. + * The refresh stays in the sentence because a withdrawn action is the commonest of them and + * the refresh is the only act that settles it — but it is named as the remedy for ONE of the + * readings rather than as the remedy, and the reader is given the fact that separates them: + * whether every other action of every other app is failing too. That is a thing they can look + * at, which "no longer publishes" was not. + * + * THE VENDOR'S OWN WORDS STILL WIN WHERE THERE ARE ANY. Where the failure underneath was an API + * error carrying a server sentence, the guard at the top of this function has already returned + * null and none of this is reached — so what this row answers is the half of the class that + * explained itself least. + */ + case "ComposioToolNotFoundError": + return refusal( + `Composio would not hand that action over at the version this deployment recorded for it, so ${outcome}. This deployment's @composio/core reports an action Composio has withdrawn and a request for one that failed — a timeout, a dropped connection, a 500, a refused key — under one condition and says nothing that tells the two apart, so neither can this deployment. Refreshing ${app}'s tools on its Plugins page records what Composio publishes now, which settles it where the action is gone; where every action of every app is failing the same way, it is the request rather than the action, and Composio's status page is where that shows.`, + ); + + /* + * A RECORDED VERSION OF "latest" IS A ROW THAT NEEDS REWRITING, not a call that needs repeating. + * The SDK refuses `latest` for a tool executed one at a time (`src/models/Tools.ts`), and the + * version travelling with a call is whatever the listing wrote down for the action, so the fix + * is on the row rather than at Composio. + */ + case "ComposioToolVersionRequiredError": + return refusal( + `Composio refuses a call whose toolkit version is "latest", and that is the version travelling with this one, so ${outcome}. A dated version is recorded when an app's actions are listed, so refreshing ${app}'s tools on its Plugins page replaces "latest" with a version Composio will accept.`, + ); + + /* + * AN ANSWER THE VENDOR'S OWN PACKAGE COULD NOT READ, WHICH IS THE ROW THE FILE HAD BACKWARDS. + * + * This used to be classified as a bug of this deployment's — see the note now on + * {@link askForEach} — on the premise that a `TypeError` is what a program's own mistake looks + * like and never something Composio can reply. Running `@composio/core` 0.18.1 falsifies that + * outright. Its list transformers dereference the answer before returning it, so a malformed + * reply dies inside the vendor's code and arrives here as a bare `TypeError`: + * `response.items.map(transformAuthConfigRetrieveResponse)` off a bare list or an `items` that + * is a string (`src/utils/transformers/authConfigs.ts:79`), `authConfig.toolkit.logo` off a row + * that is not an object (`:41`), and the same two shapes at + * `src/utils/transformers/connectedAccounts.ts:113` and `:60` and at `src/models/Tools.ts:561`. + * Five vendor answers, five `TypeError`s, none of them this deployment's doing. + * + * IT IS TRANSLATED HERE BECAUSE HERE IS WHERE IT SURFACES. {@link askVendor} wraps the + * `await vendor.*` and nothing else, so a throw reaching this function came out of the vendor's + * code by construction rather than by inspection — which is exactly the distinction the guards + * this replaces were trying to make one layer too late, in readers the SDK never let them reach. + * + * WHAT THE READER IS TOLD IS DELIBERATELY NOT A THING TO TRY. Nobody holding an admin page can + * correct the shape of a reply, and "try again" would be advice to repeat a request that will + * be answered identically; the one act that changes anything is a package upgrade, and the two + * things the route's default sends an operator to check are both already proven fine — the + * request went out and Composio replied to it. The crash itself is carried as `cause` and never + * quoted: a sentence that reads like a stack trace is what every refusal here exists not to be. + */ + case "TypeError": { + const about = call.app === null ? "" : ` for ${call.app}`; + return refusal( + `Composio's answer${about} was a shape this deployment's @composio/core could not read, so ${outcome}. The failure was raised inside the vendor's own package as it read the reply, so the request went out and Composio answered it: neither the key nor anything on this deployment's pages is what to check. ${VENDOR_SHAPE_REMEDY}`, + ); + } + + default: + return null; + } +} + +/** + * One vendor call, with whatever it refuses with translated on the way out. + * + * WRAPPED AROUND THE `await vendor.*` AND NOTHING ELSE, which is the discipline that makes this + * safe to apply everywhere. Everything else inside these methods is this file's own reading and + * refusing, and those already carry authored sentences; passing one back through + * {@link vendorRefusal} could only find a name it does not know, but the narrower scope is what + * makes that true by construction rather than by inspection. + * + * A CALL WITH NO ENTRY HERE IS THE FAILURE MODE THE TABLE IN THE TESTS EXISTS FOR. TypeScript has + * no checked exceptions, so nothing enumerates the calls that translate and nothing notices a new + * one that does not; the seam's own test walks every method of both projections and fails the + * method that forgot. + */ +async function askVendor( + call: VendorCall, + ask: () => Promise, +): Promise { + try { + return await ask(); + } catch (error) { + const refusal = vendorRefusal(error, call); + if (refusal !== null) throw refusal; + throw error; + } +} + +/** + * Every reason a set-wide refusal collected, in one value a `cause` can hold. + * + * ALL OF THEM, WHICH IS THE CORRECTION. The two loops below used to throw with `cause: refused[0]`, + * so a person with five accounts of which three refused left one reason attached and two discarded + * — and the sentence those throws carry is a COUNT, deliberately, because a count is the thing a + * reader can act on. The reasons were therefore the only place the detail existed at all, and two + * thirds of it was being dropped on the floor. + * + * CARRIED RATHER THAN LOGGED, and that is this file's rule rather than a preference. A vendor error + * out of `connectedAccounts.link` or an account delete carries the request it was made for, and a + * console line is a line in an aggregator; nothing in this module logs a vendor object, for the same + * reason nothing in it logs the key. A `cause` travels to whoever is already holding the failure. + * + * ONE REFUSAL STAYS ITSELF. Wrapping a single error in an `AggregateError` would make every reader + * unwrap a list to find one thing, and `store.ts` already reads `error.cause` directly. + */ +function everyRefusal(refused: unknown[]): unknown { + if (refused.length === 1) return refused[0]; + return new AggregateError( + refused, + `Composio refused ${refused.length} of the requests this call made.`, + ); +} + +/** + * What this adapter needs of `@composio/core`'s client, written as a shape rather than as a class. + * + * A TEST SATISFIES IT WITH AN OBJECT LITERAL, which is the entire argument for it and the reason + * {@link buildComposioClient} takes one of these instead of an API key. The vendor's own client + * cannot be constructed without a key and answers nothing without a network, so an adapter that + * built its own client would be an adapter no test could reach — and this is the file where the + * field names, the argument order and the refusal below would go wrong unnoticed. + * + * Every member is written with METHOD syntax deliberately. Method parameters are compared + * bivariantly, so a real `Composio` — whose signatures carry optional request-options arguments + * and wider parameter types than the calls here use — satisfies this without a cast. + * + * THE TWO DELETES ARE NOT `Composio`'s, AND THAT IS THE ONE PLACE THIS SHAPE DIVERGES FROM IT. Both + * of the vendor's own wrappers hard-code the request body they send — `this.client.authConfigs + * .delete(nanoid, undefined, requestOptions)` and the same line for connected accounts + * (`@composio/core` 0.18.1, `src/models/AuthConfigs.ts:303-311` and + * `src/models/ConnectedAccounts.ts:532-540`) — so through them the `revoke_on_delete` parameter + * cannot be passed at all, and both calls soft-delete while the grant at Google or Slack stands. So + * the shape below asks for the underlying client's signature instead, and + * {@link createComposioClient} satisfies it from `composio.getClient()`. See + * {@link ComposioBroker.revoke} for what that flag is and what its absence made this deployment + * claim. + */ +export type ComposioVendor = { + tools: { + /** + * Every action of one app, one page at a time, THROUGH THE RAW CLIENT RATHER THAN THE WRAPPER. + * + * `getRawComposioTools` was here and could not be paged. `ToolListParamsSchema` names no cursor + * field at all (`@composio/core` 0.18.1, `src/types/tool.types.ts:257-266`) and the method ends + * `tools.items.map(...)` (`src/models/Tools.ts:561`), dropping the response's `next_cursor` + * before any caller sees it — so through the wrapper a page at {@link LISTING_LIMIT} and a + * listing longer than one were the same array, and `./composio` refused rather than commit a + * fragment as the whole truth about an app. The raw client is the difference: `ToolListParams` + * carries `cursor`, "a base64 encoded string of the page and limit", and `ToolListResponse` + * carries `next_cursor` (`@composio/client` 0.1.0-alpha.76, `resources/tools.d.ts:421-432` and + * `:200-204`). The request IS expressible, and {@link everyRowOf} makes it. + * + * WHAT THE WRAPPER WAS DOING THAT THIS HAS TO KEEP DOING IS TWO PARAMETERS AND ONE NO-OP. + * `toolkit_versions` is the SDK's own `toolkitVersions` config, which defaults to "latest" + * (`src/utils/config-defaults/ConfigDefaults.node.ts`) and was forwarded on every listing it + * made (`src/models/Tools.ts:548`); it decides which `version` each action comes back with, + * which is the value a later call sends back to Composio, so it is passed here as the literal + * the default is. `important` is the one this file already knew about and is now simply not + * named: the wrapper set it to "true" whenever a toolkit query carried no limit (`:505-515`), + * which NARROWS the answer to a featured subset that nothing in the answer declares. And the + * no-op is `applyDefaultSchemaModifiers`, which returns its argument untouched unless + * `dangerouslyAllowAutoUploadDownloadFiles` is on (`:242-248`); it defaults off + * (`ConfigDefaults.node.ts`) and {@link createComposioClient} does not turn it on — which is + * the premise `./composio`'s file-upload guard is written on. + * + * AND `ToolSchema.parse` IS GONE WITH IT, WHICH IS A GAIN AND A COST, BOTH WRITTEN DOWN + * ELSEWHERE. The gain is that the parse STRIPPED every schema key its `ParametersSchema` did + * not name before any caller could see it — see {@link ComposioAction.inputParameters}, where + * that loss was recorded as unavoidable and where the fix was named as this exact call. The + * cost is that nothing validates the row: {@link actionOf} carries every check now, including + * the container, and {@link pageOf} carries the envelope. + */ + list(query: { + toolkit_slug: string; + limit: number; + toolkit_versions: "latest"; + /** Where the last page left off, ABSENT on the first request rather than undefined. */ + cursor?: string; + }): Promise<{ + /** + * `unknown` BECAUSE NOTHING HAS LOOKED AT IT, which is the difference between this listing + * and the two the SDK still serves. Their transformers open `response.items.map(...)`, so a + * malformed envelope dies inside the vendor's package; a generated client parses the body and + * returns it. {@link pageOf} is where that is answered. + */ + items?: unknown; + /** The vendor's own word for "there is another page", read by {@link everyRowOf}. */ + next_cursor?: unknown; + } | null>; + getRawComposioToolBySlug( + slug: string, + options?: { version?: string }, + ): Promise; + execute( + slug: string, + body: { + arguments: Record; + userId: string; + version: string; + }, + ): Promise; + }; + toolkits: { + /** + * The app catalogue, one page at a time, THROUGH THE RAW CLIENT FOR THE SAME REASON. + * + * AND THIS IS THE ONE THAT WAS BROKEN IN FRONT OF PEOPLE. `Toolkits.getToolkits` does forward a + * `cursor` (`@composio/core` 0.18.1, `src/models/Toolkits.ts:68`) — so the wrapper could ASK + * for a second page — but `transformToolkitListResponse` returns `response.items.map(...)`, a + * bare array (`src/utils/transformers/toolkits.ts:16-43`), so there is no cursor to put in it. + * One half of a pager is not a pager, and `fetchDirectory` refused a full page because it could + * not tell a complete catalogue from a truncated one. Composio publishes more than + * {@link LISTING_LIMIT} toolkits, so that refusal fired on the FIRST call, every time, and an + * operator opening the app picker saw no apps at all. `ToolkitListResponse` carries + * `next_cursor` and `ToolkitListParams` carries `cursor` (`@composio/client` 0.1.0-alpha.76, + * `resources/toolkits.d.ts:322-326`, `:467-478`), so the whole request was expressible the + * entire time. + * + * `sort_by` IS THE WIRE'S SPELLING OF WHAT WAS `sortBy`, and it still matters even though the + * listing is no longer finite. What it buys now is order rather than coverage: the rows reach a + * picker in the order Composio thinks people want them, and the page ceiling in + * {@link PAGE_CEILING} is the one case left where being cut off is possible at all. + * + * NO SEARCH TERM, WHICH IS THE SAME DECISION FOR A DIFFERENT REASON THAN BEFORE. It used to be + * that the SDK's params named no search field and the parse stripped what it did not name, so a + * term passed here would vanish before the request. `ToolkitListParams` DOES name `search` + * (`:480-484`), so that is no longer the argument. The argument is that the catalogue is held + * for ten minutes and searched in this process by both of its callers — the picker and the + * enable route's slug check — and a per-term request would be a per-term cache. Searching over + * the held rows is this deployment's own job and stays so. + */ + list(query: { + limit: number; + sort_by: "usage"; + /** Where the last page left off, ABSENT on the first request rather than undefined. */ + cursor?: string; + }): Promise<{ + /** `unknown` for the reason the action listing's is — see {@link pageOf}. */ + items?: unknown; + /** The vendor's own word for "there is another page", read by {@link everyRowOf}. */ + next_cursor?: unknown; + } | null>; + /** + * ONE app, read for the one thing the catalogue listing does not carry: what it asks a person. + * + * The listing above answers which apps exist and which scheme each of them authenticates with; + * it does not answer which boxes a form for that scheme needs. That is published per app and it + * moves — one app wants a key, the next wants a key and a workspace subdomain — so it is asked + * for at the moment a person presses Connect rather than derived from anything held here. See + * {@link ComposioBroker.connectionFields}. + * + * NO PAGE AND NO CURSOR, because a toolkit is one object rather than a listing: this is the one + * vendor read in this file where there is no second page to be mistaken for the whole answer. + */ + retrieve(slug: string): Promise; + }; + authConfigs: { + list(query: { + toolkit: string; + limit: number; + /** + * Ask for the disabled ones too, ALWAYS, which is why this is the literal and not a boolean. + * + * The vendor's listing returns enabled configs unless asked otherwise + * (`AuthConfigListParamsSchema.showDisabled`, `@composio/core` 0.18.1, + * `src/types/authConfigs.types.ts:124-131`), and a config this listing cannot see is a config + * {@link ComposioBroker.ensureAuthConfig} creates a second of — which is the exact split that + * method exists to prevent, arriving through the one door it was not watching. Every question + * this file asks of the listing is better served by seeing a disabled config and saying so: + * creation must not duplicate it, deletion must remove it, and consent must refuse against + * it rather than mint a link that cannot work. + */ + showDisabled: true; + /** + * Where the last page left off, ABSENT on the first request rather than undefined. + * + * `AuthConfigListParamsSchema` names it and `AuthConfigs.list` forwards it + * (`@composio/core` 0.18.1, `src/types/authConfigs.types.ts:124-131`, + * `src/models/AuthConfigs.ts:95`), which is the fact that decides how a truncated answer is + * handled here: the catalogue refuses a full page because it has no way to ask for the next + * one, and this listing does. See {@link everyRowOf}. + */ + cursor?: string; + }): Promise<{ + items: VendorAuthConfig[]; + /** + * The vendor's own word for "there is another page", which was being discarded at this type. + * + * `unknown` RATHER THAN `string | null`, because the transformer writes `response.next_cursor + * ?? null` (`src/utils/transformers/authConfigs.ts:80`) and that is the wire's value with no + * check on it — a numeric cursor reaches {@link everyRowOf} exactly as Composio sent it, which + * is the one shape that could be read as the end of a listing that has not ended. + * + * `AuthConfigListResponseSchema` carries it (`@composio/core` 0.18.1, + * `src/types/authConfigs.types.ts:129-133`) and + * `transformAuthConfigListResponse` fills it in from `next_cursor` on every answer + * (`src/utils/transformers/authConfigs.ts:72-80`). Omitting it here did not make the + * truncation go away; it made it unobservable, because a field a projection does not name is + * a field no caller and no test of a caller can ask about. {@link everyRowOf} reads it, and + * follows it until the vendor stops offering one — so a listing at {@link LISTING_LIMIT} is + * no longer a fragment this file can mistake for the whole answer. + */ + nextCursor?: unknown; + }>; + /** + * Create one auth config, and hand back the id Composio gave it. + * + * `Promise` UNTIL NOW, AND THE ANSWER WAS NEVER LOOKED AT. That was read as harmless + * because nothing here needs the id — the next listing finds the config by its name. It is not: + * `transformCreateAuthConfigResponse` builds what this resolves to by dereferencing + * `response.auth_config.id` and `response.toolkit.slug` (`@composio/core` 0.18.1, + * `src/utils/transformers/authConfigs.ts:96-106`), so a drift in the answer's SHAPE raises a + * `TypeError` inside the vendor's package — after the create has gone out and been answered. + * {@link ComposioBroker.ensureAuthConfig} then reported that no config had been created, over a + * config standing at Composio that nothing in this deployment names. Reading the reply is what + * makes the difference between "nothing was created" and "something may have been" a fact + * rather than an assumption. + * + * `id?: unknown` FOR THE REASON EVERY OTHER FIELD IN THIS PROJECTION IS ONE: the `transform()` + * around it is the warn-only kind (`src/utils/transform.ts:26-36`), so + * `CreateAuthConfigResponseSchema` spelling the id a required string is the answer Composio + * MEANS to send rather than a fact about the one that arrived. + * + * NULLABLE THOUGH THE VENDOR'S OWN DECLARATION IS NOT, exactly as the account delete below is: + * the generated client parses the body and returns it, and reading a field off a `null` would + * be a crash carrying a sentence that reads like a stack trace. + */ + create( + toolkit: string, + /** + * THE UNION, BECAUSE THE MANAGED TYPE IS RIGHT FOR ONE KIND OF APP AND WAS SENT FOR ALL OF + * THEM. A self-registering app has no Composio-owned OAuth client behind it and a key app + * has no consent screen to send anybody to, so both are created as custom configs — carrying + * the scheme, and never a credential, which is per-connection rather than per-config. The + * managed shape has no field to name a scheme with, which is why this is a union of two + * shapes rather than one shape with an optional field. + */ + options: + | { type: "use_composio_managed_auth"; name: string } + | { + type: "use_custom_auth"; + authScheme: FieldScheme | "DCR_OAUTH"; + name: string; + /** + * EMPTY, AND PRESENT, WHICH IS NOT THE CONTRADICTION IT LOOKS LIKE. + * + * Neither config this deployment creates carries a secret — a self-registering app + * needs none and a key app's key belongs to each connection made against the config + * rather than to the config — but `CreateCustomAuthConfigParamsSchema` spells + * `credentials` REQUIRED (`@composio/core` 0.18.1, + * `src/types/authConfigs.types.ts:52-65`) and `AuthConfigs.create` `safeParse`s its + * options before it builds a body (`src/models/AuthConfigs.ts:132-137`). So an absent + * field is not "no credentials sent"; it is a `ValidationError` raised inside the + * vendor's package with no request made at all. The empty record is what carries + * nothing THROUGH that check, and the type says so rather than leaving the next reader + * to discover it from a failed enable. + */ + credentials: Record; + }, + ): Promise<{ id?: unknown } | null>; + /** + * Delete one auth config, and ask for the upstream credentials on it to be revoked too. + * + * THE SAME TRAP AS THE ACCOUNT DELETE BELOW, one level up. The endpoint "soft-deletes an + * authentication configuration" and revokes "the upstream credentials of every connection using + * this auth config" only when the flag is passed (`@composio/client` 0.1.0-alpha.76, + * `resources/auth-configs.d.ts:60-72` and `:651-659`), so a delete without it leaves every + * grant that was ever made against this config alive at the provider. + * + * WHICH IS WHY IT IS PASSED HERE EVEN THOUGH THE ACCOUNTS WERE ALREADY ASKED FOR INDIVIDUALLY. + * Removing an app revokes each connected person first and drops the config last, and that loop + * reaches exactly the people this deployment has a `composio_connections` row for. An account + * whose row drifted — cleared by a confirm that Composio answered `false` to, or lost with a + * database this deployment restored — is invisible to it and still live at the vendor. This is + * the one call that reaches those, and there is nothing on this config that removing the app is + * not meant to end. + */ + delete(id: string, params: { revoke_on_delete: true }): Promise; + }; + connectedAccounts: { + list(query: { + userIds: string[]; + toolkitSlugs: string[]; + /** + * Which statuses this particular question is about — see {@link CONNECTED} and + * {@link REVOCABLE}, which are the only two answers this file gives. + */ + statuses: VendorAccountStatus[]; + /** + * Both sharing models, ALWAYS, which is why this is the literal and not the enum. + * + * OMITTING IT IS NOT "NO OPINION", in exactly the way an omitted limit is not. The parameter + * defaults to private accounts only (`ConnectedAccountListParamsSchema.accountType`, + * `@composio/core` 0.18.1, `src/types/connectedAccounts.types.ts:286-293`), so a person whose + * account for an app is a SHARED one reads as not connected, is told to connect an app they + * already have, and — far worse — is invisible to the revoke, which then reports that there + * was nothing to withdraw while their grant stands. Neither of the two questions this file + * asks has any reason to care how an account is shared: the gate is about whether this person + * can act through the app, and the revoke is about ending every account they can act through. + */ + accountType: "ALL"; + /** + * Which authorization configs the question is about, ABSENT where it is about all of them. + * + * THE TWO QUESTIONS DIFFER HERE TOO, AND ONLY ONE OF THEM MAY ACT. `authConfigIds` is a + * parameter of the listing (`ConnectedAccountListParamsSchema.authConfigIds`, + * `@composio/core` 0.18.1, `src/types/connectedAccounts.types.ts:260-264`) forwarded as + * `auth_config_ids` (`src/models/ConnectedAccounts.ts:117`), and omitting it asks about every + * config in the project — this deployment's and an operator's hand-made ones alike. That is + * the right question for {@link ComposioBroker.isConnected}, which only reads whether a person + * can act through the app, and the wrong one for {@link ComposioBroker.revoke}, which deletes + * what it finds: see there for why an account on somebody else's config is not this + * deployment's to end. + * + * NEVER THE EMPTY ARRAY. A caller with no configs of its own has nothing to scope TO, and an + * empty filter is the shape most likely to be read as no filter at all by whatever is on the + * far side — which would be the unscoped listing arriving through the parameter added to + * prevent it. `revoke` answers before it asks in that case. + */ + authConfigIds?: string[]; + limit: number; + /** + * Where the last page left off, ABSENT on the first request rather than undefined. + * + * `ConnectedAccountListParamsSchema` names it and `ConnectedAccounts.list` forwards it + * (`@composio/core` 0.18.1, `src/types/connectedAccounts.types.ts:259-266`, + * `src/models/ConnectedAccounts.ts:118`). This is the listing the paging matters most for: + * {@link ComposioBroker.revoke} answers `true` for "this person's access has ended", and one + * page of their accounts is not the set of their accounts. See {@link everyRowOf}. + */ + cursor?: string; + }): Promise<{ + /** + * The id is the only field read off an account, and it is read off the wire unchecked. + * + * `transformConnectedAccountResponse` spreads the raw item and overrides the fields it + * renames (`@composio/core` 0.18.1, `src/utils/transformers/connectedAccounts.ts:52-66`), so + * `id` arrives exactly as Composio sent it inside the same warn-only `transform()` as + * everything else here. An account with no id is the one shape the revoke below cannot act + * on, and {@link withdrawableAccounts} is what says so about it. + * + * THE ROW ITSELF IS AN OBJECT BY CONSTRUCTION, which is why only the field is in doubt. The + * same function reads `response.auth_config.id` (`:60`) on its way to building each row, so a + * row that is not an object raises inside the vendor's code and never reaches this listing. + */ + items: { id?: unknown }[]; + /** + * The same truncation signal as the auth-config listing above, from the same vendor schema. + * + * `ConnectedAccountListResponseSchema` spells it `nullish` + * (`src/types/connectedAccounts.types.ts:297-303`) and the transformer sets it on every + * answer (`src/utils/transformers/connectedAccounts.ts:109-117`). It matters more here than + * anywhere else in this file: {@link ComposioBroker.revoke} answers `true` for "this + * person's access has ended", and it used to have seen only one page of the accounts it + * would have to end. {@link everyRowOf} follows it until there is none left. + */ + nextCursor?: unknown; + }>; + /** + * Mint one person's connect link against one auth config, with the page to come back to. + * + * `link` RATHER THAN `initiate`, AND RATHER THAN `toolkits.authorize`. All three end at a + * redirect url, and only the choice between them decides whether this deployment keeps working. + * `toolkits.authorize` takes a user id, a toolkit and an optional auth config id and has no + * parameter for a callback at all (`@composio/core` 0.18.1, `src/models/Toolkits.ts:333-338`), + * which is why consent used to end on Composio's hosted page — the address below has nowhere + * to travel on that call. `initiate` does carry one (`:249`), but the endpoint under it is + * retired for Composio-managed OAuth on redirectable schemes — cutover 2026-05-08 for new + * organizations and 2026-07-03 for the rest, after which it throws + * `ComposioLegacyConnectedAccountsEndpointRetiredError` (`src/models/ConnectedAccounts.ts:146-160`) + * — and `use_composio_managed_auth` is exactly what {@link ComposioBroker.ensureAuthConfig} + * creates. `link` is the vendor's own named replacement for that combination, carries the + * callback, and answers in the same shape. + * + * TAKING THE AUTH CONFIG ID IS NOT A COST HERE. It is the one thing `toolkits.authorize` was + * doing for us, and it did it by listing the configs and creating one at Composio's managed + * defaults where it found none — which this deployment already does for itself, at enable + * time, named so an operator can find it in their dashboard. The listing below is that same + * read; the creation is not repeated, because an app with no config is a state to report + * rather than one to paper over. + */ + link( + userId: string, + authConfigId: string, + options: { callbackUrl: string }, + ): Promise<{ redirectUrl?: unknown }>; + /** + * Create one person's account from the secret they typed, WITHOUT a consent screen anywhere. + * + * THE RAW CLIENT FOR THE REASON BOTH DELETES ARE RAW, and a sharper one. `@composio/core`'s + * `connectedAccounts.initiate` is the wrapper over this endpoint, and the endpoint under it is + * retired for the managed-auth path — it throws + * `ComposioLegacyConnectedAccountsEndpointRetiredError` (`@composio/core` 0.18.1, + * `src/models/ConnectedAccounts.ts:146-160`) — while `link`, which replaced it, mints a consent + * url and has nowhere to put a typed value at all. What this flow needs is neither: the person + * has already typed their credential into a form here, so there is no screen to send them to + * and nothing to come back from. `@composio/client`'s own create takes the state directly + * (`0.1.0-alpha.76`, `resources/connected-accounts.d.ts:33`, `:7502-7511`), which is the whole + * of what this call is. + * + * NO `validate_credentials`, AND ITS ABSENCE IS DELIBERATE RATHER THAN AN OVERSIGHT. The + * parameter exists on the same body and the vendor marks it EXPERIMENTAL (`:7505-7509`). + * Whether a typed key actually works is settled here by making a call with it rather than by + * asking Composio to grade it, because a connection Composio accepts is not a connection that + * works: a wrong value comes back `ACTIVE`. + * + * `id` AND `status` READ AS `unknown`, for the reason every other vendor field in this + * projection is. The generated client declares both as required + * (`ConnectedAccountCreateResponse`, `:121-146`), which is the schema's promise about what + * Composio means to send rather than a fact about what arrived — and the id is the one field + * this whole call exists to answer, so a missing one is a refusal here rather than an + * `undefined` handed on as the account somebody is meant to be able to undo. + */ + create(body: { + auth_config: { id: string }; + /** + * The person, and the secret they typed, in the shape the vendor's own builder assembles. + * + * `state` IS `unknown` BECAUSE WHAT GOES IN IT IS THE APP'S QUESTION RATHER THAN THIS FILE'S + * ANSWER. The generated client declares it as a fourteen-member union keyed on the scheme, + * each member's `val` carrying whatever fields that app publishes plus `[k: string]: unknown` + * (`@composio/client` 0.1.0-alpha.76, `resources/connected-accounts.d.ts:7551`, `:8083-8086`) + * — so naming a shape here would be this file asserting which boxes an app asks for, which is + * exactly the thing {@link ComposioBroker.connectionFields} exists to go and ask. + */ + connection: { user_id: string; state: unknown }; + }): Promise<{ id?: unknown; status?: unknown }>; + /** + * Delete one connected account, and ask for the grant behind it to be revoked too. + * + * WITHOUT THE FLAG THIS CALL DOES NOT REVOKE ANYTHING, and that is the vendor's own description + * of it: it "soft-deletes a connected account by marking it as deleted in the database", which + * "prevents the account from being used for API calls but preserves the record" + * (`@composio/client` 0.1.0-alpha.76, `resources/connected-accounts.d.ts:59-72`). The refresh + * token at Google or Slack survives that untouched. Every path in this deployment that claims + * to end somebody's access — a person disconnecting, an administrator removing an app, a person + * being offboarded — runs through here, so an unflagged delete made all three of those claims + * false at once and wrote `true` into the audit trail beside them. + * + * WHAT THE FLAG BUYS IS A REQUEST AND NOT A RESULT, which is the whole reason + * {@link ComposioBroker.revoke}'s answer is named the way it is. The upstream revocation runs as + * a background job; the response carries its `revoke_job_id` and the vendor documents that no + * generally available endpoint polls it (`:7447-7459`). Nothing here can say the provider tore + * the refresh token up, and nothing here pretends to. + * + * BUT THE ANSWER DOES SAY WHETHER COMPOSIO DID ITS OWN HALF, AND THAT WAS BEING DISCARDED. + * `ConnectedAccountDeleteResponse` carries a REQUIRED `success: boolean`, "indicates whether + * the connected account was successfully deleted" (`@composio/client` 0.1.0-alpha.76, + * `resources/connected-accounts.d.ts:7445-7459`). This used to be `Promise`, read for + * nothing, on the argument that no field on the answer could support a stronger claim than "we + * asked". That argument is true of the REVOCATION and false of the DELETE: a 200 carrying + * `success: false` is Composio saying it did not delete the account, so the background job the + * flag asks for was never started either — and {@link ComposioBroker.revoke} answered `true` + * over the top of it while the grant stood at the provider. Reading it is not a stronger claim + * than "we asked"; it is the difference between having asked and having been refused. + * + * DECLARED `success?: unknown` RATHER THAN AT THE VENDOR'S OWN `boolean`, for the reason every + * other field in this projection is: the generated client parses the body and hands it over, so + * "required" is the schema's promise about what Composio means to send rather than a fact about + * what arrived. {@link withdrawalDeclined} is where the three answers are told apart. + * + * `revoke_job_id` IS DELIBERATELY NOT READ, and its absence is deliberately not a refusal. The + * same declaration marks it optional and says it is present "only when `revoke_on_delete=true`" + * — which says when it CAN appear, not that it always does — so a guard on it would turn every + * withdrawal Composio accepted into a permanent failure the first time they stopped sending it, + * which is the shape of mistake this file has already made twice in the other direction. + * + * NULLABLE AND OPTIONAL THOUGH THE VENDOR'S OWN DECLARATION IS NEITHER, because the generated + * client has two answers it resolves with no document: a 204 becomes `null` and a JSON reply + * carrying `content-length: 0` becomes `undefined` (`@composio/client` 0.1.0-alpha.76, + * `src/internal/parse.ts:16-42`). Declaring this at the vendor's `ConnectedAccountDeleteResponse` + * would be the same assertion-over-a-wire-value every other field here refuses to make, and it + * would hide the one case {@link withdrawalDeclined} has to tell from a refusal. + */ + delete( + id: string, + params: { revoke_on_delete: true }, + ): Promise<{ success?: unknown } | null | undefined>; + }; +}; + +/** + * The suffix every auth config this deployment creates carries, so a reader can tell whose it is. + * + * An auth config is visible in Composio's own dashboard beside any that were made by hand there, + * and the two are otherwise indistinguishable. The name is the only field this deployment gets to + * choose, so it is where the provenance goes. + */ +const CONFIG_SUFFIX = "(OpenBot)"; + +/** + * How many different unrecognised statuses one refusal names before it stops naming them. + * + * The set it bounds is as large as the app's config listing, which is paged — see + * {@link everyRowOf} — so without a bound the length of an operator's refusal is decided by how + * many authorization configs somebody made. Five is past the number of distinct words this can + * plausibly be about: `AuthConfigRetrieveResponseSchema` names two, and a vendor that has invented + * five more at once is a package upgrade rather than a sentence to read. + */ +const STATUSES_NAMED = 5; + +/** + * Whether this deployment made that auth config, which is the question every decision here turns on. + * + * THE SUFFIX IS WRITTEN FOR EXACTLY THIS AND WAS NOT BEING READ. Both callers used to take the + * first row of an unordered listing, and the two consequences are of different sizes. Removing an + * app deleted whatever came back first — which can be a config an operator built by hand, with + * their own scopes and their own tool restrictions, taking every account on it down with it. + * Beginning a connection attached a person to whatever came back first — which can be a + * configuration nobody here chose and this deployment cannot see or tighten. And because the order + * is the vendor's, the two calls can resolve DIFFERENT rows, so an app could be removed while + * people kept connecting against a config the removal left behind. + * + * MATCHED ON THE END OF THE NAME rather than on the whole of it, because the rest of the name is an + * app's title as an administrator saw it at enable time and titles are edited. The suffix is the + * part this file writes. Trailing whitespace is tolerated for the same reason it is tolerated + * anywhere a human-edited string is compared: a name that picked up a space in a dashboard is the + * same config. + * + * IT TAKES A {@link CheckedAuthConfig} AND NOT A {@link VendorAuthConfig}, which is what makes this + * one line honest. A predicate cannot refuse — it answers true or false — so reading a name the + * vendor may not have sent here could only ever have meant silently answering `false`, and `false` + * from this function means "somebody else's config": untouched by a removal, and satisfying the + * check that stops a second one being created. {@link readableConfigs} is where the absence becomes a + * sentence instead, upstream of every caller. + */ +function madeHere(config: CheckedAuthConfig): boolean { + return config.name.trimEnd().endsWith(CONFIG_SUFFIX); +} + +/** + * The statuses that answer "is this person connected", which is the narrow question of the two. + * + * ACTIVE ONLY. An `INITIATED` account is somebody who started an authorization and never finished + * it, and an `EXPIRED` or `REVOKED` one is a grant that no longer opens anything; counting any of + * them as connected tells a person their app is wired up and then fails every call they make with + * it. + */ +const CONNECTED: VendorAccountStatus[] = ["ACTIVE"]; + +/** + * The statuses that answer "what is there to revoke", which is a deliberately wider question. + * + * THE TWO QUESTIONS ARE NOT THE SAME ONE, AND TREATING THEM AS ONE LEFT GRANTS STANDING. This used + * to be a single ACTIVE listing shared by both, on the argument that "connected" and "there is + * something to revoke" are the same fact. They are not. A half-finished consent can already have + * been granted at the provider with the callback never delivered; an `EXPIRED` account is an access + * token that lapsed and a refresh token that did not; an `INACTIVE` one is a live grant the vendor + * has set aside. None of them should tell a person they are connected, and every one of them is + * something whose withdrawal is the entire point of pressing disconnect. + * + * `REVOKED` IS THE ONE STATUS LEFT OUT, and left out on purpose rather than forgotten. It is the + * only value that positively says the grant is already gone, so including it would have this + * deployment delete a tombstone and then record that it ended somebody's access — the one way the + * audit field can be made to lie in the direction nobody would check. + */ +const REVOCABLE: VendorAccountStatus[] = [ + "INITIALIZING", + "INITIATED", + "ACTIVE", + "FAILED", + "EXPIRED", + "INACTIVE", +]; + +/** + * How long one catalogue answer is served to everybody who asks for it. + * + * THE CATALOGUE IS READ ONCE PER SEARCH KEYSTROKE OTHERWISE, WHICH IS WHAT THIS IS ABOUT. The admin + * picker debounces its search field and then asks `/composio/apps`, and that route filters in this + * process precisely because Composio's toolkit listing takes no search term — so every distinct term + * a person types is another request for the whole directory, a few hundred rows of it, to answer a + * question about one. Typing "linear" pulls the catalogue four or five times, and enabling the app + * afterwards pulls it once more. + * + * TEN MINUTES BECAUSE OF WHAT GOES STALE IN IT. The rows are Composio's published toolkits: an app + * is added to their catalogue or its action count moves every so often, never within one + * administrator's sitting, and the worst a stale row can do here is show a description or a count + * that is a few minutes behind. Held for a working session it would be a cache nobody could explain + * to an operator whose new app is missing; held for seconds it would not survive the debounce it + * exists for. + */ +const DIRECTORY_TTL_MS = 10 * 60 * 1000; + +/** + * The held catalogue as a copy nobody else holds, which is what makes handing it out safe. + * + * WHAT WAS HANDED OUT WAS THE CACHE ITSELF. One array of one set of row objects was returned to + * every caller for ten minutes, so a route that sorted the rows in place reordered the catalogue for + * everybody, and one that edited a row — a title trimmed for display, a description truncated — + * edited what the next caller would read as Composio's answer. Nothing does that today, which is + * precisely the problem with leaving it: the first caller that does will have changed a cache it + * had no idea it was holding, and the fault will surface in the NEXT request rather than its own. + * + * `categories` IS COPIED TOO, because a shallow spread of the row would hand the same array on. It + * is the one field here that is not a primitive. + * + * COPIED RATHER THAN FROZEN, which was the other candidate. Freezing would make the sharing safe by + * making a mutation throw, but the type says `BrokerApp[]` and a caller is entitled to sort a list + * it was given; turning a reasonable caller into a `TypeError` is a worse answer than a few hundred + * small objects, which is nothing beside the request this cache exists to avoid. + */ +function copyOf(apps: Promise): Promise { + return apps.then((held) => + held.map((app) => ({ ...app, categories: [...app.categories] })), + ); +} + +/** + * Both seams, over one vendor client. + * + * TAKING THE VENDOR OBJECT RATHER THAN A KEY IS THE SEAM. It is what lets every test of this + * file's own decisions — which limit went out, which field became which, which call was refused + * before it was made — run against an object literal and never a socket. {@link createComposioClient} + * is the one line that turns a key into a vendor, and it is deliberately too thin to have a bug in. + * + * The vendor is captured in a closure rather than stored on either returned object, so neither + * `actions` nor `broker` offers a route back to the client or to the key it holds. + * + * @param now The clock the catalogue's lifetime is measured against, injected for the same reason + * the vendor is. A test that could not move the clock could only assert the cache's hit by counting + * calls and would have to sleep ten minutes to assert its expiry, so the window would be the one + * thing here no test could reach. It is a parameter of the builder rather than of + * {@link ComposioBroker.listApps}, because the seam's callers are routes and none of them has an + * opinion about what time it is. + */ +export function buildComposioClient( + vendor: ComposioVendor, + now: () => number = Date.now, +): { + actions: ComposioActions; + broker: ComposioBroker; +} { + /** + * This person's accounts for this app, in whichever states the ASKING question is about. + * + * ONE LISTING WITH THE STATUSES AS ITS ARGUMENT, rather than one listing both callers share. + * They shared one until it turned out that the shared answer was wrong for one of them: see + * {@link CONNECTED} and {@link REVOCABLE} for why "is this person connected" and "what is there + * to revoke" are different questions. What they do share is everything a drift between them + * would come from — the breadth of `accountType`, the limit, and the fact that both ask about one + * person and one app — so the difference between them is exactly the list of statuses and is + * visible at both call sites. + * + * AND SO ARE THE OTHER TWO THINGS THE TWO QUESTIONS DO NOT SHARE, for the same reason the + * statuses are. `configs` is which authorization configs the answer is about — every one of them + * for the gate, and only this deployment's for the withdrawal, because the withdrawal ACTS on + * what it finds. `enough` is when the rows in hand already settle the question, which is true of + * a boolean the moment one row arrives and never true of a set of accounts to end. Both are + * written at the call sites below, beside the statuses, so that the whole of the difference + * between the two questions is one argument list a reader can compare. + */ + const accountsFor = async ( + userId: string, + toolkit: string, + statuses: VendorAccountStatus[], + asked: { + configs?: string[]; + enough?: (rows: { id?: unknown }[]) => boolean; + } = {}, + ): Promise<{ id?: unknown }[]> => { + /* + * EVERY PAGE UNLESS THE CALLER SAYS OTHERWISE, READ HERE RATHER THAN AT EITHER CALLER, so that + * the two questions cannot drift on the one thing they do share. A truncated listing is the + * wrong answer to both of them for the same reason: `false` claims somebody has no account for + * an app when nobody looked at all of them, and a withdrawal that saw one page ends fewer + * grants than it reports. What a caller may say is that it has ENOUGH — see {@link everyRowOf} + * — which is not truncation: it is a question that has been answered. + * + * THE ROWS GO BACK AS ROWS, WHICH IS NARROWER THAN WHAT THIS USED TO HAND OVER. It read every + * id here, and the two callers do not want the same thing: `isConnected` is a COUNT — the id is + * nothing it reads — so taking ids on its behalf turned an account Composio described without + * one into a thrown refusal against a person who is, in fact, connected. Reading the ids is the + * withdrawal's business, and it is done there, where a row that cannot be named is something to + * report alongside the grants that were ended rather than something to stop them. + */ + const rows = await everyRowOf( + { + noun: `this person's ${toolkit} accounts`, + consequence: + "neither whether they are connected nor what there is to withdraw could be read", + }, + (cursor) => + askVendor( + { + outcome: `this person's ${toolkit} accounts were not read`, + app: toolkit, + }, + () => + vendor.connectedAccounts.list({ + userIds: [userId], + toolkitSlugs: [toolkit], + statuses, + accountType: "ALL", + // Spread for the reason the cursor is: an explicit `undefined` reaches the vendor's + // `parse` as a key, and "about every config" is said by not naming any. + ...(asked.configs === undefined + ? {} + : { authConfigIds: asked.configs }), + limit: LISTING_LIMIT, + ...(cursor === undefined ? {} : { cursor }), + }), + ), + asked.enough, + ); + return rows; + }; + + /** + * Every auth config Composio holds for one app, ours and anybody else's alike, in one order. + * + * THE LISTING IS SCOPED TO THE PROJECT AND NOT TO THIS DEPLOYMENT, which is the correction. An + * auth config is scoped to the project the API key belongs to — so nothing here is hidden from + * this listing, and that was read as "everything it returns is ours". It is not: an operator with + * the same project open in Composio's dashboard can create configs for the same app by hand, for + * purposes this deployment knows nothing about. {@link madeHere} is the only thing that tells the + * two apart, and every caller below is about an object one of them must not touch. + * + * THE UNCLAIMED ROWS ARE RETURNED RATHER THAN DROPPED HERE, which is the part that moved. The + * filter used to live on the way out, so "no configs at all" and "configs, none of them ours" + * reached every caller as the same empty array — and telling those two apart is the whole of what + * {@link ComposioBroker.deleteAuthConfig} was missing, and then of what + * {@link ComposioBroker.revoke} was missing a wave later, on the same distinction, one function + * away. The `ours` filter is still offered, beside rather than instead of the rest, because a + * caller that can only see its own configs is a caller that cannot notice the other two states. + * + * SORTED SO THAT TWO CALLERS AGREE. The vendor's order is not documented, and the whole failure + * being fixed here is two calls resolving different rows; a total order on the id makes the + * choice this file makes a stable one, whoever asks and whenever. + * + * BY CODE UNIT RATHER THAN BY `localeCompare`, WHICH IS THE WHOLE POINT OF THE SORT RATHER THAN A + * QUIBBLE WITH IT. `localeCompare` called with no locale collates in the HOST's, and the hosts + * are not one host: "ac_B" comes before "ac_a" by code unit and after it under an English + * collation, and the two callers this order exists to keep in step — a person pressing Connect + * and an administrator pressing Remove — need not be answered by the same process, the same + * container or the same build of ICU. An order that two machines can disagree about is not an + * order two callers agree on. `<` is the same total order everywhere, which is the only property + * asked of it here. + * + * THREE PARTS RATHER THAN A LIST, AND THAT IS A GUARD AGAINST THIS FILE'S OWN HISTORY. Each of + * the four callers below has to answer three different questions about one listing — what is + * ours, what is standing that is not, and what could not be read at all — and every defect this + * function has been corrected for was one caller answering one of them while its neighbour, one + * function away, answered it differently or not at all. A shape that hands over only `ours` + * lets a caller not notice the other two; this one cannot be destructured without saying so. + */ + const configsFor = async ( + toolkit: string, + ): Promise<{ + /** Every readable row, ours and anybody else's alike, deduplicated and in one order. */ + held: CheckedAuthConfig[]; + /** The subset of `held` carrying {@link CONFIG_SUFFIX}, which is what this deployment claims. */ + ours: CheckedAuthConfig[]; + /** One refusal per row that could be sorted into neither, which is never nothing. */ + unreadable: BrokerRefusalError[]; + }> => { + /* + * EVERY PAGE, BECAUSE A CONFIG ON THE SECOND ONE IS STILL OURS. Read one page and the two + * callers below are wrong in the two opposite directions {@link madeHere} describes: + * `ensureAuthConfig` finds none and creates the second config it exists to prevent, and + * `deleteAuthConfig` leaves one standing, reports a clean removal, and lets `removeServer` + * delete the app's row over the top of a live grant. + */ + const rows = await everyRowOf( + { + noun: `this deployment's authorization configs for ${toolkit}`, + consequence: + "whether one exists is not something this deployment can tell", + }, + (cursor) => + askVendor( + { + outcome: `this deployment's authorization configs for ${toolkit} were not read`, + app: toolkit, + }, + () => + vendor.authConfigs.list({ + toolkit, + limit: LISTING_LIMIT, + showDisabled: true, + ...(cursor === undefined ? {} : { cursor }), + }), + ), + ); + /* + * CHECKED BEFORE THE FILTER AND NOT AFTER IT, which is the order the whole guard turns on. The + * filter's question IS the name, so a row checked only once it had been kept would be a row + * sorted by a field nobody had read — see {@link madeHere} for what each of the two guesses + * costs. Every row therefore passes {@link readableConfigs} first, including the ones that turn + * out to belong to an operator's own dashboard work. + */ + const { configs, unreadable } = readableConfigs(rows, toolkit); + const held = configs.sort((one, other) => + one.id < other.id ? -1 : one.id > other.id ? 1 : 0, + ); + return { held, ours: held.filter(madeHere), unreadable }; + }; + + /** + * Ask for every one of them and answer with what refused, rather than stopping at the first. + * + * A THROW MID-LOOP ABANDONS GRANTS THAT ARE STILL LIVE. Both callers below are deleting a set of + * things that each independently hold somebody's access, and an exception out of the second of + * five leaves three untouched and unmentioned — while the caller is told only about the one that + * failed, so nothing in the answer says the loop did not finish. Attempting all of them makes the + * failure a statement about a set: this many were asked for and this many refused. + * + * SERIALLY RATHER THAN TOGETHER, for the same reason every other call here goes out one at a + * time: the vendor rate-limits, and a person with several accounts is not a reason to open + * several connections. The order is the listing's, which is sorted. + * + * EVERY REFUSAL IS ANSWERED AND NOT ONLY THE FIRST. What the callers do with this list is throw a + * COUNT — "two of three were withdrawn and the rest refused" — because a count is what a reader + * can act on, which makes the reasons the only place the detail lives. Returning them all is what + * lets {@link everyRefusal} put all of them on the failure the caller raises; the previous version + * collected them and both callers then read `refused[0]`, so the second and third reason existed + * for the length of one expression and were then dropped. + * + * EVERY FAILURE IS A REFUSAL HERE, AND THE CLASSIFICATION THAT USED TO SIT IN THIS LOOP HAS MOVED + * ONE LAYER DOWN. There was an `isOurFault` test in the catch that re-threw a `TypeError`, a + * `ReferenceError` or a `RangeError` rather than counting it, on the premise that those are what + * a program's own mistake looks like and are never "a thing Composio can reply". The premise is + * false — see the `TypeError` row in {@link vendorRefusal} for the five vendor shapes that raise + * exactly that from inside `@composio/core`'s own transformers — so its effect was inverted: a + * vendor fault escaped the loop as a bug of ours, abandoning every account after it unasked, and + * the person was handed a crash instead of a sentence. + * + * IT MOVED RATHER THAN BEING RETUNED because this loop cannot make the distinction and + * {@link askVendor} can. Every `ask` below is one `await vendor.*` wrapped by that function, so + * whether a fault came from inside the vendor's code is a fact about the call stack there, where + * here it could only ever have been guessed at from an error class. + */ + const askForEach = async ( + items: T[], + ask: (item: T) => Promise, + ): Promise => { + const refused: unknown[] = []; + for (const item of items) { + try { + await ask(item); + } catch (error) { + refused.push(error); + } + } + return refused; + }; + + /** + * The catalogue answer this process is currently serving, and the moment it was asked for. + * + * A PROMISE RATHER THAN THE ROWS, WHICH IS THE WHOLE ANSWER TO CONCURRENCY. The entry is written + * before the request is answered, so a second caller arriving while the first is still in flight + * finds it and awaits the same request. Holding the resolved rows instead would leave the window + * this cache exists to close wide open: three people opening the picker together, or one person's + * debounce firing twice, are exactly the case where nothing is cached yet, and each of them would + * start their own catalogue fetch and then overwrite each other's answer. + * + * IT IS PER BUILT CLIENT, not per module. This adapter is built once per deployment key, so in + * this process that is one cache; in a test it is one cache per {@link buildComposioClient}, which + * is what lets each test below start from nothing without an API for emptying it. + */ + let heldDirectory: { at: number; apps: Promise } | null = null; + + /** + * The catalogue as the vendor answers it, EVERY PAGE OF IT, mapped to the rows a person picks from. + * + * IT USED TO BE ONE PAGE AND A REFUSAL, AND THAT REFUSAL IS THE BUG THIS FUNCTION WAS FIXED FOR. + * It asked for {@link LISTING_LIMIT} rows, met exactly that many, and threw — because a full page + * and a truncated one are the same array and it believed no second request could tell them apart. + * Composio publishes more than {@link LISTING_LIMIT} toolkits, so the condition was true on every + * call and the app picker showed an operator nothing at all, with a sentence explaining why a + * partial directory would be worse. The reasoning was right and the premise was false: the raw + * client has a cursor for this listing and always did. See {@link ComposioVendor} and + * {@link everyRowOf}. + * + * SORTED BY USAGE, AND NO SEARCH TERM — see {@link ComposioVendor}, where both are argued now + * that neither is holding a finite page together. + * + * THROWN FROM INSIDE THE FETCH, WHICH IS WHAT KEEPS A FAILURE OUT OF THE CACHE. Every refusal + * below — a cursor that cannot be followed, a page that is not a page, a row that cannot be read + * — rejects the promise `listApps` holds, and `listApps` drops an entry whose request rejected. + * A fragment committed here would be a fragment served for ten minutes to BOTH callers: an + * administrator searching for an app past the cut is told nothing matched, and the enable route, + * which checks a slug against this same directory, tells them a real app is not one Composio + * lists. + */ + const fetchDirectory = async (): Promise => { + const listing: Listing = { + noun: "Composio's app catalogue", + consequence: "the directory was not shown", + }; + const toolkits = await everyRowOf(listing, (cursor) => + pageOf(listing, () => + askVendor( + { outcome: "the app catalogue was not read", app: null }, + () => + vendor.toolkits.list({ + limit: LISTING_LIMIT, + sort_by: "usage", + // Spread rather than an explicit undefined, for the reason `everyRowOf` gives: a + // `cursor: undefined` is a key on the wire, and "the first page" is said by omission. + ...(cursor === undefined ? {} : { cursor }), + }), + ), + ), + ); + + /* + * Each absence becomes the value that reads honestly on an administrator's screen, and each + * PRESENT field that is not what it is declared to be becomes a refusal — see {@link appOf}, + * where both halves of that and every sentence live. An empty description shows as no + * description; a null logo is the field's documented way of saying the vendor published none, + * which renders as a gap rather than as a broken image. + * + * The categories are the DISPLAY names rather than the slugs, because this list is read by a + * person choosing an app and "Productivity" is what they are choosing by. + * + * A MISSING COUNT BECOMES ZERO, WHICH IS THE ONE IMPERFECT ANSWER HERE. `actionCount` is a + * number and the shape offers no way to say "not published", so a toolkit that publishes no + * count reads as an app with no actions. It is the conservative direction — it understates + * the size of a change rather than overstating it — and Composio publishes a count for every + * toolkit measured, so this is a guard against the vendor rather than a routine case. A count + * that arrives as something other than a number is the different case and is refused. + */ + return toolkits.map(appOf); + }; + + const actions: ComposioActions = { + async listActions(toolkit, page): Promise { + /* + * A PAGE OF NOTHING IS NOT A PAGE, AND THE REASON IT IS REFUSED HAS MOVED. + * + * It used to be a fact about the wrapper: `getRawComposioTools` composed its request with + * `...(limit ? { limit } : {})` (`@composio/core` 0.18.1, `src/models/Tools.ts:536`) over a + * schema spelling the field `z.number().optional()` with no floor + * (`src/types/tool.types.ts:257`), so a zero was not sent short — it was not sent at all, and + * Composio's own page of twenty came back looking exactly like everything a small app + * publishes. The raw client passes a zero through, so what a zero means now is Composio's to + * say rather than a silent substitution. + * + * IT IS STILL REFUSED, AND THE ARGUMENT IS THE ONE THAT DID NOT DEPEND ON THE WRAPPER. The + * page is required on this seam precisely so that no layer supplies one quietly; a caller + * asking for no rows is a caller with a fault, and a fault is a thing to report rather than a + * thing to correct on their behalf. What changed with paging is that the limit is now a PAGE + * SIZE rather than the whole listing — {@link everyRowOf} reads on until the cursor stops — + * so a small one costs requests rather than actions. A zero would cost every request there + * is, or none. + */ + if (!Number.isInteger(page.limit) || page.limit < 1) { + throw new Error( + `A page of ${page.limit} rows is not a page Composio can be asked for, so ${toolkit}'s action list was not refreshed and the tools already held are untouched. The page a listing asks for is required on this seam so that no layer supplies one quietly, and a request for no rows is a fault to report rather than one to correct on a caller's behalf.`, + ); + } + + /* + * EVERY PAGE, WHICH IS WHAT THIS SEAM COULD NOT DO UNTIL THE LISTING LEFT THE WRAPPER. + * `./composio` used to meet a listing at {@link LISTING_LIMIT} and refuse it, for the reason + * the catalogue above used to: an app with exactly that many actions and one with more of + * them answer identically, and committing the second deletes every action past the cut from + * `mcp_tools` under a refresh that reported success. That refusal is gone with this, and the + * guard it cannot be confused with — `store.ts`'s empty-listing guard — stays where it is. + */ + const listing: Listing = { + noun: `${toolkit}'s actions`, + consequence: `${toolkit}'s action list was not refreshed and the tools already held are untouched`, + }; + const tools = await everyRowOf(listing, (cursor) => + pageOf(listing, () => + askVendor( + { + outcome: `${toolkit}'s action list was not refreshed and the tools already held are untouched`, + app: toolkit, + }, + () => + vendor.tools.list({ + toolkit_slug: toolkit, + limit: page.limit, + // The SDK's own default, forwarded on every listing it made, and the thing that + // decides which `version` each action carries. See {@link ComposioVendor}. + toolkit_versions: "latest", + ...(cursor === undefined ? {} : { cursor }), + }), + ), + ), + ); + + return tools.map((row, position) => actionOf(row, position, toolkit)); + }, + + async execute(call, args): Promise { + /* + * THE TOOL IS RESOLVED BEFORE IT IS RUN, AND THAT COSTS A ROUND TRIP ON PURPOSE. + * + * Composio's execute takes the slug alone — its REST parameters have no toolkit field — so + * the pair the caller was gated on cannot travel on the wire, and the obligation + * {@link ComposioActions.execute} writes down has to be discharged here instead. The + * resolved tool carries the app the vendor will actually run it against, so asking for it + * first is what makes the check possible at all. + * + * `tools.execute` resolves the same tool again internally, so this is a second request + * rather than a saved one. It buys the one thing a single request cannot: a mismatch that is + * refused before anything runs, rather than discovered in an audit row afterwards. + * + * AND IT IS TAKEN AT ITS DECLARED TYPE, WHICH IS THE ONE ANSWER IN THIS FILE MOST SAFE TO DO + * THAT WITH. `getRawComposioToolBySlug` ends in `this.transformToolCases(tool)` (`@composio/core` + * 0.18.1, `src/models/Tools.ts:719`), whose last act is `ToolSchema.parse(...)` — a throwing + * parse — so what resolves here is an object satisfying that schema or a `ZodError` that + * `./composio` recognises and answers with a package remedy. An answer that is not an object, + * and a `toolkit` that is present and not an object, both die at that parse; two refusals + * stood here for exactly those and neither could be reached. + */ + const resolved = await askVendor( + { + outcome: `${call.slug} was not resolved and nothing was run`, + app: call.toolkit, + }, + () => + vendor.tools.getRawComposioToolBySlug(call.slug, { + version: call.version, + }), + ); + + /* + * AN UNREADABLE APP IS NOT THE SAME FACT AS NO APP, AND THEIR REMEDIES DIFFER. The mismatch + * refusal below ends by telling an administrator to refresh this app's tools, which is right + * for a slug recorded against a url that has since changed and useless for an SDK that has + * begun answering a different shape. So a toolkit whose slug is not a usable name is refused + * as what it is rather than folded into "no app at all", where it would arrive wearing a + * remedy that cannot work. + * + * AND IT IS STILL READ, DESPITE `ToolkitSchema` SPELLING THE SLUG REQUIRED, for the reason + * {@link actionOf} reads the action's own: `z.string()` is satisfied by the empty string, so + * a passing parse still admits an app with no name — which would compare unequal to every + * toolkit and refuse this call as a mismatch with nothing on the other side of the sentence. + */ + const answeredApp = resolved.toolkit; + let ran: string | undefined; + if (answeredApp !== undefined) { + /* + * `answeredSlug` RATHER THAN `named`, WHICH IS ONLY A RENAME AND IS WORTH ONE LINE. This + * binding was called `named` and shadowed the module helper of that name for the rest of + * the block — so {@link named} was unreachable here, and an edit reaching for it would have + * been calling a string. Nothing was wrong today; the next change to this block is what the + * rename is for. + */ + const answeredSlug = textOf(answeredApp.slug); + if (answeredSlug === null) { + throw new Error( + `Composio sent ${sent(answeredApp.slug)} where the slug of the app ${call.slug} belongs to should be, so nothing was run: a name this deployment cannot read is not one it can compare with ${call.toolkit}. ${VENDOR_SHAPE_REMEDY}`, + ); + } + ran = answeredSlug; + } + + if (ran !== call.toolkit) { + /* + * REFUSED RATHER THAN FORWARDED, and both apps are named. + * + * The gate in `./access` cleared this run against the app the connection's url names + * NOW; the slug was recorded by a listing made at some earlier time. Where the two + * disagree — a url edited between a refresh and a call — forwarding runs one person's + * Gmail action under a gate that only ever examined their Slack connection. A reader + * holding only one of the two names cannot tell which of the two is the wrong one, so + * both go in the sentence. An app the vendor did not name at all is the same refusal: + * this deployment cannot show that the call is about the app it was gated on. + */ + throw new Error( + `${call.slug} was not sent to Composio: this connection is for ${call.toolkit}, and Composio resolves that action to ${ + ran ?? "no app at all" + }. Refreshing this app's tools on its Plugins page recovers it where the action was recorded against a url that has since changed.`, + ); + } + + return askVendor( + { outcome: `${call.slug} was not run`, app: call.toolkit }, + () => + vendor.tools.execute(call.slug, { + arguments: args, + userId: call.userId, + version: call.version, + }), + ); + }, + }; + + const broker: ComposioBroker = { + /** + * The catalogue, from memory where this process asked for it less than ten minutes ago. + * + * BOTH CALLERS READ THE SAME HELD ANSWER, AND THE SECOND OF THEM IS THE INTERESTING ONE. The + * search route filters the directory in this process, so caching it is what stops a debounced + * search field from pulling a few hundred rows once per term. The enable route then reads the + * directory again to check that the slug it was handed is one Composio lists, and that read + * comes out of the same cache — which is the right answer rather than a concession, because + * the slug being checked is one this deployment handed the browser out of THIS cache moments + * earlier. The check exists to refuse a slug the catalogue never published — a request composed + * by hand, or a row left over from a url somebody edited — and a ten-minute-old catalogue + * settles that question exactly as well as a fresh one. The case it gives up is an app Composio + * withdrew within the window, whose cost is one `mcp_servers` row for an app that answers + * nothing, removable on the page that added it; the case it buys is that pressing Add does not + * re-read a catalogue the picker just read. + * + * A FAILURE IS NEVER HELD. The entry is dropped when its request rejects, so a vendor that + * refused once is asked again by the next caller rather than refusing from memory for ten + * minutes — the failures here are an unset or wrong API key and Composio being down, and the + * first two are fixed by an operator who then presses the button again, which must be allowed + * to work. The callers already sharing that one in-flight request do share its failure, which + * is the truth about their request: they asked while it was being answered. + * + * THERE IS NO INVALIDATION, AND THE DESIGN ASKED FOR ONE. It wanted the directory "refreshable + * by an explicit reload", and that is not built: the lifetime above is the whole of the + * freshness story. Nothing in this deployment reloads a catalogue today — no page, route or job + * has such a control — so the method would have no caller, and an invalidation API with no + * caller is an untested path that reads like a guarantee. The moment a reload button exists, + * this is where it attaches. + */ + async listApps(): Promise { + const held = heldDirectory; + if (held && now() - held.at < DIRECTORY_TTL_MS) return copyOf(held.apps); + + /* + * Stamped when the request goes out rather than when it comes back, so a slow catalogue is + * held for slightly less than the full window rather than for the window plus its own + * latency. Written into the slot before it is awaited, which is what a concurrent caller + * finds. + */ + const entry = { at: now(), apps: fetchDirectory() }; + heldDirectory = entry; + /* + * The drop on failure, registered here rather than written as a try/catch around an await so + * that this method hands every caller the one shared promise. `heldDirectory === entry` + * because a later request may already have replaced this one, and clearing that would throw + * away a good answer over an old failure. + */ + entry.apps.catch(() => { + if (heldDirectory === entry) heldDirectory = null; + }); + return copyOf(entry.apps); + }, + + async ensureAuthConfig({ toolkit, name, connection }): Promise { + /* + * NOTHING AT ALL FOR AN APP THAT NEEDS NO AUTHENTICATION, AND THAT IS THE VENDOR'S RULE + * RATHER THAN A SHORTCUT. Composio refuses an auth config for such a toolkit — "Cannot + * create an auth config for toolkit hackernews because it does not require authentication. + * You can use its tools directly without creating a connected account." — so the listing + * below is not even worth making: there is nothing to find and nothing to create. + */ + if (connection.kind === "no-auth") return; + + /* + * AND A REFUSAL BEFORE ANY WRITE for the one kind this deployment cannot drive. The sentence + * is the derivation's own, which named the scheme and what it wants; a refusal here that + * invented a second sentence would drift from the one the picker filters on. + */ + if (connection.kind === "unsupported") { + throw new BrokerRefusalError( + `${toolkit} was not enabled: ${connection.reason}`, + ); + } + + /* + * IDEMPOTENT BY LOOKING FIRST, because a second config is not a duplicate — it is a split. + * A person's existing connection is created against one particular auth config, so creating + * another and connecting the next person to that leaves two populations of connections for + * one app, and removing "the" config later drops half of them. + * + * THE LOOK IS FOR ONE THIS DEPLOYMENT MADE, WHICH IS NARROWER THAN "ANY". It used to be any, + * and the two ways that was wrong pull in opposite directions. A disabled config of ours was + * invisible to the listing, so this created the very second config it exists to prevent — and + * then `authorize` refused, because it looked with the same blind listing and found the app + * had no config at all. A config an operator made by hand, meanwhile, satisfied the check and + * this created nothing, leaving every later decision here pointed at an object nobody here + * chose. Asking for our own answers both: the disabled one counts, and somebody else's does + * not. + * + * WHICH MEANS AN APP CAN END UP WITH TWO CONFIGS, ONE OF THEM SOMEBODY ELSE'S, and that is + * the intended outcome rather than a tolerated one. Adopting a hand-made config would have + * this deployment mint people's connections against scopes and tool restrictions it cannot + * see, and delete it when the app is removed. A config of our own, named, is the thing every + * decision in this file can actually reason about. + * + * This is a read followed by a write and therefore not atomic: two administrators pressing + * enable at the same instant can both find nothing and both create. Composio offers no + * create-if-absent, so the window is the vendor's rather than this deployment's, and the + * cost of losing that race is a spare config rather than a lost connection — spare rather + * than orphaned, because both carry the suffix and `deleteAuthConfig` takes every one of + * ours. + */ + const { ours, unreadable } = await configsFor(toolkit); + if (ours.length > 0) return; + + /* + * AND A ROW THAT COULD NOT BE READ IS NOT A ROW THAT IS NOT OURS. This is the one of the four + * callers that must NOT act on what it can name and report the rest — see + * {@link readableConfigs} for why the other three do. What it would be acting on is a + * CREATION, and a config created beside a row that is in fact ours under a name Composio sent + * unreadably is the second config this whole method exists to prevent: two populations of + * connections for one app, and a removal later that drops half of them. Finding nothing of + * ours in a listing this deployment could not read is not the same as finding nothing. + */ + if (unreadable.length > 0) { + throw new BrokerRefusalError( + `Composio described ${unreadable.length} of its authorization configs for ${toolkit} in a way this deployment cannot read, so whether one of them is already its own is not something it can tell — and the app was not enabled, rather than a second config being created beside one that may already be there. ${VENDOR_SHAPE_REMEDY}`, + { cause: everyRefusal(unreadable) }, + ); + } + + /* + * THE OUTCOME NO LONGER CLAIMS NOTHING WAS CREATED, BECAUSE THIS CALL CANNOT KNOW THAT. + * + * It said "no authorization config was created for gmail", and {@link vendorRefusal} reads + * every condition out through that clause — including the `TypeError` row, which is reached + * exactly when the vendor's own transformer could not read a reply it had already received. + * So the one condition most likely to mean the config EXISTS was the one telling an + * administrator it did not, and nothing in this deployment then named the object standing at + * Composio. What is true of every condition here is the half that is said now: the app is not + * enabled, and what is at Composio is not something this call can report. + */ + const configName = `${name} ${CONFIG_SUFFIX}`; + + /* + * WHICH TYPE OF CONFIG, AND THE MANAGED ONE IS RIGHT FOR EXACTLY ONE OF THE THREE KINDS THAT + * REACH HERE. It was sent for all of them, and only one of the failures announced itself: + * Composio has no OAuth client of its own for a self-registering app, so the managed path + * answers 404 and the app is simply unconnectable — which is what made Linear's MCP app + * impossible to attach. A key app's was quiet and worse: the config was accepted, and every + * person enabled onto it was then sent to a consent screen that had nothing to ask them for. + * + * AND NEITHER CUSTOM BRANCH CARRIES A CREDENTIAL, which is the point rather than an omission. + * A self-registering app needs none by definition — the vendor registers a client of its own + * at connect time. A key app needs none HERE because the key is one person's: it belongs to + * each connection made against this config, which is per-deployment, and a key written onto + * it would be one person's secret shared by everybody the app is enabled for. + */ + const options = + connection.kind === "consent" + ? ({ type: "use_composio_managed_auth", name: configName } as const) + : ({ + type: "use_custom_auth", + authScheme: + connection.kind === "self-registering" + ? ("DCR_OAUTH" as const) + : connection.authScheme, + name: configName, + /* + * THE EMPTY RECORD IS THE VENDOR'S PRICE FOR SENDING NOTHING — see the field's own + * comment on {@link ComposioVendor}. Omitting it is a `ValidationError` raised before + * any request, which reads as a refusal to enable rather than as the secret-free + * config this branch is for. + */ + credentials: {}, + } as const); + + const created = await askVendor( + { + outcome: `the app is not enabled, and whether an authorization config for ${toolkit} now stands at Composio is not something this deployment can tell`, + app: toolkit, + }, + () => vendor.authConfigs.create(toolkit, options), + ); + + /* + * THE REPLY IS READ, WHICH IS THE HALF THAT WAS MISSING. Composio names the config it just + * made, and the answer was awaited and dropped — so an answer carrying no id at all was a + * successful enable of an app whose config this deployment could not show existed. It is not + * an id anything here needs: the next listing finds the config by its name. It is the only + * evidence in the reply that the creation this method reports actually happened, and a method + * that returns nothing has no other way to have checked. + * + * AND THE REFUSAL SAYS WHAT IS PROBABLY TRUE RATHER THAN WHAT WOULD BE TIDY. The request went + * out and Composio replied to it, so a config very likely IS standing there — saying "none + * was created" would be the same lie the outcome above stopped telling. The remedy is the + * button they just pressed, because {@link ComposioBroker.ensureAuthConfig} is idempotent + * through the name: a second enable finds the suffix and adopts what is there. + */ + if (textOf(created?.id) === null) { + throw new BrokerRefusalError( + `Composio answered the creation of an authorization config for ${toolkit} with ${sent(created?.id)} where the new config's id belongs, so this deployment cannot show that the config it just asked for exists and the app is not enabled. The request went out and Composio replied to it, so one may well be standing there: enabling ${toolkit} again finds it rather than making a second, because a config whose name ends with ${CONFIG_SUFFIX} is one this deployment claims. ${VENDOR_SHAPE_REMEDY}`, + ); + } + }, + + async deleteAuthConfig(toolkit): Promise { + /* + * EVERY CONFIG OF OURS, AND NOTHING THAT IS NOT OURS. + * + * This used to delete whichever row the vendor happened to return first, on the reasoning + * that {@link ComposioBroker.ensureAuthConfig} creates at most one, so a second one must be + * somebody's dashboard work and must be left alone. The reasoning was right and the code did + * the opposite of it: with no test of the name, "the first row" is as likely to BE the + * hand-made config — deleting it, and with it every account anybody had connected against it. + * Reading the name inverts that. Anything without the suffix is untouched whatever order it + * arrives in, and everything with it goes, which is also the only way the spare config from a + * lost enable race is ever cleaned up. + * + * QUIET WHERE THERE IS NOTHING OF OURS TO DELETE, because removing an app has to be able to + * happen twice. An app can be removed, re-enabled and removed again, two administrators can + * press the button together, and an app enabled before this deployment created configs at all + * has none to drop. In every one of those the end state is the one that was asked for, so a + * throw would report a failure while the caller got exactly what they wanted. + * + * AND QUIET USED TO MEAN QUIET OVER A CONFIG THAT WAS STILL STANDING, which is the failure + * being closed here. "Nothing of ours" and "nothing at all" are not the same state, and the + * listing could not tell a caller which one it was in: rename a config in Composio's + * dashboard — drop the suffix, or edit the app's title past it — and every decision in this + * file stops recognising the object it made. `ensureAuthConfig` would create a second beside + * it, `authorize` would refuse against it, and this returned normally, after which + * `removeServer` deleted the app's row. The config and every grant made against it outlive + * the removal with nothing in this deployment naming them, and an administrator is told the + * app was withdrawn. + */ + const { held, ours, unreadable } = await configsFor(toolkit); + if (ours.length === 0 && held.length > 0) { + /* + * REPORTED, NOT DELETED AND NOT SWALLOWED, AND THE THIRD OPTION IS THE ONLY HONEST ONE. + * + * Deleting anyway is the worse half of the same guess {@link madeHere} exists to stop: a + * row with a readable name that does not carry the suffix is as likely to be an operator's + * own dashboard work — their scopes, their tool restrictions, and every account anybody + * connected against it — as it is to be ours under a new name. Nothing in the row tells + * them apart, which is why nothing here chooses. + * + * WHICH IS THE SAME REASONING AS THE UNREADABLE NAME IN {@link readableConfigs} AND NOT THE SAME + * CASE. There the ambiguity is about ADDRESSING: the field that decides ownership did not + * arrive, so no row can be sorted and the removal cannot begin. Here every name arrived and + * every row is legible; what is in doubt is whether this deployment's own config is among + * them under a title somebody edited. So the refusal is narrower than that one — it fires + * only where the removal found nothing it could claim, and stays quiet where a config of + * ours was found and dropped beside somebody else's, which is the contract + * {@link ComposioBroker.deleteAuthConfig} states. + * + * IT IS A BLOCK, AND THE BLOCK IS THE POINT. The app's row survives this throw — + * `removeServer` deletes it only after this returns — so the app stays on its Plugins page + * and stays removable, which is the one thing a silent success took away. The remedy is an + * operator's and it is one act in a dashboard: rename the config back so it ends with the + * suffix and remove the app again, which finishes the withdrawal, or satisfy yourself that + * it is your own and delete it there, which is the only way anything can tell this + * deployment that the config it made is genuinely gone. + * + * A COUNT AND THE SUFFIX, because together they are the whole of what an operator has to + * look at: how many objects are standing, and the exact string that would have claimed + * them. The names are not quoted — a config's title is an app name an administrator typed + * and this file's refusals quote no vendor field it does not have to. + * + * AND IT CLAIMS NOTHING ABOUT WHAT THIS DEPLOYMENT ONCE MADE. An app enabled before this + * deployment created configs at all never had one, which the quiet case above names, so a + * sentence opening "the config this deployment made" would be a guess in the one place a + * guess is what is being refused. What is said is only what was just read. + */ + throw new BrokerRefusalError( + `Removing ${toolkit} found none of this deployment's own authorization configs at Composio, and Composio holds ${held.length} for ${toolkit} whose name does not carry ${CONFIG_SUFFIX} — so nothing was deleted and the app has not been withdrawn, rather than a config this deployment cannot show is its own being deleted along with every account connected against it. Nothing here can tell one of ours, renamed in Composio's dashboard, from an operator's own work. If it is this deployment's, the grants made against it are still live, and renaming it to end with ${CONFIG_SUFFIX} lets removing the app again withdraw them. If it is an operator's, only taking it out of that dashboard leaves this app with nothing standing, after which the removal goes through.`, + ); + } + + const refused = await askForEach(ours, (config) => + askVendor( + { + outcome: `one of this deployment's authorization configs for ${toolkit} was not removed`, + app: toolkit, + }, + () => + vendor.authConfigs.delete(config.id, { revoke_on_delete: true }), + ), + ); + if (refused.length > 0 || unreadable.length > 0) { + /* + * LOUD, because the caller is `removeServer` and the thing it is in the middle of is taking + * an app away from everybody. A config left standing is a live grant that the removal was + * supposed to end, and the app's row is deleted after this returns — so a swallowed failure + * here is the one state nothing in this deployment can find again. The count is the whole + * message: an operator who can see that one of two configs went knows that pressing remove + * again finishes the job rather than repeating it. Every refusal the loop met travels as + * `cause` — see {@link everyRefusal} — because the count is deliberately all the sentence + * says, which leaves the reasons nowhere else to live. + * + * AND AN UNREADABLE ROW LANDS HERE RATHER THAN AHEAD OF THE DELETES, which is the half that + * moved. Every config of ours is dropped first and the rows that could not be sorted are + * reported after — {@link readableConfigs} says why at length: refusing before the first + * delete meant one unreadable row made an app permanently unremovable, with the readable + * configs of ours standing the whole time. It is still a refusal, so `removeServer` does + * not delete the app's row and the app stays on its Plugins page. + * + * TWO CLAUSES BECAUSE THEY ARE TWO REMEDIES. A config Composio refused is one a second press + * reaches. A row it described with no id or no name is not — it will be exactly as + * unreadable next time — so the only instruction that helps names the dashboard rather than + * the button the operator just pressed. + */ + const removed = ours.length - refused.length; + const left: string[] = []; + if (refused.length > 0) { + left.push("Removing it again asks only for what is left."); + } + if (unreadable.length > 0) { + left.push( + `Composio described ${unreadable.length} more of its ${toolkit} authorization configs with no id or no name, so nothing here can tell whether one of those is this deployment's own under an answer it could not read: reading them in Composio's own dashboard is what says whether anything this deployment made is still standing.`, + ); + } + throw new BrokerRefusalError( + `Composio removed ${removed} of this deployment's ${ours.length} authorization configs for ${toolkit} and the app has not been fully withdrawn. ${left.join(" ")}`, + { cause: everyRefusal([...refused, ...unreadable]) }, + ); + } + }, + + async authorize({ + userId, + toolkit, + returnUrl, + }): Promise<{ redirectUrl: string }> { + /* + * THE CONFIG THIS DEPLOYMENT ALREADY MADE, AND NO SECOND ONE MADE HERE. + * + * `ensureAuthConfig` creates it when an administrator enables the app, which is what makes + * this a read. Creating one here instead would mint it at the moment somebody presses + * Connect, unnamed for this deployment and invisible in the dashboard until the first person + * happened to try — and where a config already existed for an app enabled twice, a second + * one would split one app's connections across two configs, so removing "the" config later + * would drop half of them. + * + * NONE IS A STATE WITH A REMEDY, NOT A NULL TO WORK AROUND. It is the app enabled before + * this deployment created configs at all, or a config deleted by hand in Composio's + * dashboard. Neither is something a person pressing Connect can fix, so the sentence names + * the app and the administrator's step rather than leaving them at a link that would attach + * their account to a configuration nobody here chose. + * + * AND "NONE" MEANS NONE OF OURS, which is the correction. The read used to take whichever row + * the vendor returned first, so an app whose only config was one an operator built by hand + * read as ready and this minted somebody's connection against it — scopes this deployment + * cannot see, tool restrictions it cannot read, and an object it must not delete. A + * connection is a lasting attachment to whatever config it was made against, so guessing here + * is not a guess that can be corrected later. + */ + const { ours, unreadable } = await configsFor(toolkit); + if (ours.length === 0) { + /* + * A ROW THIS FILE COULD NOT READ IS NOT AN APP WITH NO CONFIG, AND THE REMEDIES ARE + * DIFFERENT PEOPLE'S. The sentence below sends an administrator to remove the app and add + * it again, which is right where the listing was legible and said there is none of ours — + * and wrong here, because the row that could not be sorted may BE ours, in which case + * removing the app meets {@link ComposioBroker.deleteAuthConfig}'s own refusal and adding + * it again is refused by `ensureAuthConfig` for the same reason. Nobody should be sent + * round a loop that cannot close. + * + * WHAT IS NOT DIFFERENT IS THAT NOTHING IS MINTED. A link is a lasting attachment to one + * particular config, so a person is never sent anywhere on the strength of a listing this + * deployment could not read — see {@link readableConfigs}, where a config of OURS that was + * read is enough to go on whatever else the listing held. + */ + if (unreadable.length > 0) { + throw new BrokerRefusalError( + `Composio described ${unreadable.length} of its authorization configs for ${toolkit} in a way this deployment cannot read and none of the rest is one it made, so there is nothing it can show is its own to connect an account against and nobody was sent anywhere. ${VENDOR_SHAPE_REMEDY}`, + { cause: everyRefusal(unreadable) }, + ); + } + throw new BrokerRefusalError( + `This deployment has no authorization config at Composio for ${toolkit}, so there is nothing to connect an account against. An administrator removing the app on its Plugins page and adding it again creates one.`, + ); + } + + /* + * A DISABLED CONFIG IS NOT A CONFIG TO CONNECT AGAINST, and it is now visible enough to say + * so. The listing asks for disabled configs — it has to, or the creation above duplicates one + * — which means this is the first read that can meet one. A link minted against it does not + * work, so sending a person to the vendor would spend their consent and end with nothing + * attached; and nothing they can do from the page they are on changes it, because enabling a + * config happens in Composio's dashboard. + * + * THE FIRST OF SEVERAL, WHICH IS A CHOICE AND NOT AN ACCIDENT. More than one enabled config + * of ours means a lost enable race, and both are equally ours and equally valid. What + * mattered about the old "first row" was that the order was the vendor's and the next caller + * could get a different one; the listing is sorted on the id, so this is the same config for + * every person and for the removal that later drops all of them. + */ + const config = ours.find((held) => held.status === "ENABLED"); + if (!config) { + /* + * "DISABLED" IS A CLAIM, AND IT IS ONLY THIS DEPLOYMENT'S TO MAKE WHEN COMPOSIO MADE IT. + * + * The test above is `=== "ENABLED"`, so everything that is not that word fell through here + * — and that is three different states wearing one sentence. A config Composio calls + * DISABLED is genuinely disabled and the remedy below is genuinely the remedy. A config + * with no status at all, or one carrying a word this deployment's `@composio/core` has + * never heard of, is a config whose state is UNKNOWN, and telling an operator it is + * disabled sends them to a dashboard to enable something that may already be enabled — and + * where it is, they are left with a page insisting on a fact they can see is false and + * nothing else to try. + * + * BOTH ARE STILL A REFUSAL, WHICH IS THE PART THAT DOES NOT CHANGE. Nothing here mints a + * link against a config it cannot show is enabled: consent spent against a config that + * turns out to be disabled attaches nothing and cannot be spent again without asking the + * person to go round the loop a second time. What the status decides is which sentence a + * person reads, not whether they are sent. + * + * The status is quoted where there is one, for the reason {@link named} gives: it is a + * closed set of vendor enum names, it carries nobody's data, and it is the one fact an + * operator can search a dashboard and a changelog for. + */ + const unreadable = ours.filter((held) => held.status !== "DISABLED"); + if (unreadable.length > 0) { + /* + * EVERY STATUS THAT WAS ACTUALLY READ, AND NONE OF THEM SPEAKING FOR THE REST. The + * sentence counted the whole set and quoted `unreadable[0]` — "Composio describes 3 of + * this deployment's configs as PENDING" is a claim about three rows established of one, + * and an operator searching their dashboard for the word they were handed would never + * reach the two that say something else. Each distinct word once, so two configs wearing + * one status do not read as two findings. + * + * AND THE LIST IS BOUNDED, for the reason {@link named} bounds each word. This set is as + * large as the listing, which is paged; a refusal whose length is decided by how many + * configs an app has is a refusal nothing downstream can hold. + */ + const words = [ + ...new Set(unreadable.map((held) => named(held.status))), + ]; + const shown = words.slice(0, STATUSES_NAMED); + const said = + words.length > shown.length + ? `${shown.join(", ")} and ${words.length - shown.length} other words` + : shown.join(", "); + throw new BrokerRefusalError( + `Composio describes ${unreadable.length} of this deployment's ${ours.length} authorization configs for ${toolkit} as ${said}, which ${words.length === 1 ? "is" : "are"} neither ENABLED nor DISABLED, so whether a connection begun against one could complete is not something this deployment can tell. No link was made, because consent spent against a config that turns out to be disabled attaches nothing. ${VENDOR_SHAPE_REMEDY}`, + ); + } + throw new BrokerRefusalError( + `Every authorization config this deployment holds at Composio for ${toolkit} is disabled, so a connection begun against one could not complete. An administrator can enable it in Composio's dashboard, or remove the app on its Plugins page and add it again.`, + ); + } + + /* + * THE RETURN ADDRESS IS THE WHOLE POINT OF THIS CALL, and it is the caller's rather than + * this file's: the adapter knows Composio, and where a person belongs afterwards is a fact + * about this deployment's own pages. It is never logged and never quoted in the refusal + * below, for the reason the url itself is not. + * + * NO `allowMultiple`, WHICH LEAVES THE VENDOR ENFORCING THE RULE THIS DEPLOYMENT ALREADY + * STATES. One person holds one account per app here, because the call that runs an action + * names the person and not the account — so with two accounts attached, which mailbox a Bot + * reads would be Composio's choice and nothing here could say which one it had been. The + * route refuses a second connection before it ever reaches this method, and that refusal is + * the sentence a person reads; this is the same rule one layer further down, where the + * vendor is the only party that can still see an account this deployment's rows have lost + * track of. `toolkits.authorize` passed `allowMultiple: true` unconditionally — the SDK + * calls it a "magic function" for exactly that — which is the opposite of what this + * deployment wants. + * + * THE ANSWER IS AN OBJECT BY CONSTRUCTION AND ITS ONE FIELD IS NOT. `link` builds what it + * returns with `createConnectionRequest(client, response.connected_account_id, INITIATED, + * response.redirect_url)` inside a try that turns anything thrown into + * `ComposioFailedToCreateConnectedAccountLink` (`@composio/core` 0.18.1, + * `src/models/ConnectedAccounts.ts:420-453`), and that builder assembles a literal + * (`src/models/ConnectionRequest.ts:39-43`). So a refusal for "Composio answered something + * that is not an object" could not be reached — the vendor either hands over its own object + * or raises a class {@link vendorRefusal} already translates. What the builder copies across + * untouched is the url, which is why that is the field still read. + */ + const request = await askVendor( + { + outcome: `this person's connection to ${toolkit} was not begun`, + app: toolkit, + }, + () => + vendor.connectedAccounts.link(userId, config.id, { + callbackUrl: returnUrl, + }), + ); + + const redirectUrl = request.redirectUrl; + /* + * PRESENT AND NOT A URL IS THE SHAPE THE ABSENCE GUARD BELOW CANNOT SEE. `!redirectUrl` is + * false for an object, a number and a list alike, so each of those would be returned as the + * `redirectUrl: string` this method promises and put in a `Location` header — a page nobody + * can visit, handed to a person as the consent screen they were sent to. + */ + if ( + redirectUrl !== undefined && + redirectUrl !== null && + typeof redirectUrl !== "string" + ) { + throw new BrokerRefusalError( + `Composio sent ${sent(redirectUrl)} where the page to send this person to for ${toolkit} belongs, so nobody was sent anywhere. ${VENDOR_SHAPE_REMEDY}`, + ); + } + if (!redirectUrl) { + /* + * The SDK spells `redirectUrl` nullable because not every auth scheme has one — an API-key + * toolkit is connected by typing a secret, not by visiting a page. This deployment's + * enablement flow sends a person to a url, so no url is nothing to do rather than a + * success, and the sentence says which app it was about. The url itself is never quoted + * anywhere, here or elsewhere: whoever opens it attaches an account to this person's + * connection, so it is handed to the browser that asked and then forgotten. + */ + throw new BrokerRefusalError( + `Composio began a connection to ${toolkit} but answered with no page to visit, so there is nothing to send this person to. An app that is connected by entering a credential rather than by visiting a page cannot be connected from here.`, + ); + } + return { redirectUrl }; + }, + + async isConnected({ userId, toolkit }): Promise { + /* + * A COUNT, AND NOTHING IS READ OFF A ROW TO REACH IT. An ACTIVE account Composio described + * without an id is still an ACTIVE account: this person can act through the app, which is the + * whole of what this gate asks. Reading the id here used to turn that into a refusal, so a + * field this question never looks at decided its answer. + * + * AND IT STOPS AT THE FIRST ROW, WHICH IS THE SAME CORRECTION ONE LEVEL OUT. This method + * answers a boolean, and a boolean settled by page one cannot be improved by page two — but + * reading on left the answer exposed to three faults that belong to pages nobody needed: a + * cursor Composio sent as a number, a cursor it repeated, and the fiftieth page of a listing + * that will not end. Each of those threw at a person whose ACTIVE account had already been + * found and told them the vendor's shape was wrong, and `store.ts` deletes their connection + * row on a `false` — so a question that had been answered `true` was made failable by the + * machinery that made the `false` complete. Paging is still what makes the `false` honest: + * with no row yet, the next page is the only thing that can settle it, so it is read. + * + * ASKED OF EVERY CONFIG, WHICH IS WHERE THIS DIVERGES FROM `revoke` BELOW. A person whose + * only account for the app sits on a config an operator built by hand can still act through + * the app — the call that runs an action names the person and the toolkit, not the account — + * so scoping this to configs of ours would refuse somebody who is, in fact, connected. This + * method only READS; the one that acts is the one that has to be narrow. + */ + return ( + ( + await accountsFor(userId, toolkit, CONNECTED, { + enough: (rows) => rows.length > 0, + }) + ).length > 0 + ); + }, + + async revoke({ userId, toolkit }): Promise { + /* + * THE ANSWER IS WHAT WAS ASKED FOR, not whether the call threw. `false` here means there was + * nothing to withdraw, which is what the audit trail's `vendorRevocationRequested` is for: a + * reader has to be able to tell an account this deployment acted on from one that outlives it + * somewhere else. + * + * ASKED FOR, RATHER THAN DONE, AND THE FIELD IS NAMED FOR THAT. The delete carries + * `revoke_on_delete`, which is what turns it from a record-keeping soft-delete into an actual + * withdrawal — and what it starts is a background job the vendor gives no supported way to + * poll. So the account is gone at the broker by the time this returns and nothing here can + * call with it again; whether Google has torn up the refresh token happens afterwards. `true` + * claims exactly that much. See {@link ComposioVendor} for the two declarations this rests on. + * + * EVERY ACCOUNT, not the first, and in every state that could still be a grant. One person + * can hold more than one account for one app — two mailboxes, or a stale account beside a + * fresh one, or a shared account beside their own — and each of them is access this + * deployment's calls could run under. See {@link REVOCABLE} for why the listing here is wider + * than the one behind `isConnected`. + * + * AND EVERY ACCOUNT MEANS EVERY ACCOUNT ON A CONFIG THIS DEPLOYMENT MADE, WHICH IS NARROWER + * THAN WHAT THIS USED TO DELETE. The listing was asked by person, app and "all account + * types" and by nothing else, so it returned accounts attached to authorization configs an + * operator built by hand in Composio's dashboard — for purposes this deployment knows nothing + * about, on scopes it cannot see, and, with `accountType: "ALL"`, including the SHARED ones + * that other people are acting through. Every one of those was then deleted with + * `revoke_on_delete`, which tears the grant up at Google or Slack. One person pressing + * disconnect on their own settings page ended somebody else's integration. + * + * WHICH IS THE PRINCIPLE {@link ComposioBroker.deleteAuthConfig} ALREADY STATES, ARRIVING + * ONE LEVEL DOWN. That method refuses to delete a config it cannot show is this + * deployment's, on the reasoning that an operator's dashboard work is not ours to destroy — + * and the accounts hanging off that config are the same work. {@link madeHere} is the only + * thing that tells the two apart, so it decides both. + * + * NOTHING AT ALL IS NOTHING TO WITHDRAW, AND IT IS ANSWERED WITHOUT ASKING. An app Composio + * holds no configs for never had a connection begun through it — {@link + * ComposioBroker.authorize} mints every link against a config of this deployment's and + * refuses where there is none — so there is nothing here that this deployment granted. + * Answering before the listing is also what keeps the empty filter off the wire; see + * `authConfigIds` on {@link ComposioVendor} for why an empty one must never be sent. + * + * BUT "NOTHING OF OURS" IS NOT THAT STATE, AND ANSWERING `false` TO IT WAS THE SEVENTH ROUTE + * TO A REVOCATION THAT DID NOT REVOKE. The reasoning above is sound about an app with no + * configs and cannot tell that app from this one: an operator renames a config in Composio's + * dashboard — drops the suffix, or edits the app's title past it — and this deployment's own + * live grants read as somebody else's work. `false` then means "there was nothing to + * withdraw", `store.ts` writes `vendorRevocationRequested: false` into the audit trail and + * deletes the `composio_connections` row, and the person's grant stands at Google with + * nothing in this deployment naming it. The trail records that no withdrawal was even asked + * for, which is the one direction nobody thinks to check. + * + * {@link ComposioBroker.deleteAuthConfig} REFUSES IN EXACTLY THIS STATE, and two halves of + * one operation cannot disagree about one condition. Its reading is the right one and this is + * the half that moves: the app's configs are legible, none of them carries the suffix, and + * nothing in the row says whether that is an operator's own work or ours under a title + * somebody edited. A refusal leaves the connection row standing, which is what keeps the + * person's grant findable, and names the same one act in a dashboard that the removal does. + * + * `false` STILL MEANS WHAT IT SAID, and now only where it is true: Composio holds nothing for + * this app, so this deployment granted nothing through it. + */ + const { held, ours, unreadable } = await configsFor(toolkit); + if (ours.length === 0 && held.length > 0) { + throw new BrokerRefusalError( + `Disconnecting ${toolkit} found none of this deployment's own authorization configs at Composio, and Composio holds ${held.length} for ${toolkit} whose name does not carry ${CONFIG_SUFFIX} — so nothing was withdrawn and this person's access has not ended, rather than their connection being forgotten here while their grant stands. Nothing here can tell one of ours, renamed in Composio's dashboard, from an operator's own work. If it is this deployment's, this person's grants on it are live, and renaming it to end with ${CONFIG_SUFFIX} lets disconnecting again withdraw them. If it is an operator's, this deployment granted nothing through it and only that dashboard can end what it holds.`, + ); + } + /* + * AND A ROW THAT COULD NOT BE READ IS NOT A ROW THAT IS NOT OURS — the same distinction one + * field further in. A config whose id or name Composio sent unreadably cannot be put in the + * account listing's filter, so any grant of this person's sitting on it is invisible to + * everything below and a `false` would be the same false trail entry as the rename above. + * Where there IS something of ours it is withdrawn first and this is reported afterwards, for + * the reason {@link readableConfigs} gives; here there is nothing to withdraw first. + */ + if (ours.length === 0 && unreadable.length > 0) { + throw new BrokerRefusalError( + `Composio described ${unreadable.length} of its authorization configs for ${toolkit} in a way this deployment cannot read and none of the rest is one it made, so whether this person holds a grant on one of this deployment's own could not be told and nothing was withdrawn. Their access has not been shown to end. ${VENDOR_SHAPE_REMEDY}`, + { cause: everyRefusal(unreadable) }, + ); + } + if (ours.length === 0) return false; + const accounts = await accountsFor(userId, toolkit, REVOCABLE, { + configs: ours.map((config) => config.id), + }); + /* + * THE READABLE ONES GO FIRST AND THE UNREADABLE ONES ARE REPORTED AFTERWARDS. Reading the ids + * of all of them before sending any delete is what made one unnameable row a permanent block + * on a person's disconnect — see {@link withdrawableAccounts}. Partitioning puts the withdrawal + * back in front of the report, which is the order a person's grants actually need. + */ + const { ids, nameless } = withdrawableAccounts(accounts, toolkit); + /* + * WHAT CAME BACK IS READ, WHICH IS THE HALF THAT USED TO BE MISSING. Not throwing is not the + * same as having been done: Composio answers a delete with a `success` saying whether it + * performed one, and a `false` there means the account is still attached and no revocation + * job was started. It is counted as a refusal — into the same list a thrown vendor error + * lands in — so that the sentence below reports it as an account that was not withdrawn, + * which is exactly what it is. See {@link withdrawalDeclined}. + * + * INSIDE THE LOOP AND OUTSIDE {@link askVendor}, deliberately. `askVendor` wraps the + * `await vendor.*` and nothing else, because everything it translates is a fault raised + * inside the vendor's package; the refusal below is this file's own reading of a reply that + * arrived intact, and it already carries an authored sentence. + */ + const refused = await askForEach(ids, async (id) => { + const answer = await askVendor( + { + outcome: `one of this person's ${toolkit} accounts was not withdrawn`, + app: toolkit, + }, + () => vendor.connectedAccounts.delete(id, { revoke_on_delete: true }), + ); + const declined = withdrawalDeclined(answer, toolkit); + if (declined !== null) throw declined; + }); + + if (refused.length > 0 || nameless.length > 0 || unreadable.length > 0) { + /* + * A PARTIAL WITHDRAWAL IS A FAILURE AND NOT A `true`, and the reason is the row this throw + * protects. `store.ts` revokes and only then deletes the `composio_connections` row, which + * is the only thing in this deployment that names which app this person connected. Answer + * `true` here on a partial and that row is deleted, the trail records a disconnection, and + * the account this call could not end is left live with nothing pointing at it — the exact + * state the store's revoke-before-delete order exists to make impossible. Throwing leaves + * the row standing, so pressing disconnect again is a second attempt with everything the + * first one had, and the accounts already gone are no longer in the listing, so the retry + * converges rather than repeating. + * + * WHICH IS ALSO WHY IT IS NOT A `true` WITH A GRUMBLE. Nothing was disconnected in the + * sense the person asked about: their app still answers. The count is in the sentence + * because "some of your accounts were withdrawn" is the one thing a reader cannot work out + * for themselves, and EVERY refusal the loop met is kept as `cause` for whoever is reading + * a log rather than a page — see {@link everyRefusal} for why all of them rather than the + * first, which is what this used to keep. + * + * AND THE TWO WAYS A GRANT SURVIVES ARE NOT THE SAME ADVICE. An account Composio refused is + * one a second press reaches, which is what "disconnecting again" is worth saying about. An + * account it described with no id is not: the row will be as unnameable next time, so the + * only honest instruction is the one that does not run through this page at all. + * + * AND THE DENOMINATOR IS THE ACCOUNTS, NOT THE ROWS. It was `accounts.length`, which is how + * many rows the listing handed over, and a listing that named one account twice is a + * listing with more rows than accounts — see {@link withdrawableAccounts}. "Withdrew 1 of + * this person's 2 accounts" over one account that is gone is a count nothing measured, in + * the sentence a reader is meant to act on. The two halves this call actually holds are the + * accounts it could name and the ones it could not, and their sum is the set. + */ + const accountsHeld = ids.length + nameless.length; + const left: string[] = []; + if (nameless.length > 0) { + left.push( + `Composio described ${nameless.length} of them with no id at all, so this deployment has no way to name those in a withdrawal and disconnecting again meets them unchanged: removing them in Composio's own dashboard is what ends them.`, + ); + } else if (refused.length > 0) { + left.push( + "Disconnecting again asks only for the accounts that are left.", + ); + } + /* + * AND A CONFIG ROW THAT COULD NOT BE READ IS ITS OWN CLAUSE, because it is a fact about a + * different thing from all of the above. Every sentence before this one counts ACCOUNTS + * that were looked at; this one says that the set of accounts looked at may not have been + * the set — a config of this deployment's, sitting behind a row whose id or name Composio + * sent unreadably, is one the listing was never scoped to. The grants already withdrawn + * stay withdrawn, which is why this arrives after them rather than instead of them. + */ + if (unreadable.length > 0) { + left.push( + `Composio also described ${unreadable.length} of its authorization configs for ${toolkit} in a way this deployment cannot read, so any grant of theirs on one of those was never in the question and disconnecting again meets the same answer: reading those configs in Composio's own dashboard is what says whether anything is left.`, + ); + } + throw new BrokerRefusalError( + `Composio withdrew ${ids.length - refused.length} of this person's ${accountsHeld} accounts for ${toolkit} and their access to it has not been shown to end. ${left.join(" ")}`, + { cause: everyRefusal([...refused, ...nameless, ...unreadable]) }, + ); + } + + /* + * THE ACCOUNTS WITHDRAWN, WHICH IS THE SAME NUMBER BY A HONESTER ROUTE. Reaching this line + * means nothing refused and nothing was nameless, so the rows and the accounts differ only + * where the listing repeated one — and `true` is a claim about having asked the vendor to + * withdraw something, which is exactly what `ids` counts. + */ + return ids.length > 0; + }, + + /** + * What the app itself says it wants typed in, mapped onto the boxes a form can draw. + * + * READ A STEP AT A TIME OFF `unknown`, for the reason every other vendor read here is: the + * detail's `auth_config_details` is copied across verbatim, so a mode that is not a list and a + * field row that is not an object are both shapes the wire can send. Those answer an empty form + * rather than a crash — an app whose scheme publishes nothing to fill in is an app with nothing + * to ask, which is a true thing to say and the one thing a `[]` here means. + * + * AND A SCHEME THIS APP NO LONGER PUBLISHES IS THE ONE OF THOSE THAT IS NOT AN EMPTY FORM, + * WHICH IS THE CORRECTION. The scheme is the RECORDED one and is never re-derived — that is the + * whole point of the column, and {@link ComposioBroker.connectionFields} says so — so "Composio + * does not publish this mode for this app" is exactly the drift between the row and the vendor + * that recording it anticipates, and it used to surface as a form with no boxes in it. A person + * presses submit on that form, {@link ComposioBroker.connectWithFields} creates a connection + * carrying no credential at all, Composio answers `ACTIVE` because it does not grade what it is + * given, and the first call made with the account is what discovers anything is wrong. An empty + * form is also, from the person's end, a box they cannot fill in. So the two states are told + * apart here: no such mode refuses and names the recorded one, and a mode that exists and + * publishes nothing visible still answers `[]`. + * + * REQUIRED AND OPTIONAL IN THAT ORDER, because the order is what a person reads down. The + * vendor publishes them as two lists and the required ones are the ones that stop the form; a + * form that interleaved them, or put the optional base-url box above the key, would be asking + * somebody to hunt for the field they came to fill in. + */ + async connectionFields({ toolkit, authScheme }): Promise { + const detail = await askVendor( + { + outcome: `what ${toolkit} asks for could not be read, so there is nothing to show`, + app: toolkit, + }, + () => vendor.toolkits.retrieve(toolkit), + ); + + const modes = Array.isArray(detail?.auth_config_details) + ? detail.auth_config_details + : []; + const mode = modes.find( + (candidate: { mode?: unknown }) => candidate?.mode === authScheme, + ); + /* + * THE REMEDY IS AN ADMINISTRATOR'S BECAUSE THE RECORDED SCHEME IS ONLY THEIRS TO REWRITE. + * Nothing a person pressing Connect can do changes which mode this app was enabled as, and + * nothing on this path may quietly pick a different one — a form drawn for whatever Composio + * publishes today, in front of an authorization config created for the word on the row, is + * the same disagreement one layer further in. Removing the app and adding it again is the one + * path that records the scheme afresh, so it is the one named. + */ + if (mode === undefined) { + throw new BrokerRefusalError( + `Composio no longer publishes a ${authScheme} connection for ${toolkit}, and ${toolkit}'s authorization config here was created as ${authScheme}, so there is nothing to ask this person for — and an empty form is a box they cannot fill in and a connection carrying no credential at all. An administrator removing the app on its Plugins page and adding it again is what records the scheme Composio publishes for it now.`, + ); + } + const published = mode.fields?.connected_account_initiation; + const rows = [ + ...(Array.isArray(published?.required) ? published.required : []), + ...(Array.isArray(published?.optional) ? published.optional : []), + ]; + + return rows + .filter((row) => row?.user_visible !== false) + .map((row) => { + /* + * A TYPE THIS DEPLOYMENT CANNOT DRAW IS A REFUSAL RATHER THAN A TEXT BOX. Every required + * field measured across the catalogue is a plain string, so this is a guard against the + * vendor rather than a routine case — and the failure it prevents is somebody typing a + * path into a box labelled Certificate and being told they are connected. + * + * AND THE NAME IS IN THE SAME GUARD, because it is the least guarded field here and the + * only one that travels. `label`, `help` and `default` all pass through {@link textOf} + * and are read by a person; the name is sent back to Composio verbatim and is the key + * {@link ComposioBroker.connectWithFields} spreads into the connection's `val`. Coerced + * with `String(...)`, a row carrying `type: "string"` and no name drew a box literally + * called "undefined" and then submitted whatever was typed in it under that key — a value + * no app reads, in a connection Composio accepts. + */ + const name = textOf(row?.name); + if (row?.type !== "string" || name === null) { + throw new BrokerRefusalError( + `${toolkit} asks for ${textOf(row?.displayName) ?? "a value"} as ${sent(row?.type)} under the name ${sent(row?.name)}, which cannot be filled in here: a box this deployment can draw is a string, and a box whose answer can be sent back has a name. Connecting this app is not something this deployment can offer yet.`, + ); + } + /* + * THE TRIMMED VALUE IS WHAT REACHES THE FORM, because the trimmed value is what was + * judged. This tested `textOf(row.default)` and emitted `String(row.default)`, so a + * default Composio padded passed the test on its trimmed form and arrived in the box with + * its padding — the same disagreement between guard and return that {@link vendorSentence} + * was corrected for, one field away from a value somebody then submits as typed. + */ + const suggested = textOf(row.default); + return { + name, + label: textOf(row.displayName) ?? name, + help: textOf(row.description) ?? "", + required: row.required === true, + secret: row.is_secret === true, + ...(suggested === null ? {} : { default: suggested }), + }; + }); + }, + + /** + * One person's account made from what they typed, and the ONE call here that drops its error. + * + * THIS IS THE SINGLE PLACE THIS FILE'S CAUSE-CARRYING RULE REVERSES, AND IT SAYS SO ON PURPOSE. + * The rule the module comment states and every other path keeps is that a vendor error is never + * logged and always carried as `cause`, precisely because the object holds the request it was + * made for and whoever is reading a log rather than a page deserves it. On every other call + * that request is a link mint or a delete. On this one it is somebody's API key: the body below + * carries the value they pasted into the form, and an `APIError` out of a create carries the + * body. So the catch reads the vendor's sentence through the one door {@link vendorSentence} + * owns and then drops the object entirely — not attached, not rethrown, nothing left for a + * handler further out to serialize. + * + * WHAT THAT COSTS IS THE DIAGNOSTIC TRAIL ON THE FLOW PEOPLE MOST OFTEN MISTYPE, AND THE COST IS + * ACCEPTED KNOWINGLY. A key pasted with a newline, a token from the wrong workspace, a secret + * for the staging tenant — these are the ordinary failures here, and this leaves nothing behind + * about any of them. What an operator gets instead is Composio's own sentence and the request + * id inside it, which is what Composio's dashboard searches on: enough to ask the vendor about + * that attempt, and not enough to rebuild it here. A `cause` that made the next mistyped key + * easier to explain would put every correctly typed one in a log for as long as the log is kept. + * + * NOT {@link askVendor}, WHICH IS THE SAME DECISION SEEN FROM THE OTHER SIDE. That function is + * what every other vendor call in this file goes through, and every refusal it builds attaches + * the original — see {@link vendorRefusal}, where the `cause` is the point. Routing this call + * through it would be the rule applying here by default, which is the one place it must not. + * + * NO VERIFICATION HERE, AND THE ABSENCE IS NOT AN OVERSIGHT. Composio does not grade a submitted + * key: a connection created with an obviously wrong value comes back `ACTIVE`. Whether the + * credential works is settled by making a call with it, which is why this answers the + * `accountId` — so whoever does that can take back exactly the account it made and nothing else. + * See {@link ComposioBroker.revokeAccount}. + */ + async connectWithFields({ + userId, + toolkit, + authScheme, + values, + }): Promise<{ accountId: string }> { + /* + * THIS DEPLOYMENT'S OWN CONFIG, FOR THE REASON {@link ComposioBroker.authorize} READS ONE: an + * account is a lasting attachment to whatever config it was made against, so attaching + * somebody to an operator's hand-made config — scopes this deployment cannot see, tool + * restrictions it cannot read, an object it must not delete — is not a guess that can be + * corrected afterwards. + */ + const { ours, unreadable } = await configsFor(toolkit); + if (ours.length === 0) { + /* + * A ROW THIS FILE COULD NOT READ IS NOT AN APP WITH NO CONFIG, AND IT IS THE SAME TWO + * REMEDIES {@link ComposioBroker.authorize} TELLS APART. "Remove the app and add it again" + * is right where the listing was legible and said none of these is ours, and wrong here: + * the row that could not be sorted may BE ours, in which case the removal meets + * {@link ComposioBroker.deleteAuthConfig}'s own refusal and the re-enable meets + * `ensureAuthConfig`'s. That is an administrator sent round a loop that cannot close, and + * this method used to hand them exactly that sentence. + * + * NO `cause` ON ANY OF THE REFUSALS IN THIS METHOD, which is the local rule rather than the + * file's. Not one of the four refusals this config read can raise carries a vendor object at + * all — they are this file's own reading of a listing — but attaching + * `everyRefusal(unreadable)` here, as `authorize` correctly does, would put an error chain + * on the one method whose call frame holds somebody's API key, and the whole of this + * method's doc comment is about not doing that. The count is the finding, and the count is + * in the sentence. + */ + if (unreadable.length > 0) { + throw new BrokerRefusalError( + `Composio described ${unreadable.length} of its authorization configs for ${toolkit} in a way this deployment cannot read and none of the rest is one it made, so there is nothing it can show is its own to connect an account against and what was typed into the form was not sent anywhere. ${VENDOR_SHAPE_REMEDY}`, + ); + } + throw new BrokerRefusalError( + `This deployment has no authorization config at Composio for ${toolkit}, so there is nothing to connect an account against and nothing was sent. An administrator removing the app on its Plugins page and adding it again creates one.`, + ); + } + + /* + * THE ENABLED ONE, WHICH IS THE SAME CHOICE {@link ComposioBroker.authorize} MAKES AND FOR ONE + * REASON MORE. `configsFor` lists with `showDisabled: true` — it has to, or `ensureAuthConfig` + * creates a second config beside one it cannot see — so `ours[0]` could perfectly well be a + * DISABLED config, and an account created against one cannot work. Reading that state only + * after the create is the wrong order on this path more than on any other: the body of that + * request is the key somebody just pasted in, so the refusal has to arrive BEFORE the + * credential leaves this process rather than after Composio has been handed it and declined. + * + * AND IT IS THE SAME CONFIG THE CONSENT PATH WOULD HAVE PICKED, which is the other half. + * Two configs from a lost enable race, the first of them disabled, and `ours[0]` against + * `find(ENABLED)` attach one app's accounts to two different configs depending on which door + * a person came through — after which removing "the" config drops half of them. + */ + const config = ours.find((held) => held.status === "ENABLED"); + if (!config) { + /* + * "DISABLED" IS ONLY THIS DEPLOYMENT'S CLAIM TO MAKE WHEN COMPOSIO MADE IT, for the reason + * spelled out at length in {@link ComposioBroker.authorize}: the test above is + * `=== "ENABLED"`, so a config with no status and a config wearing a word this deployment's + * `@composio/core` has never heard of both fall through here, and telling an operator those + * are disabled sends them to enable something that may already be enabled. + */ + const unsettled = ours.filter((held) => held.status !== "DISABLED"); + if (unsettled.length > 0) { + const words = [ + ...new Set(unsettled.map((held) => named(held.status))), + ]; + const shown = words.slice(0, STATUSES_NAMED); + const said = + words.length > shown.length + ? `${shown.join(", ")} and ${words.length - shown.length} other words` + : shown.join(", "); + throw new BrokerRefusalError( + `Composio describes ${unsettled.length} of this deployment's ${ours.length} authorization configs for ${toolkit} as ${said}, which ${words.length === 1 ? "is" : "are"} neither ENABLED nor DISABLED, so whether an account connected against one could work is not something this deployment can tell. Nothing was sent, and what was typed into the form did not leave this deployment. ${VENDOR_SHAPE_REMEDY}`, + ); + } + throw new BrokerRefusalError( + `Every authorization config this deployment holds at Composio for ${toolkit} is disabled, so an account connected against one could not work and nothing was sent. An administrator can enable it in Composio's dashboard, or remove the app on its Plugins page and add it again.`, + ); + } + + let created: { id?: unknown }; + try { + created = await vendor.connectedAccounts.create({ + auth_config: { id: config.id }, + connection: { + user_id: userId, + /* + * THE SCHEME AND THE TYPED VALUES, IN THE SHAPE THE VENDOR'S OWN BUILDER ASSEMBLES. + * `AuthScheme.APIKey` and its siblings all return `{ authScheme, val: { status: + * ACTIVE, ...fields } }` (`@composio/core` 0.18.1, `src/models/AuthScheme.ts:84-94`), + * and the field NAMES are Composio's own — published per app by + * {@link ComposioBroker.connectionFields} and sent back verbatim, because a name this + * file renamed on the way through is a box somebody filled in that no app ever reads. + */ + state: { + authScheme, + val: { status: "ACTIVE", ...values }, + }, + }, + }); + } catch (error) { + /* + * ONLY THE SENTENCE LEAVES THIS BLOCK. `vendorSentence` is the one door in this deployment + * for reading a vendor's own words, and reading it is the whole of what `error` is used + * for: it is not attached, not rethrown and not named below this line. + */ + const said = vendorSentence(error); + throw new BrokerRefusalError( + said === null + ? `Composio did not accept the connection to ${toolkit} and said nothing this deployment can pass on. The failure arrived through this deployment's @composio/core with no sentence of Composio's on it, which is what an outage, a cancelled request and a reply the package could not read all look like from here — and the failure itself was dropped rather than recorded, because on this one call the object carrying it also carries what was typed into the form. Nothing was attached. Composio's own dashboard logs the attempt, and asking again is what settles whether the request ever landed.` + : `Composio refused the connection to ${toolkit}: ${said} Nothing was attached. Nothing further about this attempt is kept here, because what the failure carried was the value typed into the form — the request id in Composio's own words above is what their dashboard searches on.`, + ); + } + + /* + * NO ID IS NOT A CONNECTION, HOWEVER THE REPLY READS. The id is the whole of what this method + * answers and the only thing a caller can undo its own work with, so handing back an + * `undefined` cast to a string would leave an account standing at Composio that nothing on + * this deployment can name, made from a credential somebody typed a moment ago. The dashboard + * is named because it is the only place that account can now be seen and removed. + */ + const accountId = textOf(created?.id); + if (accountId === null) { + throw new BrokerRefusalError( + `Composio answered the connection to ${toolkit} with no account id, so this deployment cannot name the account it just asked for and cannot take it back. An account may be standing at Composio over this: ${toolkit} in Composio's own dashboard is where it can be seen and removed. ${VENDOR_SHAPE_REMEDY}`, + ); + } + + return { accountId }; + }, + + /** + * ONE account ended by id, and NOT {@link ComposioBroker.revoke}, which is the whole decision. + * + * `revoke` ends every account a person holds for an app. That is right for what it serves — a + * person ending their access, where any account left behind is access that still answers — and + * it is wrong for a verification undoing what it just made. The two read identically right up + * until the local row and Composio have drifted apart, and that is precisely the state a failed + * verification stands in: a connection that was working, a second account just created from a + * key that does not work, and a sweep that takes down both. So this method is handed the id and + * nothing else — nothing is listed, nothing is matched, and no account it was not given can be + * reached from here. + * + * WITH `revoke_on_delete`, FOR THE REASON {@link ComposioVendor}'s `delete` GIVES AND ONE MORE + * OF ITS OWN. Without the flag the account stops being visible to this deployment and the + * credential at the far end stands; here that credential is one somebody typed into a form + * minutes ago, into a page that is about to tell them the connection was not kept. + * + * NOTHING IS ANSWERED AND NOTHING IS SWALLOWED. There is no count to report — the id names one + * account that existed moments ago — so a failure is a failure, and it leaves through + * {@link askVendor} like every other vendor call in this file. + * + * AND "I DID NOT DELETE IT" IS ONE OF THOSE FAILURES, WHICH THE AWAIT USED TO DISCARD. The + * delete answers `{ success?: unknown }` for the reason {@link ComposioVendor}'s declaration + * gives: a 200 carrying `success: false` is Composio saying it did NOT delete the account, so + * no revocation was started and nothing was asked of the provider — the same lie + * {@link withdrawalDeclined} exists to stop one method away. It matters MORE here than there, + * because of what this call's caller does with a clean return. The verification step withdraws + * the account behind a key that failed, and where the withdrawal ITSELF fails it writes the row + * unverified so the account stays reachable and disconnectable. A `success: false` resolving + * normally takes the other branch: no row is written, and what is left standing is a live + * account holding a working-or-not credential that nothing on any screen names and nobody can + * press disconnect on. + * + * ITS OWN SENTENCES RATHER THAN {@link withdrawalDeclined}'s, because that function's two are + * written around a toolkit and around pressing disconnect again, and this method has neither: it + * was handed an id, the app is the caller's to name, and the second press it would invite is a + * button that is not on any page for an account no row points at. What carries across unchanged + * is the null/undefined exemption — a 204 or a body of content-length zero is the vendor saying + * it DID delete and having nothing to add, and reading those as a refusal is the same lie + * pointing the other way. + */ + async revokeAccount(accountId): Promise { + const answer = await askVendor( + { + outcome: + "the one account this call was handed was not withdrawn and may be standing at Composio", + /* + * NO APP IN THE QUESTION, which is what the id being the whole of it means. The caller + * holds the toolkit it connected and can say it; this call was given an account. + */ + app: null, + }, + () => + vendor.connectedAccounts.delete(accountId, { + revoke_on_delete: true, + }), + ); + + if (answer === null || answer === undefined) return; + const verdict = answer.success; + if (verdict === true) return; + if (verdict === false) { + throw new BrokerRefusalError( + `Composio answered the withdrawal of the account this connection just made with success: false, so it did not delete the account and started no revocation of the credential behind it. That account is still standing at Composio and the credential behind it is still live there.`, + ); + } + throw new BrokerRefusalError( + `Composio sent ${sent(verdict)} where its verdict on the withdrawal of the account this connection just made belongs, and that field is the only thing in the reply that says whether the account was deleted at all. This deployment cannot tell a withdrawal that happened from one that did not, so the account is reported as still standing and the credential behind it as not withdrawn. ${VENDOR_SHAPE_REMEDY}`, + ); + }, + }; + + return { actions, broker }; +} + +/** + * The lines that turn this deployment's API key into a vendor client. + * + * Everything this file decides lives in {@link buildComposioClient}, which is why this function has + * no decision worth testing: it constructs the vendor and hands it over. The key is a parameter here + * and a private field of the vendor's client thereafter, and no path out of this module carries it + * — see the module comment. + * + * IT IS NO LONGER ONE LINE, AND THE REASON WAS THE TWO DELETES AND IS NOW ALSO THE TWO LISTINGS. + * `Composio` used to satisfy {@link ComposioVendor} whole, passed straight in. It cannot any more, + * for two separate faults in the same wrapper. Its own `authConfigs.delete` and + * `connectedAccounts.delete` send a hard-coded empty body and therefore cannot ask for the upstream + * revocation, which is the difference between ending somebody's access and filing it away. And its + * `tools.getRawComposioTools` and `toolkits.get` cannot be paged — one takes no cursor, the other + * drops the response's — which is the difference between a catalogue and the first thousand rows + * of one, and which showed an operator an empty app picker. The underlying `@composio/client` + * answers all four, so those members are satisfied from `getClient()` and the rest from the SDK's + * own models. A wrapper per member rather than a spread, so that the arrow's own type checks + * against the shape above — a vendor method whose signature drifted would fail here rather than at + * the call site. + * + * NO NEW IMPORT, WHICH IS WHY THE ONE-IMPORT-SITE RULE SURVIVES THIS. `getClient()` is public on the + * SDK's own object and the client's types are inferred from it; `@composio/client` is not named + * anywhere under `server/src`, so a version bump still has exactly this file to be read against. + */ +export function createComposioClient(apiKey: string): { + actions: ComposioActions; + broker: ComposioBroker; +} { + const composio = new Composio({ + apiKey, + // Their default telemetry installs its own interrupt handlers, and this is a self-hosted + // product whose operator never opted into a third party's analytics. + allowTracking: false, + // Both default the other way, so both have to be said. The version check reaches npm for the + // SDK's latest release as the client is constructed, and a deployment's boot must not depend + // on the vendor's release feed. + disableVersionCheck: true, + }); + const client = composio.getClient(); + + return buildComposioClient({ + tools: { + // The listing is the raw client's because the wrapper has no cursor; the single-tool read and + // the execute stay the SDK's, because both are one call about one action and neither pages. + list: (query) => client.tools.list(query), + getRawComposioToolBySlug: (slug, options) => + composio.tools.getRawComposioToolBySlug(slug, options), + execute: (slug, body) => composio.tools.execute(slug, body), + }, + toolkits: { + list: (query) => client.toolkits.list(query), + retrieve: (slug) => client.toolkits.retrieve(slug), + }, + authConfigs: { + list: (query) => composio.authConfigs.list(query), + create: (toolkit, options) => + composio.authConfigs.create(toolkit, options), + delete: (id, params) => client.authConfigs.delete(id, params), + }, + connectedAccounts: { + list: (query) => composio.connectedAccounts.list(query), + link: (userId, authConfigId, options) => + composio.connectedAccounts.link(userId, authConfigId, options), + /* + * The create is the raw client's for a third reason of its own: the SDK's wrapper over this + * endpoint is `initiate`, which is retired for the managed-auth path, and `link` above — its + * replacement — mints a consent url and has nowhere to put a value a person typed. + * + * THE ONE ASSERTION IN THIS FUNCTION, AND IT IS ABOUT `state` ALONE. The generated client + * declares that field as a fourteen-member union keyed on the scheme, each member's `val` + * ending in `[k: string]: unknown` — which is to say the vendor's own type admits any object + * once the scheme is picked, and picking it here would be this file asserting which boxes an + * app asks a person for. {@link ComposioVendor} therefore says `unknown` and the widening is + * spent here, at the one line where this deployment's projection meets the vendor's schema + * and nothing else is decided. + */ + create: (body) => + client.connectedAccounts.create( + body as Parameters[0], + ), + delete: (id, params) => client.connectedAccounts.delete(id, params), + }, + }); +} diff --git a/server/src/plugins/composio.ts b/server/src/plugins/composio.ts new file mode 100644 index 000000000..dc606fca7 --- /dev/null +++ b/server/src/plugins/composio.ts @@ -0,0 +1,1378 @@ +import { brokerSentence } from "./broker"; +import { type ListedTool, MAX_RESULT_CHARS, type McpCallResult } from "./mcp"; + +/** + * The Composio transport: an app somebody enabled, reached as the person asking. + * + * WHAT MAKES THIS DIFFERENT FROM THE OTHER TRANSPORTS. `mcp` dials somebody else's server and + * `google-drive-rest` dials Google; both answer to a credential, and whose it is was settled before + * the connection was built. `builtin-routines` has no credential at all. This one has a credential + * that is not the answer: the deployment holds ONE Composio key, and which person's Gmail it opens is + * decided by a user id we send alongside it. So the ACTOR is the authorization here, exactly as it is + * for Routines, and for the same reason {@link callTool} refuses a run that is not attributed to + * anybody. + * + * THE USER ID IS NEVER AN ARGUMENT. It comes off the connection, which the call path derives from the + * session. A model that could name a user id could open somebody else's mailbox, and that is not + * hypothetical: it is the defect OpenTag shipped and fixed three separate times. Nothing below reads + * `args` looking for an identity, which is what makes it structurally impossible rather than merely + * checked. + * + * It implements the same interface as the other three, as module-level exports, because that is the + * shape {@link ./transport} resolves: a `TransportKind` maps to a MODULE. Which is also why the client + * arrives through a setter rather than a constructor — the registry is built at import time, long + * before anything has read configuration. {@link useComposioClient} is that setter. + * + * `index.ts` CALLS IT AT STARTUP, from the one place that holds the key: it builds the client + * through `./composio-adapter` where `config.composioApiKey` is set and installs the actions seam + * with it. So on a deployment that has a key `installed` is a real client, and every mention of + * "the client" below describes wiring that runs. A deployment with no key installs nothing and + * leaves it null, which is a state this module is written for rather than an outage — + * {@link listTools} throws a sentence saying so and {@link callTool} refuses with one. + */ + +/** + * The argument key the call path uses to hand this transport the recorded version. + * + * A reserved key on `args` rather than a fourth parameter on the shared `callTool` signature, because + * that signature is MCP's own and three other transports implement it — widening it for one vendor's + * requirement would put a field on every transport that only one of them can use. Stripped before + * anything reaches Composio, and asserted stripped, so a vendor never sees a key it did not publish. + * + * Underscored so it cannot collide with a real argument name: Composio's schemas are snake_case. + */ +export const VERSION_ARG = "__version"; + +/** + * How many rows one PAGE of a listing asks for, which is as many as Composio will answer with. + * + * A NUMBER RATHER THAN NO NUMBER, because omitting it is not "no opinion". Composio's page defaults + * to 20 and Gmail publishes 63 actions, so an omitted limit truncates — and through the SDK wrapper + * it also NARROWED: `getRawComposioTools` set `important=true` whenever the query named toolkits + * and gave no limit, no tags and no search (`@composio/core` 0.18.1, `src/models/Tools.ts:505-515`), + * so the short answer was a filtered one and nothing in it said a filter had been applied. + * + * 1000 BECAUSE THAT IS THE CEILING, not because it is generous. Both REST parameters document "max + * allowed is 1000" (`@composio/client` 0.1.0-alpha.76, `resources/tools.d.ts:441-444`, + * `resources/toolkits.d.ts:483-486`), so this is the fewest round trips a listing can be read in. + * + * IT IS A PAGE AND NOT THE LISTING, WHICH IS WHAT CHANGED AND WHY THE REFUSAL BELOW IS GONE. This + * used to say that one page at the ceiling "is not a page — it is the whole listing, and the only + * listing expressible here", and {@link listTools} refused a page that came back FULL on the + * strength of it. That was a fact about the wrapper rather than about the vendor: `ToolListParams` + * and `ToolkitListParams` both carry a `cursor` and both responses carry a `next_cursor`, and + * `./composio-adapter` follows them to the end. A listing of exactly this many rows is now an app + * with a lot of actions, and nothing is truncated by it. + */ +export const LISTING_LIMIT = 1000; + +/** One action, as much of Composio's listing as anything here reads. */ +export type ComposioAction = { + slug: string; + description?: string; + /** + * The action's JSON Schema AS COMPOSIO PUBLISHED IT, which for two waves it was not. + * + * THIS FIELD USED TO ARRIVE SHORT, AND THE FIX WAS NAMED HERE BEFORE IT WAS MADE. Whatever lands + * in it is what {@link listTools} puts in front of a model as the vendor's own schema, and + * `./composio-adapter` filled it from `tools.getRawComposioTools` — the call that ends in + * `ToolSchema.parse`. Its `ParametersSchema` is a plain `z.object` with no passthrough + * (`@composio/core` 0.18.1, `src/types/tool.types.ts:134-174`), so every key it did not name was + * dropped before anything here could see it: `if`, `then`, `else`, `examples` and every `x-` + * extension at the schema ROOT, and `deprecated` and `contentEncoding` per property + * (`JSONSchemaPropertySchema`, `:77-131`). This comment recorded that as unavoidable and named + * the one place it could be avoided — "that same file, by reading `client.tools.list` directly + * and never running `ToolSchema` over the answer". + * + * THAT IS NOW WHAT THE ADAPTER DOES, FOR A REASON THAT HAD NOTHING TO DO WITH THIS FIELD. The + * wrapper is the one method of the vendor's tool model that cannot be paged, so the listing moved + * to the raw client to follow Composio's cursor — and the keys came back as a side effect. What + * this module promises is unchanged and is now worth more: it adds nothing to this schema and + * removes nothing from it, so what a model is shown is what the vendor published. + * + * Absent for the occasional action that publishes none. An action that published `{}` now arrives + * as `{}` rather than as absent — the SDK normalized that away before parsing + * (`src/models/Tools.ts:76-93`) and nothing does now — which `./store` records as the open schema + * an empty one is. + */ + inputParameters?: Record; + /** Behaviour labels mixed in with topical ones. See {@link effectOf}. */ + tags?: string[]; + /** The version calling this action requires — `20260903_00` and the like. */ + version?: string; +}; + +/** + * What Composio answers an execute with, as its own SDK defines it. + * + * `ToolExecuteResponseSchema` in `@composio/core` 0.18.1 spells all three of these REQUIRED — `data` + * a record, `error` a nullable string, `successful` a boolean — so the outcome of a call is a field + * on a resolution and not only a thrown exception. Named here rather than imported so this module + * keeps no compile-time dependency on the vendor's package; `./composio-adapter` is the one file + * under `server/src` that imports `@composio/core`, and that is where their types belong. + * + * `logId` and `sessionInfo` are the rest of the envelope, carried so the type stays a true statement + * about what arrives. Nothing here reads them and nothing here shows them to a model. + */ +export type ComposioResult = { + data: Record; + error: string | null; + successful: boolean; + logId?: string; + sessionInfo?: unknown; +}; + +/** + * What this module needs of Composio, and nothing more. + * + * A narrow projection rather than their client, so a test satisfies it with two functions and the + * SDK's shape is confined to one place: `./composio-adapter`, which `index.ts` builds from + * `config.composioApiKey` and installs here at startup. That adapter is the only implementation + * that reaches Composio; every other one is a stub in the suite. + * + * `execute` RESOLVES AN OUTCOME, AND RESOLVING IS NOT SUCCEEDING. This comment used to say the + * opposite — "resolves or throws, with no error field to check" — and {@link callTool} was written to + * match the comment rather than the library, which is how a 200 answer carrying `successful: false` + * came back from this transport as `isError: false`, was audited as `mcp.call_succeeded`, and was + * handed to the model as though the failure were content. The installed schema is the authority: + * `successful` is required. Throws still happen too, for a transport fault or a 4xx, so both a + * resolution and an exception have to be read. + */ +export type ComposioActions = { + /** + * Every action of one app, read to the end, in pages the CALLER has to name the size of. + * + * `page` is required rather than optional, and that is the whole point of it being here. The + * original signature took the toolkit alone, so an adapter had nothing to pass a limit through + * and the SDK's default applied — 20 rows, silently narrowed to the vendor's "important" subset. + * A required argument makes the page a thing a caller asks for on purpose instead of a thing they + * get by leaving something out. See {@link LISTING_LIMIT}. + * + * WHAT IT NO LONGER BOUNDS IS THE ANSWER. `./composio-adapter` follows Composio's cursor until + * the vendor stops offering one, so this names how many rows each request carries and not how + * many actions can come back — which is why the full-page refusal that used to stand in + * {@link listTools} is gone, and why an implementation that reads one page and stops is a defect + * nothing about the signature would report. + */ + listActions( + toolkit: string, + page: { limit: number }, + ): Promise; + /** + * One action, of one app, as one person, at one version. + * + * THE APP IS PART OF THE CALL AND NOT A CHECK BESIDE IT, which is the whole reason this takes a + * named record rather than four strings. {@link callTool} resolves the app from the connection's + * url and the brokered gate in `./access` looks a person's `composio_connections` row up by that + * same name — and then the call used to go out as the slug alone. A slug is what a LISTING + * recorded, so a url edited between a refresh and a call was gated on the app it names NOW and + * run against the app it named THEN: somebody's Slack connection satisfying the gate for a Gmail + * action that still runs in their Gmail. The gate and the call have to be about one fact. + * + * THE VENDOR'S WIRE CANNOT CARRY THE PAIR, so the obligation is written down here instead. The + * REST parameters have no toolkit field and the client's method takes the slug alone — + * `execute(toolSlug, params, options)` with `ToolExecuteParams` of `arguments`, `user_id`, + * `version` and connection overrides (`@composio/client` 0.1.0-alpha.76, + * `resources/tools.d.ts:41` and `:480-532`) — and the core SDK sends exactly that, + * `clientWithoutRetries.tools.execute(tool.slug, executeBody)` (`@composio/core` 0.18.1, + * `src/models/Tools.ts:1013`). + * + * SO AN IMPLEMENTATION MUST REFUSE A MISMATCH RATHER THAN FORWARD ONE, and it has what it needs + * to. `tools.execute` already resolves the tool by slug before running it + * (`src/models/Tools.ts:1163`, resolver at `:693`), and the resolved tool carries the app the + * vendor will actually run it against as `Tool.toolkit.slug` (`src/types/tool.types.ts:189`). + * Where that disagrees with `call.toolkit`, an implementation is required to throw instead of + * executing — which {@link callTool} already turns into a refusal with a sentence, because a + * throw out of here is the vendor-reported failure it is written to catch. + */ + execute( + call: { + /** The app the connection's url names, resolved by {@link toolkitOf} at call time. */ + toolkit: string; + slug: string; + /** Never from `args`. See the module comment. */ + userId: string; + version: string; + }, + args: Record, + ): Promise; +}; + +let installed: ComposioActions | null = null; + +/** + * The seam `index.ts` hands this module its client through, once, at startup. + * + * A SETTER RATHER THAN A CONSTRUCTOR ARGUMENT, BECAUSE THERE IS NOTHING TO HAND A CLIENT TO. A + * transport is reached as a MODULE — `transportFor` maps a kind to one — and that registry is built + * at import time, long before there is configuration to read. So the client is installed globally + * instead, from the one place that holds the key: `index.ts` builds it from `config.composioApiKey` + * and calls this with `composio.actions`. + * + * `null` is a supported argument, and not only for symmetry: the suite is one process, so a test that + * installs a stub has to be able to take it back out. It is also the unconfigured state — a + * deployment with no Composio key installs nothing. What that state produces is not an empty answer: + * {@link listTools} THROWS and {@link callTool} refuses, both saying which of the two it is, because + * an empty listing is indistinguishable from an app that advertises nothing and would be committed + * as one. + */ +export function useComposioClient(client: ComposioActions | null): void { + installed = client; +} + +/** + * The tool list needs no credential FROM THE CONNECTION, which is not the same as needing none. + * + * The sentence here used to be "Composio publishes an action's schema to anybody", and the vendor's + * own client says otherwise: the listing is an authenticated request carrying the deployment's + * Composio API key, which the client holds and this module never sees. What is genuinely not + * required is a PERSON. An action's schema is the same whoever asks, so nothing about whose account + * is connected has to be settled before listing — which is exactly what this flag is asked to + * decide by `refreshTools`, and the only thing it decides. See {@link ./transport}. + */ +export const listNeedsCredential = false; + +/** + * Which app this connection is about. + * + * The slug lives in the url — `composio://gmail` — rather than in a column of its own, because the url + * is the field every transport already gets and `effectiveUrl` already owns. Null for anything that is + * not one of ours, so a misrouted connection lists nothing instead of asking Composio about a + * hostname. + * + * ONE SLUG OR NOTHING, and the strictness is the security property rather than tidiness. This answer + * becomes `ServerAccess.toolkit` (`./access`), which is the name the brokered gate looks a person's + * row up by in `composio_connections` — so a url read loosely is somebody's connection to one app + * satisfying a call against another. Whatever follows the scheme has to be a slug and nothing else: + * `composio://gmail/messages` used to answer `"gmail/messages"`, taking a path segment for an app. + * + * TRIMMED BEFORE THE SLASHES COME OFF, because the other order does not work. `composio://gmail/ ` + * ran the strip against a string whose last character was a space, so the slash was not at the end, + * nothing matched, and the trim then produced `"gmail/"`. + * + * The character class is deliberately not case-folded. `composio_connections.toolkit` documents the + * column as lower case and this function does not lower-case what it returns; that mismatch is a + * separate known issue, and matching case-insensitively here keeps this change to the shape of the + * url rather than quietly settling it. + */ +const TOOLKIT_SLUG = /^[A-Za-z0-9_-]+$/; + +export function toolkitOf(url: string): string | null { + const prefix = "composio://"; + if (!url.startsWith(prefix)) return null; + const slug = url.slice(prefix.length).trim().replace(/\/+$/, ""); + return TOOLKIT_SLUG.test(slug) ? slug : null; +} + +/** + * What an action does, from the labels Composio publishes with it. + * + * SIX LABELS, AND ONLY TWO DECIDE ANYTHING. `readOnlyHint` is the one thing that can produce a read. + * `destructiveHint` produces a destructive write. `createHint` and `updateHint` are writes, which is + * also what an unlabelled action is, so reading them buys nothing over the default. `idempotentHint` + * and `openWorldHint` say nothing about effect — DELETE is idempotent, so treating idempotence as + * safety would wave through exactly the calls worth asking about. + * + * ANYTHING UNLABELLED IS A WRITE. Measured across Gmail, Linear, Calendar, Notion and Slack, every + * action carried at least one label, so this is a guard against the future rather than the present: an + * app that labels nothing, or a label added later that this code has never heard of, must land on + * write. The opposite default would silently classify new actions as safe. + * + * DESTRUCTIVE WINS OVER READ-ONLY. Both at once is somebody else's bug, and the strict reading is the + * only safe one. + */ +export function effectOf(tags: readonly string[] | undefined): { + effect: "read" | "write"; + destructive: boolean; +} { + const labels = new Set(tags ?? []); + if (labels.has("destructiveHint")) + return { effect: "write", destructive: true }; + if (labels.has("readOnlyHint")) return { effect: "read", destructive: false }; + return { effect: "write", destructive: false }; +} + +/** + * A plain object — a JSON Schema node, a listed action, an execute envelope — or null for anything + * that is not one, which is the only question three separate readers in this file have of a value + * the vendor sent. An array answers null, because `typeof [] === "object"` is the trap every one of + * them would otherwise fall into on its own. + */ +function schemaNode(value: unknown): Record | null { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as Record) + : null; +} + +/** + * Whether an action asks for a file, anywhere in its schema. + * + * `file_uploadable` is Composio's own extension keyword, and one of the few the SDK's + * `JSONSchemaPropertySchema` whitelists rather than strips (`@composio/core` 0.18.1, + * `src/types/tool.types.ts:89`) — so unlike most of what the vendor publishes, this one is still + * here to be read. See {@link listTools} for what is done with the answer. + * + * WALKED, NOT LOOKED UP. Composio toolkits routinely put the flag behind a `$ref`/`$defs` + * indirection or inside an `anyOf` variant, which is why the vendor's own predicate recurses + * through both (`src/utils/modifiers/FileToolModifier.utils.neutral.ts:77-134`). A check that read + * only the top level of `properties` would answer false for every ref-based schema, which is the + * majority of the ones that carry a file. + * + * WHAT BOUNDS THE LIST IS NOT WHAT COMPLETES IT, and this comment used to claim the second from the + * first. A keyword `ParametersSchema` and `JSONSchemaPropertySchema` strip cannot be present to be + * walked, so nothing outside those two needs a branch — but every subschema-bearing keyword inside + * them does, and `additionalProperties` had none. Both of them keep it as a FULL SUBSCHEMA + * (`src/types/tool.types.ts:154` and `:111`), which is how a toolkit spells a bag of attachments, so + * a file hidden there was offered to a model under both auto-upload settings and every call against + * the action failed. + * + * `additionalProperties` and `items` are unions rather than plain subschemas — the first with + * `boolean`, the second with a tuple array. The boolean arm falls out of {@link schemaNode} and the + * array arm is what the second loop's `Array.isArray` is for, so neither needs a case of its own. + * + * Wider than the vendor's predicate by `patternProperties`, `not` and the conditional trio, which + * that one skips: a file staged only under a condition is still a file this deployment cannot stage. + */ +function stagesAFile(schema: unknown): boolean { + const node = schemaNode(schema); + if (!node) return false; + if (node.file_uploadable === true) return true; + + for (const key of [ + "properties", + "patternProperties", + "$defs", + "definitions", + ]) { + const children = schemaNode(node[key]); + if (children && Object.values(children).some(stagesAFile)) return true; + } + + for (const key of [ + "anyOf", + "oneOf", + "allOf", + "items", + "additionalProperties", + "not", + "if", + "then", + "else", + ]) { + const branch = node[key]; + if ( + Array.isArray(branch) ? branch.some(stagesAFile) : stagesAFile(branch) + ) { + return true; + } + } + + return false; +} + +/** + * Every action this app publishes, in the shape a `tools/list` answer has, plus what we know about it. + * + * An action with no schema is still listed, with an open one. The vendor is the right party to reject a + * bad argument, and an action silently missing from the list reads to an administrator as an app that + * does not have it. The one exception is an action that asks for a FILE, which is dropped — see the + * criterion beside the filter below, and note that it turns on the action being uncallable rather + * than on its schema being unfamiliar. + * + * A listing that could not be read at all is a THROW rather than an empty list, for the same reason + * turned around: an empty list is what an app with no actions looks like, so answering emptily would + * report a success and strand every grant. What throws is a sentence, never a vendor object. See the + * catch below. + * + * AND SO IS A LISTING NOBODY WAS ASKED FOR, which is the same criterion applied one step earlier. + * `[]` from a `listTools` means, in `mcp.ts`, `google-drive-rest.ts` and `builtin-routines.ts` + * alike, "the vendor was asked and advertises no actions" — and `refreshTools` commits that as a + * healthy refresh. This function used to answer `[]` for a url naming no app and for a deployment + * with no client installed, neither of which involved asking anybody, and the commit deleted every + * `mcp_tools` row for the app: the recorded `effect`, `destructive` and, fatally, `version`, which + * `callTool` refuses to run without and which only a listing can put back. So the two "asked + * nobody" cases throw, and they throw SEPARATELY, because one sends an operator to this + * deployment's configuration and the other to the row's url. + * + * WHAT IS DELIBERATELY NOT REFUSED IS THE VENDOR'S OWN EMPTY PAGE, and it is worth saying why. + * `@composio/core` used to manufacture one — `getRawComposioTools` ends `if (!tools) { return []; }` + * (0.18.1, `src/models/Tools.ts:553-557`), so a response it could not read arrived here as the same + * `[]` an app with no actions would send — and that particular hazard is gone with the wrapper: + * `./composio-adapter` reads the raw client and refuses an answer that is not a page rather than + * turning it into an empty one. What remains is the honest case, a listing Composio really did + * answer with no rows, and that is answered a layer down rather than here — `store.ts`'s own + * empty-listing guard keeps every recorded action, + * its `effect`, its `destructive` and its `version` whenever an app that HAS actions lists none, + * writes the sentence saying so, and stamps no refresh. Refusing here as well would buy nothing + * that guard does not already hold, and would cost the case it is careful to allow: an app that + * genuinely advertises nothing stays recordable, rather than reading as broken for good. + */ +export async function listTools(connection: { + url: string; +}): Promise { + const toolkit = toolkitOf(connection.url); + if (!toolkit) { + throw new Error( + `${connection.url} does not name a Composio app, so nothing was asked what it offers. A row reached through this transport is one whose provenance says composio, and its url has to be composio:// followed by an app slug; correct the url on the Plugins page.`, + ); + } + if (!installed) { + /* + * A STATE, NOT A FAULT, and the sentence has to read as one. + * + * `index.ts` installs a Composio client only where `COMPOSIO_API_KEY` is set — see + * {@link useComposioClient} — so this is what every Composio refresh on a deployment without + * one answers, by design and not by accident. An operator who reads it as a crash goes looking + * for a broken vendor; what they need to know is that the connector is not configured here and + * that nothing was lost. + */ + throw new Error( + `Composio is not configured for this deployment, so nothing could be asked what ${toolkit} offers. That is the expected answer until COMPOSIO_API_KEY is configured, and the actions already recorded for this app are kept rather than cleared.`, + ); + } + + let actions: ComposioAction[]; + try { + actions = await installed.listActions(toolkit, { limit: LISTING_LIMIT }); + } catch (error) { + /* + * THROWN, NOT ANSWERED EMPTY, and with a sentence rather than the vendor's raw object. + * + * The two candidate behaviours are not equivalent. `refreshTools` records a throw in the row's + * `lastError` and leaves the tools it already holds alone; an empty answer is indistinguishable + * from an app that genuinely publishes no actions, so it would report a success and leave every + * grant pointing at a name nothing advertises. So a listing this deployment could not read must + * propagate. + * + * What propagates is a sentence. `refreshTools` puts `error.message` on the admin page, and a + * `ToolSchema` mismatch's message is the Zod issue array as JSON — an operator reading 400 + * characters of `{"code":"invalid_type","path":[...]}` learns nothing they can act on, and the + * same string was reaching a model's context. The original is kept as `cause` for a log. + */ + throw new Error(listingSentence(toolkit, error), { cause: error }); + } + + /* + * NOTHING IS READ OFF THE ANSWER UNTIL IT IS A LIST, and the check is here rather than assumed + * from the type. `ComposioActions` is this module's own projection, what satisfies it is + * `./composio-adapter` mapping an answer off the wire that no type checker here has seen, and a + * return type is not a promise about what resolves at runtime — a client that answers null, or a + * bare envelope with the array one level down, is a mistake this file will meet before a type + * checker does. + * + * Both reads below sit OUTSIDE the try that wraps the vendor's call, so before this guard + * `actions.length` propagated `null is not an object (evaluating 'actions.length')` — which is + * the string `refreshTools` writes into the row's `lastError` for an administrator to read. What + * this path owes that reader is a sentence naming the app and saying nothing was lost, so the + * shape is settled while such a sentence can still be written. + * + * THE ELEMENTS ARE CHECKED TOO, AND FOR THE SAME REASON RATHER THAN A DIFFERENT ONE. `[null]` + * reaches `action.inputParameters` in the filter below and `action.slug` in the map, both + * outside the try; it is the identical failure one level down, so it is answered here rather + * than left to produce a different unreadable message. What is checked is only that each + * element is an object — this module does not validate the vendor's schema, and an action + * missing a field it does not have is the vendor's business. + */ + if ( + !Array.isArray(actions) || + actions.some((action) => schemaNode(action) === null) + ) { + throw new Error( + `Composio did not answer with an action list for ${toolkit}: what came back was not a list of actions at all. Nothing was refreshed and the actions already recorded for this app are kept.`, + ); + } + + /* + * AN ACTION WITH NO SLUG BREAKS THE LISTING RATHER THAN BEING DROPPED FROM IT. + * + * The slug is the action's whole identity here: it becomes `name` in `mcp_tools`, which is NOT + * NULL and half the primary key, it is what a grant records, and it is the `slug` {@link callTool} + * sends back to Composio. `ToolSchema` spells it required, so an element without one is not an + * action the vendor published — and left to the map below it becomes a tool named `undefined` + * that fails the insert, taking down the refresh of an app whose other sixty actions were fine. + * + * SKIPPING IT WOULD BE THE WRONG REPAIR, and this file's own distinction between an empty answer + * and a broken one says why. Dropping the element makes this a SHORT listing, and `refreshTools` + * commits a listing as the complete truth about the app: the replace is a delete and an insert, + * so every recorded action missing from it is deleted with its `effect`, `destructive` and + * `version` — and `version` is the one no refresh can reconstruct where the vendor publishes + * none. That is the fragment committed as complete that the full-page refusal below exists to + * prevent, arriving one element at a time; and here nobody could even be told which action went + * missing, because the thing that names it is the thing that is absent. + * + * NOR IS IT THE FILE FILTER'S CASE, which drops actions and is right to. Those are well-formed + * actions the vendor published in full that this deployment cannot serve — a standing decision + * about a known action, taken the same way on every refresh. This is an answer that could not be + * read, which is the criterion the element check above already throws on, one field further in. + * + * BLANK COUNTS AS ABSENT, for the reason the version below is trimmed: `callTool` would send the + * padding to Composio as the action's name, and no `mcp_tools` row keyed on whitespace is a name + * anybody meant to grant. + */ + if ( + actions.some( + (action) => typeof action.slug !== "string" || action.slug.trim() === "", + ) + ) { + throw new Error( + `Composio's action list for ${toolkit} contained an action with no slug, which is the name this deployment would have to record it under and send back to call it. Nothing was refreshed and the actions already recorded for this app are kept rather than replaced by a listing this one could not be read from.`, + ); + } + + /* + * A SLUG LISTED TWICE IS NOT REFUSED HERE, and that is a decision rather than an omission. + * + * `mcp_tools` holds one row per name, so the collision is real — but it is already settled one + * layer down and in the other direction: `storableTools` in `./store` keys the insert by name + * and keeps the first occurrence, deliberately, so a vendor that names one action twice records + * it once under a refresh that stays healthy. Refusing here would turn that refresh into a total + * failure and strand every grant on the app, which is the loss the refusals around this one + * exist to prevent. What this function owes that de-duplication is the trimmed name the map + * below records, so two spellings of one slug collide there rather than surviving as two rows. + */ + + /* + * AN ACTION'S LABELS ARE READ, SO THEY HAVE TO BE READABLE. + * + * {@link effectOf} builds a Set out of this field, and the element check above settles only that + * the action is an object. A `tags` that is not iterable — `{}`, a number, a bag of labels keyed + * by index — throws `{} is not iterable` out of the map below, which sits OUTSIDE the try that + * wraps the vendor's call, so that string is what `refreshTools` writes into the row's + * `lastError` for an administrator to read. + * + * A STRING IS THE HALF THAT DOES NOT THROW, and it is the worse one. `new Set("readOnlyHint")` + * is that word's characters, no hint matches any of them, and a read-only action is recorded as + * a write — a classification nobody can see is wrong, on a row an administrator grants from. + * Defaulting either shape to "write" would make the same silent answer deliberate, so both are + * refused here while a sentence can still name the action. + * + * AND THE LABELS THEMSELVES ARE READ, SO THEY HAVE TO BE LABELS. This guard checked the CONTAINER + * and trusted its CONTENTS, and that is the third distinct way this one field has been found + * unheld. {@link effectOf} decides by `Set.has("destructiveHint")`, which is an identity + * comparison: no object, nested array or number in that set can ever match it, whatever it spells. + * So `[{ name: "destructiveHint" }]` and `[["destructiveHint"]]` — the two shapes a listing + * carries when a vendor changes how it spells a label — are not an unreadable field that throws. + * They are a DESTRUCTIVE action recorded as `destructive: false`, on the row that decides whether + * a Bot is stopped before it runs the action at all, and nothing about the row looks wrong. + * + * That is the string case's silent wrong answer again, one level in and failing the dangerous + * way round rather than the cautious one, so it is answered the same way and in the same breath. + * ASKED OF EVERY ELEMENT rather than of any, because a list that is mostly labels with one + * malformed entry is the shape a vendor actually sends, and it is the one a looser check passes. + */ + const oddTags = actions.find( + (action) => + action.tags !== undefined && + (!Array.isArray(action.tags) || + action.tags.some((label) => typeof label !== "string")), + ); + if (oddTags) { + throw new Error( + `Composio's action list for ${toolkit} described ${oddTags.slug.trim()}'s tags as something other than a list of labels, and those labels are the only thing that says whether an action reads or writes and whether it destroys anything. Nothing was refreshed and the actions already recorded for this app are kept rather than replaced by a listing whose effects could not be read.`, + ); + } + + /* + * AND THE VERSION BESIDE THEM, WHICH IS READ IN THE SAME MAP AND WAS HELD BY NOBODY. + * + * `action.version?.trim()` is the read, and `?.` guards null and undefined and nothing else — so a + * number, an object, a list or a boolean throws `action.version?.trim is not a function` out of a + * map that sits OUTSIDE the try wrapping the vendor's call. That engine sentence is exactly what + * `refreshTools` writes into the row's `lastError` for an administrator to read, which is the + * failure the labels guard above exists to prevent, on the field immediately next to it. + * + * REFUSED RATHER THAN READ AS NO VERSION, which is the other candidate repair and is the wrong + * one for the reason every refusal on this path is a refusal. Recording the action with no + * version makes it permanently uncallable and sends its reader to a refresh that writes the same + * unreadable field back — the loop {@link callTool}'s version refusal already names — and + * committing the listing at all is a delete and an insert that takes the `version` of every OTHER + * action on the app with it, which no later refresh reconstructs where Composio publishes none. + * + * `null` IS NOT ONE OF THESE, and that is deliberate rather than an oversight in the check. "This + * action has no version" is a real and common state with an answer already — the action is listed + * with no version key — and `null` is how JSON spells it. The `?.` in the map has always read it + * that way; refusing it here would turn a healthy refresh into a total failure for every app that + * publishes one, which is the loss this guard is written to avoid rather than to cause. + * + * Both fields are read as `unknown` for the reason {@link reportedFailure} reads its two that way: + * the types above are this module's projection and the values are the vendor's. + */ + const oddVersion = actions.find((action) => { + const version: unknown = action.version; + return ( + version !== undefined && version !== null && typeof version !== "string" + ); + }); + if (oddVersion) { + throw new Error( + `Composio's action list for ${toolkit} described ${oddVersion.slug.trim()}'s version as something other than a version string, and the version is what this deployment has to send back to call the action at all. Nothing was refreshed and the actions already recorded for this app are kept rather than replaced by a listing whose versions could not be read.`, + ); + } + + /* + * A FULL PAGE USED TO BE REFUSED HERE, AND THAT REFUSAL IS GONE BECAUSE ITS PREMISE WAS FALSE. + * + * It said that `LISTING_LIMIT` is the largest page the vendor's REST parameter allows and that + * the core SDK offers no cursor to ask for a second one, so an app with exactly that many actions + * and an app with more of them answer identically — and that committing the second deletes every + * action past the cut from `mcp_tools` under a refresh that reported success. The consequence was + * real and the premise was about the WRAPPER. `ToolListParamsSchema` names no cursor, but + * `@composio/client`'s `ToolListParams` does, and its `ToolListResponse` carries `next_cursor` + * (0.1.0-alpha.76, `resources/tools.d.ts:421-432`, `:200-204`). `./composio-adapter` reads that + * client directly and follows the cursor to the end of the listing, so what arrives here is every + * action the app publishes and a full page is just a large app. A refusal that cannot be told + * from a healthy answer is one thing; a refusal that fires ON a healthy answer is another, and + * this had become the second — the same defect, at the same ceiling, that emptied the app picker + * one file over. + * + * NOTHING ELSE MOVED WITH IT. A listing that could not be read at all is still a throw rather than + * an empty list, for the reason at the top of this function, and `store.ts`'s empty-listing guard + * still keeps every recorded action when an app that HAS actions lists none. What is no longer + * claimed is that a listing this long might be a fragment, because it cannot be: the adapter + * refuses a cursor it cannot follow rather than handing over what it had. + */ + + /* + * AN ACTION IS OFFERED ONLY IF A MODEL COULD ACTUALLY FILL IN ITS ARGUMENTS. + * + * A `file_uploadable` parameter fails that. Under the SDK's default file handling — the flag is + * `dangerouslyAllowAutoUploadDownloadFiles` and it is off unless a client asks for it + * (`src/models/Tools.ts:136`, `:242-248`) — the parameter reaches the model as the vendor's + * internal staging descriptor, `{ name, mimetype, s3key }`. An `s3key` is issued by an upload to + * Composio's bucket. Nothing in this deployment performs one, and a model has no way to obtain + * one, so the only value it can produce is invented and the vendor's staging lookup rejects the + * call. The SDK says as much itself in the warning it logs on that path (`:349-366`). + * + * WHY THIS IS NOT THE SAME AS THE SCHEMALESS ACTION ABOVE, which is deliberately still offered. + * There the vendor is the right party to reject a bad argument, and the action might well + * succeed. Here it cannot: every call is a rejection, and an advertised action that can only + * fail is worse than an absent one, because an administrator grants it, the audit trail records + * attempts against it, and the model spends turns retrying with a different invented key. + * + * ENABLING AUTO-UPLOAD WOULD NOT FIX IT EITHER, which is why the answer is not "turn the flag + * on". That flag collapses the parameter to `{ type: 'string', format: 'path' }` — a promise + * that the SDK will read a local path off this server's disk. A model naming a server-side path + * is a worse offer than one naming a bucket key, not a better one. + */ + const offered = actions.filter( + (action) => !stagesAFile(action.inputParameters), + ); + + /* + * THE FILTER MAY SHORTEN A LISTING AND MAY NOT EMPTY ONE. + * + * Dropping an action is a standing decision about an action this deployment cannot serve, taken + * the same way on every refresh, and the answer is still a listing. Dropping the last one is not + * that: what leaves here is `[]`, which means "the vendor was asked and advertises nothing" + * everywhere in this codebase, and `refreshTools` commits it as a healthy refresh — a delete and + * an insert that takes every recorded action with its `effect`, `destructive` and, fatally, its + * `version`, which no later refresh reconstructs where Composio publishes none. That is the same + * tool-and-version wipe the empty answer, the unreadable answer, the slug-less action and the + * full page above all refuse; arriving through this filter does not make it a different event. + * + * A vendor answer that was genuinely empty is left alone, because that one IS the vendor + * advertising nothing and is the sentence `refreshTools` should record. Which is also the one + * case `store.ts` settles rather than this file: its own empty-listing guard keeps what is held + * whenever an app that HAS actions recorded answers with none, and lets the empty answer commit + * where there is nothing to lose. What that guard cannot do is tell an app that listed nothing + * from an app whose every action this deployment dropped, and the sentence it writes says the + * first. So the emptying that happens HERE has to be refused HERE, where the count that makes + * it true is still in hand. + */ + if (actions.length > 0 && offered.length === 0) { + throw new Error( + `Every one of the ${actions.length} actions Composio listed for ${toolkit} asks for a file upload, which this deployment cannot stage, so there is none it can offer. Recording that would say the app advertises nothing and delete every action, effect and version already held for it, so nothing was refreshed and those are kept.`, + ); + } + + return offered.map((action) => { + const { effect, destructive } = effectOf(action.tags); + /* + * TRIMMED HERE BECAUSE IT IS TRIMMED AT THE OTHER END. {@link callTool} trims the recorded + * version and refuses an empty one, so a whitespace-only string that counted as a version + * was written to `mcp_tools` as a version this deployment believes it holds and was then + * permanently uncallable — and the refusal its caller reads names a refresh, which records + * the same blank again. Recording exactly what `callTool` will send is what closes that + * loop; a blank becomes no version, which is the state whose refusal says so truthfully. + * + * WHAT THE `?.` GUARDS IS ABSENCE AND NOT TYPE, which is why this line is no longer the only + * thing standing between the vendor's field and a `trim` that is not a function. A version + * that is neither absent nor a string is refused above, beside the labels, where a sentence + * can still name the action; here it cannot arrive. + */ + const version = action.version?.trim(); + return { + /* + * TRIMMED FOR THE REASON THE VERSION BESIDE IT IS. The guard that admitted this action + * measured `slug.trim()`, so padding was never what made it a name — but the padded string + * was what got recorded: `mcp_tools.name` is NOT NULL and half the primary key, it is what + * a grant points at, and {@link callTool} sends it back to Composio as the action's slug. + * A row keyed on " GMAIL_SEND " is a different action from the one an administrator + * granted and one Composio has never heard of. + */ + name: action.slug.trim(), + description: action.description ?? "", + inputSchema: action.inputParameters ?? {}, + effect, + destructive, + ...(version ? { version } : {}), + }; + }); +} + +/** + * The one sentence in a thrown Composio error that is worth showing anybody. + * + * WHY THIS IS A FUNCTION AND NOT AN INLINE READ. The top-level message is "Error executing the tool + * GMAIL_FETCH_EMAILS", which names nothing a reader could act on. The useful sentence — "No connected + * account found for user ID … for toolkit gmail" — is nested two levels inside `cause`, beside the + * entire HTTP response: headers, trace ids, rate-limit counters. So this reaches in for the sentence + * and takes nothing else, because the alternative is somebody's request id in a model's context and + * an audit row the size of a response dump. + * + * openbot already had this lesson from Drive, where a generic message cost a round of probing and the + * vendor's own "The caller does not have permission" named the problem immediately. + * + * Null when there is no such sentence, which leaves the caller to choose a fallback rather than + * inventing one here. That choice is not simply "the thrown message": the thrown message is often the + * placeholder above, and passing it on tells the reader nothing. See {@link unexplained}. + * + * AND THE PLACEHOLDER IS NOT A SENTENCE WHEREVER IT SITS, which is the half this function was + * missing. Both callers check {@link VENDOR_PLACEHOLDER} against the message that was THROWN and + * both prefer this answer over that check, so "Error executing the tool X" arriving nested inside + * `cause` — which is where the vendor puts it when their own gateway had nothing else to say — + * went out past a guard written for exactly that string. A reader who asked for that tool learns + * from it only that they asked; that is true at whatever depth it was found, so the judgement + * belongs here, in the function whose whole job is deciding what is worth passing on. + * + * TRIMMED ON THE WAY OUT AND NOT ONLY IN THE GUARD. The two used to disagree — the guard measured a + * trimmed string and the return handed back the padded one — so the decision the function had + * already made about the string was thrown away at the last line. What comes out is read by a person + * off an admin page, put in front of a model, and measured by {@link cap}, and in the third of those + * the padding is counted against somebody's context window. + * + * AND IT IS LOOKED FOR AT BOTH DEPTHS THE VENDOR THROWS IT AT, which is the half that made this + * function blind on five of this transport's calls. `cause.error.error.message` is the sentence + * inside a wrapper — `ComposioToolExecutionError` keeps the API error as its `cause` — but + * `@composio/core` 0.18.1 wraps only some of what it does. The auth-config and connected-account + * listings and the raw tool listing all `await this.client.*` with no try around them + * (`src/models/AuthConfigs.ts`, `src/models/ConnectedAccounts.ts`, `src/models/Tools.ts:552-555`), + * and `./composio-adapter` calls both raw deletes on the client itself — so what those five throw + * is `@composio/client`'s own `APIError`, which hangs the response body on `.error` and sets no + * `cause` at all (`@composio/client` 0.1.0-alpha.76, `src/core/error.ts:9-24`). The sentence is one + * level shallower there, and reaching past it cost the reader the vendor's own words on every one. + * + * WHAT IT COST THEM INSTEAD IS THE WHOLE REPLY. That class builds its own `message` as + * `${"${status}"} ${"${JSON.stringify(body)}"}` wherever the body has no top-level `message` + * (`src/core/error.ts:26-44`), and Composio's body puts its sentence at `error.message` — so the + * fallback to the thrown message handed `lastError`, the audit row and a model's context a status + * code followed by the entire response. See {@link VENDOR_RESPONSE_DUMP}, which refuses it. + * + * THE JUDGEMENT BELOW APPLIES AT BOTH DEPTHS, because that is why it lives in this function at all. + * A second place to read the field would otherwise be a second way past the check on the vendor's + * placeholder, on the blank string and on a `message` that is not a string — which is exactly the + * bypass this function was written to close. + */ +export function vendorSentence(error: unknown): string | null { + const thrown = schemaNode(error); + for (const carrier of [thrown, schemaNode(thrown?.cause)]) { + const body = schemaNode(carrier?.error); + const sentence = passableSentence(schemaNode(body?.error)?.message); + if (sentence !== null) return sentence; + } + return null; +} + +/** + * THE ONE DOOR. Whether a candidate string is worth showing anybody, and the trimmed string if so. + * + * EVERY READER OF A CANDIDATE SENTENCE IN THIS MODULE ASKS THROUGH HERE, and that is the whole + * point of it rather than a tidiness. There are four places a string is picked up and handed to a + * model, to an administrator reading `lastError` off the Plugins page, or to `store.ts`'s audit + * row: the two depths {@link vendorSentence} reaches, the message a failure was THROWN with, the + * `error` field of a resolved envelope, and the reason a serialization failed. Each of them used to + * make this judgement itself, and the judgement then drifted — which is not a hypothesis. A first + * extraction moved the blank and the placeholder rules into one place and left the `error` field + * reading `VENDOR_PLACEHOLDER` inline; {@link VENDOR_RESPONSE_DUMP} was then added to the extracted + * side only, so a status code followed by an entire response body was refused where it was thrown + * and passed on where it was reported. The fix for that is not a third copy of the rule. It is that + * there is nowhere left to put one. + * + * THREE REFUSALS, AND THEY ARE THE SAME REFUSAL. A blank string, "Error executing the tool X" and + * "502 {…}" are one condition wearing three shapes: the vendor emitted something where an + * explanation belongs and none of it explains anything. What each caller does about a null differs + * — one falls through to the next depth, one to this deployment's own words — and that is the part + * that belongs to the caller. What is NOT worth passing on does not vary by door, and the moment it + * is allowed to, the guard is decoration. + * + * A NON-STRING IS NOT A SENTENCE, which is why the parameter is `unknown` rather than `string`. The + * types in this file are its own projection of somebody else's JSON; a `message` that is a number, + * an object or a list reaches a reader as `1810` or `[object Object]`, and collapsing all of those + * to the blank case here is what stops each caller inventing its own `typeof`. + */ +function passableSentence(message: unknown): string | null { + const sentence = typeof message === "string" ? message.trim() : ""; + return sentence === "" || + VENDOR_PLACEHOLDER.test(sentence) || + VENDOR_RESPONSE_DUMP.test(sentence) + ? null + : sentence; +} + +/** + * The vendor's placeholder, which is the one sentence never worth passing on. + * + * "Error executing the tool GMAIL_FETCH_EMAILS" tells a reader only the name of the thing they asked + * for. Matched on its opening rather than on the whole string, because the slug varies and the + * punctuation after it has not been stable across vendor versions. + */ +export const VENDOR_PLACEHOLDER = /^error executing the tool\b/i; + +/** + * The client's other non-sentence: a status code with the whole reply stringified behind it. + * + * `APIError` builds its `message` from the body's own `message` where there is one and otherwise + * from `JSON.stringify(body)` (`@composio/client` 0.1.0-alpha.76, `src/core/error.ts:26-44`), and + * Composio's bodies put their sentence at `error.message` instead — so the second branch is the + * common one, and it is a response dump rather than an explanation. Handed on as the fallback it + * put a trace id and a validation payload on an admin page, in `store.ts`'s audit row and in a + * model's context, which is the one thing {@link vendorSentence} exists to keep out of all three. + * + * REFUSED ON THE SAME GROUNDS THE PLACEHOLDER IS, and no wider. What is matched is a three-digit + * status followed by the opening of a JSON document, because that is the shape the client builds + * and nothing a person would write; "404 status code (no body)" and a body whose own `message` came + * through — "400 Invalid auth config id" — are both sentences, and both still pass. + */ +export const VENDOR_RESPONSE_DUMP = /^\d{3} [[{]/; + +/** + * The message a failure was THROWN with, where that is worth showing, and null where it is not. + * + * Its own function because both callers ask the identical question and one of them used to ask it + * differently: a guard that grew a third refusal on one path and not the other would put the + * vendor's response dump in front of a model or an operator depending on which door they arrived + * through, which is the divergence `callTool`'s catch already had to be corrected for once. + * + * WHICH IS WHY THE JUDGEMENT ITSELF IS NOT HERE ANY MORE. This function once held all three + * refusals, and holding them is what let it drift from the other readers — the dump rule was added + * to this copy and to no other, so the escape it closed here stayed open on the envelope's `error` + * field and at both depths {@link vendorSentence} reads. All this owes its callers now is WHICH + * string is the candidate on a throw; {@link passableSentence} settles what any candidate is worth. + */ +function thrownSentence(error: unknown): string | null { + return passableSentence(error instanceof Error ? error.message : null); +} + +/** + * What to say when the vendor reported a failure and said nothing about it. + * + * A sentence naming the one thing the reader can actually do, because the alternative is echoing the + * placeholder above — and a model handed "Error executing the tool X" will either retry the identical + * call or invent a reason. The likely cause by a wide margin is a connection that has lapsed, which + * is a person's own two-click fix on the page named here. + */ +export function unexplained(toolName: string): string { + return `${toolName} failed and Composio did not say why. Check that this app is still connected on its Plugins page, then try again.`; +} + +/** + * Whether a thrown failure is the SDK's own schema refusing the vendor's answer. + * + * Duck-typed rather than `instanceof ZodError` so this file keeps no dependency on the vendor's + * package: `@composio/core` reaches it only through {@link useComposioClient}, and importing `zod` + * here would tie the transport to whichever major version the vendor happens to bundle — which is + * exactly the coupling that makes a schema mismatch possible in the first place. + * + * WHICH IS WHY THE SHAPE HAS TO BE ASKED FOR RATHER THAN THE NAME `issues`. An array under that + * name is not rare and is mostly not Zod's: a gateway's validation payload carries one, and so + * does any error somebody wrote with a list of complaints in it. Answering true for those replaced + * the one sentence saying what actually went wrong with an instruction to upgrade a package that + * is working perfectly — the vendor's own explanation, hidden by a guess about who threw. + * + * A ZOD ISSUE IS RECOGNISED BY WHAT EVERY VERSION OF ONE CARRIES: a `code` naming the failure and + * a `path` locating it. Both have been in the type since zod 3 and neither belongs to the + * hand-written lists above. An empty array is nobody's schema complaint — a parse that refused + * says why — so it is not one either. + */ +function isSchemaMismatch(error: unknown): boolean { + const shaped = error as + | { name?: unknown; issues?: unknown } + | null + | undefined; + if (shaped?.name === "ZodError") return true; + + const issues = shaped?.issues; + return ( + Array.isArray(issues) && + issues.length > 0 && + issues.every((issue) => { + const node = schemaNode(issue); + return typeof node?.code === "string" && Array.isArray(node.path); + }) + ); +} + +/** + * Why an app's action list could not be read, as one sentence an operator can act on. + * + * The schema case names the fix, because it is a vendor change rather than a misconfiguration: the + * answer arrived and this deployment's copy of their SDK would not accept it, so nothing an + * administrator can do to this row will help and upgrading the package will. + * + * THE PLACEHOLDER IS REFUSED HERE ON THE SAME GROUNDS {@link callTool} REFUSES IT, which is the + * half this function was missing. "Error executing the tool X" names only the thing the reader + * asked for; on this path they asked to refresh an app, so it is the one fact they already had. + * Falling through to the app's name at least tells them which row went wrong. + * + * AND A SENTENCE THIS DEPLOYMENT AUTHORED BEATS ANYTHING THE VENDOR SAID, which is the half it was + * missing after that. This function consulted `brokerSentence` nowhere at all, and it is the rule + * `routes.ts` follows (`brokerRefusal`, `:89-91`) and the rule {@link callTool}'s own catch was + * corrected to. `./composio-adapter`'s `askVendor` wraps the raw tool listing exactly as it wraps + * the execute, so a {@link BrokerRefusalError} arrives on this path as readily as on that one — + * and the class is the promise that makes preferring it safe: `./broker` raises one only where the + * sentence names the step that fixes the condition and is safe to show anybody who could have + * asked. A failure it cannot explain stays a plain `Error` and falls through to the vendor's own + * words below, exactly as before. + * + * WHAT IT WAS LOSING TO IS A READ ONE LEVEL SHALLOW, the same way `callTool`'s was. `vendorRefusal` + * authors only where {@link vendorSentence} of the ORIGINAL error was null, so the vendor keeps the + * last word wherever it had one; asking the same question of the WRAPPER reaches through its + * `cause` to the original and lands somewhere the adapter never judged. A remedy written for the + * exact condition was being replaced by whatever happened to sit there. + */ +function listingSentence(toolkit: string, error: unknown): string { + const authored = brokerSentence(error); + if (authored !== null) return authored; + + if (isSchemaMismatch(error)) { + return `Composio's action list for ${toolkit} did not match the shape this deployment's @composio/core accepts, so the list was not refreshed and the tools already held are untouched. That is a vendor change rather than a setting: upgrading the package is the fix.`; + } + return ( + vendorSentence(error) ?? + thrownSentence(error) ?? + `Composio did not answer with an action list for ${toolkit}.` + ); +} + +/** + * The cap every string this module puts in front of a model goes through. + * + * Its own function because BOTH ANSWERS NEED IT, and only one of them used to get it. A refusal lands + * in a model's context exactly as a result does, and a vendor's sentence is no shorter for being a + * failure — so {@link failure} capping nothing and reporting `truncated: false` was the silent + * truncation's mirror image: unbounded text, plus a field stating that nothing had been cut. + */ +function cap(text: string): { text: string; truncated: boolean } { + if (text.length <= MAX_RESULT_CHARS) return { text, truncated: false }; + return { + text: `${text.slice(0, MAX_RESULT_CHARS)}\n\n[truncated]`, + truncated: true, + }; +} + +const failure = (message: string): McpCallResult => ({ + ...cap(message), + isError: true, +}); + +/** + * The three fields a resolved answer has to carry to be the envelope at all. + * + * Named as a list because the refusal below quotes it: the sentence tells a reader that a + * `{ data, error, successful }` envelope was required, and the only way that claim stays true as + * the check changes is if the claim and the check read the same names. + */ +const ENVELOPE_FIELDS = ["data", "error", "successful"] as const; + +/** + * What is missing before a resolved value can be read as Composio's envelope, or null for one. + * + * ASKED AS "IS IT THE ENVELOPE" RATHER THAN "IS IT AN OBJECT", which is the correction, and the + * third one this guard has needed. Written as a bare `typeof` it admitted arrays; written as + * {@link schemaNode} it admitted every other object in the world. Both fixes widened the coverage + * of a question that was the wrong question: the refusal has always told the reader that the + * `{ data, error, successful }` envelope was required, and nothing anywhere was checking for one. + * So a one-level-unwrapped envelope — the action's own `data` in the envelope's place, which is + * what a client that reaches one field too far resolves — and a bare `{}` both cleared it, read + * `error` and `successful` as absent, reported nothing, and came back `isError: false` saying "The + * action returned nothing." `store.ts` wrote `mcp.call_succeeded` beside each one. A shape this + * deployment could not read reaching a model as a call that worked and found nothing is the exact + * outcome the third kind of failure exists to keep off the audit trail, and it survived two fixes + * because each of them asked for a wider class of the wrong thing. + * + * ABSENCE IS THE QUESTION HERE AND TYPE IS NOT, which is the line between this and + * {@link reportedFailure}. This one settles whether the right OBJECT arrived — whether what + * resolved is the envelope or something else entirely. What the vendor put IN each field, and + * whether it is readable, is a separate question asked once the envelope is in hand, and it is + * asked there because the answer differs per field: an unreadable `error` and an unreadable + * `successful` produce different sentences, and neither is "this is not an envelope". + * + * A FIELD PRESENT AS `undefined` COUNTS AS ABSENT, because no reader downstream can tell the two + * apart and neither can the schema: `ToolExecuteResponseSchema` spells all three REQUIRED + * (`@composio/core` 0.18.1), so a key holding nothing is as far from that shape as no key at all. + * `error` is nullable and `null` is therefore present, which is the one distinction that matters. + */ +function envelopeGap(answer: unknown): string | null { + const node = schemaNode(answer); + if (node === null) return "what came back was not one"; + + const absent = ENVELOPE_FIELDS.filter((field) => node[field] === undefined); + return absent.length === 0 + ? null + : `what came back carried no ${absent.join(" and no ")}`; +} + +/** + * The serializations that mean the action had nothing to say. + * + * `{}` is in here because `data` is a required RECORD: an action that matched nothing answers with an + * empty object, so if that did not count as nothing the branch below would be unreachable and its + * promise a fiction. `""` and `"null"` stay for a client whose projection is looser than the schema. + */ +const NOTHING = new Set(["", "null", "{}"]); + +/** + * What the model reads, capped visibly. + * + * THE ACTION'S DATA, NOT THE WHOLE ENVELOPE. `error`, `successful` and `logId` are what + * {@link callTool} reads to decide the outcome; repeating them as content spends a model's context on + * this transport's own bookkeeping and invites the model to draw its own conclusion from a field it + * should never have seen. + * + * The same cap the MCP transport applies and for the same reason: a tool result goes straight into a + * model's context, so an unbounded one is somebody else's server deciding how much of our context + * window to spend. Truncated visibly, never silently. An empty answer is stated in words rather than + * returned empty — an empty string reads as "the action had nothing to say" rather than "there is + * nothing there", and a model closes that gap from memory. + * + * CAN THROW, and is called from outside the vendor's `try` for that reason. See {@link callTool}. + */ +function resultOf(data: ComposioResult["data"] | undefined): McpCallResult { + const text: string | undefined = JSON.stringify(data ?? null, null, 2); + /* + * `JSON.stringify` ANSWERS `undefined` RATHER THAN THROWING for a value with no JSON form — a + * function, a symbol — and this field is the vendor's while the type saying it is a record is + * ours. That `undefined` went on to {@link cap}, which measures `.length`, so the engine's + * `undefined is not an object (evaluating 'text.length')` became the second half of a sentence + * this file wrote about its own failure. Thrown here instead, in words, because the caller's + * catch is what turns this into a refusal naming the action. + */ + if (text === undefined) { + throw new Error( + "its data has no JSON form at all, so there is nothing to show", + ); + } + if (NOTHING.has(text)) { + return { + text: "The action returned nothing.", + isError: false, + truncated: false, + }; + } + return { ...cap(text), isError: false }; +} + +/** + * What the vendor said about its own call, read from BOTH fields its schema requires it to send. + * + * EITHER ONE CAN REPORT A FAILURE, and reading only the flag dropped the other. `successful === + * false` is the plain case. The second is an `error` sentence arriving beside `successful: true`: + * `ToolExecuteResponseSchema` spells the two as independent required fields and correlates them + * nowhere, and `transformToolExecuteResponse` copies both straight off the wire (`@composio/core` + * 0.18.1, `src/models/Tools.ts:215-222`), so that combination is a shape the vendor's own schema + * permits. Keyed on the flag alone it was audited as `mcp.call_succeeded` and the one sentence + * saying what went wrong was shown to nobody. + * + * TAKING THE ERROR AT ITS WORD IS THE VENDOR'S OWN ARITHMETIC rather than a rule invented here: + * where the SDK has to derive the flag itself it writes `successful: !response.error` (`:1247`). It + * is also the reading this file already applies to a vendor contradicting itself — see + * {@link effectOf} on `destructiveHint` beside `readOnlyHint`. + * + * WHICH IS EQUALLY WHY THE CRITERION IS A NON-EMPTY SENTENCE. By that same line `""` is a success, + * so an empty `error` is the vendor saying nothing went wrong in the least committal way open to it. + * Whitespace is read as empty too, and that part is this file's own reading rather than the SDK's — + * it matches {@link vendorSentence}, because a blank sentence beside an explicit `successful: true` + * would otherwise become a refusal saying only that the call failed and nobody said why. + * + * AND THE FLAG IS READ FOR ITS SHAPE BEFORE IT IS READ FOR ITS VALUE, which is the half this + * function was missing for as long as the `error` beside it had it. `successful !== false` asked + * one question of a field with three answers: `"false"`, `0` and `null` are none of them `false`, + * so each one passed as a success, and a reported failure was handed to the model as content and + * written to the audit trail as `mcp.call_succeeded`. Falsiness is not the repair either — it + * answers `"false"` correctly by accident, since a non-empty string is truthy, and would still + * take `0` for a considered "no" rather than for a field nobody here can read. + * + * SO A NON-BOOLEAN IS THE THIRD KIND OF FAILURE, exactly as an unreadable `error` is, and it gets + * the wording that kind is owed: nothing was reported, so nothing can be passed on as the vendor's + * report, and what this deployment has to say is that it cannot tell whether the action ran. The + * old comment here argued that an ABSENT flag must not be read as a failure, which was right and + * is now settled one step earlier — {@link envelopeGap} refuses an answer that carries no + * `successful` at all, as not being the envelope. What is left to this function is a field that + * arrived, and a field that arrived saying something unreadable is the vendor speaking, not the + * vendor silent. + * + * THE VENDOR'S OWN SENTENCE STILL COMES FIRST, which is why the shape check sits below the + * sentence rather than above it. `{ error: "Gmail rejected the query", successful: "false" }` is a + * failure whichever way the flag is read, and the reader is better served by what Composio said + * about it than by this file's remark that the flag was malformed. The check is reached only where + * the alternative would be calling the answer a success. + * + * AN `error` THAT IS NOT A SENTENCE IS NOT SILENCE, and reading the field through a `typeof` that + * collapsed everything else to `""` made the two indistinguishable. `{ message: … }`, or the list + * of issues a gateway puts there, arriving beside `successful: true` came out of here as null: the + * call was handed to the model as content, `store.ts` audited `mcp.call_succeeded`, and the field + * the vendor put its complaint in was shown to nobody. Which branch it takes turns on the flag, + * because the two say different things. Beside `successful: false` the vendor has already reported + * the failure and only its reason is unreadable, which is what {@link unexplained} is for. Beside + * anything else nothing here knows whether the action ran at all — the third kind of failure + * {@link callTool} names, ours rather than the vendor's, and so worded in our own words. + * + * Null when there is nothing to report, so the caller can tell "succeeded" from "failed silently". + */ +function reportedFailure( + answer: ComposioResult, + toolName: string, +): string | null { + // Both fields are read as `unknown` because the types are this module's projection and the values + // are the vendor's: `ToolExecuteResponseSchema` spells `error` a nullable string and `successful` + // a boolean, and a field that is neither is exactly what the two shape checks below are for. + // Neither can be absent — {@link envelopeGap} settled that before this was called. + const reported: unknown = answer.error; + const outcome: unknown = answer.successful; + + if (reported !== null && typeof reported !== "string") { + return outcome === false + ? unexplained(toolName) + : `${toolName} was sent to Composio and Composio answered, but this deployment could not read what it said about the call: the answer's error was neither a sentence nor null, which is all Composio's own schema permits it to be, so nothing here can tell whether the action ran.`; + } + + /* + * THE SAME DOOR THE THROWN MESSAGE GOES THROUGH, which is the correction, and it is the one this + * whole extraction was made for. This branch tested {@link VENDOR_PLACEHOLDER} inline while + * {@link thrownSentence} had grown {@link VENDOR_RESPONSE_DUMP} beside it — so one condition, an + * unreadable non-explanation in the `error` field, was answered two different ways depending on + * whether Composio threw it or reported it in a 200, and on this side a status code followed by + * the whole response body went to the model as the vendor's report and into `store.ts`'s audit + * row beside it. + * + * THE PRESENCE TEST STAYS SEPARATE FROM THE JUDGEMENT, because the two answer different + * questions and only one of them is the door's. A blank `error` means the vendor reported NO + * failure and the flag below decides the outcome; a non-blank one this deployment will not pass + * on means the vendor reported a failure it cannot explain, which is exactly {@link unexplained}. + * Collapsing both to null here would turn the second into a success. + */ + const sentence = reported === null ? "" : reported.trim(); + if (sentence !== "") { + return passableSentence(sentence) ?? unexplained(toolName); + } + + if (typeof outcome !== "boolean") { + return `${toolName} was sent to Composio and Composio answered, but this deployment could not read whether the call worked: the answer's successful was neither true nor false, which is all Composio's own schema permits it to be, so nothing here can tell whether the action ran.`; + } + + return outcome === false ? unexplained(toolName) : null; +} + +/** + * Call one action, in the account of the person this run belongs to. + * + * `args` is passed through with only the reserved version key removed, and is never read for an + * identity. See the module comment: that is the property, and it holds because there is no line here + * that could break it. + * + * A failure comes back as a result rather than a throw, matching `builtin-routines`. The model is + * mid-run with a person waiting; an exception ends the turn with nothing said, and the refusal is in + * the audit trail either way. + * + * THE APP THIS CALL RUNS AGAINST IS THE ONE THE URL NAMES RIGHT NOW, and it goes out WITH the call + * rather than being checked beside it. `toolkitOf`'s answer used to be validated and then dropped, + * which left the brokered gate and the vendor's call resting on two different facts — the app the + * url names today, and the app whose listing recorded the slug. See {@link ComposioActions.execute} + * for why the pair has to travel together and what an implementation owes it. + * + * THREE KINDS OF FAILURE, all of them `isError: true` and each with its own sentence, because + * `store.ts` records that sentence beside the audit row: this transport refused before dialling, the + * vendor reported a failure — by throwing, or in the `successful` field of a 200 answer — or the + * vendor answered and this deployment could not read what it said. Only the last of those is ours, + * and it must not arrive wearing the vendor's words. + */ +export async function callTool( + connection: { url: string; actorId?: string }, + toolName: string, + args: Record, +): Promise { + const userId = connection.actorId?.trim(); + if (!userId) { + return failure( + "This action runs in the account of the person asking, and this run is not attributed to anybody.", + ); + } + + const toolkit = toolkitOf(connection.url); + if (!toolkit) { + return failure(`${connection.url} does not name a Composio app.`); + } + if (!installed) { + return failure( + "Composio is not configured for this deployment, so this action cannot be called.", + ); + } + + const { [VERSION_ARG]: rawVersion, ...rest } = args; + const version = typeof rawVersion === "string" ? rawVersion.trim() : ""; + if (!version) { + /* + * Refused rather than guessed. Composio will not execute an action without a specific version and + * rejects `latest`, so there is no default to fall back on — and a version invented here would be + * a call against some other revision of the action, whose arguments and behaviour are not the ones + * that were listed, classified and granted. + * + * THE REMEDY IS CONDITIONAL ON THE VENDOR, and this sentence used to state it as certain. + * "Refresh this app's tools and try again" is right for one of the two causes — a list recorded + * before the version column existed — and wrong for the other. Where Composio published no + * version for the action, {@link listTools} records none, `store.ts` writes `tool.version ?? + * null`, and the next refresh writes the same null back: the reader presses the button, is told + * nothing changed, and presses it again. So the sentence names the refresh and names the + * condition under which it helps, which is the part nobody in this deployment controls. + */ + return failure( + `${toolName} has no recorded version, so it cannot be called: Composio requires a specific one and rejects "latest", so there is nothing to fall back on. Refreshing this app's tools on its Plugins page recovers it only if Composio publishes a version for this action. Where Composio publishes none, no refresh will make it callable.`, + ); + } + + /* + * THE VENDOR'S TRY HOLDS THE VENDOR'S CALL AND NOTHING ELSE. + * + * `resultOf` used to be invoked inside it, so a `JSON.stringify` throw of ours — a circular + * reference, a BigInt, a RangeError on something enormous — was reported as the action having + * failed after it ran. Those are two different events: in one the vendor refused, in the other the + * vendor did its part and this deployment could not read the answer. The audit trail has to be able + * to tell them apart, and it cannot if both arrive wearing the vendor's words. + */ + let answer: ComposioResult; + try { + answer = await installed.execute( + { toolkit, slug: toolName, userId, version }, + rest, + ); + } catch (error) { + /* + * A SENTENCE THIS DEPLOYMENT AUTHORED BEATS ANYTHING THE VENDOR SAID, and the order was + * inverted here against the one `routes.ts` uses on the identical class of error. + * + * `brokerRefusal` in that file reads `brokerSentence` first and falls back to `vendorSentence` + * (`routes.ts:89-91`); this catch read `vendorSentence` first and reached `error.message` only + * where that found nothing. Both see the same throws — `./composio-adapter`'s `askVendor` + * raises a {@link BrokerRefusalError} out of the execute path as readily as out of a listing — + * so one vendor condition was being answered with two different sentences depending on which + * door the reader came through, and on this door the authored one lost. + * + * WHAT IT LOST TO IS WORSE THAN A TIE. `vendorRefusal` authors a refusal only where + * `vendorSentence(error)` was null — that is its documented limit, so the vendor gets the last + * word wherever it had one. Reading `vendorSentence` again on the WRAPPER is therefore not + * reading the same thing twice: the wrapper's `cause` is the original error, so the reach for + * `cause.error.error.message` lands one level shallower than it did on the original and can + * come back with a string the adapter had already judged not to be the vendor's explanation. + * A remedy written for the exact condition — "disconnect the account they already hold", "a + * dated version is recorded when an app's actions are listed" — was being replaced by whatever + * that shallower read happened to find. + * + * THE CLASS IS THE PROMISE, which is what makes preferring it safe. `./broker` raises one only + * where the sentence names the step that fixes it and is safe to show anybody who could have + * made the request; a failure it cannot explain stays a plain `Error` and falls through to the + * vendor's own words below, exactly as before. + */ + const authored = brokerSentence(error); + if (authored !== null) return failure(authored); + + /* + * THE SDK'S OWN PARSE THROWS THROUGH HERE, and its message is not a sentence. + * + * `./composio-adapter` resolves the tool before running it — `getRawComposioToolBySlug`, which + * runs `ToolSchema.parse` — and that happens outside the SDK's own try, so a vendor answer + * their schema rejects arrives as a raw `ZodError` whose `message` is the issue array as JSON. + * Handed on, 400 characters of `{"code":"invalid_type","path":[…]}` went into a model's + * context and into `store.ts`'s audit row, wearing the vendor's words for what is a version + * skew between this deployment and their package. + * + * The listing path has refused that string since it was written, and for the same reason it + * says here: nothing an administrator does to this connection will help, and upgrading the + * package will. There is no `cause` to hang the original on either, because a failure leaves + * this function as a RESULT rather than as a throw — so the sentence is the whole of what the + * reader and the audit row get, which is why it names the one step that changes anything. + * + * WHAT IT DOES NOT CLAIM IS THAT NOTHING RAN. The resolve is the likely thrower and it happens + * first, but the SDK parses the execute response through a schema of its own, so the same + * `ZodError` can arrive from after the action ran. Which of the two it was is exactly what + * this deployment cannot read, and a refusal must not settle it by guessing. + */ + if (isSchemaMismatch(error)) { + return failure( + `Composio was asked about ${toolName} and this deployment's @composio/core would not accept what came back: it did not match the shape that package parses with, so nothing here can say whether the action ran. That is a vendor change rather than a setting on this connection — upgrading the package is the fix.`, + ); + } + // The vendor's own sentence when there is one, because a generic message costs a diagnosis. + return failure( + vendorSentence(error) ?? thrownSentence(error) ?? unexplained(toolName), + ); + } + + /* + * NOTHING IS READ OFF THE ANSWER UNTIL IT IS AN ENVELOPE, and here the reason is stronger than + * the listing's. {@link callTool} is documented as never throwing and `store.ts` relies on that, + * so a shape this module did not expect has to become a refusal rather than an exception. + * `reportedFailure` reads `answer.successful` and was called from outside every try, so a client + * resolving null threw a `TypeError` straight out of here — ending a person's turn mid-run with + * nothing said and nothing audited, which is exactly what returning a result instead of throwing + * exists to prevent. + * + * A vendor fault it is not, so it does not get the vendor's words. This is the third kind of + * failure the comment above names: Composio answered and this deployment could not read it. + * + * THE QUESTION IS ASKED THROUGH {@link envelopeGap}, which is the one that names what is wrong + * as well as that something is. Two earlier versions of this guard asked only whether an object + * had arrived and let every object through, including the two this refusal was written about; + * see that function for why the shape of the question was the defect rather than its reach. + */ + const gap = envelopeGap(answer); + if (gap !== null) { + return failure( + `${toolName} was sent to Composio and its client resolved, but this deployment could not read what it resolved with: Composio's own schema requires a { data, error, successful } envelope and ${gap}, so nothing here can tell whether the action ran. That is a change in what the vendor or this deployment's @composio/core answers with rather than a setting on this connection — upgrading the package is the fix.`, + ); + } + + const reported = reportedFailure(answer, toolName); + if (reported !== null) return failure(reported); + + try { + return resultOf(answer.data); + } catch (error) { + /* + * THE LAST PATH THAT REACHED A MODEL WITHOUT PASSING THE DOOR. This quoted a raw `error.message` + * into the sentence `store.ts` records, asking nothing of it — the one refusal in this module + * that read a candidate string and judged it nowhere. + * + * WHAT IT CAN BE HANDED IS NOT ONLY OURS. `resultOf`'s own throw is this file's sentence and + * the engine's circular-reference and length errors are the engine's, but `JSON.stringify` + * calls `toJSON` on whatever the vendor put in `data`, so a throw out of there arrives wearing + * whatever the vendor's object felt like throwing — the same class of string refused four lines + * above, arriving through the one reader that was not asking. + * + * A REASON IT WILL NOT QUOTE STILL LEAVES A FINISHED SENTENCE, which is why the fallback is a + * clause rather than nothing. What this refusal has to carry is which of the two events it was: + * the action ran, Composio answered, and it is this deployment that could not read the answer. + * That claim is ours and holds whether or not there is a reason worth repeating. + * + * `String(error)` KEEPS THE NON-`Error` THROW READABLE, which {@link thrownSentence} does not + * do and should not: there the alternative is the vendor's own sentence one level in, and here + * there is no other candidate at all. + */ + const why = + passableSentence( + error instanceof Error ? error.message : String(error), + ) ?? "the reason it failed with is not one this deployment will pass on"; + return failure( + `${toolName} ran and Composio answered, but this deployment could not turn that answer into text: ${why}`, + ); + } +} diff --git a/server/src/plugins/google-drive-rest.ts b/server/src/plugins/google-drive-rest.ts index 99b49bb35..b7a5865b9 100644 --- a/server/src/plugins/google-drive-rest.ts +++ b/server/src/plugins/google-drive-rest.ts @@ -15,8 +15,9 @@ import { MAX_RESULT_CHARS, type McpCallResult, type McpTool } from "./mcp"; * WHAT MAKES IT SWAPPABLE. This module implements the interface {@link ./mcp} already had — * `listTools` and `callTool`, same shapes — rather than inventing one for itself. MCP is therefore * not the default with an exception carved out of it; both are implementations of the same contract, - * chosen per catalogue entry by {@link ./transport}. Going back to the MCP server when the preview - * opens is one field on one entry, with nothing else in the system aware it changed. + * chosen per catalogue entry by {@link ./access} and looked up in {@link ./transport}. Going back to + * the MCP server when the preview opens is one field on one entry, with nothing else in the system + * aware it changed. * * The TOOL NAMES are deliberately the ones Google's MCP server advertises, character for character. * A grant is stored as `google-drive/search_files`, so keeping the names identical means every grant diff --git a/server/src/plugins/mcp.ts b/server/src/plugins/mcp.ts index 23e5599d7..f53fb16a7 100644 --- a/server/src/plugins/mcp.ts +++ b/server/src/plugins/mcp.ts @@ -1,5 +1,6 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; +import type { ToolAnnotations } from "@modelcontextprotocol/sdk/types.js"; /** * The only place in this deployment that speaks MCP to somebody else's server. @@ -85,6 +86,33 @@ export type McpTool = { inputSchema: Record; }; +/** + * A tool as a transport listed it, including anything that transport happens to know about it. + * + * Three optional fields rather than a separate type per transport, so `refreshTools` reads + * `tool.effect` with no cast and no `"effect" in tool` sniffing. Optional because a transport may + * know none of it for a given tool, and a field always left undefined would be an invitation to + * read it as meaning something. + * + * AN MCP SERVER CAN PUBLISH AN EFFECT, and this docblock used to say it could not. That sentence + * was not a stale comment, it was load bearing: the argument that surfacing a recorded effect could + * not disturb any existing curated read rested on MCP listings never carrying one, which was true + * only because {@link listTools} was discarding `annotations`. The specification defines + * `annotations.destructiveHint`, servers publish it, and a tool a vendor declared destructive was + * classifying as a read for any curated entry whose hand-written `writeTools` happened to omit the + * name. Version is the field MCP genuinely has no concept of; effect and destructive are not. + * + * `McpTool` stays exactly what a `tools/list` answer contains, because that is what it is for. + */ +export type ListedTool = McpTool & { + /** What the vendor said this action does, when it said anything. */ + effect?: "read" | "write"; + /** Whether the vendor marked it as destroying something. */ + destructive?: boolean; + /** The vendor's version string, when calling the action requires one. */ + version?: string; +}; + export class McpServerError extends Error { constructor(message: string) { super(message); @@ -255,8 +283,43 @@ async function withClient( */ export const listNeedsCredential = true; +/** + * ONLY THE HINT THAT NARROWS IS BELIEVED, and the omission of the other one is the decision here. + * + * The SDK declares four hints on `annotations` — `readOnlyHint`, `destructiveHint`, + * `idempotentHint`, `openWorldHint` — and warns in the same place that a client should never make + * tool use decisions from annotations a server it does not trust supplied. That warning is the + * whole design of this function. `destructiveHint` can only ever move an action from read to write, + * so a server that lies with it can restrict itself and nothing else. `readOnlyHint` moves an + * action the other way, and `classifyTool` exists to make sure nothing but review can do that. + * + * WHAT HONOURING `readOnlyHint` WOULD ACTUALLY BUY, which is the reason withholding it costs + * nothing. For a curated vendor it changes no answer: an advertised name absent from the reviewed + * `writeTools` already classifies as a read, so recording `read` for it lands on the same result by + * a worse route. For a name the reviewed list DOES hold, `classifyTool` consults review first and + * ignores the column, so the hint would be discarded anyway. The single case where it would change + * an answer is a server an administrator added by URL, which has no reviewed list behind it and + * whose every tool is a write for exactly that reason — and there, believing it means letting an + * arbitrary server declare its own tools harmless and be believed. Zero accuracy gained, one + * fail-open introduced, so it is not read at all. + * + * `destructiveHint` is taken on presence of `true` only, never inverted. The specification gives it + * a default of true when a tool is not read-only, and applying that default would reclassify every + * unannotated action of every MCP vendor as a write — correct by the letter and a mass revocation + * of grants people already hold. An absent hint stays absent, which leaves the reviewed list + * deciding exactly as it did before, and only an explicit declaration narrows anything. + * + * A server that sets both hints is contradicting itself, and is read as destructive. The + * specification says `destructiveHint` is meaningless while `readOnlyHint` is true, but resolving + * an incoherent listing towards the permissive reading is the one direction that could hurt. + */ +function declaredEffect(annotations: ToolAnnotations | undefined) { + if (annotations?.destructiveHint !== true) return {}; + return { effect: "write", destructive: true } as const; +} + /** What this server says it offers, right now. */ -export async function listTools(connection: Connection): Promise { +export async function listTools(connection: Connection): Promise { return withClient(connection, async (client) => { const result = await client.listTools(undefined, { timeout: LIST_TIMEOUT_MS, @@ -265,6 +328,7 @@ export async function listTools(connection: Connection): Promise { name: tool.name, description: tool.description ?? "", inputSchema: (tool.inputSchema ?? {}) as Record, + ...declaredEffect(tool.annotations), })); }); } diff --git a/server/src/plugins/routes.ts b/server/src/plugins/routes.ts index 8fdc88535..8509898af 100644 --- a/server/src/plugins/routes.ts +++ b/server/src/plugins/routes.ts @@ -3,9 +3,20 @@ import { Hono } from "hono"; import type { BotAccessCheck } from "../agents/profile-policy"; import type { AppVariables } from "../auth/guards"; import { requireAdmin } from "../auth/guards"; +import { + type BrokerApp, + type BrokerField, + BrokerUnconfiguredError, + brokerReturnUrl, + brokerSentence, + type ComposioBroker, + isFieldScheme, +} from "./broker"; import { CATALOGUE, catalogueEntry } from "./catalogue"; +import { toolkitOf, vendorSentence } from "./composio"; import { authorizationUrlFor, + type ConnectOrigin, challengeFor, connectedAccountsUrlFor, createVerifier, @@ -17,6 +28,8 @@ import { import { CatalogueEntryUnknownError, CustomServerRefusedError, + deploymentFaultSentence, + isDeploymentFault, type OAuthClient, type PluginKind, PluginRefusedError, @@ -36,6 +49,61 @@ import { */ export type ConnectingPersonCheck = (userId: string) => Promise; +/** + * What somebody is told when the broker itself failed, and what is deliberately kept out of it. + * + * EVERY BROKERED ROUTE NEEDS THIS AND NONE OF THEM USED TO HAVE IT. A listing, a connect, a confirm + * and a disconnect all end in a call to another company's API, and an unhandled rejection out of any + * of them is a bare 500 with the vendor's thrown object on this deployment's console. That object is + * the whole HTTP response — headers, trace ids, rate-limit counters — and the person reading the 500 + * gets none of it and no sentence either. A wrong `COMPOSIO_API_KEY` is the first failure a new + * operator meets, and it is the one this used to answer worst. + * + * `vendorSentence` IS THE SAME ONE THE TRANSPORT USES, for the same reason it exists there: the + * useful sentence — "Invalid API key", "No connected account found for user …" — is nested two + * levels inside `cause` beside everything that must not be shown, so this reaches in for that one + * string and takes nothing else. Null from it is a failure this deployment cannot explain, and the + * caller's own generic sentence is what a reader gets instead of the vendor's placeholder. + * + * NOTHING FROM THE REQUEST OR THE ANSWER TRAVELS WITH IT. Not the API key, which never leaves the + * adapter; not a connect link, which is a bearer capability handed to one browser; and not the + * thrown object, whether by spreading it, stringifying it or logging it. + * + * 502 RATHER THAN 500, because nothing here broke: this deployment asked a third party and the third + * party did not answer usefully, which is the same reading the dynamic-registration failure below + * already gives. + * + * `./broker`'s `BrokerRefusalError` IS THE EXCEPTION, AND IT IS A WIDER ONE THAN IT WAS. It used to + * be the one no-key state; it is now every refusal the broker layer AUTHORED — no key, an app whose + * authorization config this deployment never created or cannot use, a consent the vendor answered + * with nowhere to send anybody, a catalogue too large to be sure of. All of them share the property + * that made the first one special: Composio answered, this deployment decided, and the sentence + * already names the step that fixes it. So the message is passed through and the status stays 503, + * which reads correctly for every one of them — the brokered surface is unavailable for this app + * until somebody changes something here, rather than unavailable because a third party is down. The + * generic sentence below is for failures this deployment genuinely cannot explain, and answering one + * of these with it would tell an administrator to check a key that is fine. + */ +function brokerRefusal( + error: unknown, + generic: string, +): { error: string; status: 502 | 503 } { + const authored = brokerSentence(error); + if (authored) return { error: authored, status: 503 }; + return { error: vendorSentence(error) ?? generic, status: 502 }; +} + +/** + * What a reader is told when Composio would not answer with its directory. + * + * One constant because two routes make that call — browsing the catalogue and enabling an app out of + * it — and a reader meeting the same failure through two doors should not meet two sentences. It + * names the setting rather than quoting it: a key that was pasted with a space in it, or one for + * another project, is the likeliest reason Composio will not talk to this deployment at all. + */ +const DIRECTORY_UNAVAILABLE = + "Composio would not answer with its app directory, and said nothing about why. Check that COMPOSIO_API_KEY is this project's key, and check Composio's status if it persists."; + /** * The Plugins surface: what this deployment has added, and which Bots may use it. * @@ -104,6 +172,23 @@ export function createPluginRoutes( */ appUrl: string | undefined; }, + /** + * The broker this deployment talks to when an app is connected for somebody rather than + * registered by an administrator. + * + * Its own parameter rather than a field on `connect`, because nothing on that object applies + * here. `connect` is the OAuth consent flow this deployment runs itself: the key its state is + * sealed with, the redirect URI a vendor sends people back to, the access check the sessionless + * callback asks. A brokered app uses none of it — the vendor holds the consent, so there is no + * state to seal, no callback to land here and no redirect URI to publish. Folding it in would put + * a field on an object whose every other field is about a flow it never enters. + * + * Optional, and last for the same reason `connect` is: a deployment with no Composio API key + * configured simply has no broker, and the surface says so rather than pretending one exists. The + * trap `connect` documents applies here with one more argument in it — every parameter from that + * position on is optional, so a misplaced one typechecks and quietly does nothing. + */ + composio?: { broker: ComposioBroker }, ) { const routes = new Hono<{ Variables: AppVariables }>(); @@ -162,6 +247,15 @@ export function createPluginRoutes( * every call was refused before it reached the boundary. */ botsMayCallBack: connect?.botsMayCallBack === true, + /* + * Whether this deployment has a broker at all, so the page knows whether brokered apps are + * on offer. + * + * A boolean about configuration, never the key. The API key that builds the broker is a + * deployment credential and the Plugins page is reachable by any signed-in person; what the + * screen needs is whether to draw the app directory, which is a yes or a no. + */ + composioConfigured: Boolean(composio), servers: await store.listServers(), // Scoped: the deployment's skills plus this person's own. An administrator sees them all. skills: await store.listSkills(skillActor(context)), @@ -211,6 +305,23 @@ export function createPluginRoutes( ) { return context.json({ error: error.message }, 400); } + /* + * The same mapping the refresh route makes, on the routes that call the same method. + * + * CRITERION. Every admin route whose store call can reach a fault on the + * `isDeploymentFault` shelf answers with the sentence rather than leaving it to the default + * handler. + * + * REASON. Adding a server REFRESHES it before answering — deliberately, so a bad credential + * is reported now rather than the first time a Bot uses it — so every fault `refreshTools` + * raises arrives here too, and a vendor listing one action twice or a query of ours failing + * is exactly that. Mapped on one route and not on its siblings, the same fault is a named + * sentence or "That did not work" depending on which button was pressed, which is the shape + * that made this class hard to see the first time. + */ + if (isDeploymentFault(error)) { + return context.json({ error: deploymentFaultSentence(error) }, 409); + } throw error; } }); @@ -255,6 +366,11 @@ export function createPluginRoutes( ) { return context.json({ error: error.message }, 400); } + // As on the curated add above, and for the same reason: this path refreshes before it + // answers. + if (isDeploymentFault(error)) { + return context.json({ error: deploymentFaultSentence(error) }, 409); + } throw error; } }); @@ -299,6 +415,11 @@ export function createPluginRoutes( ) { return context.json({ error: error.message }, 400); } + // Registering a client resolves the row first, so a row this deployment cannot say how to + // reach refuses here as well. + if (isDeploymentFault(error)) { + return context.json({ error: deploymentFaultSentence(error) }, 409); + } throw error; } }); @@ -330,10 +451,264 @@ export function createPluginRoutes( if (error instanceof CatalogueEntryUnknownError) { return context.json({ error: error.message }, 404); } + /* + * The one audience the sentence was written for, and the only route that may show it. + * + * CRITERION. A contradiction between this deployment's own columns comes back to an + * administrator as itself: a body, naming the row and what to do about it. + * + * REASON. Unmapped, it reached the framework's default handler — a 500 with no JSON at all, + * which the admin page reads as "That did not work", the fallback it uses when a response + * carries no message. So the one refusal that names exactly which row is wrong and how to + * correct it was the one an operator could not see, while the same sentence WAS reaching a + * model on the tool-call path. This route is `requireAdmin`, which is what makes showing it + * here safe and showing it anywhere else not. + * + * 409 rather than 500: nothing broke, and nothing about the request was malformed. Two rows + * of ours disagree, and the request cannot be answered until one of them changes — which is + * what the sentence tells the reader to go and do. + */ + if (isDeploymentFault(error)) { + // `deploymentFaultSentence` rather than `error.message`: the shelf now includes a query + // this database refused, and that one's message is the statement and every value bound to + // it. An administrator is entitled to the reason, not to the dump. + return context.json({ error: deploymentFaultSentence(error) }, 409); + } throw error; } }); + /** + * The broker's catalogue, as an administrator chooses an app out of it. + * + * THE SEARCH IS OURS, AND HAS TO BE. `@composio/core`'s toolkit listing forwards category, + * managed_by, sort_by, cursor and limit, and takes no search term at all — a term handed to it is + * dropped without a word, and what comes back is an unfiltered first page that looks exactly like + * a result. So the whole directory is read and filtered in this process, over the three fields an + * administrator would actually be typing at: the slug, the name and the description. A few + * hundred rows is a list, not a query. + * + * NO BROKER IS A 503 NAMING THE SETTING, not an empty list. An empty directory and an absent one + * are different facts: the first says Composio has nothing to offer, the second says nobody was + * asked. Answered as `{ apps: [] }`, a deployment with no key draws "no apps available" over a + * remedy that is one environment variable long, which is why + * {@link BrokerUnconfiguredError}'s own message is what is sent rather than a sentence written + * here. + * + * `enabled` comes off `toolkitOf(url)` and never off the row's id. The url is where the + * transport reads which app a call is against, so it is the only reading that decides anything; + * the id names the row — `composio-linear` — and reading one as the other would quietly work + * until somebody renamed a row. `serverUrls` hands over the urls and nothing else, which is that + * property made structural: there is no id here to read by mistake. + */ + routes.get("/composio/apps", requireUser, async (context) => { + /* + * INSIDE THE HANDLER, and the response returned. `requireAdmin` is a function that answers a + * response, not Hono middleware: put in the middleware position it typechecks against Hono's + * variadic signature, runs, and gates nothing at all, because nobody reads what it returned. + */ + const forbidden = requireAdmin(context); + if (forbidden) return forbidden; + + if (!composio) { + return context.json( + { error: new BrokerUnconfiguredError().message }, + 503, + ); + } + + let directory: BrokerApp[]; + try { + directory = await composio.broker.listApps(); + } catch (error) { + // The vendor's own sentence where there is one, because "invalid api key" is the diagnosis + // and a 500 is not. See {@link brokerRefusal} for what is kept out of the answer. + const refusal = brokerRefusal(error, DIRECTORY_UNAVAILABLE); + return context.json({ error: refusal.error }, refusal.status); + } + /* + * AN APP THAT CANNOT BE CONNECTED IS NOT AN APP TO OFFER. Every kind but this one ends at a + * person with a working account; `unsupported` ends at an administrator pressing Add and + * meeting Composio's refusal, because the OAuth client it wants is one this deployment has + * nowhere to hold. Hidden here rather than at the vendor: the filter is a fact about what this + * deployment can drive, not about what Composio publishes. + */ + const connectable = directory.filter( + (candidate) => candidate.connection.kind !== "unsupported", + ); + const term = (context.req.query("q") ?? "").trim().toLowerCase(); + const matched = term + ? connectable.filter((app) => + [app.slug, app.name, app.description].some((field) => + field.toLowerCase().includes(term), + ), + ) + : connectable; + + const enabled = new Set( + (await store.serverUrls()) + .map((url) => toolkitOf(url)) + .filter((toolkit): toolkit is string => toolkit !== null), + ); + return context.json({ + apps: matched.map((app) => ({ ...app, enabled: enabled.has(app.slug) })), + }); + }); + + /** + * Enable one app of that catalogue, which is a third way for a server to arrive. + * + * THE SLUG VALIDATION IS THE WHOLE ROUTE. `addBrokeredApp` composes `composio://`, and that + * url is what every future call for the app is resolved against — so a slug the directory never + * answered with is a row pointing at an app that does not exist: added, grantable, enabled on a + * Bot, and dead at the first call, with nothing on the page saying so. The live directory is what + * it is checked against rather than a pattern, because the question is not whether the text is + * well formed but whether Composio has such an app right now. + * + * The title comes off the directory entry too. The caller chose an app; they did not choose a + * name for it. + */ + routes.post("/composio/apps", requireUser, async (context) => { + const forbidden = requireAdmin(context); + if (forbidden) return forbidden; + + if (!composio) { + return context.json( + { error: new BrokerUnconfiguredError().message }, + 503, + ); + } + + const body = (await context.req.json().catch(() => null)) as { + slug?: string; + } | null; + const slug = body?.slug?.trim(); + let directory: BrokerApp[] = []; + if (slug) { + try { + directory = await 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, DIRECTORY_UNAVAILABLE); + 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 Composio lists for this deployment.` + : "An app is required.", + }, + 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 context.json({ error: app.connection.reason }, 503); + } + + try { + const server = await store.addBrokeredApp({ + slug: app.slug, + title: app.name, + 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. + if (isDeploymentFault(error)) { + return context.json({ error: deploymentFaultSentence(error) }, 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 stringified, + * never spread, logged as an object or reached into. No key, no vendor response, no request + * body. + */ + 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 Composio put a sentence to. The administrator was answered 502 with the generic sentence.", + error: String(error), + }), + ); + } + + const refusal = brokerRefusal( + error, + `${app.name} could not be enabled, and Composio said nothing about why. Try again, and check this deployment's Composio key if it persists.`, + ); + return context.json({ error: refusal.error }, refusal.status); + } + }); + /** * Where a person's own connections are, and how to start a new one. * @@ -341,7 +716,55 @@ export function createPluginRoutes( * everybody connects their own account. Somebody can only ever see or start their own. */ routes.get("/connections", requireUser, async (context) => { - const connections = await store.connectionsFor(context.var.actor.id); + /* + * BOTH TABLES, ONE LIST, because the person asking has one question. + * + * "Am I connected to this?" is the same question whether the grant is a refresh token in this + * deployment's vault or an account Composio holds on our behalf. Which table a connection lives + * in is a fact about how the vendor is reached — the transport, the credential, who keeps the + * secret — and none of that is something a settings page should have to know in order to draw a + * word beside a row. Answering out of `connectionsFor` alone left the brokered half invisible, + * so the page could only either say "Not connected" over a live account or say nothing at all, + * and it chose to say nothing. + * + * CONCATENATED WITHOUT REWRITING, because the fields a settings page draws from — the server + * id, the scope, the date — line up across the two reads, and one row template can draw either + * kind. What does not line up is what `brokeredConnectionsFor` adds — `verified`, `verifiedAt`, + * `probe` and `checkable` — so the list that leaves here is not uniform. Those travel because a + * brokered row is the only one with anything to re-check: this deployment holds no secret for + * it, only a note that Composio said yes, and that note can drift when somebody ends the + * connection in Composio's own dashboard. `probe` rides with the first two because the flag + * cannot be read alone — three situations share one `verified: false`, and which action the + * check actually SPENT is what separates them on a page that has done nothing but load. + * + * `probe` AND `checkable` ARE TWO ANSWERS AND NOT ONE SENT TWICE, and a caller that treats them + * as interchangeable breaks the page in one of two opposite ways. `probe` is a record of the + * check that was made; `checkable` is whether the app has anything to check with NOW. They + * agreed while the first was derived, and an administrator's press of Refresh moved what the + * app publishes without touching what the check spent — so the page accused a key nobody had + * tried. Recording the first fixed that and deadlocked the other half: a key nothing was spent + * on reads null for good, and the button that is the only way to ever spend one was gated on + * that null. So the sentence is drawn off `probe` and the button off `checkable`; see + * `brokeredConnectionsFor`, which sets both failures out in full. A held connection has no + * equivalent question, so its rows carry none of these fields, and their absence is what tells + * the two READS apart. It is not how a reader learns how an app connects: that is the app's + * recorded `authScheme`, and a page asking this list instead would be deriving a second answer + * to a question the row already carries. + * + * SORTED, so two requests answer in the same order. Each read is ordered by server id within + * its own table, and concatenating two sorted lists is not a sorted list. Compared as plain + * strings rather than by `localeCompare`, because the order only has to be the SAME one every + * time, and a collation that varies with the deployment's locale is not that. + */ + const [held, brokered] = await Promise.all([ + store.connectionsFor(context.var.actor.id), + store.brokeredConnectionsFor(context.var.actor.id), + ]); + const connections = [...held, ...brokered].sort((left, right) => { + if (left.serverId < right.serverId) return -1; + return left.serverId > right.serverId ? 1 : 0; + }); + return context.json({ connections, // Shown to an administrator so they can register the client at the vendor with the exact value @@ -360,6 +783,319 @@ export function createPluginRoutes( */ routes.post("/servers/:id/connect", requireUser, async (context) => { const serverId = context.req.param("id"); + + /* + * A BROKERED APP IS ANSWERED HERE AND GOES NO FURTHER DOWN THIS HANDLER. + * + * Everything below this branch belongs to the consent flow THIS deployment runs: a public URL + * to build a redirect URI out of, a catalogue entry naming the vendor's authorization + * endpoint, an OAuth client an administrator registered, a sealed state the callback reads + * back. A brokered app has none of it. Composio holds the consent, so no authorization code + * ever comes back to us, no refresh token is stored here, and no redirect URI of ours is + * registered with anybody — there is nothing for those checks to be about. + * + * WHICH IS WHY THE ORDER IS THE WHOLE POINT AND NOT A TIDINESS. Falling through, a brokered + * row met `catalogueEntry`, which has never heard of `composio-linear`, and the person + * pressing Connect was told the app "is not connected as an individual person" — the exact + * opposite of true about the one kind of row that is ONLY ever connected as an individual + * person. On a deployment with no `OPENBOT_PUBLIC_URL` it failed one step earlier still, + * refusing for want of a setting that has no bearing on a flow it does not enter. + * + * The app comes off the row's url via `toolkitOf` rather than off its id, for the reason the + * directory route says: the url is where the transport reads which app a call is against, and + * the id is a row name that happens to look similar. + * + * ONE ROW, BY ID. Every request to this route pays for this read, including the ones that fall + * through to the OAuth flow below, because the branch cannot be taken until the row is in hand + * — so what it costs has to be a lookup of three columns rather than the whole plugin surface. + * `serverAddress` answering `undefined` is an id naming no row, which falls through exactly as + * a missing row did when this was a `.find`. + */ + const row = await store.serverAddress(serverId); + const toolkit = row ? toolkitOf(row.url) : null; + if (row && toolkit) { + if (!composio) { + return context.json( + { error: new BrokerUnconfiguredError().message }, + 503, + ); + } + + /* + * THE PERSON IS THE SESSION'S, HERE AND IN THE READ ABOVE IT. + * + * Nothing in this branch reads a user id out of the body or the query, and that is the + * property rather than an implementation detail: the link minted below attaches an account + * to whichever person it names, so a user id a caller could choose would let one POST hang + * somebody else's mailbox off this deployment. It is the defect the prior art this design + * follows shipped three separate times, and it is structural here — there is no line that + * could break it. + */ + const existing = await store.brokeredConnection({ + toolkit, + userId: context.var.actor.id, + }); + if (existing) { + /* + * Named with the step to take rather than only refused: a second link would attach a + * second account behind a row that already says connected, and the way to a new one is + * through the connection they have. + * + * THE APP'S TITLE, NOT THE ROW'S ID. `composio-linear` is this deployment's name for a + * table row; "Linear" is the name of the thing the person connected and the only one of + * the two they have ever seen on a screen. An internal key in a sentence addressed to a + * person is both unhelpful and a small leak of how the rows are keyed. + */ + return context.json( + { + error: `You already have an account connected to ${row.title}. Disconnect it first if you want to connect a different one.`, + }, + 409, + ); + } + + /* + * AN APP WHOSE SECRET THE PERSON HOLDS IS ANSWERED HERE AND NEVER SENT AT A CONSENT SCREEN. + * + * Most of Composio's catalogue connects this way rather than through a consent screen: the + * person already holds an API key, so there is nothing to consent to, no url to mint and no + * return leg to build — the press that opens a vendor page for a consent app has to answer + * with a form instead, and the press after it carries what was typed into it. One route + * serves both halves because the browser asks the same question both times: connect me to + * this app. + * + * THE FORK IS THE SCHEME RECORDED ON THE ROW AT ENABLE TIME, which is what this deployment's + * authorization config was actually created as — never a fresh catalogue read and never + * anything the request said. {@link isFieldScheme} is asked rather than the string compared, + * for the reason that function exists: one list, read by the guard and by the type, so the + * schemes admitted here cannot come apart from the ones the broker's signature takes. + * + * AFTER THE ONE-ACCOUNT GUARD RATHER THAN BESIDE IT, and the order is a decision. Somebody + * who already has an account attached has no business in front of a form: drawing one invites + * them to type a key that would be refused once they had entered it, and even the first press + * would spend a call at Composio on behalf of a request that is going to be refused anyway. + * The refusal above names the step to take, and it is the same step for both kinds of app. + * + * THE PERSON IS STILL THE SESSION'S, as everywhere else in this branch. Nothing below reads + * a user id out of the body — and nothing below logs the body either, which matters more + * here than anywhere else in this file: it is the one request this deployment handles that + * carries somebody's own credential. + */ + const authScheme = row.authScheme; + if (isFieldScheme(authScheme)) { + /* + * A SUBMISSION IS AN OBJECT OF VALUES, AND ANYTHING ELSE IS THE FIRST PRESS. The browser + * sends no body at all when it is asking what the app wants, so an absent, empty or + * unparseable one is that question rather than a malformed answer to it, and the worst a + * caller gets for sending something stranger is the form back. + */ + const body = (await context.req.json().catch(() => null)) as { + values?: unknown; + } | null; + const submitted = + typeof body?.values === "object" && + body.values !== null && + !Array.isArray(body.values) + ? (body.values as Record) + : null; + + /* + * ASKED OF THE VENDOR ON BOTH PRESSES, because it is the answer to both questions. On the + * first it is the form itself; on the second it is the list the submission is checked + * against, and reading it from anywhere else — a cached copy, the fields the form was drawn + * from — would be checking a body against what the app used to ask for. + */ + let published: BrokerField[]; + try { + published = await composio.broker.connectionFields({ + toolkit, + authScheme, + }); + } catch (error) { + const refusal = brokerRefusal( + error, + `Composio would not say what ${row.title} asks for, and said nothing about why. Try again, and ask an administrator to check this deployment's Composio key if it persists.`, + ); + return context.json({ error: refusal.error }, refusal.status); + } + + if (submitted === null) return context.json({ fields: published }); + + /* + * WHAT THE APP PUBLISHED, AND A NAME IT DID NOT IS REFUSED RATHER THAN QUIETLY DROPPED. + * + * ONE GUARD, THREE HOLES. What is submitted is spread into the field object the adapter + * hands Composio, beside the literal `status: "ACTIVE"` that call sets — so an unfiltered + * body lets a caller write over it. Anything else invented travels to the vendor + * unexamined. And a field this deployment recorded that the app has stopped publishing + * shows up here, at the request, rather than as a connection made without the value nobody + * was asked for and a first tool call that discovers it. + * + * REFUSED, BECAUSE A PERSON CANNOT TYPE A NAME THE FORM DID NOT DRAW. The form is drawn + * from this same list a moment earlier, so every name in an ordinary submission is one of + * these; a name that is not leaves exactly two readings, and dropping it silently is the + * wrong answer to both. If the app's published fields have moved, the person is holding a + * stale form and the honest thing is to send them back for the current one — connecting + * them with the part that still matches makes a credential-less account that every screen + * here draws as connected. And if it is a caller reaching past the form on purpose, + * "connected" is the one answer they must not get for a request this deployment edited + * behind their back. Pressing Connect again costs a person one press and redraws the form + * from what the app asks for now. + * + * THE VALUES ARE BUILT FROM THE NAMES THAT PASSED rather than the request object forwarded + * once the check is done, so what reaches the store is the published subset by + * construction and not on the strength of the loop above having run. + * + * A VALUE THAT IS NOT TEXT IS THE SAME REFUSAL. The store's signature promises strings, and + * a number or an object under a published name is a lie told to that signature that reaches + * Composio as whatever JSON makes of it. + */ + const names = new Set(published.map((field) => field.name)); + const values: Record = {}; + for (const [name, value] of Object.entries(submitted)) { + if (!names.has(name) || typeof value !== "string") { + /* + * THE SENTENCE CARRIES NOTHING THAT WAS SUBMITTED — not the value, which is somebody's + * own credential and belongs in no message, and not the name either, which on this + * request is the caller's own text rather than the vendor's. It names the app and the + * press that fixes it, which is the whole of what the person needs. + */ + return context.json( + { + error: `That is not the form ${row.title} publishes, so nothing was sent to Composio. Press Connect again to draw it from what the app asks for now, and fill in the boxes it shows; each one holds text.`, + }, + 400, + ); + } + values[name] = value; + } + + try { + /* + * THE STORE'S ANSWER, WHOLE. `connected`, `verified` and `probe` are three facts and not + * one dressed up: a null probe with `verified: false` is an app that publishes nothing + * safe to check a key against, and a named probe with the same flag is a key the vendor + * rejected on an account this deployment could not take back. A route that forwarded the + * boolean alone would leave the row to infer which of those two it was, and it would tell + * the second person that nothing had ever been tried. + */ + return context.json( + await store.connectBrokeredWithFields({ + toolkit, + userId: context.var.actor.id, + values, + }), + ); + } catch (error) { + /* + * A REFUSAL THE STORE AUTHORED IS PASSED THROUGH AS ITSELF, BEFORE THE BROKER MAPPING. + * + * A mistyped key is the ordinary failure on this path — a token from the wrong workspace, + * a key pasted with a newline — and the store's sentence for it already carries the + * vendor's own words and the step to take. `brokerRefusal` cannot see that: a + * {@link PluginRefusedError} is neither an authored broker refusal nor a vendor object, + * so it would come back as "Composio said nothing about why", sending somebody whose key + * was rejected to ask an administrator about this deployment's Composio key. 400 for the + * reason the skills route gives its own refusal one: it is something the person who made + * the request can fix, and the message says what to fix. + */ + if (error instanceof PluginRefusedError) { + return context.json({ error: error.message }, 400); + } + const refusal = brokerRefusal( + error, + `${row.title} could not be connected with what you entered, and Composio said nothing about why. Try again, and ask an administrator to check this deployment's Composio key if it persists.`, + ); + return context.json({ error: refusal.error }, refusal.status); + } + } + + /* + * NO APP URL IS A REFUSAL, NOT A LINK WITH NO WAY BACK. + * + * The address below is where Composio sends this person once they have consented, and it has + * to be absolute: the consent screen is on another company's origin, so a relative path + * resolves against theirs. A deployment that cannot say where its own pages are cannot + * produce one — and minting the link anyway would leave somebody stranded on Composio's + * hosted page having just granted access, with no route back to the deployment that asked + * for it and nothing here knowing it happened. + * + * The OAuth flow below refuses for its missing `OPENBOT_PUBLIC_URL` in these same terms and + * for this same reason. `OPENBOT_APP_URL` is the setting here because the two addresses are + * genuinely different: the API is one origin and the browser app is another, and it is a + * page this person is coming back to rather than an endpoint. + * + * BELOW THE FIELD BRANCH AND NOT ABOVE IT, FOR THE REASON THIS BRANCH'S OWN HEADER GIVES ONE + * GUARD EARLIER. An app whose secret the person types mints no link and has no return leg, so + * this setting has no bearing on that flow at all — and standing before the fork, this guard + * meant no key app could be connected on a deployment without `OPENBOT_APP_URL`, refused in + * the name of a remedy that would not have helped. It stays after the one-account guard for + * the reason that guard's own comment gives: somebody who already has an account attached is + * told the step to take, rather than handed an operator's configuration complaint about a + * link that was never going to be minted for them. + */ + if (!connect?.appUrl) { + return context.json( + { + error: + "This deployment has no app URL configured, so Composio would have nowhere to send you back to. Set OPENBOT_APP_URL.", + }, + 503, + ); + } + + /* + * WHERE THE CONSENT COMES BACK TO, BUILT HERE AND NEVER READ OFF THE REQUEST. + * + * Composio sends the person to this address when they are done, so whoever chooses it + * chooses where somebody lands holding a just-completed consent. A url taken from the body, + * the query or a header would therefore be an open redirect with a consent screen in front + * of it — the exact thing {@link ConnectOrigin} exists to stop on the OAuth flow below, and + * it is narrowed here in the same way: the caller may name one of two PAGES, and the origin + * underneath them is this deployment's configured app URL in both cases. + * + * Both pages confirm on load, which is what makes either of them a correct destination: the + * return trip carries nothing signed, so arriving proves nothing, and the page asks Composio + * whether the account is really attached before anything here says it is. + */ + const returnTo: ConnectOrigin = + context.req.query("returnTo") === "admin" ? "admin" : "settings"; + + /* + * THE URL IS A BEARER CAPABILITY. Whoever opens it attaches an account to this person's + * connection, so it is answered to the browser that asked and to nothing else: not logged, + * not audited, not put in an error body. A redirect url in a log line is somebody else's + * mailbox for as long as it stays valid — which is why the failure below answers with the + * vendor's sentence and never with what was being minted when it failed. + */ + let redirectUrl: string; + try { + ({ redirectUrl } = await composio.broker.authorize({ + userId: context.var.actor.id, + toolkit, + /* + * THE REFUSAL ABOVE CHECKS THAT A SETTING IS SET; THIS CHECKS THAT IT IS AN ADDRESS. + * `appUrl` is an environment string — `OPENBOT_APP_URL`, or the first `TRUSTED_ORIGINS` + * entry — and nothing between there and Composio has ever looked at it, so + * `openbot.example.com` with the scheme left off builds a callback that is not a + * callback. That failure lands after somebody has consented, on the vendor's page, where + * this deployment cannot tell them anything; the guard moves it to before the link is + * minted, where the sentence reaches an operator who can set the variable. + */ + returnUrl: brokerReturnUrl( + connectedAccountsUrlFor(connect.appUrl, { serverId }, returnTo), + ), + })); + } catch (error) { + const refusal = brokerRefusal( + error, + `Composio would not begin a connection to ${row.title}, and said nothing about why. Try again, and ask an administrator to check this deployment's Composio key if it persists.`, + ); + return context.json({ error: refusal.error }, refusal.status); + } + return context.json({ authorizationUrl: redirectUrl }); + } + if (!connect?.publicUrl) { return context.json( { @@ -453,6 +1189,273 @@ export function createPluginRoutes( }); }); + /** + * Which app one of the three routes below is about, or the refusal that ends it. + * + * Each of them acts on a brokered connection and on nothing else, so each asks the same two + * questions in the same order and answers them in the same words. It is one function because the + * sentence somebody reads when they aim any of those routes at an ordinary OAuth row should not + * be able to drift into three sentences. + * + * THE APP COMES OFF THE ROW'S URL AND NEVER OFF ITS ID, for the reason the directory route and + * the connect branch above both give: the url is where the transport reads which app a call is + * against, and the id is a row name that happens to look similar. + * + * ONE ROW, BY ID, AND CONFIRM IS WHY. Both brokered account screens call that route from an + * effect when they mount, so this read runs on every page load — and it used to be + * `listServers`, which materialises every tool and every grant in the deployment to answer + * whether one row is brokered. + * + * A ROW THAT IS NOT BROKERED IS REFUSED IN SO MANY WORDS. The id may well name a server this + * deployment really has — what is wrong is that its connection does not live at Composio, and + * there is nothing for either route to confirm or to end. `null` from `toolkitOf` also covers an + * id naming no row at all, which is the same answer from the caller's side. + * + * NO BROKER IS A 503 NAMING THE SETTING, as it is on the directory and on connect, and it is + * {@link BrokerUnconfiguredError}'s own message rather than a sentence written here. + * + * The connect route's brokered branch does not come through this function, deliberately: a row + * that is not brokered has an OAuth flow below it to fall through to, so refusing there would be + * wrong. + */ + const brokeredAppFor = async ( + serverId: string, + ): Promise< + | { toolkit: string; refusal?: undefined } + | { toolkit?: undefined; refusal: { error: string; status: 400 | 503 } } + > => { + const row = await store.serverAddress(serverId); + const toolkit = row ? toolkitOf(row.url) : null; + if (!toolkit) { + return { + refusal: { + error: "That app is not reached through a broker.", + status: 400, + }, + }; + } + if (!composio) { + return { + refusal: { error: new BrokerUnconfiguredError().message, status: 503 }, + }; + } + return { toolkit }; + }; + + /** + * Ask Composio whether this person's account is really attached, and write the answer down. + * + * THE ROUTE EXISTS SO THAT THE VENDOR IS ASKED. The return trip from a consent screen is an + * ordinary redirect with nothing signed in it, so a browser landing back on the settings page + * proves nothing: not that the flow finished, and not that it finished with the account a row + * would go on to claim. Composio tells this deployment nothing by itself — there is no callback + * of ours in that flow — so unless something asks, all that stands behind the gate every later + * brokered call passes through is a guess about what a redirect meant. + * + * AND IT IS MEANT TO BE CALLED AGAIN, on any page load, which is the other half of why it is + * here. The row is only a cache of the vendor's last answer, so it drifts by construction — an + * account ended in Composio's own dashboard, a consent this deployment never saw finish — and + * calling this heals it in whichever direction it went: written where the vendor says yes, + * deleted where it says no. Repeating it files no trail rows and moves no timestamps; the store + * is where that is settled. + * + * BEHIND `requireUser` AND NOT ADMIN-GATED. An administrator adds the app once; confirming one's + * own connection to it is not an administrative act. + */ + routes.post( + "/servers/:id/connection/confirm", + requireUser, + async (context) => { + const resolved = await brokeredAppFor(context.req.param("id")); + if (resolved.refusal) { + return context.json( + { error: resolved.refusal.error }, + resolved.refusal.status, + ); + } + + /* + * THE PERSON IS THE SESSION'S, AND THERE IS NO SECOND SOURCE FOR THEM. Nothing here reads a + * user id out of the body or the query, and that is the property rather than an + * implementation detail: a confirm writes the row every later brokered call is gated on, so + * a caller who could name somebody else would be one POST away from recording a connection + * under a person who never made one — or, the same defect turned around, from deleting the + * row of a person the vendor answers no for. + * + * The store's answer is passed straight back rather than restated here. `connected` is what + * Composio said, and a shape invented at this layer would be a second opinion about a fact + * only the vendor holds. + */ + try { + return context.json( + await store.confirmBrokeredConnection({ + toolkit: resolved.toolkit, + userId: context.var.actor.id, + }), + ); + } catch (error) { + /* + * A BROKER THAT WOULD NOT ANSWER IS NOT A CONNECTION THAT IS ABSENT. + * + * This route is called on every page load, so the tempting answer to a failure is + * `{ connected: false }` — and that would be this deployment inventing a fact only Composio + * holds, drawing "Not connected" over a live account and, one step on, deleting the row + * that says otherwise. The store deletes on a NO from the vendor, and a failure is not a + * no. So the page is told the ask failed, and what it goes on showing is the last answer + * Composio gave rather than a guess about this one. + */ + const refusal = brokerRefusal( + error, + "Composio would not say whether this account is connected, and gave no reason, so what is shown here is the last answer it gave rather than a fresh one. Try again, and ask an administrator to check this deployment's Composio key if it persists.", + ); + return context.json({ error: refusal.error }, refusal.status); + } + }, + ); + + /** + * Try this person's key against the app, because they pressed the button that asks. + * + * A BUTTON, AND NEVER A PAGE-LOAD EFFECT, which is the one thing a caller of this route has to + * know. Composio never re-checks a key — it accepts one when it is typed and says nothing about it + * again — so this is the only thing in the product that can correct a row whose key was rotated, + * revoked or left to expire. That is also the argument somebody will make for calling it from an + * effect on mount, and it is wrong: the call goes out to the app on the person's OWN account and + * against their own rate limit at the vendor, so verifying on every render would spend somebody's + * quota at Linear to redraw one word on a settings page. The confirm route above is the one that + * runs on mount; it asks Composio about its own records and costs the person nothing. + * + * AND IT IS NOT A CONNECT. The store's argument is made there in full: the account already exists, + * so nothing here creates one, nothing withdraws one when the key turns out to be bad — their + * account stays, it is their key that is wrong — and nothing changes but the verification and its + * date. + * + * A PROBE THAT RAN AND FAILED COMES BACK AS A FAILURE, never as a 200 saying `verified: false`. + * That flag is also what an app publishing nothing safe to call produces, and the row drawing this + * answer cannot tell the two apart — so an answer would quietly drop the Re-check button in + * exactly the state somebody needs it, having just fixed their key, while telling them nothing had + * ever been checked. The store raises with Composio's own sentence in it, and this passes that + * through as a refusal the browser surfaces. The only `verified: false` that arrives as an answer + * is the one carrying `probe: null`, which says there was nothing to check with. + * + * THE PERSON IS THE SESSION'S, as on the two routes around it and for the sharper reason this one + * adds: a user id a caller could name would let one POST spend a stranger's rate limit at the + * vendor and rewrite the verification on their row. Nothing here reads a user id out of the body + * or the query. + * + * BEHIND `requireUser` AND NOT ADMIN-GATED, for confirm's reason: this is somebody checking their + * own account, not an administrator checking anybody's. + */ + routes.post( + "/servers/:id/connection/recheck", + requireUser, + async (context) => { + const resolved = await brokeredAppFor(context.req.param("id")); + if (resolved.refusal) { + return context.json( + { error: resolved.refusal.error }, + resolved.refusal.status, + ); + } + + try { + // The store's answer, whole. `verified` is the flag, `verifiedAt` is the date the row's + // sentence is drawn from, and `probe` is what says a call was really made — three facts, + // and a route that forwarded the boolean alone would leave the row to guess the other two. + return context.json( + await store.recheckBrokeredConnection({ + toolkit: resolved.toolkit, + userId: context.var.actor.id, + }), + ); + } catch (error) { + /* + * A REFUSAL THE STORE AUTHORED IS PASSED THROUGH AS ITSELF, BEFORE THE BROKER MAPPING, for + * the reason the connect route's field branch gives: a key the vendor rejected is the + * ordinary failure on this path, and the store's sentence for it already carries Composio's + * own words and the step to take. `brokerRefusal` cannot see that — a + * {@link PluginRefusedError} is neither an authored broker refusal nor a vendor object — so + * it would answer somebody whose key is wrong with "Composio said nothing about why" and + * send them to an administrator about this deployment's key. 400 because it is theirs to + * fix and the message says what to fix. + */ + if (error instanceof PluginRefusedError) { + return context.json({ error: error.message }, 400); + } + /* + * And a failure this deployment cannot explain says what is on the screen instead of + * guessing. Nothing was written on the way out of the store here, so the row still carries + * the last answer anybody earned rather than a verdict invented by a call that failed. + */ + const refusal = brokerRefusal( + error, + "Composio would not say whether this connection still works, and gave no reason, so what is shown here is the last answer it gave rather than a fresh one. Press Re-check again, and ask an administrator to check this deployment's Composio key if it persists.", + ); + return context.json({ error: refusal.error }, refusal.status); + } + }, + ); + + /** + * End this person's own brokered account, at the vendor first and here after. + * + * The order is the store's and the argument for it is made there: the row is the only thing that + * says which app this person connected, so a delete that ran before the revoke could leave a live + * grant on somebody's mailbox that nothing here can reach. What comes back is what was asked for + * — `vendorRevocationRequested` false is a grant that was already gone — and it is passed through + * rather than rewritten, because telling those two apart is the whole value of the field. + * + * `reason` IS "self" BECAUSE OF WHO IS ASKING. The other word the store takes is + * `person_removed`, which belongs to an administrator offboarding somebody from the People + * screen. The trail tells the two acts apart by this word and by whether `by` and the owner + * differ, and on this route they are the same person by construction. + * + * BEHIND `requireUser` AND NOT ADMIN-GATED, for the reason confirm gives: this is somebody + * ending their own account, not an administrator ending anybody's. + */ + routes.delete("/servers/:id/connection", requireUser, async (context) => { + const resolved = await brokeredAppFor(context.req.param("id")); + if (resolved.refusal) { + return context.json( + { error: resolved.refusal.error }, + resolved.refusal.status, + ); + } + + /* + * WHOSE ACCOUNT THIS IS COMES FROM THE SESSION, here as on confirm and for a sharper reason: a + * user id a caller could name would be a DELETE that revokes somebody else's grant at the + * vendor. It is read once, from `context.var.actor`, and used for both the owner and the actor + * — nothing in the body or the query is looked at at all. + */ + try { + return context.json( + await store.disconnectBrokered({ + toolkit: resolved.toolkit, + userId: context.var.actor.id, + by: context.var.actor.id, + reason: "self", + }), + ); + } catch (error) { + /* + * REPEATING IT IS THE RECOVERY, AND THE SENTENCE SAYS SO RATHER THAN GUESSING HOW FAR IT GOT. + * + * The revoke runs before anything here is deleted, which is what makes a second press safe: + * whatever this failed at, the state it leaves is access dead or access untouched, never + * access live with nothing here able to reach it. Claiming "nothing was changed" would be a + * guess — a delete that succeeded and an audit write that did not is the same throw — and the + * one thing a person needs is the button to press, not this deployment's theory of where it + * stopped. + */ + const refusal = brokerRefusal( + error, + "Composio would not end this account, and gave no reason. Press Disconnect again: the revoke at Composio runs before anything here is deleted, so repeating it is safe and is the whole recovery. Ask an administrator to check this deployment's Composio key if it persists.", + ); + return context.json({ error: refusal.error }, refusal.status); + } + }); + /** * Where the vendor sends somebody back. * @@ -879,6 +1882,28 @@ export function createPluginRoutes( if (error instanceof CatalogueEntryUnknownError) { return context.json({ error: error.message }, 404); } + /* + * Ours, and so neither the vendor's fault nor this caller's business. + * + * CRITERION. A fault on the `isDeploymentFault` shelf is not reported through the branch + * below, and its sentence does not leave this process by this route. + * + * REASON. Two things would be wrong at once. `failed: true` and 502 say somebody else's + * software did not answer, which is a false statement about a call that never went out — + * and this route is `requireUser`, not `requireAdmin`, so the sentence naming our columns + * and the correction to make would be readable by anybody with a session. The operator who + * can act on it reads it on the refresh route above, which is admin-gated; here the honest + * answer is that the deployment cannot make this call as it stands. + */ + if (isDeploymentFault(error)) { + return context.json( + { + error: + "That tool is not configured in a way this deployment can act on. An administrator has to look at the server it belongs to.", + }, + 500, + ); + } // A server that failed is not a refusal, and saying so matters: one means the deployment // decided against it, the other means somebody else's software did not answer. return context.json( diff --git a/server/src/plugins/store.ts b/server/src/plugins/store.ts index 63d611f34..8c6395649 100644 --- a/server/src/plugins/store.ts +++ b/server/src/plugins/store.ts @@ -21,6 +21,7 @@ import type { Database } from "../db/client"; import { agentProfiles, agents, + composioConnections, // Aliased: `credentials` is already the injected vault interface in this module, and the table and // the interface are two different things to reach for. credentials as credentialRows, @@ -31,6 +32,18 @@ import { skills, skillTools, } from "../db/schema"; +import { + accessFor, + type ServerAccess, + ServerUnresolvableError, +} from "./access"; +import { + type BrokerConnection, + BrokerRefusalError, + BrokerUnconfiguredError, + type ComposioBroker, + isFieldScheme, +} from "./broker"; import { type CatalogueEntry, catalogueEntry, @@ -39,8 +52,13 @@ import { resolveServerUrl, serverCredentialKind, } from "./catalogue"; +import { + callTool as composioCallTool, + toolkitOf, + VERSION_ARG, +} from "./composio"; import { inspectToolArguments } from "./content-governance"; -import { McpServerError } from "./mcp"; +import { type ListedTool, McpServerError } from "./mcp"; import { registerDynamicClient } from "./oauth"; import { transportFor } from "./transport"; @@ -87,6 +105,15 @@ export type ToolRecord = { /** `/`. What a grant names and what the model's tool name is derived from. */ ref: string; effect: "read" | "write"; + /** + * Whether the vendor warns that this action destroys something. + * + * Beside {@link ToolRecord.effect} rather than folded into it: the rule engine judges reads and + * writes and gains nothing from a third value, while a person deciding whether to switch an action + * on is asking a different question. Recorded from the vendor's own labels, so false is an absence + * of a claim rather than a claim of safety. + */ + destructive: boolean; grantedTo: string[]; }; @@ -131,6 +158,20 @@ export type ServerRecord = { * there is nothing for it to collect. */ dynamicClient: boolean; + /** + * How this server's authorization config was created, for the brokered rows that have one. + * + * WHAT WAS WRITTEN DOWN WHEN SOMEBODY ENABLED THE APP, NOT WHAT THE CATALOGUE PUBLISHES TODAY. + * The catalogue is the vendor's, and an app it starts advertising under a different scheme has + * not moved the config this deployment already created — so a reader deciding what a live + * connection does must read this and not a fresh listing. The `authScheme` column carries the + * same fact and the same warning about which vocabulary it holds: the vendor's own scheme + * literals (`OAUTH2`, `DCR_OAUTH`, `API_KEY`, `NO_AUTH`, and the rest), never a + * {@link BrokerConnection} kind. + * + * Null is not an older brokered row. It is a row that is not brokered at all. + */ + authScheme: string | null; tools: ToolRecord[]; /** * Grants on tools this server no longer advertises. @@ -141,6 +182,36 @@ export type ServerRecord = { withdrawn: WithdrawnGrant[]; }; +/** + * A server row as the surfaces that only need to know where it is see it. + * + * Four columns of {@link ServerRecord} and none of what hangs off it, because the callers this is + * for ask one question: which vendor is this row addressed at. The title travels with the url + * because their refusals name it — "You already have an account connected to Linear" is the app's + * name, which is the only one of the two a person has ever seen on a screen. + */ +export type ServerAddress = { + id: string; + title: string; + url: string; + /** + * How this row's authorization config was created, for the brokered rows that have one. + * + * THE RECORDED SCHEME, NEVER A FRESH CATALOGUE READ. An app's config was created as one + * particular scheme and every connection standing against that config depends on it, so the + * connect path has to open the flow this column names rather than the one the catalogue + * publishes for the app today. Re-derived from a listing instead, a vendor that starts + * advertising a new scheme would silently move live connections onto a different flow — minting + * a consent link against a config that holds keys, or asking for a key where a consent screen is + * waiting. + * + * The same vocabulary the column holds, which the schema comment spells out: the vendor's own + * scheme literals, never a {@link BrokerConnection} kind. Null is not an older brokered row; it + * is a row that is not brokered. + */ + authScheme: string | null; +}; + export type SkillRecord = { id: string; slug: string; @@ -221,6 +292,237 @@ export class CustomServerRefusedError extends Error { } } +/** + * A state this deployment's own code says cannot exist, found existing. + * + * CRITERION. Nothing here is a vendor's doing, a credential's doing or anything a person asking can + * act on, so no path may record one of these as though a vendor had misbehaved. + * + * REASON. `refreshTools` wrapped the listing, the replace and both audit writes in one `catch` that + * copied every message into `lastError` and answered `{ tools: 0 }`. A plain `Error` is what the + * narrowing throws in {@link createPluginStore}'s `connectionTokenFor` raise, so a row that resolved + * to a brokered credential with no app in its url — or to a per-person credential with no + * `user-oauth` entry — came out on the Plugins page as a sentence about the vendor, next to a + * refresh that looked like it had merely failed. An operator reading that is sent to somebody else's + * status page over a contradiction in our own tables. + * + * A class rather than a message, because telling these apart by prose is telling them apart by a + * substring that a reword would silently change. Distinct from {@link PluginRefusedError}, which is + * a refusal somebody CAN act on and which does belong in `lastError` — an administrator who has not + * connected their account is the honest reason a listing did not happen. + */ +export class PluginInvariantError extends Error { + constructor(message: string) { + super(message); + this.name = "PluginInvariantError"; + } +} + +/** + * Whether a throw is this deployment contradicting itself, rather than anything anybody asked for. + * + * CRITERION. Every audience boundary asks THIS instead of listing classes of its own. A fault it + * answers true for reaches an operator as its own sentence, on a surface only an operator can + * reach, and reaches everybody else as the fact that the call did not happen — no message, no + * column names, no instruction about a row. + * + * REASON. The distinction already existed and was drawn by hand, once, in each place that + * remembered to draw it: {@link PluginRefusedError} is relayed verbatim because it is a refusal + * the asker can act on, and everything else fell into a branch that copies `error.message` + * onwards. {@link ServerUnresolvableError} was caught by none of them — the refresh route rethrew + * it into the framework's default handler, which answers a bodiless 500, so the admin page said + * "That did not work" and named nothing; `grantedTools` put its message in a model's context, + * where a sentence telling an operator to correct a provenance column became a Bot's explanation + * to an end user of why their tool failed. Two audiences, one refusal, neither served. + * + * {@link PluginInvariantError} is on the same shelf and answers true for the same reason: it is + * this deployment finding a state its own code says cannot exist. That is not a vendor + * misbehaving and not a person's to act on mid-call, and its own docblock has said so since it + * was written — what it lacked was anywhere that asked. + * + * A PREDICATE RATHER THAN A SHARED BASE CLASS, because the two live in different modules and must + * keep doing so: `access.ts` is a leaf that `store.ts` imports, so the shelf cannot be declared + * once without one of them importing the other back. + */ +/** + * The one character no PostgreSQL `text` or `jsonb` value can hold, whatever the vendor sent. + * + * Not a length limit and not an encoding preference: the server rejects the statement outright, + * mid-transaction, and the rejection arrives as a query error rather than as anything about the + * value. + */ +const NUL = "\u0000"; + +/** + * Whether a throw is a query failure carrying the statement and the values bound to it. + * + * CRITERION. Anything this answers true for has a message that must never be relayed — not to a + * model, not to a browser, not into a column an operator reads. + * + * REASON. drizzle wraps every failure as a `DrizzleQueryError` and puts `Failed query: ` and `params: ` in its `message`. Along the tool-call path those + * values are credential ids, user ids and server ids; along the refresh path they are the vendor's + * entire tool list. + * + * BY SHAPE, NOT BY CLASS, and that is the one place this file departs from its own "tell them apart + * by a class, never by prose" rule. The class is drizzle's, reachable only through a deep import + * that is not part of its published surface, so an `instanceof` here would pin this deployment to + * an internal path a minor release may move. `query` and `params` as own properties on an `Error` + * is not prose — it is the shape the constructor assigns, it is what makes the message dangerous, + * and anything else carrying both fields is a query failure too. + */ +function isQueryFailure( + error: unknown, +): error is Error & { query: unknown; params: unknown } { + return ( + error instanceof Error && + Object.hasOwn(error, "query") && + Object.hasOwn(error, "params") + ); +} + +/** + * As much of a failure as may be shown to whoever is entitled to see it. + * + * CRITERION. Every place that copies a message out of a caught error asks this instead of reading + * `.message`. What comes back never contains a statement or a bound value. + * + * REASON. The message is the useful thing for a vendor's refusal, a person's missing connection or + * an invariant of ours — that is why those paths quote it, and they should go on quoting it. It is + * the wrong thing for exactly one kind of error, and that kind announces itself by shape. Asking + * here rather than at each site means a new audience cannot be added without the question already + * answered for it. + */ +function withoutStatement(error: Error): string { + return isQueryFailure(error) ? databaseComplaint(error) : error.message; +} + +/** + * The driver's own complaint about a query, without the query. + * + * CRITERION. What this returns never contains the statement or the values bound to it. + * + * REASON. drizzle's `DrizzleQueryError` puts both in its own `message` and hangs the driver's + * error off `cause`. The driver's message is the useful half — `duplicate key value violates + * unique constraint`, `invalid byte sequence`, `canceling statement due to statement timeout` — + * and it is the half that names nothing anybody sent. An error shaped differently gets a fixed + * sentence rather than its own message, because the reason this exists is that a message from an + * unexamined shape is exactly what leaked the last one. + * + * Capped where every other quoted failure in this file is capped, for the same reason: parts of + * it come from somewhere else and none of it is a promise about length. + */ +function databaseComplaint(error: unknown): string { + const cause = error instanceof Error ? error.cause : undefined; + return cause instanceof Error + ? cause.message.slice(0, 400) + : "The database gave no reason this deployment can quote."; +} + +/** + * What a vendor listed, as rows this database will actually take. + * + * CRITERION ONE. No two rows carry the same name, whatever the vendor listed. + * + * CRITERION TWO. No string reaching the insert contains U+0000, in a column or inside a schema. + * + * REASON. Both of these used to abort the replace from INSIDE the transaction and OUTSIDE the + * vendor `try` above it, so they came out of `refreshTools` as a raw `DrizzleQueryError` — whose + * message is `Failed query: ` followed by `params:` and every value bound to + * it. That reached an operator's page and the logs as a SQL dump, which is the same disclosure + * shape as a leaked credential one layer out, and it left `lastError` holding whatever was there + * before: stale, or null, on a refresh that had in fact failed. + * + * FIXED BY NOT REACHING THE DATABASE WITH IT, rather than by catching it better. A vendor that + * names one action twice is answering about one action — `mcp_tools`' `(server_id, name)` primary + * key says so, and the first listing is as good an answer as the second, so the duplicate is + * dropped rather than made into an error somebody has to act on. A control character in a + * description is not content anybody wants to keep either. What is left after this is a + * transaction that fails for reasons that are genuinely not the vendor's, which is what the + * comment on the replace has always claimed. + * + * FIRST OCCURRENCE WINS, and the order is the vendor's own. Anything else needs a rule for which + * of two identical names is the real one, and there is no such rule. + */ +function storableTools(serverId: string, listed: ListedTool[]) { + const byName = new Map< + string, + { + serverId: string; + name: string; + description: string; + inputSchema: Record; + effect: "read" | "write" | null; + destructive: boolean; + version: string | null; + } + >(); + + for (const tool of listed) { + const name = tool.name.replaceAll(NUL, ""); + if (byName.has(name)) continue; + byName.set(name, { + serverId, + name, + /* + * Defaulted where the COLUMN has a default, because that is what the previous mapping leaned + * on: it passed these two straight through, so a transport handing back undefined got the + * `""` and `{}` the schema declares. Reading a method off the value instead would turn the + * same absence into a TypeError thrown from outside the vendor `try`. Both fields are + * required by `McpTool` and supplied by every transport here; this keeps the tolerance the + * insert already had rather than adding a new answer. + */ + description: (tool.description ?? "").replaceAll(NUL, ""), + /* + * Through JSON rather than by walking the object, because the escape is what has to go and + * the schema is JSON by definition — it is stored in a `jsonb` column and came off the wire + * as JSON. `JSON.stringify` writes a literal U+0000 as the six characters `\u0000`, so that + * is the sequence removed here; a schema with none is rebuilt identical. + */ + inputSchema: JSON.parse( + JSON.stringify(tool.inputSchema ?? {}).replaceAll("\\u0000", ""), + ), + /* + * What the vendor said, when the vendor said anything. + * + * Only Composio publishes an effect and a version, and an MCP server publishes a + * destructive hint — see `mcp.ts`. All three stay null or false for a transport that says + * nothing, and `classifyTool` reads null as silence rather than as a value, which is what + * leaves Notion and Drive classified by their reviewed write list exactly as they were. + */ + effect: tool.effect ?? null, + destructive: tool.destructive ?? false, + version: tool.version?.replaceAll(NUL, "") ?? null, + }); + } + + return [...byName.values()]; +} + +export function isDeploymentFault(error: unknown): error is Error { + return ( + error instanceof ServerUnresolvableError || + error instanceof PluginInvariantError || + /* + * A query this database refused is on the shelf for the reason the other two are: it is not a + * vendor's doing, it is not the asker's to act on, and its message is the one thing here that + * must not travel. The replace in `refreshTools` was fixed at its own site; every other query + * on the call path — the advertised-tool read, the connection gate, the vault read, the locked + * credential swap — throws the same shape into a `catch` that copies `error.message` onward, + * so answering it here is what makes the four audiences agree without four more branches. + * + * Callers that SHOW the sentence to an operator must still ask {@link withoutStatement} for + * it rather than reading `.message`; this predicate settles who may be told, not what. + */ + isQueryFailure(error) + ); +} + +/** The operator-facing sentence for a fault on that shelf, with no statement in it. */ +export function deploymentFaultSentence(error: Error): string { + return withoutStatement(error); +} + /** * The vendor's `error` code, when a token endpoint refuses an exchange. * @@ -291,8 +593,11 @@ export function refFromToolName(toolName: string): string | null { * the vendor. Naming those would be noise in front of the one case that has no second barrier at all * — Notion, whose access is per-page on a consent screen and whose `scopes` are therefore empty. * - * A server with no catalogue entry is not reconciled either, and for the opposite reason: nothing - * reviewed says any tool of theirs only reads, so all of them are already writes. + * A server with no catalogue entry is not reconciled either, and the two shapes that reach here do + * so for different reasons. A brokered app's actions are classified from the vendor's own + * per-action label rather than from a list here, so there is no hand-written under-inclusion to + * find. A server an administrator added by URL has neither a label nor a list, so every tool it + * offers is already a write and there is no wrongly-permitted read to reconcile. * * Sorted, so two readings of the same listing produce the same row. */ @@ -312,21 +617,56 @@ const iso = (value: Date | string | null): string | null => value === null ? null : value instanceof Date ? value.toISOString() : value; /** - * Whose credential reaches this server, as the trail names it. + * The two things an actor field says when the actor is not a person, and they are not the same + * thing. + * + * CRITERION. A field whose purpose is to name who did something must never be written as the empty + * string. An absent field reads as absent; `""` reads as a value, so a reader grouping the trail by + * actor gets a person called nothing, and every count of "acts by X" is quietly wrong about them. + * + * `deployment` is a positive answer: nobody was asking because the deployment itself acted — a + * shared credential, a public endpoint, a refresh it ran on its own behalf immediately after an app + * was added. `unattributed` is the opposite, and the distinction is the whole point of having two: + * something happened that SHOULD have had a person behind it and this deployment could not say who. + * `identifyActor` answers `{ id: "" }` for exactly that, and the run is then refused — which is + * precisely the moment the trail is worth reading, so it must not be the moment it goes blank. + * + * Neither is an address, so neither can collide with a user id: every actor written here otherwise + * is `users.id` or the email a session resolved to. + * + * NOT THE SAME AXIS AS `initiator_kind`, and a row carrying both is not contradicting itself. + * `initiator_kind` answers what set a run in motion; this field answers whose account it reached + * and who can be named for it. So `initiator_kind: "person"` beside `actor: "unattributed"` reads + * correctly as a person-initiated request whose person this deployment could not identify. That is + * the honest reading, and it is the reason this is NOT recorded as `deployment`: that would assert + * the call went out on the deployment's own credential, and it did not go out at all. + * + * `DEPLOYMENT_INITIATOR`'s own doc claims the case of "refusing a caller it could not identify", + * which overlaps this one and would answer it the other way. Nothing sends it there — the tool path + * defaults its initiator to person and `identifyActor` returns an empty id rather than a deployment + * — so the overlap is in the prose, not in the behaviour. It is left alone deliberately rather than + * resolved by widening either vocabulary unilaterally; whoever owns that constant should narrow its + * sentence, or a third initiator kind should exist, and neither is this branch's call to make. + */ +const DEPLOYMENT_ACTOR = "deployment"; +const UNATTRIBUTED_ACTOR = "unattributed"; + +/** + * Whose account this call went out as, for the trail. * - * One definition, because this was two: `connectionTokenFor` returned it and the audit payload - * recomputed the same condition a few lines later. Two expressions for one fact can disagree, and - * the one place that would show is an audit row claiming a call ran as somebody it did not — which is - * the row a per-person connector exists to be able to trust. + * Reads the resolved descriptor rather than re-deriving from the entry's auth kind. That derivation + * had no answer for a Composio app — the entry is null, so it fell through to `deployment` for a call + * that ran in one person's own mailbox, which is the trail being wrong about the one thing a + * per-person connector exists for. * - * `deployment` for a shared token; the asker's own id for a server reached as the person asking. - * `builtin` is the third case and the only one with no credential at all — the actor is not whose - * token was used, it is whose rows were touched. + * A person-reached server with no actor is `unattributed` and never `deployment`: the call did not + * go out on a shared credential, it did not go out at all, and naming the deployment would assert + * an attribution that never happened. See {@link DEPLOYMENT_ACTOR}. */ -const reachedAsFor = (entry: CatalogueEntry | null, actorId: string): string => - entry?.auth.kind === "user-oauth" || entry?.auth.kind === "builtin" - ? actorId - : "deployment"; +const reachedAsFor = (access: ServerAccess, actorId: string): string => + access.reachedAs === "person" + ? actorId || UNATTRIBUTED_ACTOR + : DEPLOYMENT_ACTOR; /** * Where this server actually is, when the stored row and the catalogue disagree. @@ -535,6 +875,21 @@ type StoredClient = { client: OAuthClient; registeredAt: Date | null }; */ const CLIENT_REREGISTRATION_BACKOFF_MS = 5 * 60_000; +/** + * The names that read as "tell me who this key belongs to". + * + * A PREFERENCE AND NOT THE RULE. What makes an action safe to probe with is decided by + * {@link createPluginStore}'s `probeActionFor` on the vendor's own labels; this is only which of the + * safe ones to reach for first. An identity call is the cheapest request an app has and the one + * whose failure most clearly means "this key is wrong" rather than "that record does not exist" — + * but sampling fifteen key-based apps in the live catalogue, only four publish one, so a chooser + * that INSISTED on this shape would refuse to probe most of the apps this deployment offers. + * + * Anchored at the end, because these are suffixes of a prefixed action name — `STRIPE_GET_ME`, + * `LINEAR_GET_ME` — and an unanchored match would take `SLACK_PROFILE_SET` for an identity read. + */ +const IDENTITY_ACTION = /(_GET_ME|_PROFILE|_CURRENT_USER|_USER_INFO|_WHOAMI)$/; + /** What a vendor's token endpoint gave back for a refresh token. */ export type AccessToken = { accessToken: string; @@ -597,10 +952,38 @@ export type PluginStoreOptions = { registrationUrl: string; redirectUri: string; }) => Promise; + /** + * Composio the broker, absent on a deployment that has not configured one. + * + * OPTIONAL BECAUSE ITS ABSENCE IS A STATE RATHER THAN A MISCONFIGURATION. An unset + * `COMPOSIO_API_KEY` is the documented default: where it is unset there is nothing to connect, + * nothing to grant and no brokered tool for a Bot to call, and what is left on screen is one row + * that goes nowhere under More apps on the admin Plugins page. So the store is constructible + * without one and every path that needs one says so by raising {@link BrokerUnconfiguredError}. + * A required field would make every caller that never enables an app — the routes, the tests + * above — invent a broker to get a store. + */ + broker?: ComposioBroker; /** Where the vendor sends people back; needed to (re)register a dynamic client. */ redirectUri?: string; }; +/** The literal recorded on the row, which is the scheme a later call must keep using. */ +function schemeFor(connection: BrokerConnection): string | null { + switch (connection.kind) { + case "consent": + return "OAUTH2"; + case "self-registering": + return "DCR_OAUTH"; + case "fields": + return connection.authScheme; + case "no-auth": + return "NO_AUTH"; + case "unsupported": + return null; + } +} + export function createPluginStore(options: PluginStoreOptions) { const { database, auditStore, credentials, encryptionKey } = options; /* @@ -612,6 +995,9 @@ export function createPluginStore(options: PluginStoreOptions) { const exchangeRefreshToken = options.exchangeRefreshToken ?? exchangeRefreshTokenOverHttp; const registerClient = options.registerClient ?? registerDynamicClient; + // No default, unlike the seams above: there is no real implementation in this tree to fall back + // to, and a deployment with no Composio key is supposed to have no broker. See `./broker`. + const broker = options.broker; /* * One exchange at a time per (server, person). A rotating vendor invalidates the refresh @@ -752,28 +1138,149 @@ export function createPluginStore(options: PluginStoreOptions) { } /** - * The token one call goes out with, and whose it is. + * The token one call goes out with, and whose it is — decided from `access.credential`, so that + * this function and the audit row cannot disagree about whose account a call ran in. + * + * For a `deployment-token` server this is what it always was: the one credential an administrator + * gave the server, used for everybody. A `none` server reaches the same branch and finds nothing + * to decrypt, which is the right answer for an endpoint that takes no credential at all. * - * For a `deployment-bearer` server this is what it always was: the one credential an administrator - * gave the server, used for everybody. + * For a `brokered` server there is no token here AT ALL. The deployment's one key belongs to the + * transport and never travels through this function, so nothing here can leak it into a connection + * object, an error or an audit row. What this function contributes instead is the two refusals + * that have to happen before a call is spent at the broker: a run nobody is attributed for, and an + * asker who has not connected the app — so a person is told their own next step rather than shown + * the broker's error about an account it cannot find. A third refusal sits between those two, for + * a brokered row whose url names no Composio app; nothing in the product creates such a row, so no + * person's situation reaches it. * - * For a `user-oauth` server it is the asker's own, and every branch that cannot prove it has the + * For a `person-oauth` server it is the asker's own, and every branch that cannot prove it has the * asker's grant refuses. There is deliberately no fallback. A fallback is the one bug this design * exists to make impossible: answering out of whatever the deployment, or the last person to * connect, happened to be able to see — which returns a confident answer assembled from documents * the person asking cannot open, and looks exactly like a correct answer. * - * Nothing is cached. The refresh token is exchanged for an access token per call and the access - * token is thrown away, so there is no stored copy of anybody's access for a disconnect to have to - * find. That costs a round trip to the vendor's token endpoint on every call, which is the price - * of revocation being complete by construction rather than by cleanup. + * Nothing is cached on any path, and only on the `person-oauth` one is that a decision. There, the + * refresh token is exchanged for an access token per call and the access token is thrown away, so + * there is no stored copy of anybody's access for a disconnect to have to find. That costs a round + * trip to the vendor's token endpoint on every call, which is the price of revocation being + * complete by construction rather than by cleanup. The other two paths have nothing to cache: a + * `deployment-token` is decrypted out of the vault per call, and a `brokered` key is never held + * here at all. */ async function connectionTokenFor( - row: { id: string; url: string; credentialId: string | null }, + row: { + id: string; + title: string; + credentialId: string | null; + /** + * The scheme recorded when the app was enabled, which decides whether a brokered call needs a + * connection row at all. The vendor's own literal, never a {@link BrokerConnection} kind. + */ + authScheme: string | null; + }, entry: CatalogueEntry | null, actorId: string, + access: ServerAccess, ): Promise<{ token?: string }> { - if (entry?.auth.kind !== "user-oauth") { + /* + * A brokered app, where the deployment holds one key and Composio keeps the accounts apart. + * + * Refused HERE rather than in the transport, for the two reasons the `user-oauth` branch below + * is: a person gets a sentence naming the step they can take, and no call is spent at the + * vendor finding out. The transport refuses an unattributed run again as a last line, so + * deleting either that guard or this one has to turn a test red. The unconnected case has no + * such twin: the transport has no notion of a connection at all, so the last line there is + * Composio itself — which is what refusing locally earns its place for, since it turns the + * broker's error about an account it cannot find into a sentence naming the person's own next + * step. + * + * The throw between the two is a third refusal, but not one anybody can act on: it fires only + * for a brokered row whose url names no Composio app, which nothing in the product can create. + * It is what keeps this gate keyed on the app the url names, and its own comment says why + * neither fallback is available. + * + * There is no token. The key belongs to the transport and never travels through this function, so + * nothing here can leak it into a connection object, an error or an audit row. + */ + if (access.credential === "brokered") { + if (!actorId) { + throw new PluginRefusedError( + `${row.title} runs in the account of the person asking, and this run is not attributed to anybody.`, + null, + ); + } + + /* + * Narrowing, and a refusal that is genuinely reachable. + * + * `access.toolkit` is the app slug read off this row's url in `access.ts`, and it is NULL + * whenever that url does not name a Composio app — `accessFor` still answers `brokered` for + * any row whose provenance column says composio, so `{ credential: "brokered", toolkit: null }` + * is a state a hand-edited or restored row really produces. The test beside `accessFor` + * asserts it, and `plugin-store.integration.test.ts` gates this branch end to end. + * + * A throw rather than a fallback, for the reason the `user-oauth` narrowing below throws: both + * alternatives fail open. Falling back to `row.id` checks the connection against a spelling + * nothing dials, and skipping the gate spends the deployment's shared key on a connector whose + * whole purpose is to keep one person's account out of another's. The compiler forces SOME + * narrowing here — drizzle's `eq` will not take `string | null` — but only the test named + * above stops that narrowing from being the fallback. + */ + if (!access.toolkit) { + throw new PluginInvariantError( + `${row.id} resolves to a brokered credential with no Composio app in its url.`, + ); + } + + /* + * AN APP THAT NEEDS NO AUTHENTICATION HAS NO ROW TO FIND, AND CANNOT EVER HAVE ONE. + * + * `composio_connections` is the whole of the permission for a brokered call, and every row in + * it means one thing: this person granted this deployment access to their account at this + * app. A `NO_AUTH` app has no account and no consent — Composio refuses even to hold an + * authorization config for one — so nobody presses Connect and nothing could write the row. + * + * THE ALTERNATIVE WAS WRITING ONE ANYWAY, and it is worse than it looks. Offboarding reads + * this table to find what to revoke, the audit trail reads it to say what somebody had, and + * disconnect reads it to know what to end. Rows where no person consented and no account + * exists are indistinguishable, a year on, from rows where somebody did. + * + * The scheme is the one RECORDED when the app was enabled rather than a fresh read of the + * catalogue: a vendor that re-labels an app must not turn a gate off underneath a deployment + * that is already running. + */ + if (row.authScheme === "NO_AUTH") return {}; + + /* + * Keyed on the app the call will run in, which is the one the url names. + * + * `row.id` is a display key and nothing holds it equal to the slug in the url, so a row named + * `gmail` at `composio://slack` passed this gate on a Gmail connection and then ran a Slack + * action — the person having connected an app they were never asked about. + */ + const [connected] = await database + .select({ toolkit: composioConnections.toolkit }) + .from(composioConnections) + .where( + and( + eq(composioConnections.toolkit, access.toolkit), + eq(composioConnections.userId, actorId), + ), + ) + .limit(1); + + if (!connected) { + throw new PluginRefusedError( + `You have not connected your ${row.title} account. Connect it in Settings and ask again.`, + null, + ); + } + + return {}; + } + + if (access.credential !== "person-oauth") { const token = row.credentialId ? await secretFor( row.credentialId, @@ -783,6 +1290,24 @@ export function createPluginStore(options: PluginStoreOptions) { return { token }; } + /* + * Narrowing, not a second decision. + * + * `access.credential === "person-oauth"` is derived in `access.ts` from exactly this auth kind, + * so the branch above has already established it — but the derivation runs through a lookup + * table the compiler cannot follow back to `entry`. Nothing below re-decides whether this is a + * per-person server; it only reads the OAuth details that kind carries. + * + * A throw rather than a fallback. If the descriptor and the entry ever did disagree, answering + * out of the deployment's own credential is precisely the failure the comment above this function + * says must be impossible. + */ + if (entry?.auth.kind !== "user-oauth") { + throw new PluginInvariantError( + `${row.id} resolves to a per-person credential with no user-oauth catalogue entry.`, + ); + } + /* * The anonymous actor is the empty string, and an empty string must never match a row. * @@ -1585,6 +2110,7 @@ export function createPluginStore(options: PluginStoreOptions) { .limit(1); if (!row) throw new CatalogueEntryUnknownError(serverId); + // Null for a custom server, and every caller handles that by assuming the worst about it. const entry = catalogueEntry(row.id); if (row.provenance === "first-party" && !entry) { // The row outlived its catalogue entry, which means a build removed a vendor while a @@ -1593,8 +2119,14 @@ export function createPluginStore(options: PluginStoreOptions) { // is one we agreed to talk to. throw new CatalogueEntryUnknownError(row.id); } - // Null for a custom server, and every caller handles that by assuming the worst about it. - return { row, entry }; + /* + * Resolved here so every caller reads the same answer. + * + * Three call sites used to derive their own — the transport, the credential and the audit row — + * and a Composio app made all three of them wrong at once. One derivation means they cannot + * disagree, and `access.ts` is the only place a new kind of server has to be taught about. + */ + return { row, entry, access: accessFor(row, entry) }; } return { @@ -1714,6 +2246,19 @@ export function createPluginStore(options: PluginStoreOptions) { `${input.id} is the name of a server this deployment already knows. Choose another.`, ); } + + /* + * Nor may it take the name of a screen. `/admin/plugins/composio` is a static route — the app + * directory's own page — and a static route is matched ahead of `/admin/plugins/$key`, so a + * server sitting at this id would be listed and then open somebody else's page instead of its + * own. Brokered servers are `composio-` and no catalogue entry is called this, which + * leaves a hand-typed id as the only way to reach it. + */ + if (input.id === "composio") { + throw new CustomServerRefusedError( + "composio is the name of this deployment's own Composio screen, so a server added there could never be opened. Choose another.", + ); + } if (!/^[a-z0-9][a-z0-9-]{0,38}[a-z0-9]$/.test(input.id)) { throw new CustomServerRefusedError( "A server name is lower-case letters, numbers and hyphens.", @@ -1845,6 +2390,167 @@ export function createPluginStore(options: PluginStoreOptions) { return added; }, + /** + * Enable one app of the broker's catalogue, which is a third way for a server to arrive. + * + * NO URL IS TAKEN FROM A CALLER, WHICH IS WHY THERE IS NO HOST RULE HERE. `addCustomServer` + * guards the address because the address is what an administrator typed and what a credential + * would then be spent at; this one composes `composio://` itself, and a brokered row is + * never dialled at a host at all — the transport reads the app off that url and asks Composio, + * over the deployment's own key. So the only thing left to check about the url is that it says + * what this call meant, and {@link toolkitOf} is what checks it: the slug goes in, the url comes + * back out through the very function `accessFor` will read it with, and a slug those two + * disagree about is refused rather than stored. A pattern written here instead would be a second + * opinion about the shape of an app name, and the reading that decides which app a call runs + * against is the one that has to be satisfied. + * + * THE AUTH CONFIG COMES BEFORE THE ROW, in that order and not the other. An auth config is what + * a person's connection is then created against, so a row written first is an app an + * administrator can see on the page, grant to a Bot and press Connect on, with nothing at the + * vendor for any of it to attach to. Asking first is also what makes a failure leave nothing + * behind: the broker throws, this call throws, and no row, no action and no audit entry claims + * an app was enabled. {@link ComposioBroker.ensureAuthConfig} is idempotent precisely so that + * enabling an app twice — two administrators, or a retried request — is allowed to do this. + * + * THE ID IS PREFIXED AND THE URL IS NOT. `composio-linear` is what prefixes tool names and what + * a grant and a policy rule are written against, so it must not land on a curated entry's key — + * which `accessFor` refuses outright as a row claiming to be two servers at once — nor on one of + * the ids the integration suite reserves for its own fixtures: `gmail`, `notion`, `bot_helper`. + * The prefix puts every brokered row out of reach of all of them. WHICH APP THE ROW IS still + * comes off the url and only off the url, because that is where `accessFor` and the brokered + * gate behind it both read it from; the id names the row and never the app, and nothing may + * start reading one as the other. + */ + async addBrokeredApp(input: { + slug: string; + title: string; + by: string; + /** + * How this app connects, resolved from the catalogue row the administrator chose. + * + * Taken rather than derived here, because the caller has already read it off that row and a + * second derivation is a second answer — the one thing {@link BrokerConnection} exists to + * prevent. It decides what config is created at the vendor, and it is what this row records. + */ + connection: BrokerConnection; + }): Promise { + // Before anything at all. A deployment with no key has no catalogue for this app to have been + // chosen from, so there is nothing here to half-do and nothing to say but the setting. + if (!broker) throw new BrokerUnconfiguredError(); + + const url = `composio://${input.slug}`; + if (toolkitOf(url) !== input.slug) { + throw new CustomServerRefusedError( + `${input.slug} is not a name a Composio app can have. An app is named in letters, numbers, underscores and hyphens, because that name is read back out of this row's url to decide which app a call is against.`, + ); + } + + await broker.ensureAuthConfig({ + toolkit: input.slug, + name: input.title, + connection: input.connection, + }); + + const id = `composio-${input.slug}`; + await database + .insert(mcpServers) + .values({ + id, + title: input.title, + // The broker, whoever publishes the app behind it. `vendor` is what the first-party rule + // is checked against, and Composio is who this deployment is actually talking to. + vendor: "Composio", + url, + provenance: "composio", + // Nothing for the vault to hold. A brokered call runs as the person asking, on their own + // connection at the vendor, which is a `composio_connections` row rather than a secret. + credentialId: null, + // What the config this call just made was created AS, which is the scheme every later + // connection against it has to keep using. Written on the way in, and after that only + // where nothing is connected to be moved by it — see the statement below the upsert. + authScheme: schemeFor(input.connection), + addedBy: input.by, + }) + .onConflictDoUpdate({ + target: mcpServers.id, + set: { + title: input.title, + url, + addedBy: input.by, + updatedAt: new Date(), + /* + * `credential_id` is neither written here nor cleared here. + * + * Every row this method creates has none and no path in this module attaches one, so + * there is nothing for an enable to set. Clearing it anyway would matter in the single + * case it could apply — a pointer that arrived by hand edit or restore — because + * `removeServer` retires a server's secret by reading it off this column, and a null + * written over it leaves that secret live with nothing left to name it. + */ + /* + * `auth_scheme` is not written here either, and for a neighbouring reason. + * + * This is the branch a second press of Add takes, and the scheme is the one thing on + * the row that live connections depend on rather than merely display. Rewriting it + * here would move them; the statement below rewrites it only where there are none. + */ + }, + }); + + /* + * WRITE-ONCE, EXCEPT WHERE THERE IS NOTHING TO STRAND. + * + * A row's scheme is what its authorization config was created as, and every connection made + * against that config depends on it. Re-enabling must not rewrite it underneath them: a + * vendor that starts publishing managed OAuth for an app somebody connected by key would, + * one press of Add later, leave this deployment minting consent links against a config full + * of keys. + * + * With no connections there is no such dependence, so the rewrite is safe and useful — it is + * how an operator picks up a vendor's change without removing and re-adding the app. + */ + const connections = await database + .select({ userId: composioConnections.userId }) + .from(composioConnections) + .where(eq(composioConnections.toolkit, input.slug)) + .limit(1); + + if (connections.length === 0) { + await database + .update(mcpServers) + .set({ + authScheme: schemeFor(input.connection), + updatedAt: new Date(), + }) + .where(eq(mcpServers.id, id)); + } + + await recordAuditEvent(auditStore, { + eventType: "configuration.changed", + targetType: "mcp_server", + targetId: id, + payload: { + actor: input.by, + change: "mcp_server_added", + server: id, + url, + // Named for the same reason the custom path names its own: "who enabled an app whose + // actions nobody reviewed" is a question somebody will ask, and the answer should not + // require knowing how ids were spelled in a past build. + provenance: "composio", + }, + }); + + // Refreshed now for the reason the paths above are: the page that enabled the app can show + // what it offers, and a broker that will not list it says so here rather than at first use. + await this.refreshTools(id); + const added = (await this.listServers()).find( + (server) => server.id === id, + ); + if (!added) throw new CatalogueEntryUnknownError(id); + return added; + }, + /** * Remove a server, and stop every secret it was reached with being live. * @@ -1862,6 +2568,15 @@ export function createPluginStore(options: PluginStoreOptions) { * * Revoked rather than deleted, because the vault keeps revoked rows for audit. * + * A THIRD KIND OF ACCESS THAT IS NOT A SECRET. A brokered app holds no per-person secret at all + * — Composio keeps the accounts and the deployment sends a user id — so the only thing standing + * between a person and their mailbox is a `composio_connections` row, and that table references + * nothing that would cascade it. Removing the app therefore left every one of them behind, and + * adding the app back turned them live again without anybody being asked. That row goes too — + * and before it goes, the account it stands for is ended at Composio, because clearing the row + * alone shuts a gate and leaves the mailbox attached. The deployment's auth config for the app + * goes last, once nobody is connected to it any more. + * * The revokes go first. These are writes on two tables and the store exposes no transaction that * spans both, so the order decides what a failure between them leaves: revoke-then-delete leaves * a server whose secrets no longer work and which removing again will finish off, while @@ -1869,7 +2584,13 @@ export function createPluginStore(options: PluginStoreOptions) { */ async removeServer(serverId: string, by: string): Promise { const [existing] = await database - .select({ credentialId: mcpServers.credentialId }) + .select({ + credentialId: mcpServers.credentialId, + // Read so the brokered connections below can be keyed on the app the url names, which is + // the same key the call gate uses. See there for why the row id will not do. + provenance: mcpServers.provenance, + url: mcpServers.url, + }) .from(mcpServers) .where(eq(mcpServers.id, serverId)); @@ -1948,16 +2669,155 @@ export function createPluginStore(options: PluginStoreOptions) { * access should see which of the three this was. */ reason: "mcp_server_removed", - vendorRevoked: false, + vendorRevocationRequested: false, }, }); } - await database.delete(mcpServers).where(eq(mcpServers.id, serverId)); - await recordAuditEvent(auditStore, { - eventType: "configuration.changed", - targetType: "mcp_server", - targetId: serverId, + /* + * The third kind of access, which is not a secret at all: everybody's brokered connection. + * + * CRITERION. Removing an app must leave nobody holding brokered access to it, so that adding + * it back again grants nothing until each person has consented afresh. + * + * REASON. `composio_connections` is the whole gate on a brokered call and it references + * nothing — not `mcp_servers`, not `users` — so nothing cascaded it and removing the app left + * every row standing. Two ordinary administrative acts, remove and add back, then restored + * everybody's access at a url the second act chose, with nobody asked again and no screen + * saying it had happened. Consent that reattaches by itself is not consent. + * + * KEYED ON THE APP THE URL NAMES, exactly as `connectionTokenFor` keys the gate. `row.id` is a + * display key and nothing holds it equal to the slug in the url, so a delete by id would clear + * some other app's connections, or none, for the very row shape that gate already refuses to + * trust. `accessFor` is asked rather than the url parsed here, so this cannot drift from it. + * + * ASKED WITH NO ENTRY, deliberately, and that is not the entry-wins order being dodged. An + * entry can only ever SUPPRESS this answer — `accessFor` returns a null toolkit for every row + * that has one — so passing the entry a colliding id looks up would hide the brokered state of + * the one row most in need of clearing, and would now refuse outright the very row this method + * exists to get rid of, leaving the collision unremovable. Nothing is dialled here, so there is + * no vendor for an entry to protect; the only question is which app's consent rows this row's + * own url stands for. + * + * Before the server row goes, for the reason the revokes above are: what a failure between + * two writes leaves has to be the recoverable half. A connection cleared with the app still + * present is fixed by removing it again; an app deleted with the connections standing is + * reachable by no operation at all, because the toolkit was only ever readable off its url. + * + * AND THE ACCOUNT IS ENDED AT THE VENDOR, not merely forgotten here. Deleting the row closes + * the gate this deployment owns and does nothing whatever to the account: the person's + * mailbox stays attached at Composio, the grant stays live, and an administrator who pressed + * "remove" was told the connector was gone. So every connected person is revoked through the + * broker first, exactly as {@link disconnectBrokered} revokes for one — the same + * shape at the scale of an app. + * + * REVOKE BEFORE DELETE, ALWAYS, and the argument is the one that method makes. The row is the + * only thing here that names which app this person connected, so a delete that ran first + * would leave a failed revoke with nothing to revoke under: a live grant on somebody's + * mailbox that no operation in this deployment can reach. The other order costs a repeat of + * an administrative act nobody minds repeating. Dead and reachable beats live and + * unreachable. + * + * WHICH MAKES THE FAILURE LOUD. Nothing is caught around the revokes: a broker that will not + * answer ends this method with the rows still standing and the app still present, rather than + * letting it report an ending that did not happen. + * + * THE AUTH CONFIG GOES LAST, after every account is dead and every row is gone, for the same + * reasoning one step out. An orphaned auth config grants nobody anything — it is a shape this + * deployment holds at Composio, not an account — while a live account whose config has + * already been deleted is access that nothing left here can end. + */ + const toolkit = existing ? accessFor(existing, null).toolkit : null; + + if (toolkit) { + /* + * Read before anything is deleted, because the revokes below need the people and the rows + * are where the people are. Sorted, so two removals of the same app revoke in the same + * order and write their trail rows in the same order. + */ + const connected = await database + .select({ userId: composioConnections.userId }) + .from(composioConnections) + .where(eq(composioConnections.toolkit, toolkit)) + .orderBy(asc(composioConnections.userId)); + + /* + * What the broker was actually asked for each of them, kept so the trail below records the + * answer rather than the call. False where there is no broker at all: a deployment whose + * key has since been unset can still remove the app, and it could not have been calling it + * either way — but nothing was asked of Composio and the row must not claim otherwise. + */ + const vendorRevocationRequested = new Map(); + for (const connection of connected) { + vendorRevocationRequested.set( + connection.userId, + broker + ? await broker.revoke({ userId: connection.userId, toolkit }) + : false, + ); + } + + await database + .delete(composioConnections) + .where(eq(composioConnections.toolkit, toolkit)); + + for (const connection of connected) { + await recordAuditEvent(auditStore, { + eventType: "mcp.account_disconnected", + targetType: "mcp_server", + /* + * THE APP, not this row's id, and the same key `retireConnectionsFor` files under. + * + * CRITERION. Every `mcp.account_disconnected` row a brokered connection produces is + * keyed on the app at the broker, whichever act produced it, so one query answers + * what happened to one person's brokered access. + * + * REASON. The two acts that can end such a connection were keyed differently: this + * one on `mcp_servers.id`, offboarding on `composio_connections.toolkit` — which is + * all that row records and all that is left once the server row is gone. Nothing + * holds the two strings equal, so on any renamed row half the trail is filed under a + * name the other half never mentions, and the disagreement is invisible everywhere + * they happen to match. + * + * THE APP IS WHAT WAS CONSENTED TO. The gate is `(toolkit, user_id)`, the delete + * above is by toolkit, and the row outlives the server row entirely; the id is a + * display key that may not exist by the time somebody asks. Which server row was + * removed is not lost — the `configuration.changed` row written below names it. + */ + targetId: toolkit, + payload: { + actor: by, + server: toolkit, + owner: connection.userId, + // The same three-way distinction the vault loop above draws, and the same answer: an + // administrator took the whole app away and the person did nothing. + reason: "mcp_server_removed", + /* + * What was asked of the vendor, not that a call was made — {@link + * ComposioBroker.revoke}'s own answer, passed through. True where an account was + * found and its withdrawal asked for, false where there was none to withdraw or + * where this deployment has no broker to have asked. The value of the field is + * exactly that a reader can tell an account this deployment acted on from one that + * outlives it somewhere else, so a constant here would be worse than none. It says + * "requested" because that is the strongest thing the vendor's answer supports: + * the upstream withdrawal runs as a background job nothing here can poll. + */ + vendorRevocationRequested: + vendorRevocationRequested.get(connection.userId) ?? false, + }, + }); + } + + // Last of all, for the reason above, and skipped entirely on a deployment with no + // broker to have made one. + if (broker) await broker.deleteAuthConfig(toolkit); + } + + await database.delete(mcpServers).where(eq(mcpServers.id, serverId)); + await recordAuditEvent(auditStore, { + eventType: "configuration.changed", + targetType: "mcp_server", + targetId: serverId, payload: { actor: by, change: "mcp_server_removed", server: serverId }, }); }, @@ -1986,12 +2846,73 @@ export function createPluginStore(options: PluginStoreOptions) { serverId: string, actorId = "", ): Promise<{ tools: number }> { - const { row, entry } = await requireServer(serverId); + const { row, entry, access } = await requireServer(serverId); - try { - // The entry decides the protocol. For a custom server there is no entry, and MCP is right. - const transport = transportFor(entry); + /* + * Who the trail says asked for this listing, which is not the same value as who to list AS. + * + * CRITERION. The two audit rows below must never name an actor of `""`. + * + * REASON. `actorId` does double duty: it selects the person's credential where listing needs + * one, and it is copied into those rows. The add paths pass neither, deliberately — nobody can + * have connected an app in the second it is added, and the comment above this method says why + * requiring one there was wrong. So the absence is permanent and correct for the credential, + * and meaningless for the trail, which was left writing `actor: ""` on every row an add + * produced. The deployment refreshing on its own behalf is a real answer and `reachedAs` + * already spells it that way; see {@link DEPLOYMENT_ACTOR}. Held separately rather than + * defaulting the parameter, because defaulting it would hand `connectionTokenFor` a person + * called "deployment" to look a grant up by. + */ + const auditActor = actorId || DEPLOYMENT_ACTOR; + + // How a row is reached is resolved once, in `requireServer`. Derived from the entry here, + // a Composio app — which has no entry — was dialled as MCP at `composio://gmail`. + const transport = transportFor(access.transport); + + /* + * A brokered row with no app in its url has nobody to ask, and saying so is not the transport's + * job. + * + * CRITERION. A listing this deployment could not even attempt must not be committed as a + * refresh, and must not be written down as a vendor's answer. + * + * REASON. `accessFor` answers `brokered` for every row whose provenance column says so, and + * reads the app slug off the url — so `{ credential: "brokered", toolkit: null }` is a real + * state, which a hand edit or a restored backup produces and nothing in the product does. + * `connectionTokenFor` already refuses it, but only where listing needs a credential, and a + * brokered listing needs none: the broker publishes an action's schema to anybody. So the gate + * was skipped on exactly the path that reaches the vendor with no app named, and the transport + * answered `[]` — indistinguishable, one line later, from an app that advertises nothing. + * + * READ AS FIELDS, not as a transport. `credential` and `toolkit` are both resolved in + * `access.ts` and this asks nothing about which protocol is underneath: any broker reached + * without an app named is unroutable, which is the property `toolkit` is documented to carry. + * A `transport === "composio"` test here would put back the per-call-site derivation that + * module exists to have removed. + */ + if (access.credential === "brokered" && !access.toolkit) { + throw new PluginInvariantError( + `${row.id} resolves to a brokered credential with no app in its url, so there is nothing to ask what it offers.`, + ); + } + /* + * ASKING THE VENDOR, and the only part of this method whose failure is a vendor's. + * + * CRITERION. What lands in `lastError` must be something a vendor, a credential or a person + * could have caused. An invariant this deployment violated and a fault in its own database must + * not read as a vendor misbehaving. + * + * REASON. This used to be one `try` around everything below as well — the wholesale replace, + * the server-row update and both audit writes — with a `catch` that copied any message into + * `lastError` and answered `{ tools: 0 }`. So a statement timeout, a duplicate-key refusal or an + * `audit_events` insert that would not go in all reported a vendor that had in fact answered + * correctly, and reported it beside actions the refresh had already committed. Narrowing that + * by error class would be narrowing by prose; narrowing it by SHAPE is what this split does, so + * a line added below cannot quietly acquire a vendor's excuse. + */ + let listed: ListedTool[]; + try { /* * A credential only when listing actually needs one. * @@ -2008,120 +2929,44 @@ export function createPluginStore(options: PluginStoreOptions) { * a function that discards it. The gate outlived the reason for it. */ const token = transport.listNeedsCredential - ? (await connectionTokenFor(row, entry, actorId)).token + ? (await connectionTokenFor(row, entry, actorId, access)).token : undefined; - const tools = await transport.listTools({ + listed = await transport.listTools({ url: effectiveUrl(row, entry), token, }); - - /* - * ONE STEP, because the catch below promises that it is one. - * - * "The tools already held are left alone" is only true while nothing has been written yet. - * As two auto-committed statements the delete landed on its own whenever the insert did not: - * a pod killed mid-refresh, a dropped connection, a statement timeout — or, with no crash at - * all, a server that answers `tools/list` with the same `name` twice, which `mcp_tools`' - * `(server_id, name)` primary key refuses as one multi-row insert. `mcp_tools` is shared, so - * that is every replica at once, and nothing repopulates it: `refreshTools` is only ever - * called by `addServer`, `addCustomServer` and an administrator pressing Refresh. The - * connector kept every grant an administrator had made and offered none of them, and - * `grantedToolGuidance` then told the Bot outright that it holds none of that vendor's tools. - * - * Rolled back together, the vendor's bad answer is recorded in `lastError` and the Bots go - * on using what they were granted, which is what the comment said all along. - */ - await database.transaction(async (transaction) => { - await transaction - .delete(mcpTools) - .where(eq(mcpTools.serverId, serverId)); - if (tools.length > 0) { - await transaction.insert(mcpTools).values( - tools.map((tool) => ({ - serverId, - name: tool.name, - description: tool.description, - inputSchema: tool.inputSchema, - })), - ); - } - }); - - await database - .update(mcpServers) - .set({ - toolsRefreshedAt: new Date(), - lastError: null, - updatedAt: new Date(), - }) - .where(eq(mcpServers.id, serverId)); - + } catch (error) { /* - * A grant left pointing at nothing goes in the trail, at the moment it starts pointing at - * nothing. - * - * Reporting it on a screen answers "what is true now", which somebody has to go and look at. - * This answers "when did it stop being offered, and what was holding it" — the question asked - * after a transport is swapped back and a name starts resolving again. Without the row, the - * only record of the gap is its absence. + * Ours rather than a vendor's, asked as one question about the whole shelf. * - * Not a refusal and not an error, so `configuration.changed` rather than a new event type: - * nothing was denied and the refresh succeeded. Written after the tool list is replaced, so - * what it names is what is actually left over. - */ - const advertised = new Set(tools.map((tool) => tool.name)); - const stranded = [...(await mcpGrantsForServers([serverId])).entries()] - .filter(([ref]) => !advertised.has(ref.slice(serverId.length + 1))) - .sort(([left], [right]) => left.localeCompare(right)); - - if (stranded.length > 0) { - await recordAuditEvent(auditStore, { - eventType: "configuration.changed", - targetType: "mcp_server", - targetId: serverId, - payload: { - actor: actorId, - change: "grants_not_advertised", - server: serverId, - // The refs, because that is what a grant is keyed on and what an administrator revokes. - refs: stranded.map(([ref]) => ref), - bots: [...new Set(stranded.flatMap(([, agents]) => agents))], - note: "Held by a Bot and not offered to any model, because this server no longer advertises the tool. Offered again if it starts.", - }, - }); - } - - /* - * Tools the vendor advertises that this deployment's write list does not name. + * CRITERION. Nothing on the `isDeploymentFault` shelf is written into `lastError`, and + * nothing raised from here carries a statement or a bound value. * - * The mechanical half of the reconciliation Notion's catalogue entry says is required. See - * {@link unlistedAdvertisedTools} for why only that shape of vendor is named here: an - * advertised tool absent from `writeTools` classifies as a READ, so an under-inclusive list - * is silent, and for a vendor with no scope strings there is nothing else standing behind it. + * WHAT THIS USED TO BE, and why the difference is not cosmetic. It read `error instanceof + * PluginInvariantError` — which was DEAD, and its own comment named two throws that cannot + * arrive here: `connectionTokenFor` is only called when `transport.listNeedsCredential`, + * which is false for `composio`, the only brokered transport, so the brokered narrowing + * cannot fire inside this `try`; and the `person-oauth` narrowing is unreachable because + * `accessFor` answers that credential only for a `user-oauth` entry. So the line could be + * deleted with every test still green while the arrival it should have been catching — + * a query of ours failing — went straight past it into the column below. * - * `configuration.changed` rather than a type of its own, the same as the stranded grants - * above and for the same reason: nothing was denied and the refresh succeeded. What changed - * is that the deployment now knows a name it had not classified. + * A QUERY FAILURE IS THE REACHABLE ONE. `connectionTokenFor`'s vault read, its connection + * lookup and its locked credential swap all run inside this `try` for an MCP listing, and + * each throws a `DrizzleQueryError` whose message is the statement plus every value bound + * to it. Recorded, that put a SQL dump in the column the Plugins page draws, under a + * heading that says a vendor said it. Raised as an invariant of ours, with the driver's + * complaint and none of the query. */ - const unlisted = unlistedAdvertisedTools(entry, [...advertised]); - if (unlisted.length > 0) { - await recordAuditEvent(auditStore, { - eventType: "configuration.changed", - targetType: "mcp_server", - targetId: serverId, - payload: { - actor: actorId, - change: "unlisted_tools_advertised", - server: serverId, - tools: unlisted, - note: "Advertised by this server and not named in its reviewed write list, so each is offered to models as a read. This vendor has no read-only scope behind that list, so anything here that writes should be added to the entry.", - }, - }); + if (isDeploymentFault(error)) { + throw isQueryFailure(error) + ? new PluginInvariantError( + `${row.id}: asking this app what it offers failed on a query of this deployment's own, so nothing about the app was learned and nothing it holds was changed. ${databaseComplaint(error)}`, + ) + : error; } - return { tools: tools.length }; - } catch (error) { const message = error instanceof McpServerError || error instanceof Error ? error.message @@ -2146,6 +2991,211 @@ export function createPluginStore(options: PluginStoreOptions) { .where(eq(mcpServers.id, serverId)); return { tools: 0 }; } + + /* + * An empty listing never destroys what a real listing recorded. + * + * CRITERION ONE. An empty answer must not be committed as a healthy refresh where doing so + * would delete actions this deployment holds, and must not clear `lastError`. + * + * CRITERION TWO. "This app advertises nothing" stays recordable: an app with nothing held has + * nothing to lose, so the empty answer falls through to the replace below and commits — a + * refresh stamp, no error, no actions. + * + * REASON. The replace below is a delete and an insert, so an empty answer committed here + * deletes every `mcp_tools` row for the server, taking the recorded `effect`, `destructive` + * and `version` with it. `version` is the one that cannot be reconstructed: `callTool` refuses + * an action without it, so a refresh that reported success broke every subsequent call, and + * the grants survived pointing at rows that no longer existed — absent from `listServers`, and + * revived only by a later refresh that worked. + * + * WHAT USED TO REACH THIS LINE, and no longer does. `composio.listTools` once answered `[]` + * for a url naming no app and for a deployment with no Composio client installed, neither of + * which is a vendor's answer, and the second of those is the state of every real deployment. + * Both throw now, so that particular arrival is closed at the seam rather than here. The guard + * stays because its argument never depended on who sent the empty answer. + * + * KEPT RATHER THAN TRUSTED, and that asymmetry is the whole argument. Holding actions the vendor + * has withdrawn is visible and reversible: the next listing replaces them. Deleting actions the + * vendor never withdrew is neither — `mcp_tools` is shared, so it is every replica at once, and + * only a refresh from a deployment that can actually reach the vendor puts it back. That holds + * for any vendor that suddenly lists nothing, whatever made it do so, which is why removing + * this would reopen the same data loss for a different reason. + * + * THE SEAM REQUIREMENT this leans on, and it is satisfied: a transport that could not ask + * anybody must THROW rather than return `[]`. `composio.listTools` opens with two throws that + * say which of the two it is — no app in the url, no client installed — and no transport has + * an early `return []` left in it at all: `builtin-routines` answers a static list, and `mcp` + * and `google-drive-rest` hand back only what a request returned. So an empty listing reaching + * this line is a vendor's own answer, which is what the sentence below says. Nothing here asks + * which transport it is talking to, and nothing has to: the requirement is met at each seam + * rather than branched on here. + */ + if (listed.length === 0) { + const held = await database + .select({ name: mcpTools.name }) + .from(mcpTools) + .where(eq(mcpTools.serverId, serverId)); + + if (held.length > 0) { + await database + .update(mcpServers) + .set({ + // Named as the state it is, because "listed nothing" and "would not answer" send an + // operator to different places, and reaching this line settles which one it was: a + // listing that could not be made throws and lands in the `catch` above instead. So + // this sentence must not send anybody to check their configuration — that is the + // other state's sentence, written by the transport that refused. No + // `toolsRefreshedAt`: that column says when this deployment last learned what the app + // offers, and it did not learn it here. + lastError: `This app was asked and answered with no actions at all, so the ${held.length} already recorded for it were kept rather than deleted. Check whether it still publishes them, then refresh again.`, + updatedAt: new Date(), + }) + .where(eq(mcpServers.id, serverId)); + // What the app advertises, which is what it advertised before: the honest count, because + // nothing was replaced. + return { tools: held.length }; + } + } + + /* + * COMMITTING WHAT THE VENDOR SAID. Nothing from here down is a vendor's doing, so nothing from + * here down is caught — see the criterion on the `try` above. + * + * ONE STEP, because the paragraph above promises the held actions are left alone. + * + * "The tools already held are left alone" is only true while nothing has been written yet. + * As two auto-committed statements the delete landed on its own whenever the insert did not: + * a pod killed mid-refresh, a dropped connection, a statement timeout — or, with no crash at + * all, a server that answers `tools/list` with the same `name` twice, which `mcp_tools`' + * `(server_id, name)` primary key refuses as one multi-row insert. `mcp_tools` is shared, so + * that is every replica at once, and nothing repopulates it: `refreshTools` is only ever + * called by `addServer`, `addCustomServer` and an administrator pressing Refresh. The + * connector kept every grant an administrator had made and offered none of them, and + * `grantedToolGuidance` then told the Bot outright that it holds none of that vendor's tools. + * + * Rolled back together, the Bots go on using what they were granted — and the fault raises + * rather than being copied into `lastError`, because a transaction this database would not take + * is not something the vendor did. + */ + // Names deduplicated and vendor text made storable before a transaction is opened on any of + // it, because both failures used to abort the replace from inside one. See + // {@link storableTools}. + const storable = storableTools(serverId, listed); + + try { + await database.transaction(async (transaction) => { + await transaction + .delete(mcpTools) + .where(eq(mcpTools.serverId, serverId)); + if (storable.length > 0) { + await transaction.insert(mcpTools).values(storable); + } + }); + } catch (error) { + /* + * A database failure, with the statement and its parameters left behind. + * + * CRITERION. Nothing raised from here carries the SQL or the values bound to it. + * + * REASON. drizzle wraps every failure as a `DrizzleQueryError`, whose message is + * `Failed query:` followed by the whole statement and then every parameter — here, the + * vendor's entire tool list. That message is what an unhandled throw puts in the logs and + * what any caller that prints an error puts on a screen. A SQL dump on an error path is + * the same disclosure shape as a credential leak one layer out, and it is gratuitous: the + * driver's own complaint says what went wrong without any of it. + * + * RAISED, NOT RECORDED, which is what the paragraph above this transaction argues for and + * is now true rather than merely intended: with duplicate names and unstorable text + * removed before the statement is built, what is left is this database refusing something + * this deployment's own schema says it will take, and `lastError` is where a VENDOR's + * answer goes. + */ + throw new PluginInvariantError( + `${row.id}: the actions this app listed were not stored, so what it already had is unchanged. ${databaseComplaint(error)}`, + ); + } + + await database + .update(mcpServers) + .set({ + toolsRefreshedAt: new Date(), + lastError: null, + updatedAt: new Date(), + }) + .where(eq(mcpServers.id, serverId)); + + /* + * A grant left pointing at nothing goes in the trail, at the moment it starts pointing at + * nothing. + * + * Reporting it on a screen answers "what is true now", which somebody has to go and look at. + * This answers "when did it stop being offered, and what was holding it" — the question asked + * after a transport is swapped back and a name starts resolving again. Without the row, the + * only record of the gap is its absence. + * + * Not a refusal and not an error, so `configuration.changed` rather than a new event type: + * nothing was denied and the refresh succeeded. Written after the tool list is replaced, so + * what it names is what is actually left over — and only ever after a listing that was + * committed, because the guard above returns before this on an empty answer that would have + * named every grant the app holds. + */ + // The names as STORED, so a grant is compared against a row that exists: a duplicate the + // vendor listed twice is one row, and a name is spelled here the way the insert spelled it. + const advertised = new Set(storable.map((tool) => tool.name)); + const stranded = [...(await mcpGrantsForServers([serverId])).entries()] + .filter(([ref]) => !advertised.has(ref.slice(serverId.length + 1))) + .sort(([left], [right]) => left.localeCompare(right)); + + if (stranded.length > 0) { + await recordAuditEvent(auditStore, { + eventType: "configuration.changed", + targetType: "mcp_server", + targetId: serverId, + payload: { + actor: auditActor, + change: "grants_not_advertised", + server: serverId, + // The refs, because that is what a grant is keyed on and what an administrator revokes. + refs: stranded.map(([ref]) => ref), + bots: [...new Set(stranded.flatMap(([, agents]) => agents))], + note: "Held by a Bot and not offered to any model, because this server no longer advertises the tool. Offered again if it starts.", + }, + }); + } + + /* + * Tools the vendor advertises that this deployment's write list does not name. + * + * The mechanical half of the reconciliation Notion's catalogue entry says is required. See + * {@link unlistedAdvertisedTools} for why only that shape of vendor is named here: an + * advertised tool absent from `writeTools` classifies as a READ, so an under-inclusive list + * is silent, and for a vendor with no scope strings there is nothing else standing behind it. + * + * `configuration.changed` rather than a type of its own, the same as the stranded grants + * above and for the same reason: nothing was denied and the refresh succeeded. What changed + * is that the deployment now knows a name it had not classified. + */ + const unlisted = unlistedAdvertisedTools(entry, [...advertised]); + if (unlisted.length > 0) { + await recordAuditEvent(auditStore, { + eventType: "configuration.changed", + targetType: "mcp_server", + targetId: serverId, + payload: { + actor: auditActor, + change: "unlisted_tools_advertised", + server: serverId, + tools: unlisted, + note: "Advertised by this server and not named in its reviewed write list, so each is offered to models as a read. This vendor has no read-only scope behind that list, so anything here that writes should be added to the entry.", + }, + }); + } + + // What was recorded, which is what "this app offers N actions" means on the page. Counting + // the listing instead reported a duplicate the vendor named twice as two actions the + // deployment holds, when `mcp_tools` holds one row for it. + return { tools: storable.length }; }, async listServers(): Promise { @@ -2193,6 +3243,7 @@ export function createPluginStore(options: PluginStoreOptions) { dynamicClient: entry?.auth.kind === "user-oauth" && entry.auth.clientRegistration === "dynamic", + authScheme: row.authScheme, tools: tools .filter((tool) => tool.serverId === row.id) .map((tool) => { @@ -2203,7 +3254,8 @@ export function createPluginStore(options: PluginStoreOptions) { description: tool.description, inputSchema: tool.inputSchema as Record, ref, - effect: classifyTool(entry, tool.name, true), + effect: classifyTool(entry, tool.name, true, tool.effect), + destructive: tool.destructive, grantedTo: grants.get(ref) ?? [], }; }), @@ -2226,6 +3278,63 @@ export function createPluginStore(options: PluginStoreOptions) { }); }, + /** + * Where one server is, by id, and nothing that hangs off it. + * + * WHY IT EXISTS BESIDE {@link listServers}. Three routes asked that one for a single row's url + * — the brokered branch of connect, and the confirm and disconnect pair behind + * `brokeredAppFor` — and it answers by running three queries and materialising every server, + * every tool and every grant in the deployment. Confirm is the sharp end: both brokered account + * screens call it from an effect on mount, so opening a large app's page read the whole tool and + * grant table to ask whether one row is brokered. None of the three looks at a tool or a grant. + * + * THE URL IS THE ROW'S OWN, read out of the column rather than composed from the id. The whole + * brokered feature rests on the two being allowed to differ: `addBrokeredApp` writes + * `composio://` and names the row for it, but nothing holds them equal afterwards, and a + * row called `gmail` at `composio://slack` is exactly the shape the connection gate was once + * keyed on the wrong half of. The catalogue reconciliation {@link effectiveUrl} applies for + * `listServers` is deliberately not applied here, and changes no answer: it only ever + * substitutes the pinned host of a first-party entry, and neither reading of such a row names a + * Composio app. + * + * `undefined` for an id naming no row, which is what the `.find` over the whole list answered + * before — so a route that refused an unknown id still refuses it, in the same words. + */ + async serverAddress(serverId: string): Promise { + const [row] = await database + .select({ + id: mcpServers.id, + title: mcpServers.title, + url: mcpServers.url, + authScheme: mcpServers.authScheme, + }) + .from(mcpServers) + .where(eq(mcpServers.id, serverId)) + .limit(1); + return row; + }, + + /** + * Where every added server is, for the one caller whose question is about the whole set. + * + * A SECOND READ RATHER THAN {@link serverAddress} IN A LOOP, and rather than one method serving + * both. The app directory asks which of Composio's apps are already enabled here, which has no + * id to look up — answered a row at a time it would be one query per app in the directory. And + * it needs strictly less than the row read hands back: the app comes off the url, so an id and + * a title would be nothing but two fields a reader could take the app out of by mistake. The + * directory route's own comment says why that matters — the url is where the transport reads + * which app a call is against, and the id is a row name that happens to look similar. + * + * Ordered, so two readings of an unchanged deployment answer alike. + */ + async serverUrls(): Promise { + const rows = await database + .select({ url: mcpServers.url }) + .from(mcpServers) + .orderBy(asc(mcpServers.id)); + return rows.map((row) => row.url); + }, + /** * The skills this person may see: the deployment's, plus their own. * @@ -2752,65 +3861,1338 @@ export function createPluginStore(options: PluginStoreOptions) { }, /** - * Retire every connector credential belonging to one person. + * Which brokered apps this person has connected, for the same settings page. * - * WHAT THIS IS FOR. "We removed their access" has to be true of the thing that matters, which is - * the refresh token sitting at the vendor. Removing somebody from the People screen used to end - * their sessions and add them to the deny list, and leave their Google grant entirely intact in - * this deployment's vault. They could not exercise it — the actor comes from a session they no - * longer get — but the deployment still held a usable secret for a person who had been removed, - * which is not what an administrator was told they did, and is the first thing a customer asks - * about a per-person connector. + * A SECOND METHOD RATHER THAN A WIDER {@link connectionsFor}, because the two answer out of + * different tables for a reason the schema is built on: a `user-oauth` connection is a pointer + * into the vault, and a brokered one holds no secret at all because Composio keeps the account + * (see {@link brokeredConnection}). Reading only the vault side is what left a brokered + * connection invisible to the browser, so the settings screen could not honestly say whether + * somebody was connected. * - * LOOKED UP IN THE VAULT, NOT THROUGH THE JOIN TABLE. `mcp_user_credentials.user_id` cascades on - * a user row being deleted, so by the time somebody is gone the join row can be gone too and the - * credential is orphaned: unrevoked, referenced by nothing, reachable from no screen and by no - * code path. `credentials.key_id` holds the user id for an `mcp_user_token`, so the vault can - * still be asked directly — which makes this work for the person who was removed and for the one - * whose row was deleted underneath it. + * THE SERVER ID IS JOINED, NOT SPELLED. `addBrokeredApp` writes the app into the url, and every + * later call resolves against that url — so matching on it asks the row what it is, where + * composing `composio-${toolkit}` by hand would re-derive the id from a convention nothing + * holds it to. It is the reasoning the directory route already uses when it reads a row's + * toolkit off its url rather than off its id. An app this deployment has since removed + * therefore drops out of the answer, which is the honest result: there is no server row left + * for a page to name. * - * The join rows go too, so the account pages stop claiming a connection this deployment can no - * longer use. + * `scope` IS EMPTY for the reason {@link confirmBrokeredConnection} sets out: Composio grants + * none that it tells us about, and the field exists to record what the vendor said it granted + * rather than what we suppose. It is returned all the same, so the fields this shares with + * {@link connectionsFor} — `serverId`, `scope`, `connectedAt` — line up and one screen can draw + * both kinds of row. What comes back here is a SUPERSET of that shape rather than the same one: + * `verified` and `verifiedAt` ride along too, and only on a brokered row, because only a + * brokered row is a thing this deployment can re-check. + * + * `verified` AND `verifiedAt` COME ALONG BECAUSE "connected" IS NOT A PRESENT TENSE HERE. + * Composio never re-checks a key somebody typed in: it answers ACTIVE for as long as the row + * exists, whatever the vendor on the other side now thinks of that credential. So a page drawn + * off `connectedAt` alone would assert something this deployment has not known since the day it + * was written. These two fields are what lets it say when the claim was last earned instead — + * "connected with a key you provided, last checked 13 Sep". What the pair separates is a + * CHECKED key connection from an unchecked one, and nothing more: which KIND of connection a + * row is comes from the app's recorded {@link ServerRecord.authScheme}, which the page branches + * on first, and not from anything answered here. + * + * `probe` IS THE ACTION THE LAST CHECK SPENT, READ OUT OF THE ROW. The pair above says whether a + * key connection was ever checked; it cannot say WHY one was not, and three different + * situations share the one word `false`. Until this field, only the answer to a connect or a + * re-check could tell them apart — so a page reload lost the distinction, and the worst of the + * three degraded into the mildest: a row saying the key was accepted without being checked, over + * an account whose key the vendor had actually REFUSED. + * + * IT IS STORED BECAUSE IT IS A FACT ABOUT A MOMENT, NOT ABOUT TODAY'S METADATA. This field was + * once derived here, by asking {@link probeActionFor} which action this deployment WOULD check + * the app with; the argument for that was that the chooser holds every condition the probe + * itself runs on, so the two could not disagree. They cannot disagree AT AN INSTANT, and that is + * all it establishes. `verified` records a check made against the app's action listing as it + * stood THEN, and the chooser answers from the listing as it stands NOW — and `POST + * /servers/:id/refresh` is a generic administrator's route keyed on a server id, of which + * `composio-` is one, so an ordinary press of Refresh moves the second without touching + * the first. It is the very press the Composio transport tells an operator to make when an + * action appears or gains the version that makes it callable. So: somebody connects a key to an + * app that publishes nothing safe to try it on, and the row honestly says the key was accepted + * unchecked. An administrator presses Refresh. From that page load on, the derivation named an + * action, and the row drew the sentence written for a REFUSED key — your key was checked against + * this app and rejected, the account it was checked in still stands, so disconnect it. Every + * clause of that is false for somebody whose key was never tried, it tells them to take down a + * connection that works, and it persists: it is what every page load says until they press + * Re-check. + * + * SO THE WRITER RECORDS WHAT IT SPENT AND THIS READS IT BACK. {@link recordBrokeredConnection} + * is the single writer, every path into it knows the action it spent or that it spent none, and + * {@link composioConnections.probeAction} is where that goes. Read together with `verified`, the + * column tells four states apart, and no inference is made in any of them: + * + * no probe, not verified — nothing was tried: at the time of the check the app published + * nothing safe to spend a key on. A fact about the app, not the + * key. A key row written before the column existed reads this way + * too, for the reason that column gives. + * no probe, verified — a CONSENT connection. The vendor's own yes at its own screen is + * the evidence, no call was ever made against the account, and so + * there is no action to name. + * a probe, verified — it ran in this person's account and the vendor took the key. + * a probe, NOT verified — it ran and the vendor refused the key, and the account it ran in + * is still standing. A live account with a bad key behind it. + * + * AND THE LAST LINE IS A RECORD RATHER THAN AN INFERENCE, which is the whole of what changed. + * The caveat that stood here used to argue the state into existence: a key connection is ALWAYS + * probed at connect time, a probe that fails withdraws the account it just made, so an + * unverified row under an app that HAS a probe must be a refusal whose withdrawal failed — or + * else a re-check the vendor refused, which leaves the person's own older account alone. That + * reasoning was sound about the rows it described and said nothing about the row a refresh had + * quietly moved underneath it. What the row now warrants, it warrants by having been written: + * the check ran, it spent this action, and the vendor refused. + * + * WHICH PATH WROTE IT IS STILL NOT RECOVERABLE, and a reader must not invent one. The column + * records what was spent, not who spent it, and the two writers of that pair end differently: a + * connect withdraws the account it had just made and only leaves the row where Composio refused + * to take it back, while a re-check never withdraws anything, because the account predates the + * press and is the person's own. So a page may say the key was refused and the account stands; + * a page that goes on to blame a failed withdrawal is right on the connect path and FALSE on the + * re-check, where nothing ever tried to remove anything. * - * NOT vendor-side revocation. That needs the OAuth client and the vendor's revoke endpoint, and - * it belongs with disconnect. This is the half that stops us holding the secret; the grant at - * Google outlives it until somebody revokes it there. Said plainly rather than implied, because - * the difference matters to whoever has to answer for it. + * `checkable` IS THE OTHER QUESTION, AND IT TRAVELS SEPARATELY BECAUSE COLLAPSING THE TWO IS + * WHAT DEADLOCKED THE SETTINGS SCREEN. "What did the check SPEND" is a fact about the past, and + * `probe` answers it. "Does this app have anything to check with TODAY" is a fact about the + * present, and this answers that — out of {@link probeActionFor}, asked of the APP rather than + * of the connection. The two agreed for as long as `probe` was derived: one field, one moment, + * two questions nobody ever had to tell apart. They part company the instant it became a + * record, which is the same instant it started being right about the past. + * + * THE DEADLOCK IN FULL, BECAUSE IT DOES NOT SELF-HEAL. The page gates its Re-check button on + * whether there is anything to check with, and it read `probe` for that. Somebody connects a + * key to an app that publishes nothing safe to spend it on: the check spends nothing, the row + * records null, and both of those are correct and permanent. An administrator presses Refresh, + * the app gains a safe versioned read, and the button is STILL withheld — because the record + * still says, truthfully, that nothing was spent. And pressing that button is the only thing in + * this product that can ever put an action into the record. The state is stable, wrong, and + * unreachable from inside itself: the single act that would end it is the act being withheld. + * + * SO A CALLER TAKES THE PAST FROM ONE AND THE PRESENT FROM THE OTHER, and must take neither out + * of the other one. A screen drawing its SENTENCE off `checkable` would accuse a key nobody + * tried, which is the defect the recorded column was made for; a screen gating its BUTTON on + * `probe` is the deadlock above. Neither field is a weaker spelling of the other, and the + * moment one is asked to answer both questions the two failures simply trade places. + * + * WHICH COSTS ONE QUERY PER CONNECTED APP, AND THAT IS THE RIGHT PRICE. Making `probe` a stored + * column took the per-row call out and left a listing that was one query; this puts it back. + * The alternative is to fold the chooser's rule into the join — vendor-labelled read, not + * destructive, no required inputs, a recorded version — and that rule has no honest spelling in + * SQL: `required` is the vendor's own JSON Schema stored unchanged, and deciding whether it is + * a non-empty list of names is a thing JavaScript does and a `json` operator does badly. So + * folding it in means writing the rule a SECOND time, in a second language, over the same rows, + * and this file already records what a rule in two places costs: while the version condition + * sat in one of them, an app whose chosen action had no version connected honestly as "nothing + * was tried" and reloaded as "your key was checked and rejected". {@link probeActionFor} is the + * one authority on what can be checked, and a listing that asks it N times cannot disagree with + * the probe that asks it once. N is the apps ONE person has connected — a handful of indexed + * reads by server id, made in parallel — behind a settings page and not on any hot path. + * + * `verifiedAt` STAYS NULL WHERE IT IS NULL, unlike `connectedAt`, which collapses to `""` + * because a row cannot exist without one and the fallback is unreachable. Null here is + * reachable and it means something: never checked. Folding it into `""` would hand the page a + * row that was checked at a time nobody recorded, which is a different fact and not one this + * table ever holds. Note also what {@link composioConnections.verified} sets out about the rows + * migration 0030 backfilled: their `verifiedAt` is the moment of consent, not the moment of a + * probe, so a caller must not read every timestamp here as "this connection answered then". */ - async retireConnectionsFor( - userId: string, - by: string, - ): Promise<{ retired: number }> { - if (!userId) return { retired: 0 }; - - const owned = await database + async brokeredConnectionsFor(userId: string): Promise< + { + serverId: string; + scope: string; + connectedAt: string; + verified: boolean; + verifiedAt: string | null; + probe: string | null; + checkable: boolean; + }[] + > { + const rows = await database .select({ - id: credentialRows.id, - provider: credentialRows.provider, - revokedAt: credentialRows.revokedAt, + serverId: mcpServers.id, + connectedAt: composioConnections.connectedAt, + verified: composioConnections.verified, + verifiedAt: composioConnections.verifiedAt, + probeAction: composioConnections.probeAction, }) - .from(credentialRows) + .from(composioConnections) + .innerJoin( + mcpServers, + sql`${mcpServers.url} = 'composio://' || ${composioConnections.toolkit}`, + ) + .where(eq(composioConnections.userId, userId)) + .orderBy(asc(mcpServers.id)); + + return await Promise.all( + rows.map(async (row) => ({ + serverId: row.serverId, + scope: "", + connectedAt: iso(row.connectedAt) ?? "", + verified: row.verified, + verifiedAt: iso(row.verifiedAt), + // The name alone, because that is what the four states are told apart by; the version the + // check was made at is the caller-of-the-call's business, and this read makes none. + probe: row.probeAction, + /* + * WHETHER, NOT WHICH. The chooser names an action and this keeps only the yes or no, + * because the yes or no is the whole of the question being asked: is there anything to + * spend a key on. Carrying the name would put a second action name on a row that already + * has one, inches from the field that records what was actually spent — and the first + * reader to draw a sentence off the wrong one re-opens the defect that made `probe` a + * record. A boolean cannot be mistaken for a record of anything. + */ + checkable: (await this.probeActionFor(row.serverId)) !== null, + })), + ); + }, + + /** + * Whether this person has one brokered app connected, and since when. + * + * THE ROW IS A CACHE OF COMPOSIO'S ANSWER, not a record of a flow this deployment watched + * finish. Nothing here holds a secret for a brokered app: the vendor keeps the account, and + * what {@link composioConnections} holds is the sentence "Composio said yes when we asked", + * written down so that every later call can be gated without a round trip. That makes drift + * possible by construction — somebody can end the connection in Composio's own dashboard, and + * this row would go on saying yes — and it is why {@link confirmBrokeredConnection} asks the + * vendor again rather than trusting what is here. Calling confirm on any page load is + * therefore how a row that drifted heals. + * + * Read by the pair, because the pair is the primary key: an app has many people's connections + * and a person has many apps, and the only question anybody asks is about one of each. + */ + async brokeredConnection(input: { + toolkit: string; + userId: string; + }): Promise<{ connectedAt: string } | null> { + const [row] = await database + .select({ connectedAt: composioConnections.connectedAt }) + .from(composioConnections) .where( and( - eq(credentialRows.kind, "mcp_user_token"), - eq(credentialRows.keyId, userId), + eq(composioConnections.toolkit, input.toolkit), + eq(composioConnections.userId, input.userId), ), - ); + ) + .limit(1); - let retired = 0; - for (const credential of owned) { - // Already revoked is not a failure. Retiring twice is something an administrator can - // legitimately do, and the second time should be quiet rather than an error. - if (credential.revokedAt) continue; - await credentials.revoke(credential.id); - retired += 1; - await recordAuditEvent(auditStore, { - eventType: "mcp.account_disconnected", - targetType: "mcp_server", - targetId: credential.provider, - payload: { - actor: by, - server: credential.provider, + if (!row) return null; + return { connectedAt: iso(row.connectedAt) ?? "" }; + }, + + /** + * The action a key verification should call against this app, and the version to call it at. + * + * THIS CHOOSES THE ONE ACTION THAT WILL BE CALLED WITH SOMEBODY'S JUST-TYPED API KEY, which is + * what makes it the most dangerous line in the verification: whatever comes back from here runs + * against a stranger's account, once, purely to find out whether their key works. So the two + * safety conditions below are BOTH non-negotiable, and neither is a stricter spelling of the + * other. + * + * READ EFFECT, because a probe must not change anything. The label is the vendor's own and not + * a guess of ours: `effectOf` answers `read` only where Composio sent `readOnlyHint`, and + * everything unlabelled was already recorded as a write, so `read` here means Stripe or Linear + * or Notion said so. `destructive` is checked beside it rather than trusted to be implied — the + * two are separate columns precisely so a vendor can say both things, and a row that somehow + * says read AND destructive is a row this deployment has no business calling unasked. + * + * ZERO REQUIRED INPUTS, because there is nothing to invent an argument from. A probe happens + * before this deployment knows anything about the account beyond the key, so a required customer + * id or query has no honest value to carry, and a made-up one turns "is this key good" into + * "does this identifier exist" — which fails for a perfectly good key. + * + * BOTH, NEVER EITHER, and the live catalogue is why this sentence is here rather than a comment + * saying the checks are belt-and-braces. The first argument-less action on Stripe's own list is + * `STRIPE_CREATE_BILLING_METER_EVENT_SESSION`. A probe chosen on "takes no arguments" alone — + * the condition that looks sufficient, because it is the one that makes a call possible at all — + * would therefore write to somebody's account to find out whether their key works. The read + * effect is the whole of what stands between those two names. + * + * AND A RECORDED VERSION, WHICH IS PART OF "CAN THIS BE CALLED AT ALL" AND NOT A DETAIL OF THE + * CALLER. Composio refuses an execution without a specific version and rejects `latest`, so the + * transport refuses before dialling where none travels with the call — and Composio publishes + * some actions with no version at all. An action this deployment recorded without one is + * therefore an action nothing here can spend a key on, which is the same kind of fact as a + * required input: not unsafe, just not callable. The version is SELECTED AND RETURNED for the + * caller that has to send it, so the choice and the call cannot come apart. + * + * THE CONDITION LIVES IN THE FILTER RATHER THAN AFTER THE CHOICE, so there is ONE predicate for + * one question. While it sat downstream — read by the probe, unknown to everything else — there + * were two, and the weaker of them was what the connections listing derived its `probe` field + * from: an app whose chosen action had no version connected honestly as "nothing was tried", + * then reloaded as "your key was checked and rejected". That listing no longer derives that + * field from here — {@link brokeredConnectionsFor} reads what the check RECORDED, because no + * derivation from today's metadata can be right about yesterday's check. It still asks this + * function a question, but a present-tense one: whether the app has anything to check with now, + * kept as a yes or no beside the record. A split predicate would therefore no longer put a + * false sentence on a settings page; it would show up in two worse places — a probe that chose + * an action it then refused to send, and a button offered over an app nothing can be spent on. + * A filter also lets the search CONTINUE: a versionless + * candidate is passed over for the next safe read rather than short-circuiting the whole app to + * "nothing to try", so an app that can be checked is. + * + * NULL IS AN ANSWER AND NOT A FAILURE. Of fifteen key-based apps sampled, most publish some safe + * argument-less read and PostHog publishes none at all, so an app that cannot be probed is an + * ordinary app rather than a broken one. What a caller does about it — and running the probe at + * all — belongs to the verification path; this function only chooses. + */ + async probeActionFor( + serverId: string, + ): Promise<{ name: string; version: string } | null> { + // Ordered, because the fallback below is "the first candidate" and Postgres promises no order + // without one: an unordered read would make which action gets called with somebody's key a + // property of whichever plan the server happened to pick. + const actions = await database + .select({ + name: mcpTools.name, + inputSchema: mcpTools.inputSchema, + effect: mcpTools.effect, + destructive: mcpTools.destructive, + version: mcpTools.version, + }) + .from(mcpTools) + .where(eq(mcpTools.serverId, serverId)) + .orderBy(asc(mcpTools.name)); + + // One pass, and it yields the pair rather than the row: an action that survives every + // condition below has a version by definition, and building the answer here is what carries + // that fact into the type instead of leaving the caller to re-check it. + const safe = actions.flatMap((action) => { + if (action.effect !== "read" || action.destructive) return []; + // Null in the column and blank in the data are the same nothing, and neither is a version + // the transport can put on a call. + const version = action.version?.trim(); + if (!version) return []; + // Absent and empty are the same answer, and anything that is not a list is neither: the + // column is the vendor's JSON Schema stored unchanged, so `required` may be missing, may be + // `[]`, and may be some shape no schema should hold. Only a non-empty list of names is a + // reason to pass this action over. + const schema = action.inputSchema as Record | null; + const required = schema?.required; + if (Array.isArray(required) && required.length > 0) return []; + return [{ name: action.name, version }]; + }); + + const identity = safe.find((action) => IDENTITY_ACTION.test(action.name)); + return identity ?? safe[0] ?? null; + }, + + /** + * Write down that this person holds this brokered app, and how well that is known. + * + * ONE WRITER SO THE VERIFIED AND UNVERIFIED PATHS CANNOT DRIFT INTO TWO ROW SHAPES. What says + * how well a connection is known is a SET of fields and not a column: `verified` is meaningless + * without the moment it was earned, and `verified_at` without the flag is a date on a claim + * nobody made. Every path that records a connection therefore comes through here rather than + * spelling that set for itself — {@link confirmBrokeredConnection} with `true` today, and the + * verify path, which is the caller `false` exists for, when a probe against a key connection + * comes back unanswered. Two call sites each writing the set by hand is how one of them comes + * to set the flag and leave the timestamp null, or to move `connected_at` on a confirm that + * healed a row nothing changed; spelled once, a reader asking what shape a connection row takes + * has one answer and every path takes it. + * + * `verified` IS THE CALLER'S CLAIM AND `verifiedAt` FOLLOWS FROM IT, never the other way round. + * True means the caller has evidence as of now — the vendor's own yes at the end of a consent + * screen, or a call that went out and came back — so the timestamp is stamped here rather than + * passed in, and it is the moment of the write because that is the moment the evidence was in + * hand. False takes the timestamp back to null rather than leaving the old one standing: a row + * that has stopped being verified must not keep a date saying when it last was, because the one + * sentence the page builds out of the pair — "last checked 13 Sep" — would then be drawn for a + * connection this deployment is no longer claiming anything about. + * + * `connected_at` IS LEFT ALONE, which is the whole reason this is an upsert with an explicit + * `set` rather than a delete and an insert. The person connected when they connected; a write + * that moved it would make every page load look like a fresh connection on their own settings + * page, and would erase the one date the row holds that nothing else in this deployment knows. + * + * AND THE STAMP IT WROTE IS WHAT IT ANSWERS, for the same reason the caller does not pass one + * in. A caller that needs the moment — {@link recheckBrokeredConnection}, which hands it to the + * browser as the date the row's sentence is drawn from — would otherwise have to read the row + * back and hope it was reading its own write. The timestamp is still this writer's; what + * changed is that it is no longer thrown away. + * + * `probeAction` IS PASSED IN, UNLIKE THE TIMESTAMP, BECAUSE ONLY THE CALLER KNOWS IT. It is the + * action this check SPENT — the probe's name, or null where none was spent — and it is required + * rather than optional so that a new path cannot record a connection while staying silent about + * what it tried. Every existing caller knows the answer without looking anything up: a consent + * confirm spent nothing and passes null, and both probing paths pass the name the probe returned + * them, which is null there too when the app published nothing safe to call. + * + * IT IS PART OF THE SAME SET AS THE FLAG AND THE STAMP, which is the reason it is written here + * and nowhere else. `verified` alone says a check did not pass and cannot say what it was; this + * column is what separates "the app published nothing to try" from "it ran and the vendor said + * no", and a row carrying one of the three without the others is a shape no reader downstream + * has reasoned about. It used to be derived on read instead, from the app's action listing as + * that listing stood at the moment of the read — see {@link brokeredConnectionsFor}, where the + * refresh that broke the derivation is written out. + */ + async recordBrokeredConnection(input: { + toolkit: string; + userId: string; + verified: boolean; + probeAction: string | null; + }): Promise { + const verifiedAt = input.verified ? new Date() : null; + await database + .insert(composioConnections) + .values({ + toolkit: input.toolkit, + userId: input.userId, + verified: input.verified, + verifiedAt, + probeAction: input.probeAction, + }) + .onConflictDoUpdate({ + target: [composioConnections.toolkit, composioConnections.userId], + set: { + verified: input.verified, + verifiedAt, + // Overwritten rather than left standing, for `verifiedAt`'s reason: the pair describes + // ONE check, and a row keeping the action an earlier check spent beside the verdict of + // a later one would name a call this row's own state did not come from. + probeAction: input.probeAction, + updatedAt: new Date(), + }, + }); + return verifiedAt; + }, + + /** + * Spend one call on this person's key, and say what the vendor made of it. + * + * ONE PROBE, TWO CALLERS, AND THE ANSWER TO "WHAT DOES A FAILED PROBE MEAN" LIVES HERE ONCE. + * {@link connectBrokeredWithFields} probes a key somebody has just typed; + * {@link recheckBrokeredConnection} probes one this deployment has held for days. Both have to + * choose the action the same way, send it the same way, and read the vendor's answer the same + * way — and a second copy of that reading is how one of them comes to treat a rejected key as a + * connection that merely could not be checked. What the two callers do NEXT is all that differs, + * and it is all either of them keeps for itself: one withdraws the account it just made, the + * other leaves an account it did not make alone. + * + * THREE ANSWERS AND NOT A BOOLEAN, because `verified: false` means two different things about + * somebody's key and only the action's name separates them: + * + * `probe: null` — the app published nothing safe to call, or nothing at a + * version this deployment recorded. NOTHING WAS TRIED. + * `probe: , failure null` — it ran in this person's account and answered. + * `probe: , failure set` — it ran and the vendor refused, and `failure` is Composio's + * own sentence about why. + * + * A FAILURE IS RETURNED RATHER THAN THROWN, which is the one thing this function does not + * decide. Its two callers end a bad key differently — one undoes an account and refuses, the + * other writes the row unverified and refuses — so a throw here would force the undo on both or + * neither. What it owes them is the vendor's sentence and the name of what was tried. + * + * NOTHING IS WRITTEN, NOTHING IS AUDITED, AND NO ACCOUNT IS TOUCHED. This is a question asked of + * the vendor; recording the answer belongs to whoever asked it. + */ + async probeBrokeredConnection(input: { + toolkit: string; + userId: string; + }): Promise< + { probe: null; failure: null } | { probe: string; failure: string | null } + > { + /* + * THE ONE ACTION THIS DEPLOYMENT WILL SPEND THE KEY ON, chosen from what the app published. + * + * Composio accepts a key without ever trying it, so "connected" at the vendor is not evidence + * that the credential works — and a row written on that acceptance is a gate every later + * brokered call passes for a key that cannot answer. {@link probeActionFor} is what keeps the + * call safe: the vendor must have labelled the action a read and it must take no arguments, + * and both matter because the first argument-less action on Stripe's own list creates a + * billing session. It answers the VERSION beside the name, because Composio refuses an + * execution without a specific one — so an action recorded without a version is one the + * chooser passes over rather than one this method discovers it cannot call. Null is an + * ordinary answer — see that method — and it is the FIRST of the three states above. + * + * NOTHING IS ASKED A SECOND TIME HERE, AND THAT IS THE POINT. Every condition on whether an + * action can be spent on a key lives in the chooser, so the action this method sends is the + * action the chooser said was sendable, whole. A version re-read here would be a second + * predicate for one question, and the weaker of two predicates is what once had a reloaded + * page tell somebody their untried key had been rejected — in the days when the connections + * listing answered by asking the chooser too. It no longer does: what a page says about a + * check is what the check recorded, and what it recorded is the name this method returns. + */ + const serverId = `composio-${input.toolkit}`; + const candidate = await this.probeActionFor(serverId); + if (candidate === null) { + return { probe: null, failure: null }; + } + + /* + * THE TRANSPORT DIRECTLY, AND NOT `callTool` ABOVE. This deployment's own `callTool` checks a + * grant, evaluates the policy and writes an `mcp.call_*` row, and there is no Bot here to + * check a grant for, no policy context to evaluate and no Bot to attribute a row to. What + * holds this narrow is structural rather than disciplinary: no endpoint, no arguments, and an + * action chosen from recorded metadata rather than from anything a request said. See + * `mcp.connection_verified` in `./audit`, which records the same three properties as the + * reason this call may skip the checks the ordinary path cannot — and which names both of + * this function's callers as the whole of who may make it. + * + * THE VERSION IS NOT AN ARGUMENT. It travels under the transport's reserved key, which the + * Composio transport strips before anything reaches the vendor and asserts that it did, so + * what Composio is handed is the action and an empty argument object. + */ + const answer = await composioCallTool( + { url: `composio://${input.toolkit}`, actorId: input.userId }, + candidate.name, + { [VERSION_ARG]: candidate.version }, + ); + + return { + probe: candidate.name, + failure: answer.isError ? answer.text : null, + }; + }, + + /** + * Ask Composio whether this person's account is really attached, and write down the answer. + * + * THE VENDOR IS ASKED, NOT THE BROWSER. The return trip from a consent screen is an ordinary + * redirect carrying nothing signed, so a person arriving back on the page is not evidence that + * they finished the flow, nor that the account they finished it with is the one a row would + * claim. A confirm that wrote a row because somebody came back would hand every later brokered + * call a gate that passes for an account nobody has — and the first anyone would hear of it is + * the vendor's own error about a connection it cannot find, at the moment a Bot was asked to do + * something. + * + * SO THE ANSWER NO LEAVES NO ROW BEHIND. `false` from {@link ComposioBroker.isConnected} is a + * positive claim that there is no account, and the honest local state for that claim is an + * absence — so a row already sitting here is deleted rather than left standing. Leaving it + * would have the settings list go on drawing "Connected" for an account nobody has, and would + * go on passing the gate every later brokered call is decided on, while the app's own detail + * page asks the vendor and says the opposite. + * + * AND THAT DELETION FILES NO TRAIL ENTRY. Nobody disconnected anything here: the grant ended + * somewhere else, and this is our record catching up with a fact. {@link disconnectBrokered} + * owns `mcp.account_disconnected` and files it for the act it performed; a second filer here + * would have the trail claim an act that did not happen, credited to whichever page load + * happened to notice. + * + * UPSERT RATHER THAN INSERT, keyed on the pair the table itself is keyed on. This is safe to + * call repeatedly and is meant to be: because the row is only a cache of the vendor's answer + * (see {@link brokeredConnection}), a row that drifted out of step — an account ended in + * Composio's own dashboard, a connect this deployment missed the callback for — is healed by + * the next confirm on any page load, in whichever direction it drifted: by the upsert here + * where the vendor says yes, and by the delete above where it says no. + * + * `scope` IS EMPTY BECAUSE COMPOSIO GRANTS NONE THAT IT TELLS US ABOUT. The field exists so a + * later refusal for want of a permission can be explained by what the vendor actually granted, + * and Composio's connection answer is a boolean with no scope in it. Writing a plausible claim + * there — the app's full access, say — would put words in the vendor's mouth in the one field + * whose whole job is to say what it said. + * + * `reconnected` IS FALSE FOR THE SAME REASON, and trivially so. The flag distinguishes somebody + * replacing a grant from somebody making one, and the only confirms that reach the trail are + * the ones that found no row at all — so there was nothing here to replace. + * + * AND THE EVENT IS WRITTEN ONLY WHERE THE ROW IS NEW. This method runs on every page load + * rather than only when a person acts, so an event per yes from the vendor would file ten + * "account connected" rows for somebody who opened the connector page ten times having + * connected once. A confirm that heals a row nothing changed is a read, and the trail records + * acts: where a row was already there the connection has been recorded once already, by the + * confirm that first found none. + */ + async confirmBrokeredConnection(input: { + toolkit: string; + userId: string; + }): Promise<{ connected: boolean }> { + // Before anything, and for the reason `addBrokeredApp` says it first too: a deployment with + // no key has no broker to have connected anybody at, so there is nothing here to ask. + if (!broker) throw new BrokerUnconfiguredError(); + + const connected = await broker.isConnected({ + userId: input.userId, + toolkit: input.toolkit, + }); + if (!connected) { + // Deleted rather than left alone, because the row is only the vendor's last answer: an + // account ended in Composio's own dashboard reaches this deployment as the no above and + // as nothing else, and a row that outlived it would go on saying yes about an account the + // vendor has just denied. + await database + .delete(composioConnections) + .where( + and( + eq(composioConnections.toolkit, input.toolkit), + eq(composioConnections.userId, input.userId), + ), + ); + return { connected: false }; + } + + // Read before the write, because the upsert leaves nothing behind that tells the two cases + // apart, and whether a row was already here is the whole of what decides if anybody acted. + const existing = await this.brokeredConnection(input); + + // VERIFIED, BECAUSE A CONSENT SCREEN IS A VERIFICATION AND NOT A LESSER KIND OF ONE. The + // vendor has just answered that this person's account is attached, which is the same + // question a probe goes and asks; that the evidence arrived through a consent flow rather + // than through a call this deployment made does not make it weaker. Writing on the column + // defaults instead left every consent connection reading `false` with a null `verified_at` — + // the pair a key somebody typed in and nobody ever checked reads — so the settings page could + // not tell the two apart. Written through the single writer above rather than here, so this + // path and the verify path cannot come to write two different row shapes; see + // {@link composioConnections.verified}. + await this.recordBrokeredConnection({ + toolkit: input.toolkit, + userId: input.userId, + verified: true, + // NOTHING WAS SPENT TO EARN THAT FLAG, and that is what the null records rather than an + // absence of information. A consent connection is verified by the vendor's own yes at its + // own screen; no action of the app's is ever called against it, here or later, so there is + // no name to write and there never will be. The derived field could not say so — it + // answered with whatever the app happened to publish — and a consent row was listed as + // having been checked with an action nothing had called. + probeAction: null, + }); + + if (!existing) { + await recordAuditEvent(auditStore, { + eventType: "mcp.account_connected", + targetType: "mcp_server", + // The app, which is all a brokered connection is keyed on — the same id + // `retireConnectionsFor` files its rows under, so one query answers what happened to one + // person's access to one app however it ended. + targetId: input.toolkit, + payload: { + actor: input.userId, + server: input.toolkit, + scope: "", + reconnected: false, + }, + }); + } + + return { connected: true }; + }, + + /** + * Connect this person with the secret they typed, and write down everything except the secret. + * + * THE VALUES TRAVEL IN ONE DIRECTION AND THE WHOLE METHOD IS BUILT AROUND THAT. They arrive on + * the request, they are handed to {@link ComposioBroker.connectWithFields}, and they reach + * Composio. Nothing else here is given them: not the row, not the audit payload, not a log + * line, not a thrown error — the broker's own doc comment is where that promise is kept on the + * far side, and it is the one call in this tree that rethrows with no `cause` precisely because + * the vendor's error object holds the key. Every other participant in this method is a + * long-lived, widely-readable record, so a credential landing in one is not a leak somebody can + * clean up afterwards; it is a leak with a retention schedule. + * + * THE SCHEME IS THE ONE RECORDED ON THE APP'S ROW, never a fresh read of the catalogue and + * never a value a caller passed. It is what this deployment's authorization config was created + * AS, and a connection is attached to that config: a second derivation is a second answer — a + * key sent as `BASIC` against a config made for `API_KEY` — which is the reasoning {@link + * ComposioBroker.connectionFields} gives for taking the scheme rather than resolving it, one + * step earlier in the same flow. + * + * AND AN APP WHOSE SCHEME IS NOT A FIELD SCHEME IS REFUSED BEFORE THE KEY TRAVELS. A consent + * app, a `NO_AUTH` app and an app this deployment could not resolve at all have no form and + * nothing to attach typed values to, so sending them on would spend somebody's credential on a + * config that cannot hold it — and would do it having already taken the secret out of the + * request. {@link isFieldScheme} is asked rather than the string compared, for the reason it + * exists: one list, read by the guard and by the type, so the schemes this admits cannot come + * apart from the schemes the broker's signature takes. + * + * `connected` IS THE LITERAL `true` BECAUSE THERE IS NO OTHER WAY OUT OF HERE. Unlike {@link + * confirmBrokeredConnection}, which asks a question the vendor may answer no to, this performs + * an act: it either made the connection or it threw. A `boolean` would invite a caller to + * branch on a `false` this method cannot produce. + * + * `probe` IS RETURNED BESIDE `verified` BECAUSE THE FLAG ALONE NOW MEANS THREE DIFFERENT THINGS, + * AND THE BROWSER READS THIS FIELD TO CHOOSE ITS SENTENCE. While the row was written `false` + * unconditionally the flag had one meaning — nobody has checked — and a screen could say so from + * the flag alone. With a probe that can fail there are three states, and two of them share the + * flag: + * + * null probe, `verified: false` — this app publishes nothing safe to call, so nothing was + * tried. "It was accepted without being checked" is true. + * named probe, `verified: true` — the action ran in this person's account and answered. + * named probe, `verified: false` — it ran, the vendor said no, and the account could not be + * withdrawn. The key is BAD and the row exists anyway. + * + * That last one is the worst state this feature has, and under the old wording a person in it + * would be told their key was never checked — when it was checked, the vendor rejected it, and + * this deployment failed to undo the account it made. INFERRING THE STATE CLIENT-SIDE FROM + * `verified` IS EXACTLY WHAT THIS FIELD EXISTS TO PREVENT: there is nothing in the flag that + * separates "nothing to try" from "tried and failed", so a browser deriving a sentence from it + * would tell one of those two people the opposite of what happened. The audit row carries the + * same distinction under `action`, for a reader of the trail rather than of the screen. + */ + async connectBrokeredWithFields(input: { + toolkit: string; + userId: string; + values: Record; + }): Promise<{ connected: true; verified: boolean; probe: string | null }> { + // First, and for `confirmBrokeredConnection`'s reason: a deployment with no key has nobody to + // connect anybody at, and the refusal must happen before the values are touched at all. + if (!broker) throw new BrokerUnconfiguredError(); + + // Keyed on the url, which is where a brokered row records which app it is; `mcp_servers.id` + // is a display name and nothing holds the two equal. It is the same reasoning the connection + // gate in `connectionTokenFor` is keyed on, and for the sharper version of the same stake: a + // row called `gmail` at `composio://slack` would have somebody's Slack key attached to a + // scheme read off Gmail's row. + const [app] = await database + .select({ authScheme: mcpServers.authScheme }) + .from(mcpServers) + .where(eq(mcpServers.url, `composio://${input.toolkit}`)) + .limit(1); + + const authScheme = app?.authScheme ?? null; + if (!isFieldScheme(authScheme)) { + throw new BrokerRefusalError( + `${input.toolkit} is not an app this deployment connects with values somebody types, so nothing was sent. Open the app on the Plugins page and connect it the way it asks for; if it is not listed there at all, an administrator has to enable it first.`, + ); + } + + const { accountId } = await broker.connectWithFields({ + userId: input.userId, + toolkit: input.toolkit, + authScheme, + values: input.values, + }); + + /* + * THE CHECK, WHICH IS THE SAME ONE A RE-CHECK MAKES AND IS SPELLED ONCE FOR THAT REASON. + * + * {@link probeBrokeredConnection} chooses the action out of what the app published — with the + * version the listing recorded for it, which is part of what makes it choosable — calls it + * with no arguments and reads what came back. + * What belongs to THIS path and to no other is what happens next: an account this call has + * just made, which a key the vendor rejects must not be allowed to leave standing. A re-check + * runs the identical probe against an account that already existed and leaves it alone, and + * those two undo behaviours are exactly why the shared part stops where it does. + * + * `probe: null` IS THE FIRST OF THE THREE STATES THIS METHOD REPORTS — the app published + * nothing safe to call, or nothing at a version this deployment recorded, so the key was + * never tried. It is an ordinary answer and not a failure; see that method. + */ + const { probe, failure } = await this.probeBrokeredConnection({ + toolkit: input.toolkit, + userId: input.userId, + }); + + if (failure !== null) { + /* + * NOTHING IS LEFT BEHIND ON A KEY THAT DOES NOT WORK. The account is deleted at Composio + * before the refusal is raised, so a mistyped key does not leave a live connection that + * every screen here would draw as connected — which is precisely the state the + * verification exists to prevent, and it would be worse for having been created by the + * check itself. + * + * THE ACCOUNT THIS CALL MADE, BY ID, AND NEVER THE APP. `revoke` ends every account the + * person holds for the app; here the intent is narrower than that — undo the thing just + * done — and the two differ exactly when the local row and Composio have drifted apart, + * which is the case where a sweep would delete a connection that was working. + */ + const removed = await broker + .revokeAccount(accountId) + .then(() => true) + .catch(() => false); + + if (!removed) { + /* + * AND WHERE THE UNDO ITSELF FAILS, THE ROW IS WRITTEN ANYWAY. That reverses this + * method's own rule, and it reverses it in the one case where the rule is no longer + * available: Composio has an account attached and will not take it back. Leaving no row + * then does not mean "nothing was left behind" — it means a live account nothing on any + * screen names, which the person cannot disconnect, because disconnect works off the + * row. This connector's standing order everywhere else is that a failure leaves access + * dead rather than live and unreachable; here only the second half is reachable, so the + * row is written as UNVERIFIED and the sentence says all three facts. + * + * WHICH IS WHY THE SENTENCE CARRIES THE VENDOR'S OWN. This is the THIRD state — the row + * exists, unverified, and the key is bad — and it is the state the `probe` field above + * was added for. A refusal that said only "it could not be checked" would leave the + * person believing their key might be fine, standing in front of a row that says + * unchecked, with a live account at the vendor. + */ + await this.recordBrokeredConnection({ + toolkit: input.toolkit, + userId: input.userId, + verified: false, + // THE ACTION THAT WAS TRIED, which is the half of this state the flag cannot hold. This + // is the worst state the feature has — a live account at Composio with a key the vendor + // has just refused — and the name beside the `false` is the whole of what separates it + // on a later page load from a key nobody ever tried. It is the same name the audit row + // below carries under `action`, for a reader of the trail rather than of a screen. + probeAction: probe, + }); + + /* + * AND THE TRAIL SAYS SO TOO, WHICH IS THE HALF THE SENTENCE BELOW CANNOT REACH. The + * refusal is told to one person in one moment; what outlives it is an unverified row + * and a live account at the vendor, and the person who most needs to know both exist + * is an operator reading this trail a week later. Filed BEFORE the throw for the only + * reason that matters here: every way out of this branch is that throw, so a row + * written after it is a row never written — which is exactly how this state came to + * be the one thing the trail did not record. + * + * `action` IS THE PROBE THAT WAS TRIED, and it is what tells this row from the + * unchecked one. Both say `verified: false`; only the name separates "this app + * published nothing safe to call" from "it ran, the vendor said no, and the account + * could not be withdrawn" — the same distinction the `probe` response field exists + * for, made for a reader of the trail rather than of a screen. + * + * AND NOTHING IS FILED ON THE CLEAN UNDO ABOVE, which is a decision rather than the + * same omission repeated. A probe that failed and whose account WAS withdrawn leaves + * no account, no row and nothing for anybody to do: this trail records state that + * persists, the criterion {@link disconnectBrokered} already files its own row on — + * no row deleted and no grant withdrawn means nobody was disconnected and nothing is + * written. Filing one anyway would also cost this row the meaning it was just given. + * A `verified: false` verification row under an app would stop meaning "there is a + * live account here somebody has to deal with", because most of them would mean "a + * key was mistyped and cleaned up after" — and the one state an operator must act on + * would be unfindable again, in a different way. + */ + await recordAuditEvent(auditStore, { + eventType: "mcp.connection_verified", + targetType: "mcp_server", + targetId: input.toolkit, + payload: { + actor: input.userId, + action: probe, + verified: false, + }, + }); + + throw new PluginRefusedError( + `What you entered for ${input.toolkit} did not work — ${failure} — and Composio would not take the account back either, so it is recorded here as unchecked rather than left somewhere nothing could name it. Disconnect it on the Plugins page and try again.`, + null, + ); + } + + throw new PluginRefusedError( + `${input.toolkit} would not answer with what was entered: ${failure} Nothing was saved, so entering it again is the whole of the retry.`, + null, + ); + } + + /* + * THE SECOND STATE, AND THE ONLY ONE THAT EARNS THE FLAG. A probe that ran and answered is + * evidence the key works, exactly as the vendor's yes at the end of a consent screen is + * evidence for {@link confirmBrokeredConnection}; a null probe is the honest unchecked state + * {@link composioConnections.verified} documents. Written through the single writer below + * rather than spelled here, so this path and the confirm path cannot drift into two row + * shapes — `verified_at` follows from the flag there and is not passed in. + */ + const verified = probe !== null; + + // Read before the write, for `confirmBrokeredConnection`'s reason: the record below is an + // upsert, so it leaves nothing behind that tells a first key from a replacement, and whether + // a row was already here is the whole of what `reconnected` says. The route that reaches + // this today refuses a second account for the same app, which makes a constant `false` + // accidentally true — but the guard lives in another file and this method is callable + // without it, so the trail would be claiming, on that guard's word, something it never + // checked. + const existing = await this.brokeredConnection({ + toolkit: input.toolkit, + userId: input.userId, + }); + + await this.recordBrokeredConnection({ + toolkit: input.toolkit, + userId: input.userId, + verified, + // What was spent, which is the name on a probe that ran and the null that IS the first of + // the three states: this app published nothing safe to try the key on. `verified` is + // derived from this same value a few lines above, so the row cannot claim a check with an + // action beside a flag that says nothing checked it, or the other way about. + probeAction: probe, + }); + + await recordAuditEvent(auditStore, { + eventType: "mcp.account_connected", + targetType: "mcp_server", + // The app, the same id `confirmBrokeredConnection` and `retireConnectionsFor` file under, + // so one query answers what happened to one person's access to one app however it began + // and however it ended. + targetId: input.toolkit, + payload: { + actor: input.userId, + server: input.toolkit, + reconnected: existing !== null, + /* + * THE NAMES AND NEVER THE VALUES. What a reader of the trail needs is which app somebody + * connected and what it asked them for; the values are the credential itself, and an + * audit row is exactly the kind of long-lived, widely-readable record they must never + * reach. + */ + fields: Object.keys(input.values).sort(), + }, + }); + + /* + * THE CHECK ITSELF, ON THE TRAIL, WHETHER OR NOT ONE HAPPENED. + * + * Filed on every connection rather than only where a probe ran, because "this app published + * nothing safe to call, so the key was never tried" is the fact a reader most needs and the + * one nothing else records. `action` is the null the response field is: it separates an + * unchecked connection from a checked one, which `verified: false` alone cannot. + * + * THE FAILED UNDO ABOVE FILES THE SAME ROW BEFORE IT THROWS, so the worst state this feature + * has — a live account, an unverified row, and a withdrawal the vendor refused — is no longer + * named only in a sentence one person read once. The clean undo files nothing, and the reason + * is written out at that branch: this trail records state that persists, and a row there + * would cost the failed-undo row the one meaning that makes it worth reading. + * + * UNDER THE APP SLUG, which is the id `mcp.account_connected` above, {@link + * confirmBrokeredConnection}, {@link disconnectBrokered} and {@link retireConnectionsFor} all + * file under — so the two rows this method can write about one person's access to one app + * come back in ONE query, however that access began and however it ended. This row was filed + * under the SERVER row's id instead, on the reasoning that it is about an ACTION of a server + * rather than about access; that is true and it cost the reader the only question anybody + * asks this trail, which was answered half by one id and half by the other. Nothing goes with + * the change: the server id is `composio-` and the slug, and the action is in the payload. + */ + await recordAuditEvent(auditStore, { + eventType: "mcp.connection_verified", + targetType: "mcp_server", + targetId: input.toolkit, + payload: { + actor: input.userId, + action: probe, + verified, + }, + }); + + return { connected: true, verified, probe }; + }, + + /** + * Try a key this deployment already holds, because somebody pressed the button that asks. + * + * A BUTTON, AND NEVER A PAGE-LOAD EFFECT. Composio never re-checks a key: it accepts one when it + * is typed and says nothing about it again, so a row that was verified in March goes on saying + * so after the key behind it was rotated, revoked or let expire. Nothing but this can correct + * that — which is exactly the argument somebody will use for calling it from an effect when the + * settings page mounts, and it is the wrong conclusion. The call this makes is spent against the + * PERSON'S OWN rate limit at the vendor, on their account, so verifying on every render would + * burn somebody's quota at Linear to redraw one word on a page they were only passing through. + * {@link confirmBrokeredConnection} is the one that runs on mount, and it asks Composio a + * question about its own records; this one goes out to the app. + * + * A RE-CHECK IS NOT A CONNECT, AND THE DIFFERENCE IS THE WHOLE METHOD. It runs against an + * account that already exists: it must not create one, it must not withdraw one when the probe + * fails — the person's account stays, it is their KEY that is wrong — and it must change + * nothing here but the verification and its timestamp. {@link connectBrokeredWithFields} does + * undo its account on a bad key, and it is right to: the account is a thing it had just made, + * seconds earlier, for a key that turned out not to work. Here the account predates the press by + * days, the person asked to have it CHECKED, and taking it away to tell them their key is wrong + * would destroy the thing they are trying to repair. The shared probe stops short of both + * behaviours for that reason. + * + * A PROBE THAT RAN AND FAILED RAISES, AND DOES NOT COME BACK AS `verified: false`. Those two + * answers are not different spellings of one outcome. `false` is also what an app that publishes + * nothing safe to call produces, and a row handed the flag alone cannot tell "the vendor + * rejected your key" from "there was nothing here to try" — so it would draw the unchecked + * sentence, and drop the Re-check button, for the one person who most needs it: somebody who has + * just fixed their key and pressed it. The refusal carries Composio's own sentence, which is the + * whole of what they can act on. The ONLY legitimate `verified: false` from here is the one that + * arrives with `probe: null` saying there was nothing to check with. + * + * NOTHING TO PROBE WRITES NOTHING AT ALL, and answers with the row as it stands. A check that + * could try nothing has learned nothing, and writing `false` on that would take the date off a + * connection verified at a consent screen — a fact nothing else in this deployment records, + * erased by a button that claims to check one. So the answer is what the row says after the + * press, and `probe` is what says whether the press was able to try anything. + * + * THE ROW IS READ BEFORE THE VENDOR IS CALLED, and its absence is a refusal. The writer below is + * an upsert, so a re-check that probed first and recorded the answer would INSERT a connection + * for somebody who has none — the row that is the whole of the gate every later brokered call + * passes through, created by a button that only asks a question. The probe itself would be spent + * on an account the vendor does not hold, and would come back "no connected account found": this + * deployment's own state, shown to somebody as though their key had been rejected. + * + * NO BROKER IS REFUSED BEFORE ANY OF IT, though nothing here calls the broker. The broker and the + * transport are built from the same key, so a deployment without one has neither — and the probe + * would come back as the transport's "Composio is not configured for this deployment", which + * this method would otherwise report as the vendor rejecting a perfectly good key, and would + * write the row unverified on the strength of it. + * + * AND A CONNECTION WITH NO KEY BEHIND IT IS REFUSED HERE RATHER THAN IN THE BROWSER. There is + * nothing to re-check on a consent connection: the person authenticated at the vendor's own + * screen, this deployment holds no credential of theirs, and the row's `verified_at` is the + * date that screen earned — a fact nothing else here records. A probe spent on it would be a + * call against their account that this method then reads as evidence about a key that does not + * exist, and the likely failure would write `verified: false` with a null timestamp: a button + * that claims to CHECK a connection, destroying the only record that one was ever checked. That + * is precisely what the nothing-to-probe branch above is written to protect, and an app that + * happens to publish a safe action walks straight past it. The screen does not offer the button + * for a consent app, but a route takes POSTs and not only button presses, so the refusal + * belongs where {@link connectBrokeredWithFields} puts its own: on the SCHEME RECORDED ON THE + * APP'S ROW, asked through {@link isFieldScheme} so the schemes this admits cannot drift from + * the schemes that have a key to admit. + */ + async recheckBrokeredConnection(input: { + toolkit: string; + userId: string; + }): Promise<{ + verified: boolean; + verifiedAt: string | null; + probe: string | null; + }> { + if (!broker) throw new BrokerUnconfiguredError(); + + // Keyed on the url, which is where a brokered row records which app it is; `mcp_servers.id` + // is a display name and nothing holds the two equal. It is the lookup + // `connectBrokeredWithFields` and `disconnectBrokered` both make, for the same stake: a row + // called `gmail` at `composio://slack` would decide a Slack re-check on Gmail's scheme. + const [app] = await database + .select({ authScheme: mcpServers.authScheme }) + .from(mcpServers) + .where(eq(mcpServers.url, `composio://${input.toolkit}`)) + .limit(1); + + if (!isFieldScheme(app?.authScheme ?? null)) { + throw new PluginRefusedError( + `${input.toolkit} is not an app this deployment holds a key for, so there is nothing here to re-check. It was connected at ${input.toolkit}'s own sign-in screen, and if it has stopped working, disconnecting it on the Plugins page and connecting it again is what fixes it.`, + null, + ); + } + + const [held] = await database + .select({ + verified: composioConnections.verified, + verifiedAt: composioConnections.verifiedAt, + }) + .from(composioConnections) + .where( + and( + eq(composioConnections.toolkit, input.toolkit), + eq(composioConnections.userId, input.userId), + ), + ) + .limit(1); + + if (!held) { + throw new PluginRefusedError( + `You have no connection to ${input.toolkit} here, so there is nothing to re-check. Connect it on the Plugins page and it will be checked as it is made.`, + null, + ); + } + + const { probe, failure } = await this.probeBrokeredConnection(input); + + /* + * NOTHING WAS TRIED, SO NOTHING IS WRITTEN AND NOTHING IS FILED. The row keeps whatever it + * held — a consent verification and its date, or the honest unchecked pair — and the answer + * reports that state beside the null probe that says why this press could not improve on it. + * `mcp.connection_verified` records an account exercised with a REAL CALL; a row filed here + * would make the one event that means "a key was tried" also mean "somebody pressed a button". + */ + if (probe === null) { + return { + verified: held.verified, + verifiedAt: iso(held.verifiedAt), + probe: null, + }; + } + + /* + * THE ANSWER IS WRITTEN FOR BOTH OUTCOMES, and through the single writer for its reason: the + * flag and its timestamp are one set, and a second hand spelling that set is how one of them + * comes to leave a date standing on a claim nobody is making any more. A key the vendor has + * just rejected stops being verified HERE — that is the state this button exists to correct, + * in the direction nothing else in the product can move it. + */ + const verified = failure === null; + const verifiedAt = await this.recordBrokeredConnection({ + toolkit: input.toolkit, + userId: input.userId, + verified, + // The action this press spent. Never null on this path: the nothing-to-probe branch above + // returns before reaching the writer, precisely so that a check which could try nothing + // writes nothing at all. + probeAction: probe, + }); + + /* + * AND THE TRAIL CARRIES THE CHECK, whichever way it went, filed BEFORE the refusal below for + * the reason the connect path files its own row before its throw: every way out of a failure + * is that throw, so a row written after it is a row never written. `action` is the probe that + * ran, which is what separates this from a connection nothing was ever tried on. + * + * FILED UNDER THE APP'S BARE SLUG, as every row in this family is — the same id + * `confirmBrokeredConnection`, `connectBrokeredWithFields` and `retireConnectionsFor` file + * under — so one query still answers what happened to one person's access to one app. + * + * THE ROW NAMES A PERSON AND NO BOT, because none ran: this is somebody checking their own + * account. See `mcp.connection_verified` in `./audit`, whose safety argument names a person + * re-checking their own connection as one of the two callers this call may ever have. + */ + await recordAuditEvent(auditStore, { + eventType: "mcp.connection_verified", + targetType: "mcp_server", + targetId: input.toolkit, + payload: { + actor: input.userId, + action: probe, + verified, + }, + }); + + if (failure !== null) { + /* + * THE VENDOR'S OWN SENTENCE, AND THE TWO FACTS AROUND IT: the row here now says unchecked, + * and their account was left exactly as it was. The second half is what makes the retry one + * step rather than three — there is nothing to disconnect and nothing to reconnect, only a + * key to fix at the app and this button to press again. + */ + throw new PluginRefusedError( + `${input.toolkit} would not answer with the key it is holding: ${failure} Your connection here is recorded as unchecked until it does; nothing was disconnected, so fixing the key at ${input.toolkit} and pressing Re-check again is the whole of the retry.`, + null, + ); + } + + return { verified: true, verifiedAt: iso(verifiedAt), probe }; + }, + + /** + * End this person's brokered account at the vendor, and then forget where it was. + * + * REVOKE BEFORE DELETE, AND THAT ORDER IS THE WHOLE METHOD. The row is the only thing in this + * deployment that says which app this person connected: the app is read off + * `composio_connections`, and a revoke needs it. Delete first and a revoke that then fails + * leaves a live grant on somebody's mailbox that no operation here can reach, because the one + * value it would have to be revoked under is gone. The other order costs nothing by + * comparison — a revoke that throws leaves the row standing, the person presses disconnect + * again, and the second attempt has everything the first one had. + * + * WHICH ALSO MEANS THE FAILURE IS LOUD. Nothing is caught here: a broker that will not answer + * ends this call, and no row and no trail entry claims an account was disconnected when the + * account is still live. + * + * `vendorRevocationRequested` IS WHAT WAS ASKED FOR, NOT THAT A CALL WAS MADE — {@link + * ComposioBroker.revoke}'s own answer, passed through. True where an account was found and its + * withdrawal asked for, false where there was none to withdraw, and the value of the field is + * exactly that a reader can tell an account this deployment acted on from one that outlives it + * somewhere else. + * + * IT SAYS "REQUESTED" BECAUSE THE VENDOR'S ANSWER SUPPORTS NOTHING STRONGER, and the field was + * renamed from `vendorRevoked` when that turned out to be false in the plainest way: the + * adapter behind it was soft-deleting the account and asking for no upstream revocation at all, + * so every row saying a grant had been withdrawn described one still live at Google. The ask is + * now made; what a broker can promise synchronously is that the account is gone at Composio and + * that the provider has been asked, because the withdrawal itself runs as a background job with + * no supported way to poll it. + * + * EXCEPT WHERE THE APP IS ONE SOMEBODY TYPED A KEY INTO, AND THERE IT IS FALSE BY CONSTRUCTION + * RATHER THAN BY WHAT THE VENDOR FOUND. `revoke_on_delete` asks the PROVIDER to end a grant, + * which is a real request for a consent account — Google or Slack acts on it — and a + * meaningless one for an API key. There is no grant behind a key to withdraw: the value is + * still valid at the app and still works for anyone holding it, so the account ends at Composio + * and nothing was asked of anybody else. Saying otherwise would be the one row in this trail + * nobody could rely on, which is the failure the paragraph above describes arriving a second + * time by a different road — a withdrawal recorded for something that was never granted. So the + * scheme recorded on the app's row decides this field for a field connection, and the broker's + * own answer decides it for every other. + * + * THE SCHEME IS THE ONE ON THE APP'S ROW, for {@link connectBrokeredWithFields}' reason: it is + * what this deployment's authorization config was created AS and what the account was attached + * to, and a fresh read of the catalogue is a second answer — a key connection described as + * consent because the vendor has since started publishing managed OAuth for the app. The + * person's half of this fact is already written on the disconnect row they are shown: their key + * still works at the app, and rotating it there is what ends it. This is the trail's half of + * the same sentence. + * + * THE VENDOR IS ASKED WHETHER OR NOT A ROW IS HERE. The row is a cache of Composio's answer + * and never the account itself (see {@link brokeredConnection}), so its absence is not + * evidence that the grant is gone: the confirm above deletes it on any `false` from the + * vendor, and a person whose row was cleared that way can still be holding a live account at + * Composio with nothing left here pointing at it. Asking anyway is the only operation in this + * deployment that can end such a grant, and it costs nothing where there is genuinely nothing + * to withdraw — the broker answers `false` and says so. Skipping the revoke for want of a + * local row would make the safe half of disconnect unreachable for exactly the person who + * needs it, on the strength of a cache we already know drifts. + * + * BUT THE TRAIL RECORDS ONLY A DISCONNECT THAT HAPPENED. The event is filed where something + * actually ended — a row deleted here, or a grant withdrawn at the vendor — and not otherwise. + * A call that found no row and withdrew no grant disconnected nothing, and an + * `mcp.account_disconnected` row for it tells whoever reads the trail that somebody's account + * ended at a moment when nobody's did. It is the criterion + * {@link confirmBrokeredConnection} files its own event under, one act the other way round: + * the trail records acts, and a call that changed nothing performed none. + * + * WHICH IS NOT THE SAME QUESTION AS `vendorRevocationRequested`. A row here with no grant at + * the vendor is a disconnect — the gate this deployment decides every brokered call on was + * open, and this call closed it — so the event is filed, saying + * `vendorRevocationRequested: false`. A grant at the vendor + * with no row here is a disconnect too, and the weightier of the two, because somebody's live + * account was ended; the event is filed for that as well. Only where both are absent is there + * no act to record, and the two cases stay legible in the trail because the field still says + * which of them happened. + * + * SO THE FILING IS DECIDED ON THE BROKER'S OWN ANSWER AND NOT ON THE FIELD, because for a field + * connection the two part company on purpose. A key account the vendor found and ended with no + * row here is an act — somebody's live connection stopped existing — and gating the event on a + * value that is false by construction would leave exactly that act unrecorded. The field + * answers what was asked of the provider; `ended` answers whether anything was there. + */ + async disconnectBrokered(input: { + toolkit: string; + userId: string; + by: string; + /** + * Why the account ended, which is the closed pair and not free text. A brokered account ends + * in exactly two ways — the person disconnecting their own, and the person being removed + * from the People screen, which is the word {@link retireConnectionsFor} already files its + * own rows under. A reader asking the trail which of the two happened can be answered only + * if it is the same word every time, so the type is the pair rather than whatever sentence a + * caller happened to spell. + */ + reason: "self" | "person_removed"; + }): Promise<{ vendorRevocationRequested: boolean }> { + if (!broker) throw new BrokerUnconfiguredError(); + + /* + * Keyed on the url, which is where a brokered row records which app it is; `mcp_servers.id` + * is a display name and nothing holds the two equal. It is the lookup + * {@link connectBrokeredWithFields} makes, for the same stake: a row called `gmail` at + * `composio://slack` would have this disconnect reading Gmail's scheme to describe what + * happened to a Slack account. + * + * AN APP WITH NO ROW HERE IS NOT A FIELD APP. A person can hold an account at Composio for + * an app this deployment has since removed — the row is a cache and the removal takes no + * grant with it — and the revoke below is the one operation that can still end it. Nothing + * names the scheme it was connected under any more, so the honest reading is the broker's + * own answer, which is what an absent row falls through to. + */ + const [app] = await database + .select({ authScheme: mcpServers.authScheme }) + .from(mcpServers) + .where(eq(mcpServers.url, `composio://${input.toolkit}`)) + .limit(1); + const fieldScheme = isFieldScheme(app?.authScheme ?? null); + + // Whether there was an account to end at all, which is what decides if anybody was + // disconnected. Named apart from the field below because for a key the two differ: something + // ended, and nothing was asked of the provider. + const ended = await broker.revoke({ + userId: input.userId, + toolkit: input.toolkit, + }); + + const vendorRevocationRequested = fieldScheme ? false : ended; + + // `returning` because whether a row was here is half of what decides if anybody was + // disconnected, and a delete that answered nothing would leave the two cases indistinguishable. + const [deleted] = await database + .delete(composioConnections) + .where( + and( + eq(composioConnections.toolkit, input.toolkit), + eq(composioConnections.userId, input.userId), + ), + ) + .returning({ toolkit: composioConnections.toolkit }); + + if (deleted || ended) { + await recordAuditEvent(auditStore, { + eventType: "mcp.account_disconnected", + targetType: "mcp_server", + targetId: input.toolkit, + payload: { + actor: input.by, + server: input.toolkit, + // Whose account this was, which is not always who ended it: an administrator + // offboarding somebody and a person disconnecting themselves write the same shape of + // row, and only these two fields tell them apart. + owner: input.userId, + reason: input.reason, + vendorRevocationRequested, + }, + }); + } + + return { vendorRevocationRequested }; + }, + + /** + * Retire every connector credential belonging to one person. + * + * WHAT THIS IS FOR. "We removed their access" has to be true of the thing that matters, which is + * the refresh token sitting at the vendor. Removing somebody from the People screen used to end + * their sessions and add them to the deny list, and leave their Google grant entirely intact in + * this deployment's vault. They could not exercise it — the actor comes from a session they no + * longer get — but the deployment still held a usable secret for a person who had been removed, + * which is not what an administrator was told they did, and is the first thing a customer asks + * about a per-person connector. + * + * LOOKED UP IN THE VAULT, NOT THROUGH THE JOIN TABLE. `mcp_user_credentials.user_id` cascades on + * a user row being deleted, so by the time somebody is gone the join row can be gone too and the + * credential is orphaned: unrevoked, referenced by nothing, reachable from no screen and by no + * code path. `credentials.key_id` holds the user id for an `mcp_user_token`, so the vault can + * still be asked directly — which makes this work for the person who was removed and for the one + * whose row was deleted underneath it. + * + * The join rows go too, so the account pages stop claiming a connection this deployment can no + * longer use. + * + * AND THE BROKERED CONNECTIONS, which are neither a credential nor a join row. Composio holds + * the account, so there is no secret in the vault to find and the `composio_connections` row is + * itself the permission — the only thing deciding whether a call may go out as this person. + * Sweeping the vault alone therefore left that gate passing for somebody who had been removed. + * + * NOT VENDOR-SIDE REVOCATION FOR THE VAULT HALF. That needs the OAuth client and the vendor's + * revoke endpoint, and it belongs with disconnect. Those rows are the half that stops us + * holding the secret; the grant at Google outlives it until somebody revokes it there. Said + * plainly rather than implied, because the difference matters to whoever has to answer for it. + * + * THE BROKERED HALF DOES END IT AT THE VENDOR, because there is no secret of ours to stop + * holding: clearing the row alone would shut the gate this deployment owns and leave the + * mailbox attached at Composio, which is "we removed their access" being untrue of the only + * thing that matters, for the person it matters most about. So every app this person connected + * is revoked through the broker, exactly as {@link disconnectBrokered} revokes for one and + * {@link removeServer} for a whole app. + * + * REVOKE BEFORE DELETE, ALWAYS. The row is the only thing that names which apps this person + * had, and it outlives the `users` row precisely so offboarding can still find them — which + * was the table's whole justification and until now was theoretical. A delete that ran first + * would leave a failed revoke with nothing to revoke under: a live grant on a departed + * person's mailbox that no operation in this deployment can reach. The other order costs a + * repeat of an act nobody minds repeating. Nothing is caught around the revokes either, so a + * broker that will not answer ends this method with the rows still standing rather than + * letting it report an ending that did not happen. + */ + async retireConnectionsFor( + userId: string, + by: string, + ): Promise<{ retired: number }> { + if (!userId) return { retired: 0 }; + + const owned = await database + .select({ + id: credentialRows.id, + provider: credentialRows.provider, + revokedAt: credentialRows.revokedAt, + }) + .from(credentialRows) + .where( + and( + eq(credentialRows.kind, "mcp_user_token"), + eq(credentialRows.keyId, userId), + ), + ); + + let retired = 0; + for (const credential of owned) { + // Already revoked is not a failure. Retiring twice is something an administrator can + // legitimately do, and the second time should be quiet rather than an error. + if (credential.revokedAt) continue; + await credentials.revoke(credential.id); + retired += 1; + await recordAuditEvent(auditStore, { + eventType: "mcp.account_disconnected", + targetType: "mcp_server", + targetId: credential.provider, + payload: { + actor: by, + server: credential.provider, owner: userId, /* * Why, because the two reasons are not the same event to a reader. Somebody disconnecting @@ -2819,7 +5201,7 @@ export function createPluginStore(options: PluginStoreOptions) { * which one this was. */ reason: "person_removed", - vendorRevoked: false, + vendorRevocationRequested: false, }, }); } @@ -2828,6 +5210,90 @@ export function createPluginStore(options: PluginStoreOptions) { .delete(mcpUserCredentials) .where(eq(mcpUserCredentials.userId, userId)); + /* + * Every app this person connected at the broker, where there is no secret to scan the vault + * for. + * + * CRITERION. After this returns, no brokered call may go out on this person's behalf. + * + * REASON. A brokered connection is not a credential: Composio holds the account and this + * deployment sends a user id, so the vault sweep above finds nothing and `composio_connections` + * is the entire gate. Reading only the vault therefore retired nothing for somebody whose only + * connector was brokered, reported that as a retirement, and left the `(toolkit, user_id)` gate + * passing for a person who no longer exists — their access outliving them, which is the first + * thing anybody asks about a per-person connector. The table's own docblock justifies its shape + * by this path, so the shape was carrying a promise nothing kept. + * + * FOUND HERE AND NOWHERE ELSE, which is what the missing foreign key buys. The row survives the + * `users` row precisely so this can still name what the person had after they are gone — the + * same argument the vault lookup above makes, from the side that has no vault row. It is also + * why the guard at the top of this method is load-bearing rather than defensive: `not null` + * admits the empty string, so a row at `(toolkit, "")` is legal, and retiring "nobody" must not + * be what deletes it. + * + * COUNTED, because the number is what "we removed their access" claims. Retiring twice stays + * quiet on its own: the rows are gone, so the second call finds none. + * + * READ BEFORE ANYTHING IS DELETED, because the revokes below need the apps and the rows are + * where the apps are — the reason the docblock gives for revoking first. Sorted, so two + * retirements of the same person revoke in the same order and write their rows in the same + * order. + */ + const brokered = await database + .select({ toolkit: composioConnections.toolkit }) + .from(composioConnections) + .where(eq(composioConnections.userId, userId)) + .orderBy(asc(composioConnections.toolkit)); + + /* + * What the broker was actually asked for each app, kept so the trail below records the answer + * rather than the call. False where there is no broker at all: a deployment whose key has + * since been unset can still offboard somebody, and it could not have been calling Composio + * either way — but nothing was asked there and the row must not claim otherwise. + */ + const vendorRevocationRequested = new Map(); + for (const connection of brokered) { + vendorRevocationRequested.set( + connection.toolkit, + broker + ? await broker.revoke({ userId, toolkit: connection.toolkit }) + : false, + ); + } + + await database + .delete(composioConnections) + .where(eq(composioConnections.userId, userId)); + + for (const connection of brokered) { + retired += 1; + await recordAuditEvent(auditStore, { + eventType: "mcp.account_disconnected", + targetType: "mcp_server", + // The app, which for a brokered connection is all the row records. The `mcp_servers` row + // it belongs to may have been removed already, and the connection outlives that too. + targetId: connection.toolkit, + payload: { + actor: by, + server: connection.toolkit, + owner: userId, + reason: "person_removed", + /* + * What was asked of the vendor, not that a call was made — {@link + * ComposioBroker.revoke}'s own answer, passed through, and the one place this half + * differs from the vault loop above. There the grant at Google outlives our copy of + * the secret and nothing was asked of anybody, so the field can only say false; here + * there was no secret of ours and the account itself was deleted at Composio with its + * withdrawal asked for, or there was nothing to ask about, or there was no broker to + * ask. The value of the field is exactly that a reader can tell those apart, so a + * constant here would be worse than none. + */ + vendorRevocationRequested: + vendorRevocationRequested.get(connection.toolkit) ?? false, + }, + }); + } + return { retired }; }, @@ -2887,6 +5353,17 @@ export function createPluginStore(options: PluginStoreOptions) { throw new PluginRefusedError(`${input.ref} is not a tool.`, null); } + /* + * Who the trail says made this call, which is not what the call is made AS. + * + * `input.actorId` stays the value every gate is decided on, and the empty string must go on + * matching no grant and no connection anywhere. This is only what the row says: a run nobody + * could be attributed to is `unattributed` rather than blank, on the criterion at + * {@link DEPLOYMENT_ACTOR}, and never `deployment` — a run this deployment could not put a + * name to is not the deployment having acted. + */ + const auditActor = input.actorId || UNATTRIBUTED_ACTOR; + const decision = await this.decide("mcp", input.ref, input.botId); if (!decision.allowed) { await recordAuditEvent(auditStore, { @@ -2895,7 +5372,7 @@ export function createPluginStore(options: PluginStoreOptions) { targetId: input.ref, ...(input.initiator ? { initiator: input.initiator } : {}), payload: { - actor: input.actorId, + actor: auditActor, bot: input.botId, server: serverId, tool: toolName, @@ -2906,23 +5383,59 @@ export function createPluginStore(options: PluginStoreOptions) { throw new PluginRefusedError(decision.reason, null); } - const { row, entry } = await requireServer(serverId); + const { row, entry, access } = await requireServer(serverId); const advertised = await database - .select({ name: mcpTools.name, inputSchema: mcpTools.inputSchema }) + .select({ + name: mcpTools.name, + inputSchema: mcpTools.inputSchema, + effect: mcpTools.effect, + destructive: mcpTools.destructive, + version: mcpTools.version, + }) .from(mcpTools) .where( and(eq(mcpTools.serverId, serverId), eq(mcpTools.name, toolName)), ) .limit(1); - const effect = classifyTool(entry, toolName, advertised.length > 0); + const effect = classifyTool( + entry, + toolName, + advertised.length > 0, + advertised[0]?.effect, + ); const args = withoutEmptyOptionals( input.args, advertised[0]?.inputSchema as Record | undefined, ); + /* + * The version this action was listed at, handed to the transport that needs one. + * + * Under a reserved key rather than as a parameter on the shared signature, because that + * signature is MCP's and three other transports implement it. The Composio transport strips + * the key before anything reaches the vendor, and asserts that it did. + * + * A `__version` in the model's own arguments is not an argument: it is this key, and no + * vendor publishes it. So it is stripped unconditionally, whatever its value, and that strip + * is the whole protection. The recorded version is then merged into arguments that provably + * cannot carry the key, which makes both spread orders identical: the merge order has no + * reachable failure mode. Do not read the strip as belt-and-braces on top of an ordering + * guarantee — the ordering is the redundant half, and removing the strip is what would let a + * model choose which revision of an action runs. + * + * Absent when the app has not been refreshed since the column existed, and because the key + * was stripped there is then no version at all for the transport to read, which is what makes + * its refusal hold rather than guessing — a guessed version is a call against an action's + * other behaviour. + */ + const { [VERSION_ARG]: _dropped, ...modelArgs } = args; + const vendorArgs = advertised[0]?.version + ? { ...modelArgs, [VERSION_ARG]: advertised[0].version } + : modelArgs; + /** * The same policy the computer actions are judged by, asked about a tool call. * @@ -2966,7 +5479,7 @@ export function createPluginStore(options: PluginStoreOptions) { * the row goes down once, after the outcome exists. */ const decided = { - actor: input.actorId, + actor: auditActor, bot: input.botId, server: serverId, tool: toolName, @@ -2978,7 +5491,7 @@ export function createPluginStore(options: PluginStoreOptions) { * a per-person connector raises — two rows for the same tool and the same Bot can legitimately * have seen entirely different documents, and nothing else in the row says why. */ - reachedAs: reachedAsFor(entry, input.actorId), + reachedAs: reachedAsFor(access, input.actorId), decision: { allowed: verdict.allowed, mode: verdict.mode, @@ -3066,8 +5579,14 @@ export function createPluginStore(options: PluginStoreOptions) { * it did. */ try { - const { token } = await connectionTokenFor(row, entry, input.actorId); - const vendor = injectedVendor ?? transportFor(entry).callTool; + const { token } = await connectionTokenFor( + row, + entry, + input.actorId, + access, + ); + const vendor = + injectedVendor ?? transportFor(access.transport).callTool; const result = await vendor( { url: effectiveUrl(row, entry), @@ -3076,7 +5595,7 @@ export function createPluginStore(options: PluginStoreOptions) { botId: input.botId, }, toolName, - args, + vendorArgs, ); await recordAuditEvent(auditStore, { eventType: result.isError ? "mcp.call_failed" : "mcp.call_succeeded", @@ -3121,8 +5640,18 @@ export function createPluginStore(options: PluginStoreOptions) { ...(input.initiator ? { initiator: input.initiator } : {}), payload: { ...decided, + /* + * Asked through {@link withoutStatement}, because not every throw in this block is a + * vendor's sentence. + * + * The vendor's own words are what this field is for and are kept. But every query on + * the way here throws a `DrizzleQueryError` whose message is our statement and its + * bound values — credential ids, user ids, server ids — and `audit_events` is read by + * an operator and exported. A dump in the row that records a failed call is the same + * disclosure the tool-list replace was fixed for, in the trail rather than on a page. + */ failure: (error instanceof Error - ? error.message + ? withoutStatement(error) : String(error) ).slice(0, 400), }, diff --git a/server/src/plugins/tools.ts b/server/src/plugins/tools.ts index 4e54bf20c..905a815fa 100644 --- a/server/src/plugins/tools.ts +++ b/server/src/plugins/tools.ts @@ -1,7 +1,11 @@ import { z } from "zod"; import type { AuditInitiator } from "../audit"; import type { SelectableSkill } from "./selection"; -import { PluginRefusedError, type PluginStore } from "./store"; +import { + isDeploymentFault, + PluginRefusedError, + type PluginStore, +} from "./store"; /** * The tools a Bot may call, as the runtime's own tool definitions, executed on the server. @@ -203,6 +207,22 @@ export async function grantedTools(options: { if (error instanceof PluginRefusedError) { return `${REFUSAL_MARKER} ${error.message}`; } + /* + * A contradiction in this deployment's own tables says nothing to a model. + * + * CRITERION. Nothing on the `isDeploymentFault` shelf may have its message relayed from + * here, whatever it says. + * + * REASON. The branch below hands `error.message` to the model, which is right for a + * vendor's own words — that is somebody else's software explaining itself, and the + * diagnosis is worth having. These are not that. `ServerRowAmbiguousError` names two of + * our columns and tells the reader to rename a row or correct its provenance: an + * instruction only an operator can carry out, arriving in an end user's model context as + * the reason their tool failed, from which the model can only invent something to tell + * them. The operator who can act on it is served on the admin surface instead, where the + * refresh route now answers with the sentence in full. + */ + if (isDeploymentFault(error)) return "That tool could not be called."; // A vendor that failed is not a refusal, and the difference matters to the person reading // the answer: one means "not allowed", the other means "it broke". return error instanceof Error diff --git a/server/src/plugins/transport.ts b/server/src/plugins/transport.ts index 4fecdb325..60a18b333 100644 --- a/server/src/plugins/transport.ts +++ b/server/src/plugins/transport.ts @@ -1,34 +1,57 @@ import * as builtinRoutines from "./builtin-routines"; -import type { CatalogueEntry } from "./catalogue"; +import * as composio from "./composio"; import * as driveRest from "./google-drive-rest"; -import type { McpCallResult, McpTool } from "./mcp"; +import type { ListedTool, McpCallResult } from "./mcp"; import * as mcp from "./mcp"; /** - * How this deployment reaches one vendor: which protocol, chosen per catalogue entry. + * How this deployment reaches one vendor: which protocol, from the kind `./access` resolved. * * WHY THIS EXISTS. Every connector used to be MCP, so "the transport" was an import. Google's Drive * MCP server turned out to be gated behind a developer preview, and the same product's ordinary REST * API is generally available — so one vendor needed a second way in, and a second way in wants a * seam rather than a branch at each call site. * - * The interface is MCP's OWN, unchanged: `listTools` and `callTool`, the two functions - * {@link ./mcp} already exported, with the shapes it already used. That direction matters. Had the - * REST adapter been given its own interface with MCP adapted to fit, MCP would have become a special - * case of a shape invented for Drive. As it is, MCP is the contract and the adapter conforms to it, - * which is why swapping back is one field on one entry and not a refactor. + * The interface STARTED as MCP's OWN: `listTools` and `callTool`, the two functions {@link ./mcp} + * already exported, with the shapes it already used. That direction matters. Had the REST adapter + * been given its own interface with MCP adapted to fit, MCP would have become a special case of a + * shape invented for Drive. As it is, MCP is the contract the adapters conform to, which is why + * swapping Drive back is one field on one catalogue entry and not a refactor. + * + * What has been added since is a SUPERSET of that shape rather than a departure from it, so an + * MCP-shaped implementation still satisfies the seam unchanged. `listTools` answers `ListedTool[]`, + * which is `McpTool` plus fields describing what a listing said about an action; the connection + * carries an `actorId` and a `botId` for the transports whose authorization is the actor rather + * than a credential; and one reserved key on `args` hands a transport the recorded version of the + * action being called. Every addition is OPTIONAL, and that is what keeps an MCP-shaped + * implementation an implementation of this interface rather than an exception to it. + * + * This paragraph used to say those fields were ones "a broker publishes and an MCP server does + * not", and that `mcp.ts` therefore read none of them. Both were false: the MCP specification + * defines `annotations.destructiveHint`, servers do publish it, and `mcp.ts` was dropping it — so a + * tool a vendor declared destructive classified as a read wherever a curated write list omitted it. + * `mcp.ts` now reads that hint, and deliberately does not read `readOnlyHint`, because a hint may + * narrow what a Bot may do and may never widen it. The effect column is therefore not one + * transport's vocabulary; it is what any listing was willing to say. * * There are exactly two call sites in the whole system — the tool listing and the tool call — and - * both take a transport from here. Nothing else, including the OAuth flow, the per-person credential - * selection, the grants, the policy engine and the audit trail, knows which protocol is underneath. + * both take a transport from here. Nothing else reads a `TransportKind` at all: the OAuth flow, + * the grants, the policy engine and the audit trail are written without one. Whose credential a + * row goes out on is a SEPARATE axis — `./access`'s `CredentialSource` — and that one is NOT + * protocol-blind, since the brokered branch of the credential selection looks a person's + * connection up in `composio_connections` by name. Read the blindness as a claim about this union + * and not about the store. */ export type VendorTransport = { /** * Whether discovering the tool list needs somebody's credential. * * True for MCP, where the list is an answer from a remote server that will not give it up - * unauthenticated. False for an adapter whose tool list is this code, where there is nothing to ask - * and nobody to ask it of. + * unauthenticated. False whenever no credential has to be SELECTED for the listing: either + * because the list is this code, as it is for Drive and Routines, or because the transport + * already holds the one key it lists on and never receives it through the connection, as + * Composio does — a broker publishes an action's schema to anybody who asks with the + * deployment's own key. * * It is on the transport rather than assumed by the caller because getting it wrong is a whole * broken setup flow. Assumed true, an administrator configuring Drive was sent to their own @@ -41,17 +64,23 @@ export type VendorTransport = { url: string; token?: string; /** - * Who this call is for, and which Bot is making it. + * Declared by the shared connection shape, and never supplied on THIS path. + * + * `refreshTools` is the only caller of `listTools` in the system, and it passes `{url, token}`. + * No implementation here even accepts either field: `builtin-routines` takes no argument at + * all, Composio takes only `url`, and MCP and Drive take `{url, token}`. So a transport that + * read one would read `undefined` every time, and nothing on the listing path may be + * authorized by them. * - * Ignored by every transport that dials a vendor: MCP and Drive answer to a credential, and who - * holds it is already decided by the time the connection is built. The builtin transport has no - * credential and no vendor — it acts on this deployment's own tables — so the actor is not - * context, it is the authorization, and it refuses without one. A routine is somebody's. + * The actor is the authorization on the CALLING path instead — see {@link callTool} below, + * where Routines and Composio each refuse a run attributed to nobody. Listing is not + * somebody's: it is what this deployment offers everybody. A list that insisted on an actor + * would be asked without one, store zero tools, and leave the vendor advertising nothing to + * anybody. */ actorId?: string; - /** The Bot the run belongs to. A routine runs as its Bot, which is never a name a model supplies. */ botId?: string; - }): Promise; + }): Promise; callTool( connection: { url: string; @@ -59,10 +88,16 @@ export type VendorTransport = { /** * Who this call is for, and which Bot is making it. * - * Ignored by every transport that dials a vendor: MCP and Drive answer to a credential, and who - * holds it is already decided by the time the connection is built. The builtin transport has no - * credential and no vendor — it acts on this deployment's own tables — so the actor is not - * context, it is the authorization, and it refuses without one. A routine is somebody's. + * Ignored where a CREDENTIAL is the authorization: MCP and Drive answer to a token, and whose + * it is was settled before the connection was built, so neither module's `Connection` type + * carries these at all. Read where the ACTOR is the authorization: Routines acts on this + * deployment's own tables, and Composio opens one person's account with a key the deployment + * holds for everybody, so both refuse a run attributed to nobody rather than run it as + * somebody. A routine is somebody's; so is a mailbox. + * + * They come off the connection, which the call path derives from the session, and are never + * read out of `args`. A model that could name either could schedule work as another person or + * read another person's mail. */ actorId?: string; /** The Bot the run belongs to. A routine runs as its Bot, which is never a name a model supplies. */ @@ -74,27 +109,53 @@ export type VendorTransport = { }; /** - * The protocols a catalogue entry may name. + * The protocols this deployment can dial. * * A closed union rather than a string, so adding one is a change to this file and to the registry - * below together. An entry naming a transport that does not exist should not typecheck. + * below together. Named by a catalogue entry for a curated vendor and by `./access` from the row's + * provenance for a Composio app; either way, a kind that does not exist should not typecheck. + */ +export type TransportKind = + | "mcp" + | "google-drive-rest" + | "builtin-routines" + | "composio"; + +/** + * The kinds a CATALOGUE ENTRY may name, which is every one except the broker's. + * + * CRITERION. `composio` is not writable in a reviewed entry, and the compiler is what says so. + * + * REASON. A brokered row is reached by an app slug read off its url and a per-person connection + * looked up by that slug; a catalogue entry has neither, and `accessFor` answers `toolkit: null` + * and `credential` from the entry's auth kind for everything it resolves. So an entry declaring + * `transport: "composio"` yielded a Composio dial with no app named, no brokered gate, and + * `reachedAs` taken from an auth kind that has nothing to do with whose account the broker would + * have run in — a row that walks past both store gates while satisfying every type in the module + * that claims to enumerate how a row can be reached. No entry declares it, which is why this is a + * door being shut rather than a bug being fixed, and why shutting it costs nothing. + * + * `Exclude` rather than a hand-written second union, so a kind added above is offered to the + * catalogue automatically and only the broker stays out. */ -export type TransportKind = "mcp" | "google-drive-rest" | "builtin-routines"; +export type CuratedTransportKind = Exclude; const TRANSPORTS: Record = { mcp, "google-drive-rest": driveRest, "builtin-routines": builtinRoutines, + composio, }; /** - * Which transport serves this entry. + * The transport for a resolved kind. * - * MCP for anything that does not say otherwise, which covers every catalogue entry that omits the - * field and — importantly — every server an administrator added by URL, where there is no entry at - * all. A custom server is somebody else's MCP endpoint by definition, so the absent case and the - * default case are the same answer for the same reason. + * A kind rather than a catalogue entry, because deciding the kind is no longer this file's business. + * It used to read `entry?.transport ?? "mcp"`, which was complete while every server either had an + * entry or was somebody's MCP endpoint — and silently wrong for a Composio app, which has no entry + * and would have had `composio://gmail` dialled as an HTTP server. `./access` decides now, once, for + * every row shape; this is the lookup that follows. */ -export function transportFor(entry: CatalogueEntry | null): VendorTransport { - return TRANSPORTS[entry?.transport ?? "mcp"]; +export function transportFor(kind: TransportKind): VendorTransport { + return TRANSPORTS[kind]; } diff --git a/server/tests/composio-access.test.ts b/server/tests/composio-access.test.ts new file mode 100644 index 000000000..06b59497c --- /dev/null +++ b/server/tests/composio-access.test.ts @@ -0,0 +1,215 @@ +import { describe, expect, test } from "bun:test"; +import { accessFor, ServerRowAmbiguousError } from "../src/plugins/access"; +import type { CatalogueEntry } from "../src/plugins/catalogue"; +import { catalogueEntry, resolveServerUrl } from "../src/plugins/catalogue"; + +/** + * How a server row is reached, resolved once. + * + * WHY THIS FILE IS THE IMPORTANT ONE. Three separate decisions used to be derived independently at + * three call sites: which protocol dials, whose credential is spent, and whose name goes in the audit + * row. Each derived it from a different field, and a Composio row — which has no catalogue entry at + * all — answered every one of them wrongly by default: MCP would dial `composio://gmail` as if it + * were an HTTP server, the credential branch would return no token and proceed, and the trail would + * say the deployment made a call that ran in somebody's mailbox. + * + * One table of expected answers, one row per row-shape that exists. A new kind of server that nobody + * adds a row for here is a test that fails, which is the property the old three-string-checks + * arrangement could not have. + */ +describe("accessFor", () => { + test("a Composio app is dialled through Composio, brokered, and reached as the person", () => { + // No entry, because an app an operator enabled is a row and not something we shipped. + expect( + accessFor({ provenance: "composio", url: "composio://gmail" }, null), + ).toEqual({ + transport: "composio", + credential: "brokered", + reachedAs: "person", + toolkit: "gmail", + }); + }); + + test("a server somebody added by URL is MCP, on the deployment's own token", () => { + expect( + accessFor( + { provenance: "custom", url: "https://mcp.example.com/mcp" }, + null, + ), + ).toEqual({ + transport: "mcp", + credential: "deployment-token", + reachedAs: "deployment", + toolkit: null, + }); + }); + + test("Notion is MCP, on the asking person's own grant", () => { + const notion = catalogueEntry("notion"); + expect(notion).not.toBeNull(); + if (!notion) return; + const notionUrl = `${notion.host}${notion.path}`; + expect( + accessFor({ provenance: "first-party", url: notionUrl }, notion), + ).toEqual({ + transport: "mcp", + credential: "person-oauth", + reachedAs: "person", + toolkit: null, + }); + }); + + test("Drive is its REST adapter, on the asking person's own grant", () => { + const drive = catalogueEntry("google-drive"); + if (!drive) return; + const driveUrl = `${drive.host}${drive.path}`; + expect( + accessFor({ provenance: "first-party", url: driveUrl }, drive), + ).toEqual({ + transport: "google-drive-rest", + credential: "person-oauth", + reachedAs: "person", + toolkit: null, + }); + }); + + test("Routines is in-process, with no credential, and acts as the person", () => { + // Resolved the way a row is written rather than spelled by hand. The url this used to carry — + // `openbot://routines` — is a scheme this codebase does not have anywhere, so the row shape the + // test claims to cover was not the one being passed in. + const routines = resolveServerUrl("routines"); + if (!routines) { + throw new Error( + "catalogue slug `routines` no longer resolves, so this test asserts nothing about it", + ); + } + expect( + accessFor( + { provenance: "first-party", url: routines.url }, + routines.entry, + ), + ).toEqual({ + transport: "builtin-routines", + credential: "none", + reachedAs: "person", + toolkit: null, + }); + }); + + test("an entry that needs no credential reaches nobody's account, so the trail says the deployment", () => { + // Constructed here, because no catalogue slug is `auth: { kind: "none" }` yet. Whoever adds the + // first one gets this answer, and `none` sharing a credential source with `builtin` must not + // drag it to the person: a public endpoint answers everybody identically. + const publicEntry: CatalogueEntry = { + key: "public-thing", + title: "Public Thing", + vendor: "Somebody", + summary: "A server that answers without being told who is asking.", + // Scheme included, because every non-builtin entry carries one — pinned by + // `plugin-catalogue.test.ts`. A bare host here made this stand for an entry the catalogue + // would reject, and the row url below is joined from it so the two cannot drift apart. + host: "https://mcp.example.com", + path: "/mcp", + auth: { kind: "none" }, + writeTools: [], + docsUrl: "https://example.com/docs", + }; + expect( + accessFor( + { + provenance: "first-party", + url: `${publicEntry.host}${publicEntry.path}`, + }, + publicEntry, + ), + ).toEqual({ + transport: "mcp", + credential: "none", + reachedAs: "deployment", + toolkit: null, + }); + }); + + test("a curated entry wins over provenance, so a slug cannot be shadowed into a broker", () => { + const notion = catalogueEntry("notion"); + // Thrown rather than returned. A missing slug here does not make the property hold, it makes + // this test stop checking it — and the whole point of the test is that the protection is never + // unguarded. Renaming the slug must break this file, not quietly empty it. + if (!notion) { + throw new Error( + "catalogue slug `notion` is gone, so nothing here checks that an entry beats provenance", + ); + } + // A row whose provenance was tampered with must not turn a reviewed vendor into a brokered one, + // and must not acquire an app at the broker either — a url edited to `composio://gmail` on a + // curated slug is the same tampering by another field. + // + // `composio` is deliberately NOT the value used here. That one combination is now refused + // outright rather than overruled — see the test below for why the entry cannot arbitrate it — + // and this test is about every other value the column can hold, where the entry still decides. + const shadowed = accessFor( + { provenance: "custom", url: "composio://gmail" }, + notion, + ); + expect(shadowed.transport).toBe("mcp"); + expect(shadowed.credential).toBe("person-oauth"); + expect(shadowed.toolkit).toBeNull(); + }); + + test("a brokered row that carries a curated slug is refused, not dialled at the curated vendor", () => { + const notion = catalogueEntry("notion"); + // Thrown for the reason the test above throws: a renamed slug must break this file rather than + // quietly stop checking the protection it exists for. + if (!notion) { + throw new Error( + "catalogue slug `notion` is gone, so nothing here checks that a colliding row is refused", + ); + } + // Two different rows produce this pair of arguments and nothing in them tells the two apart: a + // curated Notion row whose provenance column was edited to `composio`, and a genuinely brokered + // Notion app whose id happens to be the catalogue's slug. Entry-wins answered as though only + // the first existed, so the second was dialled as MCP at Notion's pinned host on the + // deployment's grant rather than the person's brokered connection. Refusing is the only answer + // that is not wrong in one of the two worlds. + expect(() => + accessFor({ provenance: "composio", url: "composio://notion" }, notion), + ).toThrow(ServerRowAmbiguousError); + + // The url is not what makes it ambiguous. The ID is, and `entry` is how this function is told + // the id collided — so a brokered row pointed at some other app is refused on the same ground, + // and nothing here can be satisfied by reading the url more carefully. + expect(() => + accessFor({ provenance: "composio", url: "composio://gmail" }, notion), + ).toThrow(ServerRowAmbiguousError); + + // And the refusal is the collision's, not the provenance value's: the same row with no curated + // entry behind its id resolves exactly as any other brokered row does. + expect( + accessFor({ provenance: "composio", url: "composio://notion" }, null), + ).toEqual({ + transport: "composio", + credential: "brokered", + reachedAs: "person", + toolkit: "notion", + }); + }); + + test("which app a Composio row is comes from its url, not from its id", () => { + // The id is a display key and the url is what the transport dials, so the url is what decides. + // A row named `gmail` at `composio://slack` used to be checked against a Gmail connection and + // then run as Slack, because three places derived this fact and none of them compared answers. + expect( + accessFor({ provenance: "composio", url: "composio://slack" }, null) + .toolkit, + ).toBe("slack"); + + // No app in the url is no app at all. `store.ts` refuses a brokered row that reaches it, rather + // than falling back to the id — see the narrowing throw beside its connection gate. + expect( + accessFor( + { provenance: "composio", url: "https://example.com/mcp" }, + null, + ).toolkit, + ).toBeNull(); + }); +}); diff --git a/server/tests/composio-adapter.test.ts b/server/tests/composio-adapter.test.ts new file mode 100644 index 000000000..741c938ae --- /dev/null +++ b/server/tests/composio-adapter.test.ts @@ -0,0 +1,5848 @@ +import { describe, expect, test } from "bun:test"; +import { Composio } from "@composio/core"; +import { + type BrokerConnection, + BrokerRefusalError, + brokerSentence, + type ComposioBroker, +} from "../src/plugins/broker"; +import { + type ComposioActions, + LISTING_LIMIT, + vendorSentence, +} from "../src/plugins/composio"; +import { + buildComposioClient, + createComposioClient, +} from "../src/plugins/composio-adapter"; + +/** + * The three facts about the adapter that a type checker cannot settle, asserted with no network. + * + * {@link buildComposioClient} takes the vendor OBJECT rather than an API key, and that is the whole + * reason this file can exist: every test below hands it a literal whose methods record what they + * were asked and answer from memory, so the adapter's own decisions are what is under test and + * nothing here dials Composio. A `createComposioClient` that only took a key would have made this + * file either a live test or no test at all. + * + * WHAT IS WORTH ASSERTING IS WHAT IS EASY TO GET SILENTLY WRONG. The mapping of the vendor's fields + * onto ours is one such thing — a listing that omitted its limit, or a catalogue row that read the + * wrong key for an action count, both answer plausibly and both are wrong in a way no exception + * reports. The refusal is the other: it is the one place this adapter is required to NOT make a + * vendor call, and an implementation that forwarded a mismatch would pass every test that only + * looked at what came back. + */ + +/** + * The connection every fixture in this file resolves to, because none of them publishes a scheme. + * + * These rows were written to exercise the field mapping and the cache, so they carry a slug, a name + * and a meta and nothing about authentication — which is itself a readable answer: an app Composio + * says nothing about the auth of is one no flow here could run. It is named once rather than + * retyped into five assertions, so a change to the sentence is a change in one place. + */ +const NO_SCHEME: BrokerConnection = { + kind: "unsupported", + reason: + "Composio published no authentication scheme for this app, so there is no flow this deployment could run, and its own OAuth client is not something this deployment can register.", +}; + +/** A vendor method nothing in a given test should reach, which says so rather than answering. */ +function refuse(what: string) { + return async (): Promise => { + throw new Error(`${what} should not have been called in this test.`); + }; +} + +/** + * A vendor object whose every method refuses, with the few a test cares about substituted in. + * + * The refusals are the point rather than filler. A test about the catalogue that accidentally + * executed a tool, or one about a refusal that reached the vendor anyway, would otherwise fail on + * something unrelated — or, worse, pass. Here the unasked-for call names itself. + */ +function fakeVendor(parts: { + tools?: Record; + toolkits?: Record; + authConfigs?: Record; + connectedAccounts?: Record; +}) { + return { + tools: { + list: refuse("tools.list"), + getRawComposioToolBySlug: refuse("tools.getRawComposioToolBySlug"), + execute: refuse("tools.execute"), + ...parts.tools, + }, + toolkits: { + list: refuse("toolkits.list"), + retrieve: refuse("toolkits.retrieve"), + ...parts.toolkits, + }, + authConfigs: { + list: refuse("authConfigs.list"), + create: refuse("authConfigs.create"), + delete: refuse("authConfigs.delete"), + ...parts.authConfigs, + }, + connectedAccounts: { + list: refuse("connectedAccounts.list"), + link: refuse("connectedAccounts.link"), + create: refuse("connectedAccounts.create"), + delete: refuse("connectedAccounts.delete"), + ...parts.connectedAccounts, + }, + }; +} + +/** + * The page every listing here asks for, WRITTEN OUT rather than imported. + * + * AN ASSERTION THAT IMPORTS THE CONSTANT IT IS ABOUT CANNOT FAIL WHEN THAT CONSTANT MOVES, because + * both sides move together: each `limit` assertion below asked for whatever page the adapter had + * just decided to ask for, and `LISTING_LIMIT` 1000 -> 20 left all of them passing. The sibling + * `composio-transport.test.ts` writes its two numbers out for exactly this reason and documents it + * at length; this file imported one and called it pinned. + * + * So the literal lives here, and the imported constant is read in exactly one test below — which is + * where the argument for the number belongs: 1000 is the vendor's stated page ceiling and therefore + * the whole listing. + */ +const WHOLE_LISTING = 1000; + +/** + * How many pages of one listing the adapter reads before it refuses, WRITTEN OUT for the reason + * {@link WHOLE_LISTING} is — and asserted exactly, for a reason of its own. + * + * `expect(calls).toBeLessThan(200)` stood against a ceiling of 50. That bound pins nothing: it + * passes at 50 pages, at 51, at 199, and at any ceiling anybody cares to raise it to short of the + * test's own runaway stop — which is to say it asserted that the paging terminated and called that + * an assertion about the ceiling. It was also green over the off-by-one it was the only test in a + * position to see: the adapter read 51 pages while its refusal said 50, because the ceiling was + * tested before the page it was counting had been recorded. + * + * So the number of pages READ and the number the refusal STATES are both asserted, and both + * against this literal. + * + * IT WAS 50 AND IT IS 200, WHICH IS A DELIBERATE CHANGE RATHER THAN A TEST FOLLOWING A MODULE. 50 + * was argued from the only two listings that paged — one app's authorization configs and one + * person's accounts for one app — where a second page is already extraordinary. The app catalogue + * now pages too and is not that listing: Composio publishes more than {@link WHOLE_LISTING} + * toolkits, so its second page is the ordinary case. The module states the arithmetic; what matters + * here is that both numbers are written out, so a ceiling that moves again reddens the tests that + * are about it rather than redefining them. + */ +const PAGES_BEFORE_REFUSING = 200; + +/** The page this deployment sends somebody back to once the consent screen is done with them. */ +const RETURN_URL = "https://openbot.test/settings/connected-accounts/x"; + +/** + * What Composio answers a withdrawal it actually performed, returned by every double that performs + * one. + * + * A DOUBLE THAT OMITS THIS IS NOT A DOUBLE OF THE VENDOR. `success` is a REQUIRED field of + * `ConnectedAccountDeleteResponse` — "indicates whether the connected account was successfully + * deleted" (`@composio/client` 0.1.0-alpha.76, `resources/connected-accounts.d.ts:7445-7451`) — and + * every fixture here used to answer `undefined`, which Composio cannot send. That was harmless only + * for as long as the adapter read nothing off the reply; the moment it started telling a performed + * delete from a declined one, a fixture answering nothing was a fixture asserting the adapter's + * behaviour against a reply no vendor produces. + * + * SPELLED AT EACH DOUBLE RATHER THAN DEFAULTED IN {@link fakeVendor}, for the reason the refusals + * there are spelled: the two tests next door hand back `{ success: false }` and `{}` on purpose, + * and a default would put the interesting answer and the ordinary one at different distances from + * the reader. + */ +const WITHDRAWN = { success: true }; + +/** + * What Composio answers a creation it actually performed, for the reason {@link WITHDRAWN} exists. + * + * `transformCreateAuthConfigResponse` builds this answer by reading `response.auth_config.id` + * (`@composio/core` 0.18.1, `src/utils/transformers/authConfigs.ts:96-106`), so an id is the one + * thing a created config comes back with. A double answering `undefined` is a double of a reply the + * vendor cannot send — harmless only for as long as the adapter read nothing off it, which is + * exactly the state that let a shape drift here report that nothing had been created over a config + * standing at Composio. + */ +const CREATED = { id: "ac_created" }; + +/** + * The three auth configs this file reasons about, named once rather than spelled at each fixture. + * + * Two of them are ours and one is an operator's dashboard work, and every decision the adapter + * makes about an app's configs is a decision about which of the three it is looking at. Naming + * them is what lets a fixture be written OUT of the order its assertion expects — see {@link MIXED} + * — instead of being an array whose index quietly carries the answer. + */ +const BY_HAND = { id: "ac_by_hand", name: "Linear", status: "ENABLED" }; +const OURS = { id: "ac_ours", name: "Linear (OpenBot)", status: "ENABLED" }; +/** The spare from a lost enable race: two administrators both found nothing and both created. */ +const OURS_SPARE = { + id: "ac_ours_spare", + name: "Linear (OpenBot)", + status: "ENABLED", +}; + +/** + * The gmail config this deployment made, which is what a withdrawal is now allowed to reach. + * + * `revoke` READS THE CONFIGS BEFORE IT READS THE ACCOUNTS, which is why every fixture below that + * withdraws anything answers this listing. The account listing is scoped to the ids that come back + * from it, so a fixture that did not answer it would be describing a deployment with no config of + * its own — for which there is, correctly, nothing to withdraw — and every assertion about what the + * delete was asked would be an assertion about a call that never went out. + * + * ITS OWN CONSTANT RATHER THAN {@link OURS}, because the suffix is the whole of what the adapter + * reads and the rest of the name is the app an administrator typed. A gmail withdrawal answered + * with a config called "Linear (OpenBot)" would pass, and would leave the one fixture in this file + * that names the app it is about naming the wrong one. + */ +const OUR_GMAIL = { + id: "ac_gmail_ours", + name: "Gmail (OpenBot)", + status: "ENABLED", +}; + +/** An operator's own gmail config, carrying no suffix, which is the whole of what tells them apart. */ +const BY_HAND_GMAIL = { + id: "ac_gmail_by_hand", + name: "Gmail", + status: "ENABLED", +}; + +/** That listing as a vendor double, since every withdrawing fixture below needs the same one. */ +const ourGmailConfig = async () => ({ items: [OUR_GMAIL] }); + +/** + * The three sentences `authorize` can refuse with, told apart by the remedy each one prescribes. + * + * `rejects.toThrow(/linear/)` MATCHES ALL THREE, WHICH IS NOT A DETAIL. Deleting the no-config + * branch outright left this suite at 24 pass / 0 fail, because the call then fell through to the + * disabled branch and that sentence names the app too. The three remedies are three different acts + * by three different people — an administrator adding the app again, an operator enabling the + * config in Composio's own dashboard, and nobody at all because the app is not connected by + * visiting a page — so a test that cannot tell the sentences apart cannot tell a correct + * classification from a wrong one, which is the whole thing these refusals exist to get right. + * + * Each is the fragment of its own sentence that no other one contains, and each test below asserts + * its own AND the absence of the others. + */ +const NO_CONFIG_REMEDY = + /removing the app on its Plugins page and adding it again creates one/; +const DISABLED_REMEDY = /can enable it in Composio's dashboard/; +const NO_PAGE_REMEDY = + /connected by entering a credential rather than by visiting a page/; + +/** + * The error a call raised, or a failure saying it answered where the test required a refusal. + * + * `rejects.toThrow(...)` cannot be followed by a second question about the SAME error — which kind + * it was, what else its sentence does not say — so every refusal assertion that wanted more than + * one fact about one throw had to settle for the first. This hands the error over instead. + * + * IT TOLD TWO LIES ABOUT ITS OWN FAILURES, and a helper every refusal in this file is read through + * is the last place a wrong answer about what happened should come from. + * + * `null` WAS BOTH ANSWERS AT ONCE. It was the value the resolve arm produced AND a value the reject + * arm can hand back — `Promise.reject(null)` is a rejection, and a vendor stub or an adapter path + * that raises a falsy value is exactly the kind of thing the tests below are written to catch — so + * a call that REFUSED with one was reported as "The call answered where this test requires it to + * have refused." That sends the reader to look for a missing guard when the guard fired. The two + * outcomes are now told apart by which arm ran rather than by the value it carried. + * + * `undefined` WAS RETURNED AS AN `Error` IT IS NOT. The cast made the type check and nothing else: + * the caller's very next line reads `.message` off it and the suite fails with the runtime's own + * "undefined is not an object", which is the phrasing this file's own {@link A_CRASH} + * exists to flag as a crash wearing a refusal's clothes — raised here, in the helper, about the + * test rather than about the adapter. A rejection that is not an `Error` has no message to read, so + * this says so itself instead of handing the caller something that will. + */ +async function failureOf(work: Promise): Promise { + type Outcome = { refused: false } | { refused: true; raised: unknown }; + + const outcome = await work.then( + () => ({ refused: false }), + (raised: unknown) => ({ refused: true, raised }), + ); + if (!outcome.refused) { + throw new Error( + "The call answered where this test requires it to have refused.", + ); + } + if (!(outcome.raised instanceof Error)) { + throw new Error( + `The call refused with ${ + outcome.raised === null ? "null" : typeof outcome.raised + } rather than with an Error, so there is no message on it for this test to read.`, + ); + } + return outcome.raised; +} + +/** + * What a failure must never read like: the name of a method that was not there. + * + * AT MODULE SCOPE BECAUSE IT IS THE SAME QUESTION EVERYWHERE, and it is asked of every refusal this + * file added after the shape sweep: a guard that is missing does not answer politely, it reads a + * field off `undefined` and hands an administrator a sentence naming a vendor method. A message + * matching this is a crash wearing a refusal's place in the code. + * + * "IS NOT AN OBJECT" IS ANCHORED TO `undefined` AND `null` RATHER THAN LEFT BARE, because the + * adapter's own sentence for a malformed input schema says "a thing that is not an object cannot be + * shown as one" — the correct refusal, flagged as a crash by a pattern looking for a fragment of + * one. The runtime's phrasings are "undefined is not an object (evaluating ...)" and the same with + * null, so the anchor keeps every crash this caught and stops it catching an authored sentence. + */ +const A_CRASH = + /is not a function|(?:undefined|null) is not an object|is not iterable|cannot read propert/i; + +/** + * Every sentence one failure carries, its own first and then the ones hanging off it. + * + * A COUNT IS A SENTENCE WITH ITS REASONS SOMEWHERE ELSE, which is why a question about what a reader + * was told cannot always be asked of `message` alone. `revoke` and `deleteAuthConfig` withdraw a SET + * and make the count their whole message on purpose — it is the part a reader can act on — and hang + * every refusal the loop met on `cause`, as one error or as an `AggregateError` of them. Asking only + * the first line there would grade those two methods on a wording that is deliberately not where + * their detail lives. + */ +function everythingSaidBy(error: unknown, depth = 0): string[] { + if (!(error instanceof Error) || depth > 4) return []; + const carried = + error instanceof AggregateError + ? error.errors.flatMap((one) => everythingSaidBy(one, depth + 1)) + : []; + return [ + error.message, + ...carried, + ...everythingSaidBy(error.cause, depth + 1), + ]; +} + +describe("the page size this file is written against", () => { + test("the module's ceiling is still the number every assertion here spells out", () => { + // The one place the imported constant is read. Changing `LISTING_LIMIT` reddens exactly this + // test, which is where the argument for the number lives, rather than silently moving every + // assertion in the file to whatever the module has just decided. + expect(LISTING_LIMIT).toBe(WHOLE_LISTING); + }); +}); + +describe("listing an app's actions", () => { + test("the caller's page reaches the vendor, so the vendor's default never applies", async () => { + const asked: unknown[] = []; + const { actions } = buildComposioClient( + fakeVendor({ + tools: { + list: async (query: unknown) => { + asked.push(query); + return { + items: [ + { + slug: "GMAIL_FETCH_EMAILS", + name: "Fetch emails", + description: "Fetch emails from Gmail.", + input_parameters: { type: "object", properties: {} }, + tags: ["readOnlyHint"], + version: "20260903_00", + toolkit: { slug: "gmail" }, + }, + ], + }; + }, + }, + }), + ); + + const listed = await actions.listActions("gmail", { + limit: WHOLE_LISTING, + }); + + // An omitted limit is not "no opinion": Composio's page defaults to 20, and the wrapper this + // listing used to go through additionally set `important=true` whenever a toolkit query carried + // no limit, no tags and no search (`@composio/core` 0.18.1, `src/models/Tools.ts:505-515`), so + // the short answer was also a filtered one and nothing in it said so. The request is composed + // here now, which is why the assertion is on the query and not on the answer: the limit, the + // toolkit version the wrapper used to supply, and no `important` at all. + expect(asked).toEqual([ + { + toolkit_slug: "gmail", + limit: WHOLE_LISTING, + toolkit_versions: "latest", + }, + ]); + expect(listed).toEqual([ + { + slug: "GMAIL_FETCH_EMAILS", + description: "Fetch emails from Gmail.", + inputParameters: { type: "object", properties: {} }, + tags: ["readOnlyHint"], + version: "20260903_00", + }, + ]); + }); + + test("a page of no rows is refused rather than dropped on the way to the vendor", async () => { + const asked: unknown[] = []; + const { actions } = buildComposioClient( + fakeVendor({ + tools: { + list: async (query: unknown) => { + asked.push(query); + // Twenty rows: Composio's own default page, which is what a dropped limit produced. + return { + items: Array.from({ length: 20 }, (_, index) => ({ + slug: `GMAIL_ACTION_${index}`, + })), + }; + }, + }, + }), + ); + + const refusal = await failureOf(actions.listActions("gmail", { limit: 0 })); + + /* + * THE REASON MOVED AND THE REFUSAL DID NOT. Through the wrapper a zero did not travel short — + * it did not travel: `getRawComposioTools` composed its request with `...(limit ? { limit } : + * {})` (`@composio/core` 0.18.1, `src/models/Tools.ts:536`) over a schema spelling the field + * `z.number().optional()` with no floor (`src/types/tool.types.ts:257`), so it went out with no + * limit and came back as Composio's own page of twenty. The raw client passes a zero through, + * so that particular substitution is gone. What is left is the argument that never rested on + * it: the page is required on this seam precisely so that no layer supplies one quietly, and a + * caller asking for no rows is a fault to report rather than one to correct on their behalf. + */ + expect(refusal.message).not.toMatch(A_CRASH); + expect(refusal.message).toMatch(/0 rows is not a page/); + expect(refusal.message).toMatch(/tools already held are untouched/); + // Nothing went out, which is the point: the fault is in the request, not in the answer. + expect(asked).toEqual([]); + }); +}); + +describe("the app catalogue", () => { + test("a toolkit becomes the row an administrator chooses from", async () => { + const asked: unknown[] = []; + const { broker } = buildComposioClient( + fakeVendor({ + toolkits: { + list: async (query: unknown) => { + asked.push(query); + return { + items: [ + { + slug: "gmail", + name: "Gmail", + is_local_toolkit: false, + meta: { + description: "Send and read mail.", + logo: "https://logos.composio.dev/gmail.png", + categories: [ + { id: "productivity", name: "Productivity" }, + { id: "email", name: "Email" }, + ], + tools_count: 63, + }, + }, + // A toolkit that publishes none of the optional fields, because several do. An + // administrator picking from a few hundred apps is better served by a missing logo + // than by a broken one, so the absence has to survive as null rather than as "". + { + slug: "sparse", + name: "Sparse", + is_local_toolkit: false, + meta: {}, + }, + ], + }; + }, + }, + }), + ); + + const apps = await broker.listApps(); + + // Pages at the documented ceiling, ordered by usage so the apps anybody actually connects come + // first, and carrying no cursor field on the first request. No search term: the catalogue is + // held for ten minutes and searched in this process by both of its callers, so a per-term + // request would be a per-term cache. + expect(asked).toEqual([{ limit: WHOLE_LISTING, sort_by: "usage" }]); + expect(apps).toEqual([ + { + slug: "gmail", + name: "Gmail", + description: "Send and read mail.", + logo: "https://logos.composio.dev/gmail.png", + categories: ["Productivity", "Email"], + actionCount: 63, + connection: NO_SCHEME, + }, + { + slug: "sparse", + name: "Sparse", + description: "", + logo: null, + categories: [], + actionCount: 0, + connection: NO_SCHEME, + }, + ]); + }); +}); + +/** + * The catalogue's lifetime, asserted by counting what the vendor was asked rather than what came + * back. + * + * THE COST BEING AVOIDED IS NOT HYPOTHETICAL. The admin picker's search field debounces and then + * asks `/composio/apps`, which filters the whole directory in this process because Composio's + * toolkit listing takes no search term — so without a cache each distinct term a person types pulls + * a few hundred rows over the wire, and pressing Add pulls them once more. Every test here therefore + * asserts a CALL COUNT: an implementation that answered correctly and asked five times would pass + * any assertion that only looked at the rows. + * + * The clock is the builder's second argument, which is why these can be written at all. Each + * {@link buildComposioClient} holds its own cache, so a test starts from an empty one by building, + * and moves time by assigning rather than by waiting ten minutes. + */ +describe("holding the catalogue", () => { + /** The one row these tests map, kept out of the way of what they are actually asserting. */ + const GMAIL = { + slug: "gmail", + name: "Gmail", + meta: { description: "Send and read mail.", tools_count: 63 }, + }; + const GMAIL_ROW = { + slug: "gmail", + name: "Gmail", + description: "Send and read mail.", + logo: null, + categories: [], + actionCount: 63, + connection: NO_SCHEME, + }; + + test("a second listing inside the window asks the vendor nothing", async () => { + let calls = 0; + let clock = 1_000_000; + const { broker } = buildComposioClient( + fakeVendor({ + toolkits: { + list: async () => { + calls += 1; + return { items: [GMAIL] }; + }, + }, + }), + () => clock, + ); + + const first = await broker.listApps(); + // Nine minutes is a person searching, choosing and enabling: the whole interaction this cache + // exists for happens inside one window. + clock += 9 * 60 * 1000; + const second = await broker.listApps(); + + expect(calls).toBe(1); + expect(first).toEqual([GMAIL_ROW]); + expect(second).toEqual([GMAIL_ROW]); + }); + + test("a listing after the window asks again, and answers with what it just read", async () => { + let calls = 0; + let clock = 1_000_000; + const { broker } = buildComposioClient( + fakeVendor({ + toolkits: { + list: async () => { + calls += 1; + // The catalogue moves between the two reads, which is the only way to tell a second + // request apart from a cache that happened to be asked twice. + return calls === 1 + ? { items: [GMAIL] } + : { + items: [ + GMAIL, + { + slug: "linear", + name: "Linear", + meta: { tools_count: 12 }, + }, + ], + }; + }, + }, + }), + () => clock, + ); + + await broker.listApps(); + clock += 10 * 60 * 1000 + 1; + const later = await broker.listApps(); + + expect(calls).toBe(2); + expect(later).toEqual([ + GMAIL_ROW, + { + slug: "linear", + name: "Linear", + description: "", + logo: null, + categories: [], + actionCount: 12, + connection: NO_SCHEME, + }, + ]); + }); + + test("callers arriving while a listing is in flight share the one request", async () => { + let calls = 0; + let answer: (page: { items: unknown[] }) => void = () => {}; + const inFlight = new Promise<{ items: unknown[] }>((resolve) => { + answer = resolve; + }); + const { broker } = buildComposioClient( + fakeVendor({ + toolkits: { + list: () => { + calls += 1; + return inFlight; + }, + }, + }), + () => 1_000_000, + ); + + // Not awaited between the two, because that is the case: three people opening the picker + // together, or one debounce firing twice, all arrive before the first answer exists. A cache + // that held the ROWS rather than the request would be empty for every one of them. + const both = Promise.all([broker.listApps(), broker.listApps()]); + answer({ items: [GMAIL] }); + const [first, second] = await both; + + expect(calls).toBe(1); + expect(first).toEqual([GMAIL_ROW]); + expect(second).toEqual([GMAIL_ROW]); + }); + + test("a refusal is not held, so the next caller asks the vendor again", async () => { + let calls = 0; + const { broker } = buildComposioClient( + fakeVendor({ + toolkits: { + list: async () => { + calls += 1; + // The real first failure here is a key that is unset or wrong, and the operator who + // fixes it presses the button again within seconds. A cached refusal would keep + // refusing for ten minutes with nothing left to fix. + if (calls === 1) throw new Error("Composio refused the catalogue."); + return { items: [GMAIL] }; + }, + }, + }), + () => 1_000_000, + ); + + await expect(broker.listApps()).rejects.toThrow(/refused/); + // The clock has not moved: the window is still open and it is the FAILURE rather than the + // window that must not be remembered. + const recovered = await broker.listApps(); + + expect(calls).toBe(2); + expect(recovered).toEqual([GMAIL_ROW]); + }); +}); + +describe("executing an action", () => { + test("an action belonging to another app is refused before anything is sent", async () => { + const executed: unknown[] = []; + const { actions } = buildComposioClient( + fakeVendor({ + tools: { + getRawComposioToolBySlug: async () => ({ + slug: "GMAIL_FETCH_EMAILS", + name: "Fetch emails", + toolkit: { slug: "gmail" }, + }), + execute: async (...call: unknown[]) => { + executed.push(call); + return { data: {}, error: null, successful: true }; + }, + }, + }), + ); + + // The gate in `./access` cleared this person for slack, because slack is what the connection's + // url names. The slug was recorded by some earlier listing, and Composio's execute takes the + // slug ALONE — there is no toolkit field on the wire — so forwarding this would run a Gmail + // action under a gate that only ever looked at a Slack connection. + const refused = actions.execute( + { + toolkit: "slack", + slug: "GMAIL_FETCH_EMAILS", + userId: "user_1", + version: "20260903_00", + }, + { max_results: 5 }, + ); + + // Both apps are named, because a reader holding only one of them cannot tell whether the url + // is wrong or the recorded action is. + const refusal = await failureOf(refused); + expect(refusal.message).toMatch(/slack/); + expect(refusal.message).toMatch(/gmail/); + // AND THE STEP, because a sentence that names both apps and stops there leaves the reader + // holding a contradiction with nothing to do about it. The url this action was recorded under + // has changed, and refreshing the app's tools is what reconciles the two. + expect(refusal.message).toMatch( + /Refreshing this app's tools on its Plugins page/, + ); + expect(executed).toEqual([]); + }); + + /** + * WHOSE CALL IT IS, WHICH NOTHING IN THIS FILE WAS ASKING. + * + * Every test around this one is about a call being REFUSED, and each asserts that nothing went + * out. Not one asserted what goes out when the call is allowed — so the three fields that decide + * what a successful call MEANS were covered by nobody. `userId` is the whole of a tool call's + * attribution to a person: it is what Composio resolves to a connected account, so replacing it + * with a literal runs one person's action against somebody else's mailbox, and the audit row this + * deployment writes afterwards names the wrong human. `version` is what stops a recorded action + * being run at whatever Composio publishes today. `arguments` is the call itself. + * + * ASSERTED AS THE WHOLE CALL RATHER THAN FIELD BY FIELD, because an extra field on this request is + * as much a finding as a missing one — `allowMultiple`, a session id, a modifier — and a per-field + * assertion is blind to every one of them. + * + * THE RESOLVE IS ASSERTED TOO, for the reason the execute is. It is the round trip this adapter + * spends on purpose, and the version it carries is what decides WHICH definition of the action the + * app-mismatch check below is made against. + */ + test("a call that runs carries the person, the version and the arguments", async () => { + const resolved: unknown[] = []; + const ran: unknown[] = []; + const { actions } = buildComposioClient( + fakeVendor({ + tools: { + getRawComposioToolBySlug: async (...call: unknown[]) => { + resolved.push(call); + return { + slug: "GMAIL_FETCH_EMAILS", + name: "Fetch emails", + toolkit: { slug: "gmail" }, + }; + }, + execute: async (...call: unknown[]) => { + ran.push(call); + return { data: { messages: [] }, error: null, successful: true }; + }, + }, + }), + ); + + const answer = await actions.execute( + { + toolkit: "gmail", + slug: "GMAIL_FETCH_EMAILS", + // DELIBERATELY NOT THE `user_1` EVERY OTHER FIXTURE HERE USES. A literal standing in for + // the caller's id is the mutation this test exists to catch, and the file's own house id is + // the one literal a mutation would plausibly be — so a person named after nothing else is + // what makes the assertion able to tell attribution from coincidence. + userId: "user_whose_mailbox_this_is", + version: "20260903_00", + }, + { max_results: 5 }, + ); + + expect(resolved).toEqual([ + ["GMAIL_FETCH_EMAILS", { version: "20260903_00" }], + ]); + expect(ran).toEqual([ + [ + "GMAIL_FETCH_EMAILS", + { + arguments: { max_results: 5 }, + userId: "user_whose_mailbox_this_is", + version: "20260903_00", + }, + ], + ]); + // And the vendor's answer crosses the seam as itself: this adapter adds nothing to a result and + // takes nothing off one. + expect(answer).toEqual({ + data: { messages: [] }, + error: null, + successful: true, + }); + }); +}); + +/** + * Minting one person's connect link, which is the call that decides whether consent comes back. + * + * `connectedAccounts.link` RATHER THAN `toolkits.authorize`, and the difference is the whole + * subject of these two tests. `toolkits.authorize` takes a user id, a toolkit and an optional auth + * config id and has nowhere to put a callback, so every consent it started ended on Composio's own + * hosted page: the person had granted access and the only way back to this deployment was to find + * it again by hand. `link` carries the callback, and it is also the vendor's own named replacement + * for `initiate` on Composio-managed OAuth, which is exactly what `ensureAuthConfig` creates here. + * + * The auth config is READ rather than created, because this deployment already made it when an + * administrator enabled the app — named for this deployment, visible in an operator's dashboard. + * `toolkits.authorize` would have created one on demand at Composio's defaults, which is the + * behaviour enabling-time creation exists to replace. + */ +describe("beginning one person's connection", () => { + test("the link carries the page this deployment sends them back to", async () => { + const linked: unknown[] = []; + const listed: unknown[] = []; + const { broker } = buildComposioClient( + fakeVendor({ + authConfigs: { + list: async (query: unknown) => { + listed.push(query); + return { + items: [ + { + id: "ac_this_deployments", + name: "Linear (OpenBot)", + status: "ENABLED", + }, + ], + }; + }, + }, + connectedAccounts: { + link: async (...call: unknown[]) => { + linked.push(call); + return { redirectUrl: "https://backend.composio.dev/s/a-link" }; + }, + }, + }), + ); + + const begun = await broker.authorize({ + userId: "user_1", + toolkit: "linear", + returnUrl: + "https://openbot.test/settings/connected-accounts/composio-linear", + }); + + // The config this deployment already holds for the app — and no `authConfigs.create`, which + // would refuse in `fakeVendor` if it were reached. The listing asks for disabled configs too, + // because a config it cannot see is one `ensureAuthConfig` would create a second of. + expect(listed).toEqual([ + { toolkit: "linear", limit: WHOLE_LISTING, showDisabled: true }, + ]); + expect(linked).toEqual([ + [ + "user_1", + "ac_this_deployments", + { + callbackUrl: + "https://openbot.test/settings/connected-accounts/composio-linear", + }, + ], + ]); + expect(begun).toEqual({ + redirectUrl: "https://backend.composio.dev/s/a-link", + }); + }); + + test("an app with no auth config is a refusal naming an administrator's step", async () => { + /* + * The state is real rather than defensive: an app enabled before this deployment created + * configs at all, or a config deleted by hand in Composio's dashboard. Creating one here + * instead would mint it unnamed, at the vendor's managed defaults, at the moment somebody + * pressed Connect — and nothing would be minted for the person to visit either way, so the + * honest answer names the app and the step that fixes it. + */ + const linked: unknown[] = []; + const { broker } = buildComposioClient( + fakeVendor({ + authConfigs: { list: async () => ({ items: [] }) }, + connectedAccounts: { + link: async (...call: unknown[]) => { + linked.push(call); + return { redirectUrl: "https://backend.composio.dev/s/a-link" }; + }, + }, + }), + ); + + const refused = broker.authorize({ + userId: "user_1", + toolkit: "linear", + returnUrl: + "https://openbot.test/settings/connected-accounts/composio-linear", + }); + + const refusal = await failureOf(refused); + expect(refusal).toBeInstanceOf(BrokerRefusalError); + expect(refusal.message).toMatch(/linear/); + // THE REMEDY AND NOT THE APP'S NAME. All three of this method's refusals name the app, so + // `/linear/` passes whichever branch was taken and the branch under test could be deleted + // outright without reddening anything. What distinguishes this state is what fixes it. + expect(refusal.message).toMatch(NO_CONFIG_REMEDY); + expect(refusal.message).not.toMatch(DISABLED_REMEDY); + expect(refusal.message).not.toMatch(NO_PAGE_REMEDY); + // And nothing was begun at the vendor: a link against a config chosen by nobody would attach + // this person's account to a configuration this deployment cannot see or tighten. + expect(linked).toEqual([]); + }); +}); + +/** + * Choosing WHICH auth config, which is a question this file used to answer with "the first one". + * + * An auth config lives in the project the API key belongs to, beside any an operator built by hand + * in Composio's own dashboard, and the vendor's listing has no documented order. So "the first row" + * is a coin toss between an object this deployment created and an object it knows nothing about — + * and the two callers tossed it separately, so they could land on different rows. The name is the + * only provenance Composio offers: {@link CONFIG_SUFFIX} is written into it at creation for exactly + * this, and was then read by nobody. + */ +describe("telling this deployment's auth configs from anybody else's", () => { + /** + * What a listing of one app's configs looks like when an operator has been in the dashboard. + * + * SPELLED OUT OF THE ORDER THE ASSERTIONS EXPECT, which is what makes the sort the thing under + * test rather than scenery. Composio documents no order for this listing; `configsMadeHere` + * filters to ours and then sorts on the id, and every fixture this file used to hold was already + * id-ordered — so the sort could be replaced with a plain copy and all 24 tests stayed green. + * Here the spare arrives FIRST and sorts SECOND, so a filter alone answers `ac_ours_spare` where + * every assertion below names `ac_ours`, and the delete's order is the sort's rather than the + * vendor's. + */ + const MIXED = [OURS_SPARE, BY_HAND, OURS]; + + test("a connection is begun against the config this deployment made, not the first row", async () => { + const linked: unknown[] = []; + const { broker } = buildComposioClient( + fakeVendor({ + authConfigs: { list: async () => ({ items: MIXED }) }, + connectedAccounts: { + link: async (...call: unknown[]) => { + linked.push(call); + return { redirectUrl: "https://backend.composio.dev/s/a-link" }; + }, + }, + }), + ); + + await broker.authorize({ + userId: "user_1", + toolkit: "linear", + returnUrl: "https://openbot.test/settings/connected-accounts/x", + }); + + // A connection is a lasting attachment to whatever config it was made against: scopes, tool + // restrictions and a lifetime this deployment neither chose nor can read. Attaching somebody to + // the hand-made one is not a mistake a later call can correct. + expect(linked).toEqual([ + [ + "user_1", + "ac_ours", + { callbackUrl: "https://openbot.test/settings/connected-accounts/x" }, + ], + ]); + }); + + test("an app whose only config was made by hand is refused rather than borrowed", async () => { + const linked: unknown[] = []; + const { broker } = buildComposioClient( + fakeVendor({ + authConfigs: { + list: async () => ({ items: [BY_HAND] }), + }, + connectedAccounts: { + link: async (...call: unknown[]) => { + linked.push(call); + return { redirectUrl: "https://backend.composio.dev/s/a-link" }; + }, + }, + }), + ); + + const refused = broker.authorize({ + userId: "user_1", + toolkit: "linear", + returnUrl: "https://openbot.test/settings/connected-accounts/x", + }); + + const refusal = await failureOf(refused); + expect(refusal).toBeInstanceOf(BrokerRefusalError); + // The same state as an app with no configs at all — none of OURS — so the same remedy, and + // not the disabled one: the config that exists here is enabled, and enabling it again is + // advice that would send an operator to a dashboard to change nothing. + expect(refusal.message).toMatch(NO_CONFIG_REMEDY); + expect(refusal.message).not.toMatch(DISABLED_REMEDY); + expect(linked).toEqual([]); + }); + + test("removing an app drops every config of ours and leaves the hand-made one standing", async () => { + const deleted: unknown[] = []; + const { broker } = buildComposioClient( + fakeVendor({ + authConfigs: { + // Both of ours go — leaving one behind would leave live grants — and the hand-made + // one stands. The listing arrives spare-first, so the order asserted below is the sort's + // and not the vendor's. + list: async () => ({ items: MIXED }), + delete: async (...call: unknown[]) => { + deleted.push(call); + }, + }, + }), + ); + + await broker.deleteAuthConfig("linear"); + + // `revoke_on_delete` on each, because the endpoint soft-deletes and revokes nothing without it + // — and this is the one call that reaches an account whose local row drifted away. + expect(deleted).toEqual([ + ["ac_ours", { revoke_on_delete: true }], + ["ac_ours_spare", { revoke_on_delete: true }], + ]); + }); + + test("a config left standing is a failure, and the count is the message", async () => { + /* + * THE TWIN OF THE PARTIAL REVOKE, WHICH HAD TWO TESTS WHILE THIS HAD NONE — so this throw + * could be deleted with the whole suite green. The caller is `removeServer`, which deletes the + * app's row once this returns: a config left standing is a live grant that the removal was + * supposed to end, and nothing in this deployment can find it again afterwards. + * + * The count is asserted rather than the fact of a refusal, because the count is the whole + * remedy: an operator who reads that one of two went knows that pressing remove again finishes + * the job rather than repeats it. + */ + const { broker } = buildComposioClient( + fakeVendor({ + authConfigs: { + list: async () => ({ items: MIXED }), + delete: async (id: string) => { + if (id === "ac_ours_spare") { + throw new Error("Composio refused that one."); + } + }, + }, + }), + ); + + const refusal = await failureOf(broker.deleteAuthConfig("linear")); + expect(refusal).toBeInstanceOf(BrokerRefusalError); + expect(refusal.message).toMatch( + /removed 1 of this deployment's 2 authorization configs for linear/, + ); + }); + + test("an app whose configs no longer carry this deployment's name is not a clean removal", async () => { + const deleted: unknown[] = []; + const { broker } = buildComposioClient( + fakeVendor({ + authConfigs: { + /* + * THE CONFIG THIS DEPLOYMENT MADE, RENAMED IN COMPOSIO'S DASHBOARD. Same object, same + * grants on it, same accounts connected against it; the one field that changed is the + * only one this file writes and the only one it can recognise itself by. + */ + list: async () => ({ + items: [{ id: "ac_ours", name: "Linear", status: "ENABLED" }], + }), + delete: async (...call: unknown[]) => { + deleted.push(call); + }, + }, + }), + ); + + /* + * QUIET IS THE FAILURE HERE, AND IT IS THE ONE NOTHING REPORTS. `deleteAuthConfig` returning + * normally lets `removeServer` delete the app's row, which is the last thing in this + * deployment naming the app — so an administrator reads that the app was withdrawn while the + * config and every grant made against it stand at Composio with nothing pointing at them. + */ + const refusal = await failureOf(broker.deleteAuthConfig("linear")); + expect(refusal).toBeInstanceOf(BrokerRefusalError); + expect(refusal).not.toBeInstanceOf(AggregateError); + expect(refusal.message).not.toMatch(A_CRASH); + // The count of what is standing and the marker that would claim it, which together are the + // whole remedy: an operator can look at one config and see which of the two readings it is. + expect(refusal.message).toMatch(/Composio holds 1 for linear/); + expect(refusal.message).toMatch(/\(OpenBot\)/); + /* + * AND NOTHING WAS DELETED, which is the half this refusal is not allowed to trade away. The + * row carries no marker, so deleting it is as likely to destroy an operator's own dashboard + * work — with every account on THAT — as it is to finish the removal. + */ + expect(deleted).toEqual([]); + }); + + /** + * A CONFIG ROW THE LISTING NAMED TWICE IS ONE CONFIG, NOT TWO — the twin of the account dedupe + * that landed a wave earlier, one function away, and was not brought here. + * + * The paging loop guards against the vendor repeating a CURSOR and not against it repeating a + * ROW: a page boundary crossed while a config is created, or a proxy stitching two overlapping + * pages together, hands one id over twice with the cursor advancing perfectly each time. The + * second delete of that config then meets Composio's "there is no such auth config", which + * arrives as a refusal — so a removal that had in fact COMPLETED was counted as partial, + * `removeServer` never deleted the app's row, and every retry met the same duplicate and failed + * in the same place. An app in that state can never be removed. + * + * THE DOUBLE REFUSES THE SECOND DELETE OF ONE CONFIG, which is what the vendor does and what + * makes this test able to fail. A stub that answered both identically would be green against an + * adapter that sent the delete twice. + */ + test("a config the listing named twice is removed once", async () => { + const deleted: string[] = []; + const { broker } = buildComposioClient( + fakeVendor({ + authConfigs: { + list: async (query: unknown) => + (query as { cursor?: string }).cursor === undefined + ? { items: [OURS], nextCursor: "page_2" } + : { items: [OURS], nextCursor: null }, + delete: async (id: string) => { + if (deleted.includes(id)) { + throw new Error( + `Composio holds no auth config with the id ${id}.`, + ); + } + deleted.push(id); + }, + }, + }), + ); + + await broker.deleteAuthConfig("linear"); + + expect(deleted).toEqual(["ac_ours"]); + }); + + /** + * AND THE COUNT IN A REAL PARTIAL REMOVAL IS OF CONFIGS, NOT OF ROWS — the same pairing the + * account dedupe has, for the same reason: an inflated denominator is a figure nothing measured, + * in the one sentence an operator is meant to act on. + */ + test("a duplicated row is not a third config in the sentence a partial removal carries", async () => { + const { broker } = buildComposioClient( + fakeVendor({ + authConfigs: { + list: async () => ({ items: [OURS, OURS, OURS_SPARE] }), + delete: async (id: string) => { + if (id === "ac_ours_spare") { + throw new Error("Composio refused that one."); + } + }, + }, + }), + ); + + const refusal = await failureOf(broker.deleteAuthConfig("linear")); + expect(refusal).toBeInstanceOf(BrokerRefusalError); + expect(refusal.message).toMatch( + /removed 1 of this deployment's 2 authorization configs for linear/, + ); + }); + + /** + * A ROW THAT COULD NOT BE READ MUST NOT BLOCK THE CONNECTION IT HAS NOTHING TO DO WITH. + * + * Reading the configs used to throw on the first row it could not check, so one unreadable row + * refused every call that reads this listing — including this one, where a config of ours was + * read, is ENABLED, and is the right thing to attach somebody to whatever else the listing held. + * The person is told the vendor's shape is wrong about an app they can perfectly well connect. + */ + test("an unreadable row does not stop a connection against the config that was readable", async () => { + const linked: unknown[] = []; + const { broker } = buildComposioClient( + fakeVendor({ + authConfigs: { + list: async () => ({ + items: [{ id: "ac_nameless", status: "ENABLED" }, OURS], + }), + }, + connectedAccounts: { + link: async (...call: unknown[]) => { + linked.push(call); + return { redirectUrl: "https://backend.composio.dev/s/a-link" }; + }, + }, + }), + ); + + await broker.authorize({ + userId: "user_1", + toolkit: "linear", + returnUrl: RETURN_URL, + }); + + expect(linked).toEqual([ + ["user_1", "ac_ours", { callbackUrl: RETURN_URL }], + ]); + }); + + /** + * AND WHERE THERE IS NOTHING OF OURS TO GO ON, THE REMEDY IS NOT THE ONE FOR AN APP WITH NO + * CONFIG. "Remove the app and add it again" is a loop that cannot close in this state: the + * removal meets its own refusal over the same unreadable row, and so does the enable. The + * sentences are told apart by their remedies here for the reason {@link NO_CONFIG_REMEDY} exists. + */ + test("a listing this deployment cannot read is not an app with no config", async () => { + const linked: unknown[] = []; + const { broker } = buildComposioClient( + fakeVendor({ + authConfigs: { + list: async () => ({ items: [{ id: "ac_nameless" }] }), + }, + connectedAccounts: { + link: async (...call: unknown[]) => { + linked.push(call); + return { redirectUrl: "https://backend.composio.dev/s/a-link" }; + }, + }, + }), + ); + + const refusal = await failureOf( + broker.authorize({ + userId: "user_1", + toolkit: "linear", + returnUrl: RETURN_URL, + }), + ); + + expect(refusal).toBeInstanceOf(BrokerRefusalError); + expect(refusal.message).not.toMatch(A_CRASH); + expect(refusal.message).not.toMatch(NO_CONFIG_REMEDY); + expect(refusal.message).not.toMatch(DISABLED_REMEDY); + // And nobody was sent anywhere: a link is a lasting attachment to one config, so it is never + // minted off a listing this deployment could not read. + expect(linked).toEqual([]); + }); + + /** + * AND NOTHING IS CREATED BESIDE A ROW THAT MIGHT ALREADY BE OURS, which is the one caller of the + * four that must refuse rather than act on what it can name. A second config is not a duplicate + * but a SPLIT: two populations of connections for one app, and a removal later that drops half. + */ + test("a row this deployment cannot read stops a second config being created", async () => { + const created: unknown[] = []; + const { broker } = buildComposioClient( + fakeVendor({ + authConfigs: { + list: async () => ({ items: [{ id: "ac_nameless" }] }), + create: async (...call: unknown[]) => { + created.push(call); + return CREATED; + }, + }, + }), + ); + + const refusal = await failureOf( + broker.ensureAuthConfig({ + toolkit: "linear", + name: "Linear", + connection: { kind: "consent" }, + }), + ); + + expect(refusal).toBeInstanceOf(BrokerRefusalError); + expect(refusal.message).not.toMatch(A_CRASH); + expect(created).toEqual([]); + }); + + test("an app Composio holds no configs for at all is still a quiet removal", async () => { + /* + * THE HALF THE REFUSAL ABOVE MUST NOT SWALLOW. Removing an app has to be able to happen twice: + * an app can be removed, re-enabled and removed again, two administrators can press the button + * together, and an app enabled before this deployment created configs at all has none to drop. + * In each of those the end state is the one that was asked for, so a throw would report a + * failure to somebody who got exactly what they wanted — and an implementation that reached + * green above by refusing whenever it deleted nothing would do precisely that. + * + * AND IT SAYS SO RATHER THAN LEAVING IT TO BE INFERRED, which it did not: this test held no + * assertion at all. What it actually pinned was "the call did not throw", which a reader has to + * reconstruct from the absence of an `expect` — and the other half, that nothing was deleted, + * rested on {@link fakeVendor}'s stub refusing, where a call that WAS made and a call that was + * not both end the test the same way round. Both halves are stated here. + */ + const deleted: unknown[] = []; + const { broker } = buildComposioClient( + fakeVendor({ + authConfigs: { + list: async () => ({ items: [] }), + delete: async (...call: unknown[]) => { + deleted.push(call); + }, + }, + }), + ); + + await expect(broker.deleteAuthConfig("linear")).resolves.toBeUndefined(); + expect(deleted).toEqual([]); + }); + + test("a disabled config of ours stops a second one being created", async () => { + const created: unknown[] = []; + const { broker } = buildComposioClient( + fakeVendor({ + authConfigs: { + // Disabled configs are asked for, because this listing is what decides whether to create. + // A listing that omitted them would find nothing and create the split it exists to stop. + list: async () => ({ + items: [ + { id: "ac_ours", name: "Linear (OpenBot)", status: "DISABLED" }, + ], + }), + create: async (...call: unknown[]) => { + created.push(call); + }, + }, + }), + ); + + await broker.ensureAuthConfig({ + toolkit: "linear", + name: "Linear", + connection: { kind: "consent" }, + }); + + expect(created).toEqual([]); + }); + + test("a disabled config is a refusal rather than a link that cannot work", async () => { + const linked: unknown[] = []; + const { broker } = buildComposioClient( + fakeVendor({ + authConfigs: { + list: async () => ({ + items: [ + { id: "ac_ours", name: "Linear (OpenBot)", status: "DISABLED" }, + ], + }), + }, + connectedAccounts: { + link: async (...call: unknown[]) => { + linked.push(call); + return { redirectUrl: "https://backend.composio.dev/s/a-link" }; + }, + }, + }), + ); + + const refused = broker.authorize({ + userId: "user_1", + toolkit: "linear", + returnUrl: "https://openbot.test/settings/connected-accounts/x", + }); + + // Sending somebody to consent against a disabled config spends their consent and attaches + // nothing, and nothing on the page they are on can fix it. The act that DOES fix it is an + // operator's in Composio's own dashboard, which is the one thing the no-config sentence next + // door never says — so that is what this asserts. + const refusal = await failureOf(refused); + expect(refusal).toBeInstanceOf(BrokerRefusalError); + expect(refusal.message).toMatch(DISABLED_REMEDY); + expect(refusal.message).not.toMatch(NO_CONFIG_REMEDY); + expect(linked).toEqual([]); + }); + + test("an app with only somebody else's config still gets one of our own", async () => { + const created: unknown[] = []; + const { broker } = buildComposioClient( + fakeVendor({ + authConfigs: { + list: async () => ({ items: [BY_HAND] }), + create: async (...call: unknown[]) => { + created.push(call); + return CREATED; + }, + }, + }), + ); + + await broker.ensureAuthConfig({ + toolkit: "linear", + name: "Linear", + connection: { kind: "consent" }, + }); + + // Adopting the hand-made one would have this deployment mint connections against scopes it + // cannot see and delete an operator's work when the app is removed. + expect(created).toEqual([ + [ + "linear", + { type: "use_composio_managed_auth", name: "Linear (OpenBot)" }, + ], + ]); + }); +}); + +/** + * WHAT ENABLING AN APP ACTUALLY CREATES, WHICH IS A DIFFERENT ANSWER FOR EACH KIND OF APP. + * + * Every config this deployment ever made was `use_composio_managed_auth`, which is the right answer + * for exactly one of the five kinds. The other four were wrong in four different ways, and only one + * of them announced itself: Composio answers 404 for an app that has no managed OAuth client of its + * own, so Linear's MCP app was unconnectable. The rest were quiet — a no-auth app whose creation + * the vendor refuses outright, a key app whose people were sent to a consent screen with nothing to + * ask them, and an app this deployment cannot drive at all, enabled anyway. + * + * THE ASSERTIONS ARE ON WHAT WENT OUT, AND ON WHAT DID NOT. Two of these four are about a call that + * must not be made at all, which no assertion on a return value can see: {@link fakeVendor}'s + * refusals name the unasked-for call, and the counters here say which door was not opened. + */ +describe("the config each kind of app is enabled with", () => { + test("a self-registering app gets a custom config with no credentials in it", async () => { + const created: unknown[] = []; + const { broker } = buildComposioClient( + fakeVendor({ + authConfigs: { + list: async () => ({ items: [] }), + create: async (...call: unknown[]) => { + created.push(call); + return CREATED; + }, + }, + }), + ); + + await broker.ensureAuthConfig({ + toolkit: "linear_mcp", + name: "Linear MCP", + connection: { kind: "self-registering" }, + }); + + /* + * `DCR_OAUTH` AND NO CREDENTIALS, which is the whole of what such an app needs: the vendor + * registers a client of its own against the provider at connect time. The managed type is what + * this used to send and it is the one answer that cannot work here — Composio has no OAuth + * client of its own for these apps, so the managed path answers 404 and nobody connects. + */ + expect(created).toEqual([ + [ + "linear_mcp", + { + type: "use_custom_auth", + authScheme: "DCR_OAUTH", + name: "Linear MCP (OpenBot)", + credentials: {}, + }, + ], + ]); + }); + + test("a key app gets a custom config carrying no secret, because the secret is per person", async () => { + const created: unknown[] = []; + const { broker } = buildComposioClient( + fakeVendor({ + authConfigs: { + list: async () => ({ items: [] }), + create: async (...call: unknown[]) => { + created.push(call); + return CREATED; + }, + }, + }), + ); + + await broker.ensureAuthConfig({ + toolkit: "perplexityai", + name: "Perplexity", + connection: { kind: "fields", authScheme: "API_KEY" }, + }); + + /* + * NO KEY ON THE CONFIG, AND THAT IS NOT AN OMISSION TO BE FIXED LATER. The config is + * per-deployment and the key is one person's; it belongs to each connection made against this + * config, which is where the connect form sends it. A key here would be one person's secret + * shared by everybody the app is enabled for. + */ + expect(created).toEqual([ + [ + "perplexityai", + { + type: "use_custom_auth", + authScheme: "API_KEY", + name: "Perplexity (OpenBot)", + credentials: {}, + }, + ], + ]); + }); + + test("a no-auth app gets no config at all, because Composio refuses one", async () => { + let listed = false; + const { broker } = buildComposioClient( + fakeVendor({ + authConfigs: { + list: async () => { + listed = true; + return { items: [] }; + }, + }, + }), + ); + + await broker.ensureAuthConfig({ + toolkit: "hackernews", + name: "Hacker News", + connection: { kind: "no-auth" }, + }); + + /* + * NOT EVEN THE LISTING, which is the half that is easy to leave in. Composio's own refusal is + * "Cannot create an auth config for toolkit hackernews because it does not require + * authentication. You can use its tools directly without creating a connected account." — so + * there is nothing to find and nothing to create, and a read made anyway is a round trip whose + * answer no branch below could use. `create` is left at {@link fakeVendor}'s refusal, which + * names itself if it is ever reached. + */ + expect(listed).toBe(false); + }); + + test("an unsupported app is refused before anything is created", async () => { + const { broker } = buildComposioClient( + fakeVendor({ + authConfigs: { + list: async () => ({ items: [] }), + }, + }), + ); + + const refusal = await failureOf( + broker.ensureAuthConfig({ + toolkit: "docusign", + name: "DocuSign", + connection: { + kind: "unsupported", + reason: "needs its own OAuth client", + }, + }), + ); + + /* + * THE DERIVATION'S OWN SENTENCE, carried rather than restated. It is the one the picker filters + * on and the one an administrator has already read beside the app; a second sentence invented + * here would be a second account of why the app cannot be driven, and the two would drift. + */ + expect(refusal).toBeInstanceOf(BrokerRefusalError); + expect(refusal.message).toMatch(/needs its own OAuth client/); + expect(refusal.message).toMatch(/docusign/); + expect(refusal.message).not.toMatch(A_CRASH); + }); +}); + +/** + * Ending somebody's access, which is the claim this whole surface is here to be able to make. + * + * THE DELETE DOES NOT REVOKE, and that is the vendor's own description of it: it "soft-deletes a + * connected account by marking it as deleted in the database", preserving the record, unless + * `revoke_on_delete` is passed. Every path that says it ended somebody's access — a person + * disconnecting, an app being removed, a person being offboarded — runs through this method and + * wrote `true` into the audit trail while the refresh token at Google was untouched. The assertions + * here are therefore on WHAT WENT OUT rather than on what came back: an implementation that dropped + * the flag answers every one of them identically. + */ +describe("withdrawing one person's grants", () => { + test("the delete asks for the upstream credentials to be revoked", async () => { + const deleted: unknown[] = []; + const { broker } = buildComposioClient( + fakeVendor({ + authConfigs: { list: ourGmailConfig }, + connectedAccounts: { + list: async () => ({ items: [{ id: "ca_1" }] }), + delete: async (...call: unknown[]) => { + deleted.push(call); + return WITHDRAWN; + }, + }, + }), + ); + + expect(await broker.revoke({ userId: "user_1", toolkit: "gmail" })).toBe( + true, + ); + expect(deleted).toEqual([["ca_1", { revoke_on_delete: true }]]); + }); + + test("a delete Composio answered `success: false` is not a withdrawal", async () => { + const { broker } = buildComposioClient( + fakeVendor({ + authConfigs: { list: ourGmailConfig }, + connectedAccounts: { + list: async () => ({ items: [{ id: "ca_1" }] }), + // A 200 whose body says the account was not deleted. The flag went out, Composio read + // the request and answered it — this is the vendor declining, not failing. + delete: async () => ({ success: false }), + }, + }), + ); + + /* + * WITHOUT THE CHECK THIS ANSWERS `true`, WHICH IS THE WHOLE FINDING. `store.ts` writes that + * boolean into `mcp.account_disconnected` as `vendorRevocationRequested` and then deletes the + * `composio_connections` row — the only thing in this deployment naming which app this person + * connected. So a `success: false` nobody read ends as a trail entry claiming a grant was + * withdrawn, an account that is still live at Google, and nothing left pointing at it. + * + * ASSERTED AS A REFUSAL AND AS A COUNT, because "did not answer true" is satisfied by a crash. + */ + const refusal = await failureOf( + broker.revoke({ userId: "user_1", toolkit: "gmail" }), + ); + expect(refusal).toBeInstanceOf(BrokerRefusalError); + expect(refusal.message).toMatch( + /withdrew 0 of this person's 1 accounts for gmail/, + ); + expect(refusal.message).not.toMatch(A_CRASH); + // The reason lives on `cause`, because the count is deliberately the whole of the sentence. + expect(everythingSaidBy(refusal).join(" ")).toMatch(/success: false/); + }); + + test("a delete whose verdict Composio did not send is not counted as one either", async () => { + const { broker } = buildComposioClient( + fakeVendor({ + authConfigs: { list: ourGmailConfig }, + connectedAccounts: { + list: async () => ({ items: [{ id: "ca_1" }] }), + /* + * `success` IS REQUIRED IN THE DECLARATION AND ABSENT ON THIS WIRE, which is the shape + * the check has to survive rather than the one it is for. `@composio/client` parses the + * body and hands it over, so the schema's "required" is a promise about what Composio + * means to send and not a fact about what arrived. + */ + delete: async () => ({}), + }, + }), + ); + + const refusal = await failureOf( + broker.revoke({ userId: "user_1", toolkit: "gmail" }), + ); + expect(refusal).toBeInstanceOf(BrokerRefusalError); + expect(refusal.message).not.toMatch(A_CRASH); + + /* + * A DIFFERENT SENTENCE FROM THE ONE NEXT DOOR, and that is what this asserts. "Composio said + * no" is a fact about this account that a second press can meet again; "Composio answered + * something this deployment cannot read" is a fact about the package, correctable by nobody + * holding an admin page. Collapsing them would send an operator to press a button for a + * condition a button cannot change. + */ + const said = everythingSaidBy(refusal).join(" "); + expect(said).toMatch(/upgrading this deployment's @composio\/core/); + expect(said).not.toMatch(/success: false/); + }); + + test("the listing asks about every state a grant can be hiding in", async () => { + const asked: unknown[] = []; + const { broker } = buildComposioClient( + fakeVendor({ + authConfigs: { list: ourGmailConfig }, + connectedAccounts: { + list: async (query: unknown) => { + asked.push(query); + return { items: [] }; + }, + }, + }), + ); + + expect(await broker.revoke({ userId: "user_1", toolkit: "gmail" })).toBe( + false, + ); + + /* + * `accountType` because its default is private accounts only, so a shared account is invisible + * to a listing that omits it — and an invisible account is a live grant this answers `false` + * about. The statuses because an unfinished consent or a lapsed token is still something a + * provider is holding. `REVOKED` is the one left out: it is the only status that says the grant + * is already gone, and deleting a tombstone would have this report a withdrawal that never was. + * + * AND `authConfigIds`, WHICH THIS ASSERTION USED TO SAY WAS ABSENT. It was written out as the + * whole query on the reasoning that the breadth is the point — and it is, for three of the four + * parameters. The fourth is the opposite: omitting it asks about every authorization config in + * the project, including ones an operator built by hand in Composio's dashboard, and this + * listing is the one that decides what gets deleted with `revoke_on_delete`. The assertion was + * therefore pinning the defect in place, which is why it moved rather than being relaxed. + */ + expect(asked).toEqual([ + { + userIds: ["user_1"], + toolkitSlugs: ["gmail"], + statuses: [ + "INITIALIZING", + "INITIATED", + "ACTIVE", + "FAILED", + "EXPIRED", + "INACTIVE", + ], + accountType: "ALL", + authConfigIds: [OUR_GMAIL.id], + limit: WHOLE_LISTING, + }, + ]); + }); + + test("being connected is a narrower question, and asked as one", async () => { + const asked: unknown[] = []; + const { broker } = buildComposioClient( + fakeVendor({ + connectedAccounts: { + list: async (query: unknown) => { + asked.push(query); + return { items: [{ id: "ca_1" }] }; + }, + }, + }), + ); + + expect( + await broker.isConnected({ userId: "user_1", toolkit: "gmail" }), + ).toBe(true); + + // ACTIVE only — an unfinished or expired account must not tell somebody their app is wired up — + // but `accountType: "ALL"` all the same, because a shared account is a connected account. + expect(asked).toEqual([ + { + userIds: ["user_1"], + toolkitSlugs: ["gmail"], + statuses: ["ACTIVE"], + accountType: "ALL", + limit: WHOLE_LISTING, + }, + ]); + }); + + test("nobody with no account for the app is answered no", async () => { + const { broker } = buildComposioClient( + fakeVendor({ + connectedAccounts: { list: async () => ({ items: [] }) }, + }), + () => 1_000_000, + ); + + /* + * THE ONLY TEST IN THIS FILE THAT CAN FAIL A GATE THAT ALWAYS OPENS. Both assertions about + * `isConnected` expected `true`, so `async isConnected() { return true }` was green — and a + * gate that cannot answer no is a gate that lets every call through, which is exactly the + * question `./access` asks this method before running somebody's action. + */ + expect( + await broker.isConnected({ userId: "user_1", toolkit: "gmail" }), + ).toBe(false); + }); + + test("a refusal partway through still asks about the accounts behind it", async () => { + const deleted: string[] = []; + const { broker } = buildComposioClient( + fakeVendor({ + authConfigs: { list: ourGmailConfig }, + connectedAccounts: { + list: async () => ({ + items: [{ id: "ca_1" }, { id: "ca_2" }, { id: "ca_3" }], + }), + delete: async (id: string) => { + if (id === "ca_2") throw new Error("Composio refused that one."); + deleted.push(id); + return WITHDRAWN; + }, + }, + }), + ); + + const refused = broker.revoke({ userId: "user_1", toolkit: "gmail" }); + + // The third account is the whole point: a throw at the second used to abandon it, so a grant + // nobody ever asked about outlived a call that reported only the failure of a different one. + await expect(refused).rejects.toThrow(/2 of this person's 3 accounts/); + expect(deleted).toEqual(["ca_1", "ca_3"]); + }); + + test("a partial withdrawal is a failure rather than a reported disconnection", async () => { + const { broker } = buildComposioClient( + fakeVendor({ + authConfigs: { list: ourGmailConfig }, + connectedAccounts: { + list: async () => ({ items: [{ id: "ca_1" }, { id: "ca_2" }] }), + delete: async (id: string) => { + if (id === "ca_2") throw new Error("Composio refused that one."); + return WITHDRAWN; + }, + }, + }), + ); + + /* + * NOT A `true`. `store.ts` revokes and only then deletes the `composio_connections` row, which + * is the only thing naming which app this person connected; a `true` here deletes that row, the + * trail records a disconnection, and the account this call could not end is left live with + * nothing pointing at it. The throw leaves the row standing, so pressing disconnect again is a + * second attempt with everything the first one had. + */ + await expect( + broker.revoke({ userId: "user_1", toolkit: "gmail" }), + ).rejects.toBeInstanceOf(BrokerRefusalError); + }); + + /** + * AN ACCOUNT THIS DEPLOYMENT CANNOT NAME MUST NOT STOP IT WITHDRAWING THE ONES IT CAN. + * + * The id check used to run over every row BEFORE any delete went out, so one row whose id + * Composio omitted threw ahead of the first withdrawal — and the next attempt met the same row + * and threw in the same place. A person with three grants and one unreadable row could never + * withdraw any of them, for ever, and the page told them to try again. + * + * THE TWO FAILURES THIS SITS BETWEEN ARE BOTH WORSE, which is why the answer is neither of them. A + * delete sent with `undefined` where an id belongs is a request Composio may read as anything, + * followed by a `true` and an audit row saying this person's access ended. Refusing before + * anything goes out is a permanent block over a row nobody can remove from here. What a person is + * owed is the withdrawal of every grant this deployment CAN name, and a sentence counting what + * was left — which is a state they can act on, by removing the rest in Composio's own dashboard. + */ + test("an account with no id does not stop the accounts beside it being withdrawn", async () => { + const deleted: string[] = []; + const { broker } = buildComposioClient( + fakeVendor({ + authConfigs: { list: ourGmailConfig }, + connectedAccounts: { + list: async () => ({ + items: [{ id: "ca_1" }, { id: null }, { id: "ca_3" }], + }), + delete: async (id: string) => { + deleted.push(id); + return WITHDRAWN; + }, + }, + }), + () => 1_000_000, + ); + + const failure = await failureOf( + broker.revoke({ userId: "user_1", toolkit: "gmail" }), + ); + + // Both readable grants gone, and no delete sent for the row that had no id: a withdrawal of + // `undefined` is the request this whole guard exists to stop being made. + expect(deleted).toEqual(["ca_1", "ca_3"]); + // STILL A FAILURE, because `store.ts` deletes the `composio_connections` row on a `true` and + // that row is the only thing in this deployment naming which app this person connected. + expect(failure).toBeInstanceOf(BrokerRefusalError); + expect(failure.message).toMatch(/2 of this person's 3 accounts/); + // And the reason the third could not be asked about is named, because "try again" is not the + // remedy for it and the reader would otherwise be given a count with no way to read it. + expect(failure.message).not.toMatch(A_CRASH); + expect((failure.cause as Error).message).toMatch(/id/); + }); + + /** + * AND A CONFIG ROW THIS DEPLOYMENT CANNOT READ MUST NOT STOP IT EITHER — the same correction as + * the account above, one listing earlier, where it was still live after that one landed. + * + * Reading the configs threw on the first row it could not check, and the withdrawal reads them + * BEFORE it reads a single account. So one unreadable config row meant a person could never + * withdraw anything for that app: every press met the same row and the same throw, ahead of the + * first delete, with their readable grants standing the whole time. What they are owed is the + * withdrawal of every grant this deployment can reach and a sentence about what it could not. + */ + test("a config row that could not be read does not stop the grants behind the one that could", async () => { + const deleted: string[] = []; + const { broker } = buildComposioClient( + fakeVendor({ + authConfigs: { + list: async () => ({ + items: [{ id: "ac_gmail_nameless" }, OUR_GMAIL], + }), + }, + connectedAccounts: { + list: async () => ({ items: [{ id: "ca_1" }] }), + delete: async (id: string) => { + deleted.push(id); + return WITHDRAWN; + }, + }, + }), + () => 1_000_000, + ); + + const failure = await failureOf( + broker.revoke({ userId: "user_1", toolkit: "gmail" }), + ); + + // The grant on the config that WAS readable is gone, which is the half a throw ahead of the + // loop threw away. + expect(deleted).toEqual(["ca_1"]); + // And still a failure, because a grant of theirs may sit on the config that could not be read + // and nothing here ever asked about it — so `store.ts` must not delete the row that names this + // person's connection. + expect(failure).toBeInstanceOf(BrokerRefusalError); + expect(failure.message).not.toMatch(A_CRASH); + expect(failure.message).toMatch(/withdrew 1 of this person's 1 accounts/); + expect(failure.message).toMatch(/authorization configs for gmail/); + // And the reason that row could not be sorted travels as `cause`, because the sentence above is + // deliberately a count and leaves the reasons nowhere else to live. + expect((failure.cause as Error).message).toMatch(/name/); + }); + + /** + * THE GATE IS A COUNT AND WAS ASKING FOR AN ID IT NEVER USES. + * + * `isConnected` answers whether this person holds an ACTIVE account for an app, which is a + * question about how many rows came back — the id is the revoke's business and nothing this + * question reads. Taking the ids anyway meant an account Composio described without one turned a + * true answer into a thrown refusal: the person IS connected, and the gate that refuses their run + * says so on the strength of a field it was never going to look at. + */ + test("an account the vendor described without an id still counts as connected", async () => { + const { broker } = buildComposioClient( + fakeVendor({ + connectedAccounts: { list: async () => ({ items: [{}] }) }, + }), + () => 1_000_000, + ); + + expect( + await broker.isConnected({ userId: "user_1", toolkit: "gmail" }), + ).toBe(true); + }); + + /** + * AN OPERATOR'S OWN AUTHORIZATION CONFIG IS NOT THIS DEPLOYMENT'S TO EMPTY. + * + * The account listing was asked by person, by app and by "all account types" and by nothing else, + * so it returned every account Composio holds for that pair — including ones attached to a config + * an operator built by hand in their dashboard, for purposes this deployment knows nothing about, + * and including the SHARED ones other people are acting through. Each of those was then deleted + * with `revoke_on_delete`, which tears the grant up at the provider. One person pressing + * disconnect on their own settings page ended somebody else's integration, silently, and answered + * `true`. + * + * WHICH IS THE PRINCIPLE `deleteAuthConfig` ALREADY STATES ONE LEVEL UP: it refuses to delete a + * config it cannot show is this deployment's, on exactly the reasoning that an operator's + * dashboard work is not ours to destroy. The accounts hanging off that config are the same work. + * + * ASSERTED ON WHAT WENT OUT, because an implementation that asked the unscoped question and then + * deleted everything it found answers this test's `true` just as confidently. + */ + test("the withdrawal asks only about accounts on configs this deployment made", async () => { + const scoped: unknown[] = []; + const deleted: string[] = []; + const { broker } = buildComposioClient( + fakeVendor({ + authConfigs: { + // An operator's beside ours, which is the state the whole guard is about. Out of the + // vendor's order, so a reader that took the first row would take the wrong one. + list: async () => ({ items: [BY_HAND_GMAIL, OUR_GMAIL] }), + }, + connectedAccounts: { + list: async (query: unknown) => { + scoped.push((query as { authConfigIds?: unknown }).authConfigIds); + return { items: [{ id: "ca_1" }] }; + }, + delete: async (id: string) => { + deleted.push(id); + return WITHDRAWN; + }, + }, + }), + ); + + expect(await broker.revoke({ userId: "user_1", toolkit: "gmail" })).toBe( + true, + ); + + // The operator's config is not in the question, so no account on it can be in the answer and + // none of them can reach the delete. + expect(scoped).toEqual([[OUR_GMAIL.id]]); + expect(deleted).toEqual(["ca_1"]); + }); + + /** + * NOTHING AT ALL IS NOTHING TO WITHDRAW, AND IT IS ANSWERED WITHOUT ASKING. + * + * `authorize` mints every connect link against a config this deployment made and refuses where + * there is none, so an app Composio holds no configs for never had a connection begun through it. + * Listing accounts anyway could only turn up somebody else's, and the one thing this call does + * with an account it turns up is delete it. + * + * THE ASSERTION IS THE DOUBLE. `connectedAccounts.list` is left at {@link fakeVendor}'s refusal, + * so an implementation that asked the question at all fails here by name — which is a stronger + * statement than the `false` beside it, because a listing scoped to an EMPTY set of configs would + * answer `false` too while putting a filter on the wire that the far side is free to read as no + * filter at all. + */ + test("a person's accounts are not listed where Composio holds no config for the app", async () => { + const { broker } = buildComposioClient( + fakeVendor({ authConfigs: { list: async () => ({ items: [] }) } }), + ); + + expect(await broker.revoke({ userId: "user_1", toolkit: "gmail" })).toBe( + false, + ); + }); + + /** + * AND "NONE OF OURS" IS NOT THAT STATE — THE SEVENTH ROUTE TO A REVOCATION THAT DID NOT REVOKE. + * + * THIS TEST ASSERTED THE `false`, AND THE `false` WAS THE DEFECT. It stood on the reasoning above + * — none of ours means nothing was ever granted through this app — which is sound about an app + * Composio holds no configs for and cannot tell that app from this one. An operator renames a + * config in Composio's dashboard, dropping the suffix or editing the app's title past it, and + * this deployment's own live grants read as somebody else's work: `store.ts` then writes + * `vendorRevocationRequested: false` into the audit trail and deletes the `composio_connections` + * row, so the person's grant stands at the provider with nothing naming it and the trail records + * that no withdrawal was even asked for. The assertion is changed on purpose, and the case it + * used to cover — Composio holding nothing at all — is asserted next door, where it is true. + * + * `deleteAuthConfig` HAS REFUSED IN EXACTLY THIS STATE SINCE THE WAVE BEFORE THIS ONE, which is + * what makes this a disagreement rather than a judgement call: two halves of removing an app's + * access read one condition two different ways, one function apart. + * + * THE DOUBLE IS STILL THE OTHER HALF OF THE ASSERTION. `connectedAccounts.list` is left refusing, + * so an implementation that answered this by listing somebody else's accounts — and then deleting + * what it found — fails here by name. + */ + test("an app whose configs no longer carry this deployment's name is not a quiet disconnection", async () => { + const { broker } = buildComposioClient( + fakeVendor({ + authConfigs: { list: async () => ({ items: [BY_HAND_GMAIL] }) }, + }), + ); + + const refusal = await failureOf( + broker.revoke({ userId: "user_1", toolkit: "gmail" }), + ); + expect(refusal).toBeInstanceOf(BrokerRefusalError); + expect(refusal.message).not.toMatch(A_CRASH); + // The count standing and the marker that would have claimed it, which is the same pair + // `deleteAuthConfig` hands an operator for the same state and the same one act in a dashboard. + expect(refusal.message).toMatch(/Composio holds 1 for gmail/); + expect(refusal.message).toMatch(/\(OpenBot\)/); + }); + + /** + * A WITHDRAWAL THAT HAPPENED, REPORTED AS A FAILURE — the guard for `success: false` overreaching. + * + * The installed client resolves two answers with no document at all: a 204 becomes `null` + * ("fetch refuses to read the body when the status code is 204") and a JSON reply carrying + * `content-length: 0` becomes `undefined` (`@composio/client` 0.1.0-alpha.76, + * `src/internal/parse.ts:16-42`). Neither can be a rejection the vendor made: every `!response.ok` + * is thrown as an `APIError` before parsing (`src/client.ts:539`), so an answer arriving at all is + * Composio having accepted the request and deleted the account. + * + * READING THAT AS "NO VERDICT" TURNED A COMPLETED WITHDRAWAL INTO A PARTIAL-WITHDRAWAL REFUSAL, + * which leaves the person's connection row standing, tells them their access has not ended, and + * has them press disconnect again — against an account that is already gone, which is a second + * fault waiting on the first. It is the same lie as the unread `success: false` before it, facing + * the other way. + * + * THE OTHER DIRECTION IS ASSERTED NEXT DOOR AND IS NOT WEAKENED BY THIS: `{}` is a document that + * arrived without its verdict, which is a fact about the package, and it still refuses. + */ + for (const { shape, answer } of [ + { shape: "a 204 carrying no content", answer: null }, + { shape: "a JSON reply of content-length zero", answer: undefined }, + ]) { + test(`a withdrawal Composio answered with ${shape} is a withdrawal`, async () => { + const deleted: string[] = []; + const { broker } = buildComposioClient( + fakeVendor({ + authConfigs: { list: ourGmailConfig }, + connectedAccounts: { + list: async () => ({ items: [{ id: "ca_1" }] }), + delete: async (id: string) => { + deleted.push(id); + return answer; + }, + }, + }), + ); + + expect(await broker.revoke({ userId: "user_1", toolkit: "gmail" })).toBe( + true, + ); + // And the flag still went out, so the `true` is about a delete that asked for the grant to be + // revoked rather than about one that quietly filed the account away. + expect(deleted).toEqual(["ca_1"]); + }); + } + + /** + * A ROW THE LISTING NAMED TWICE IS ONE ACCOUNT, NOT TWO. + * + * The paging loop guards against the vendor repeating a CURSOR and not against it repeating a + * ROW, and those are different faults: a page boundary crossed while accounts are being created + * or deleted, or a proxy stitching two overlapping pages together, hands the same id over twice + * with a cursor that advanced perfectly each time. The second delete then meets Composio's "there + * is no such account" — which arrives as a refusal — so a withdrawal that in fact COMPLETED was + * thrown over as a partial one, the person's connection row was left standing, and every retry + * met the same duplicate and failed in the same place. + * + * THE DOUBLE REFUSES THE SECOND DELETE OF ONE ACCOUNT, which is what the vendor does and what + * makes this test able to fail. A stub that answered both identically would be green against an + * adapter that sent the delete twice. + */ + test("an account the listing named twice is withdrawn once", async () => { + const deleted: string[] = []; + const { broker } = buildComposioClient( + fakeVendor({ + authConfigs: { list: ourGmailConfig }, + connectedAccounts: { + list: async (query: unknown) => + (query as { cursor?: string }).cursor === undefined + ? { items: [{ id: "ca_1" }], nextCursor: "page_2" } + : { items: [{ id: "ca_1" }], nextCursor: null }, + delete: async (id: string) => { + if (deleted.includes(id)) { + throw new Error( + `Composio holds no connected account with the id ${id}.`, + ); + } + deleted.push(id); + return WITHDRAWN; + }, + }, + }), + ); + + expect(await broker.revoke({ userId: "user_1", toolkit: "gmail" })).toBe( + true, + ); + expect(deleted).toEqual(["ca_1"]); + }); + + /** + * AND THE COUNT IN A REAL PARTIAL FAILURE IS OF ACCOUNTS, NOT OF ROWS. + * + * The denominator was `accounts.length`, which is how many rows the listing handed over — so a + * listing that named one account twice made the sentence a reader is meant to act on state a + * figure nothing had counted. "One of three" over two accounts is the same class of mistake as + * the page-ceiling sentence that asserted a row count it had not measured. + */ + test("a duplicated row is not a third account in the sentence a partial failure carries", async () => { + const { broker } = buildComposioClient( + fakeVendor({ + authConfigs: { list: ourGmailConfig }, + connectedAccounts: { + list: async () => ({ + items: [{ id: "ca_1" }, { id: "ca_1" }, { id: "ca_2" }], + }), + delete: async (id: string) => { + if (id === "ca_2") throw new Error("Composio refused this one."); + return WITHDRAWN; + }, + }, + }), + () => 1_000_000, + ); + + const failure = await failureOf( + broker.revoke({ userId: "user_1", toolkit: "gmail" }), + ); + expect(failure).toBeInstanceOf(BrokerRefusalError); + expect(failure.message).toMatch(/withdrew 1 of this person's 2 accounts/); + expect(failure.message).not.toMatch(A_CRASH); + }); +}); + +/** + * The three refusals this file authors, and the one thing a route has to be able to do with them. + * + * `routes.ts` answers a thrown broker error by reaching into it for the vendor's own sentence and, + * finding none, telling the reader that Composio said nothing about why and that an administrator + * should check this deployment's key. That advice is wrong for every sentence below: Composio + * answered, this deployment decided, and the remedy is already written down. `brokerSentence` is the + * one seam that tells the two apart, so these assert the recognition rather than the wording. + */ +describe("refusals a route can tell from an outage", () => { + test("an app with no config of ours is recognised as this deployment's own refusal", async () => { + const { broker } = buildComposioClient( + fakeVendor({ authConfigs: { list: async () => ({ items: [] }) } }), + ); + + const error = await broker + .authorize({ + userId: "user_1", + toolkit: "linear", + returnUrl: "https://openbot.test/settings/connected-accounts/x", + }) + .catch((raised: unknown) => raised); + + // `/An administrator/` opens two of this method's three refusals, so it recognised the class + // and not the branch. The remedy is what a reader is being handed. + expect(brokerSentence(error)).toMatch(NO_CONFIG_REMEDY); + expect(brokerSentence(error)).not.toMatch(DISABLED_REMEDY); + }); + + test("a consent with nowhere to send anybody is recognised too", async () => { + const { broker } = buildComposioClient( + fakeVendor({ + authConfigs: { + list: async () => ({ + items: [ + { id: "ac_ours", name: "Linear (OpenBot)", status: "ENABLED" }, + ], + }), + }, + // An API-key toolkit is connected by typing a secret rather than by visiting a page, so the + // vendor answers with no url. Nothing is wrong with the key, and saying so would be a + // second wrong answer on top of a first. + connectedAccounts: { link: async () => ({ redirectUrl: null }) }, + }), + ); + + const error = await broker + .authorize({ + userId: "user_1", + toolkit: "linear", + returnUrl: "https://openbot.test/settings/connected-accounts/x", + }) + .catch((raised: unknown) => raised); + + expect(brokerSentence(error)).toMatch(NO_PAGE_REMEDY); + expect(brokerSentence(error)).not.toMatch(NO_CONFIG_REMEDY); + expect(brokerSentence(error)).not.toMatch(DISABLED_REMEDY); + }); +}); + +/** + * What the catalogue is allowed to be, given that it is cached and then believed. + */ +describe("a catalogue that might be a fragment", () => { + /** + * THIS TEST ASSERTED THE OPPOSITE AND WAS CHANGED ON PURPOSE, WHICH IS WORTH READING BEFORE THE + * CODE UNDER IT. + * + * It was "a full page is refused rather than held for ten minutes", and it pinned a refusal whose + * stated reason was that "`LISTING_LIMIT` is the largest page the toolkit endpoint allows and the + * SDK drops the response's cursor, so a catalogue of exactly this size and one larger answer + * identically". The consequence it guarded against is real and is still guarded: a fragment + * committed here is served for ten minutes to the picker AND to the enable route, which then + * tells an administrator that a real app "is not an app Composio lists". + * + * WHAT WAS FALSE WAS THE PREMISE. The cursor exists on the raw client and always did — see the + * paging tests at the end of this file — so the two answers the refusal said were + * indistinguishable are told apart by asking for the next page. And the refusal's cost was not + * hypothetical: Composio publishes more than {@link WHOLE_LISTING} toolkits, so it fired on the + * first call every time and the app picker showed an operator nothing at all. + * + * SO THE ASSERTION IS INVERTED RATHER THAN DELETED, and it is inverted at the same boundary. A + * page of exactly {@link WHOLE_LISTING} rows with a cursor still outstanding is the shape that + * used to be refused; what is asserted now is that the row on the far side of it arrives. + */ + test("a full page is read on from rather than refused", async () => { + let calls = 0; + const { broker } = buildComposioClient( + fakeVendor({ + toolkits: { + list: async (query: unknown) => { + calls += 1; + return (query as { cursor?: string }).cursor === undefined + ? { + items: Array.from({ length: WHOLE_LISTING }, (_, index) => ({ + slug: `app_${index}`, + name: `App ${index}`, + meta: {}, + })), + next_cursor: "page_2", + } + : { + items: [ + { slug: "the_one_past_the_cut", name: "Past", meta: {} }, + ], + next_cursor: null, + }; + }, + }, + }), + () => 1_000_000, + ); + + const apps = await broker.listApps(); + + expect(calls).toBe(2); + expect(apps).toHaveLength(WHOLE_LISTING + 1); + // The app an administrator would have been told Composio does not publish. + expect(apps.at(-1)?.slug).toBe("the_one_past_the_cut"); + }); + + test("each caller gets its own rows, so one of them cannot edit the cache", async () => { + const { broker } = buildComposioClient( + fakeVendor({ + authConfigs: { list: ourGmailConfig }, + toolkits: { + list: async () => ({ + items: [ + { + slug: "gmail", + name: "Gmail", + meta: { + description: "Send and read mail.", + categories: [{ id: "productivity", name: "Productivity" }], + tools_count: 63, + }, + }, + ], + }), + }, + }), + () => 1_000_000, + ); + + const first = await broker.listApps(); + first[0].name = "Not Gmail"; + first[0].categories.push("Invented"); + first.length = 0; + + // Nothing does this today, which is exactly why leaving it would be a trap: the first caller + // that sorts or trims the rows would be editing what the next nine minutes of callers read as + // Composio's answer, and the fault would surface in somebody else's request. + const second = await broker.listApps(); + expect(second).toEqual([ + { + slug: "gmail", + name: "Gmail", + description: "Send and read mail.", + logo: null, + categories: ["Productivity"], + actionCount: 63, + connection: NO_SCHEME, + }, + ]); + }); +}); + +/** + * WHAT ESCAPES THE SEAM WHEN THE VENDOR THROWS, asked of every method the seam has. + * + * ONE TABLE RATHER THAN A TEST PER DISCOVERY, because the defect this is about has been found four + * times in four methods and each finding was fixed where it was pointed at. `routes.ts` answers a + * thrown broker error by reaching into it for the vendor's own sentence and, finding none, telling + * the reader that Composio said nothing about why and that an administrator should check this + * deployment's Composio key. That is the right thing to say about a socket that hung up and the + * wrong thing to say about every failure the vendor actually explained — and which of the two a + * reader gets is decided by whether the `await vendor.*` that threw happened to sit inside + * something that translates. TypeScript has no checked exceptions, so nothing enumerates the calls + * that do and nothing notices a new one that does not. + * + * THE PROPERTY, STATED ONCE: an error leaving this seam must leave the route something to say — + * either a {@link BrokerRefusalError}, whose sentence this deployment wrote and whose remedy is + * already in it, or a vendor error whose own sentence {@link vendorSentence} can reach. Anything + * else reaches the reader as "check your Composio key", so anything else has to be named below as + * a failure that genuinely deserves that answer. + * + * THE ALLOW-LIST IS ASSERTED IN BOTH DIRECTIONS, which is what stops it becoming a list of + * excuses. An entry on it must actually arrive unexplained; the day a method starts translating + * the failure named there, its entry reddens and has to be deleted rather than quietly outliving + * the state it describes. + * + * THE METHOD LIST IS THE SEAM'S OWN. It is read off the objects {@link buildComposioClient} + * returns rather than copied into this file, and typed as `keyof ComposioBroker | keyof + * ComposioActions`, so a method added to either seam with no entry here fails the completeness + * test below instead of being covered by nobody. + */ +describe("what a vendor failure becomes on its way out of the seam", () => { + /** + * The two ways a vendor call fails, which are two different questions and not one. + * + * An OUTAGE carries nothing: a socket, a 502 from an edge, a timeout. Nobody wrote a sentence + * about it, so there is none to find and "Composio did not say why" is the honest answer. + * + * A NAMED CONDITION is the opposite case and the one that keeps being missed. `@composio/core` + * raises its own error classes — `ComposioMultipleConnectedAccountsError` and the four others + * down the same door: `ComposioAclOnlyForSharedError`, + * `ComposioFailedToCreateConnectedAccountLink`, `ValidationError`, + * `ComposioRequestCancelledError` — and each one is the vendor saying WHICH condition happened, + * in a message it wrote for a reader. None of it is nested where {@link vendorSentence} looks, so + * a seam that lets one through unexamined converts an explanation into "check the key". + * + * A SHAPE THE SDK ITSELF COULD NOT READ is the third, and it is the one three rounds of review + * wrote guards for at the wrong layer. `@composio/core` 0.18.1 does not hand a malformed listing + * over to be inspected: its own transformers dereference the answer first, so + * `response.items.map(transformAuthConfigRetrieveResponse)` off a bare list, and + * `authConfig.toolkit.logo` off a row that is not an object, raise a bare `TypeError` from inside + * the vendor's code before any reader here is reached (`src/utils/transformers/authConfigs.ts:79`, + * `:41`, and the same two shapes in `connectedAccounts.ts:113`, `:60` and `models/Tools.ts:561`). + * A `TypeError` arriving out of an `await vendor.*` is therefore Composio's shape and not a bug of + * this deployment's, and what it needs is the same thing every other vendor failure needs: a + * sentence. + */ + const THROWN: { + kind: string; + raise: () => Error; + /** What the sentence must say, where the kind of failure settles a remedy. */ + demands?: RegExp; + /** What the sentence must not have picked up from the error it translated. */ + quiets?: RegExp; + }[] = [ + { + kind: "an outage", + raise: () => new Error("socket hang up"), + }, + { + kind: "a named vendor condition", + raise: () => + Object.assign( + new Error( + "Multiple connected accounts found for user user_1 and toolkit linear.", + ), + { name: "ComposioMultipleConnectedAccountsError" }, + ), + }, + { + kind: "a shape the SDK itself could not read", + raise: () => + new TypeError( + "undefined is not an object (evaluating 'response.items.map')", + ), + // One remedy, and it is a package rather than a page: nobody operating this deployment can + // correct what Composio answers, and the key is demonstrably fine — the call went out. + demands: /@composio\/core/, + quiets: A_CRASH, + }, + ]; + + type SeamMethod = keyof ComposioBroker | keyof ComposioActions; + + /** + * The failures this seam is allowed to hand on unexplained, one entry per state and each with + * its reason written down. + * + * Every one of them is the same claim: the vendor call that failed said nothing this deployment + * could pass on, so "Composio did not answer and an administrator should check the key and their + * status page" is genuinely the best thing a reader can be told. That claim is true of a bare + * `Error` out of a listing and it is NOT true of anything the vendor named. + */ + const GENUINE_OUTAGES: { + method: SeamMethod; + kind: string; + because: string; + }[] = [ + { + method: "listApps", + kind: "an outage", + because: + "The catalogue did not answer. There is no app and no person in the question, so the only remedy is the deployment's key or the vendor's status page.", + }, + { + method: "ensureAuthConfig", + kind: "an outage", + because: + "Creating the config failed with nothing said. An administrator pressed Add; the app is not enabled, and what to check is the key.", + }, + { + method: "authorize", + kind: "an outage", + because: + "Minting the link failed with nothing said. Nobody was sent anywhere and nothing was attached, so trying again is the whole of the advice.", + }, + { + method: "isConnected", + kind: "an outage", + because: + "The account listing did not answer. This is a gate rather than a page, and its caller refuses the run either way.", + }, + { + method: "connectionFields", + kind: "an outage", + because: + "Reading what the app asks a person for did not answer. No form was drawn and nothing was attached, so there is nothing about this app to say that the route's own advice — the deployment's key, the vendor's status page — does not already cover.", + }, + { + method: "revokeAccount", + kind: "an outage", + because: + "The delete did not answer. The vendor's own bare message travels on untouched, naming nothing, because there is nothing else to say: the account may be standing at Composio and this deployment cannot tell whether the request landed. Its caller is undoing its own work rather than a person pressing a button, and what it is owed is the failure itself.", + }, + /* + * THE TWO REASONS BELOW USED TO BE FALSE, AND THIS IS THE CASE THE ALLOW-LIST CANNOT CATCH BY + * ITSELF. Its assertion asks whether an AUTHORED sentence reached the reader, so an entry whose + * `because` mis-describes what the reader gets INSTEAD stays green for ever. Both of these said + * the app gets named somewhere downstream; neither does. `listingSentence` returns the thrown + * message verbatim whenever it is neither a schema mismatch nor the vendor's placeholder + * (`./composio`), and `callTool` does the same on the execute path — so "socket hang up" reaches + * an administrator's Plugins page and a model's context exactly like that, bare, naming no app. + * The entries now say so, and the `allowed` branch of the test asserts the message travels + * untouched, which is what makes the claim hold itself up. + */ + { + method: "listActions", + kind: "an outage", + because: + "The action listing did not answer. `./composio`'s `listingSentence` passes the thrown message on verbatim, so the reader gets the vendor's own bare words with no app named — which is all there is, because a listing that did not answer leaves nothing else to say.", + }, + { + method: "execute", + kind: "an outage", + because: + "The call itself failed with nothing said. `callTool` passes the thrown message on verbatim unless it is the vendor's placeholder, so the reader gets the vendor's own bare words — the app is named by the connection they pressed, not by this sentence.", + }, + ]; + const UNEXPLAINABLE = new Set( + GENUINE_OUTAGES.map((outage) => `${outage.method}/${outage.kind}`), + ); + + /** + * Every method of the seam, with the vendor call that decides its answer aimed at the throw. + * + * THE CALL THAT DECIDES RATHER THAN THE FIRST ONE. Several of these read a listing before they + * do the thing they are named for, and a vendor whose every method threw would have each of them + * fail at that first read — so the table would be eight tests of one listing and F1, which lives + * behind an auth-config listing that succeeds, would be unreachable. Each entry below answers + * everything on the way in and throws at the step the method exists to perform. + */ + const SEAM_CASES: { + method: SeamMethod; + vendor: (raise: () => Promise) => Parameters[0]; + ask: (client: ReturnType) => Promise; + }[] = [ + { + method: "listApps", + vendor: (raise) => ({ toolkits: { list: raise } }), + ask: ({ broker }) => broker.listApps(), + }, + { + method: "ensureAuthConfig", + vendor: (raise) => ({ + authConfigs: { list: async () => ({ items: [] }), create: raise }, + }), + ask: ({ broker }) => + broker.ensureAuthConfig({ + toolkit: "linear", + name: "Linear", + connection: { kind: "consent" }, + }), + }, + { + method: "deleteAuthConfig", + vendor: (raise) => ({ + authConfigs: { list: async () => ({ items: [OURS] }), delete: raise }, + }), + ask: ({ broker }) => broker.deleteAuthConfig("linear"), + }, + { + method: "authorize", + vendor: (raise) => ({ + authConfigs: { list: async () => ({ items: [OURS] }) }, + connectedAccounts: { link: raise }, + }), + ask: ({ broker }) => + broker.authorize({ + userId: "user_1", + toolkit: "linear", + returnUrl: RETURN_URL, + }), + }, + { + method: "isConnected", + vendor: (raise) => ({ connectedAccounts: { list: raise } }), + ask: ({ broker }) => + broker.isConnected({ userId: "user_1", toolkit: "gmail" }), + }, + { + method: "revoke", + vendor: (raise) => ({ + // The configs first, because the withdrawal reads them to find out which accounts are this + // deployment's to end before it asks for any account at all. + authConfigs: { list: ourGmailConfig }, + connectedAccounts: { + list: async () => ({ items: [{ id: "ca_1" }] }), + delete: raise, + }, + }), + ask: ({ broker }) => + broker.revoke({ userId: "user_1", toolkit: "gmail" }), + }, + { + method: "connectionFields", + vendor: (raise) => ({ toolkits: { retrieve: raise } }), + ask: ({ broker }) => + broker.connectionFields({ + toolkit: "perplexityai", + authScheme: "API_KEY", + }), + }, + { + method: "connectWithFields", + vendor: (raise) => ({ + // The configs first, for the reason the withdrawal above answers them first: the connection + // is made against this deployment's own config, so the read that finds one has to succeed + // before the create this row is about can be reached at all. + authConfigs: { list: async () => ({ items: [OURS] }) }, + connectedAccounts: { create: raise }, + }), + ask: ({ broker }) => + broker.connectWithFields({ + userId: "user_1", + toolkit: "linear", + authScheme: "API_KEY", + values: { generic_api_key: "never-sent-anywhere" }, + }), + }, + { + method: "revokeAccount", + vendor: (raise) => ({ connectedAccounts: { delete: raise } }), + // No listing on the way in, which is the whole shape of this method: the id is what it was + // handed, so the delete is the first vendor call it makes and the only one it can fail at. + ask: ({ broker }) => broker.revokeAccount("ca_new"), + }, + { + method: "listActions", + vendor: (raise) => ({ tools: { list: raise } }), + ask: ({ actions }) => + actions.listActions("gmail", { limit: WHOLE_LISTING }), + }, + { + method: "execute", + vendor: (raise) => ({ + tools: { + getRawComposioToolBySlug: async () => ({ + slug: "GMAIL_FETCH_EMAILS", + toolkit: { slug: "gmail" }, + }), + execute: raise, + }, + }), + ask: ({ actions }) => + actions.execute( + { + toolkit: "gmail", + slug: "GMAIL_FETCH_EMAILS", + userId: "user_1", + version: "20260903_00", + }, + {}, + ), + }, + ]; + + test("every method the seam offers has an entry in the table", () => { + // Read off the built objects rather than listed here a second time: a method added to either + // projection arrives in this set, and the table that does not cover it fails here rather than + // being the one door nobody thought to probe. + const seam = buildComposioClient(fakeVendor({})); + expect( + [...Object.keys(seam.broker), ...Object.keys(seam.actions)].sort(), + ).toEqual(SEAM_CASES.map((seamCase) => seamCase.method).sort()); + }); + + for (const seamCase of SEAM_CASES) { + for (const thrown of THROWN) { + const allowed = UNEXPLAINABLE.has(`${seamCase.method}/${thrown.kind}`); + test(`${seamCase.method} meeting ${thrown.kind} leaves the route ${ + allowed + ? "nothing to say, and that is named as an outage" + : "a sentence" + }`, async () => { + const raised = thrown.raise(); + const client = buildComposioClient( + fakeVendor( + seamCase.vendor(async () => { + throw raised; + }), + ), + () => 1_000_000, + ); + + const escaped = await failureOf(seamCase.ask(client)); + const sentence = brokerSentence(escaped) ?? vendorSentence(escaped); + + if (allowed) { + // The allow-list's other direction. This entry claims the reader cannot be told anything + // useful here; the day that stops being true, this line is what says so. + expect(sentence).toBeNull(); + /* + * AND WHAT THE READER DOES GET, WHICH IS THE HALF THE `because` COLUMN KEPT GETTING WRONG. + * Two entries above used to claim the reader is left with a named app and only the reason + * missing; the truth is that the vendor's own bare message travels on untouched — through + * `listingSentence` into an app's `lastError`, and through `callTool` into a result — with + * nothing added and no app named. That is what "unexplainable" costs here, so the entries + * say so and this line holds them to it: the day a method starts wrapping the failure, the + * `because` beside it stops being true and this reddens rather than outliving it. + */ + expect(escaped.message).toBe(raised.message); + return; + } + expect(sentence).not.toBeNull(); + expect(sentence?.trim()).not.toBe(""); + /* + * THE REMEDY IS ASKED OF THE WHOLE FAILURE AND NOT OF ITS FIRST LINE, because two of these + * methods deliberately make a COUNT their sentence. `revoke` and `deleteAuthConfig` are + * withdrawing a SET, and what a reader can act on there is how many of it survived; every + * reason the loop met travels on `cause` instead, which is the only place the detail lives. + * So what has to be true is that the remedy reached the failure somewhere, and reading the + * chain is what says so for both shapes at once. + */ + if (thrown.demands) { + expect(everythingSaidBy(escaped).join("\n")).toMatch(thrown.demands); + } + // The vendor's crash is carried as `cause` and never quoted: a sentence that reads like a + // stack trace is the thing every refusal in this seam was written to stop being. + if (thrown.quiets) expect(sentence).not.toMatch(thrown.quiets); + }); + } + } +}); + +/** + * ONE REMEDY PER VENDOR CONDITION, WHICH IS THE HALF THE TABLE ABOVE CANNOT ASSERT. + * + * That table asks whether a reader is told ANYTHING, and a seam that answered every named condition + * with one sentence would satisfy it completely. This file has already been bitten by exactly that: + * `rejects.toThrow(/linear/)` matched three authored refusals prescribing three different acts by + * three different people, so deleting a whole branch left the suite green. The app name is the part + * every sentence shares; the remedy is the part that makes a sentence worth writing. + * + * SO EACH ROW BELOW IS ASSERTED IN BOTH DIRECTIONS: the sentence a condition produces must carry ITS + * remedy and must carry NO OTHER ROW'S. Two conditions collapsed into one wording fail here twice + * over — the row that lost its remedy, and the row that acquired a second one. + * + * THE CALL SITE OF EACH ROW IS ONE THAT ACTUALLY RAISES IT, read off `@composio/core` 0.18.1 rather + * than chosen for convenience, so a row is also a record of where its condition comes from. The + * table above already establishes that the translation is not call-site-specific; this one + * establishes that the sentences are distinguishable, which is what stops the translation being a + * fallback with a vendor's name on it. + */ +describe("each vendor condition reaches the reader as its own remedy", () => { + /** The vendor's error as it arrives: a name, and a message nothing here reads. */ + function raising(name: string): () => Promise { + return async (): Promise => { + throw Object.assign(new Error(`${name} came out of Composio.`), { name }); + }; + } + + /** Minting this person's connect link, which is where three of the rows below come from. */ + function whileLinking(raise: () => Promise): Promise { + const { broker } = buildComposioClient( + fakeVendor({ + authConfigs: { list: async () => ({ items: [OURS] }) }, + connectedAccounts: { link: raise }, + }), + () => 1_000_000, + ); + return broker.authorize({ + userId: "user_1", + toolkit: "linear", + returnUrl: RETURN_URL, + }); + } + + /** Creating this deployment's auth config, where the SDK parses what it is handed. */ + function whileCreatingConfig(raise: () => Promise): Promise { + const { broker } = buildComposioClient( + fakeVendor({ + authConfigs: { list: async () => ({ items: [] }), create: raise }, + }), + () => 1_000_000, + ); + return broker.ensureAuthConfig({ + toolkit: "linear", + name: "Linear", + connection: { kind: "consent" }, + }); + } + + /** + * ONE APP ACROSS ALL FOUR CONTEXTS, WHICH IS WHAT MAKES THE CROSS-CHECK BELOW MEAN ANYTHING. + * + * Three of these remedies name the app they are about. The linking and creating contexts were + * about `linear` and the resolving and running ones about `gmail`, so "this sentence does not + * also prescribe somebody else's step" was being asked with a regular expression naming an app + * the sentence could not have mentioned — it passed for every pair that crossed the two contexts + * for a reason that had nothing to do with the sentences, which is most of the table. One app + * makes every row comparable with every other. + */ + const CALL = { + toolkit: "linear", + slug: "LINEAR_CREATE_ISSUE", + userId: "user_1", + version: "20260903_00", + }; + + /** Resolving the tool before it is run, which is the call that reports a withdrawn action. */ + function whileResolving(raise: () => Promise): Promise { + const { actions } = buildComposioClient( + fakeVendor({ tools: { getRawComposioToolBySlug: raise } }), + () => 1_000_000, + ); + return actions.execute(CALL, {}); + } + + /** Running it, once the resolve has already agreed about which app it belongs to. */ + function whileRunning(raise: () => Promise): Promise { + const { actions } = buildComposioClient( + fakeVendor({ + tools: { + getRawComposioToolBySlug: async () => ({ + slug: CALL.slug, + toolkit: { slug: CALL.toolkit }, + }), + execute: raise, + }, + }), + () => 1_000_000, + ); + return actions.execute(CALL, {}); + } + + const CONDITIONS: { + name: string; + raisedBy: string; + remedy: RegExp; + ask: (raise: () => Promise) => Promise; + }[] = [ + { + name: "ComposioMultipleConnectedAccountsError", + raisedBy: "connectedAccounts.link", + remedy: /disconnecting the account they already hold/, + ask: whileLinking, + }, + { + name: "ComposioAclOnlyForSharedError", + raisedBy: "connectedAccounts.link", + remedy: /changing how linear is shared in Composio's own dashboard/, + ask: whileLinking, + }, + { + name: "ComposioFailedToCreateConnectedAccountLink", + raisedBy: "connectedAccounts.link", + remedy: /no consent was spent/, + ask: whileLinking, + }, + { + name: "ValidationError", + raisedBy: "authConfigs.create", + remedy: /upgrading this deployment's @composio\/core/, + ask: whileCreatingConfig, + }, + { + name: "ComposioRequestCancelledError", + raisedBy: "tools.execute", + remedy: /asking for it again is what settles/, + ask: whileRunning, + }, + { + name: "ComposioConnectedAccountNotFoundError", + raisedBy: "tools.execute", + remedy: + /connecting linear again on this deployment's Connected accounts page/, + ask: whileRunning, + }, + { + name: "ComposioToolNotFoundError", + raisedBy: "tools.getRawComposioToolBySlug", + remedy: /records what Composio publishes now/, + ask: whileResolving, + }, + { + name: "ComposioToolVersionRequiredError", + raisedBy: "tools.execute", + remedy: /replaces "latest" with a version Composio will accept/, + ask: whileRunning, + }, + ]; + + for (const condition of CONDITIONS) { + test(`${condition.name} out of ${condition.raisedBy} prescribes its own step`, async () => { + const failure = await failureOf(condition.ask(raising(condition.name))); + const sentence = brokerSentence(failure) ?? vendorSentence(failure); + + expect(sentence).not.toBeNull(); + expect(sentence).toMatch(condition.remedy); + + // The other direction: a sentence that also prescribes somebody else's step is a sentence two + // conditions are sharing, which is the state this whole table exists to catch. + for (const other of CONDITIONS) { + if (other.name === condition.name) continue; + expect(sentence).not.toMatch(other.remedy); + } + }); + } + + /** + * 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 + * rethrows everything except a cancellation as `ComposioToolNotFoundError` (`@composio/core` + * 0.18.1, `src/models/Tools.ts:709-721`), and `tools.execute` resolves through that same method + * (`:1163`). A 500, a 429, a refused key and a socket that hung up therefore all arrive wearing + * the name of an action that was withdrawn — and the sentence read the name as the finding, + * telling an administrator during an outage that Composio no longer publishes their action and + * that pressing Refresh at the vendor that is not answering will fix it. + */ + test("the withdrawn-action condition does not claim to know Composio withdrew anything", async () => { + const failure = await failureOf( + whileResolving(raising("ComposioToolNotFoundError")), + ); + const sentence = brokerSentence(failure); + + expect(sentence).not.toBeNull(); + // The refresh stays, because a withdrawn action is the commonest of them and the refresh is + // the only act that settles that reading. + expect(sentence).toMatch(/records what Composio publishes now/); + // What was missing is the other reading, and the fact that separates the two — which is + // something an administrator can go and look at. + expect(sentence).toMatch( + /a timeout, a dropped connection, a 500, a refused key/, + ); + expect(sentence).toMatch(/says nothing that tells the two apart/); + // And the claim it must no longer make. + expect(sentence).not.toMatch(/no longer publishes that action/); + }); + + /** + * The vendor's own words win where there are any, which is the limit on translating at all. + * + * `routes.ts` reads {@link brokerSentence} first and {@link vendorSentence} second, so a refusal + * authored here HIDES whatever Composio's own server said. Several of the SDK's classes are + * wrappers that carry the server's explanation underneath — `ComposioFailedToCreateConnectedAccountLink` + * is one, and it is on the table above — so translating one of those unconditionally would replace + * a specific server message with this deployment's general one. Where the vendor explained itself, + * the error is passed on untouched and the reader gets the vendor's sentence. + */ + test("a condition whose vendor message is reachable is passed on rather than reworded", async () => { + const failure = await failureOf( + whileLinking(async (): Promise => { + throw Object.assign( + new Error("Failed to create connected account link"), + { + name: "ComposioFailedToCreateConnectedAccountLink", + cause: { + error: { + error: { + message: + "The auth config linear (OpenBot) has no redirect URI registered.", + }, + }, + }, + }, + ); + }), + ); + + expect(brokerSentence(failure)).toBeNull(); + expect(vendorSentence(failure)).toBe( + "The auth config linear (OpenBot) has no redirect URI registered.", + ); + }); +}); + +/** + * WHAT THE LOOP THAT DELETES A SET OF THINGS DOES WITH WHAT IT CATCHES. + * + * `deleteAuthConfig` and `revoke` each ask Composio to end several objects that independently hold + * somebody's access, and both attempt all of them rather than stopping at the first refusal — which + * is the right shape and was reporting almost none of what it learned. Two things were wrong with + * it, and they are different failures rather than one. + * + * EVERY REASON AFTER THE FIRST WAS DISCARDED. The throw carried `cause: refused[0]` and nothing + * else, so a person with five accounts of which three refused left one reason behind and two gone — + * and the sentence a reader gets is a count, deliberately, so the reasons were the only place the + * detail lived at all. + * + * AND A BUG OF OURS WAS COUNTED AS A REFUSAL BY COMPOSIO. The catch took everything, so a + * `TypeError` out of this adapter's own code became one more "Composio refused the rest" — a + * sentence telling an operator to press disconnect again, about a fault that will do the same thing + * every time and that no amount of retrying reaches. + */ +describe("what the delete loop keeps of the failures it meets", () => { + test("every account Composio refused is carried, not only the first", async () => { + const { broker } = buildComposioClient( + fakeVendor({ + authConfigs: { list: ourGmailConfig }, + connectedAccounts: { + list: async () => ({ + items: [{ id: "ca_1" }, { id: "ca_2" }, { id: "ca_3" }], + }), + delete: async (id: string) => { + if (id !== "ca_1") throw new Error(`Composio refused ${id}.`); + return WITHDRAWN; + }, + }, + }), + () => 1_000_000, + ); + + const failure = await failureOf( + broker.revoke({ userId: "user_1", toolkit: "gmail" }), + ); + + // The sentence stays the count, which is what a reader can act on. The reasons are what a log + // reader needs, and there are two of them. + expect(brokerSentence(failure)).toMatch(/1 of this person's 3 accounts/); + const cause = failure.cause; + expect(cause).toBeInstanceOf(AggregateError); + expect( + (cause as AggregateError).errors.map((one) => (one as Error).message), + ).toEqual(["Composio refused ca_2.", "Composio refused ca_3."]); + }); + + test("a single refusal is still carried as itself rather than wrapped", async () => { + const { broker } = buildComposioClient( + fakeVendor({ + authConfigs: { list: ourGmailConfig }, + connectedAccounts: { + list: async () => ({ items: [{ id: "ca_1" }, { id: "ca_2" }] }), + delete: async (id: string) => { + if (id === "ca_2") throw new Error("Composio refused that one."); + return WITHDRAWN; + }, + }, + }), + () => 1_000_000, + ); + + const failure = await failureOf( + broker.revoke({ userId: "user_1", toolkit: "gmail" }), + ); + + expect((failure.cause as Error).message).toBe("Composio refused that one."); + }); + + /** + * A `TypeError` OUT OF A VENDOR CALL IS COMPOSIO'S SHAPE, AND THIS TEST USED TO ASSERT THE REVERSE. + * + * It was written around `isOurFault`, whose premise was that a `TypeError` is what a mistake in + * this file looks like and never something "Composio can reply". Running `@composio/core` 0.18.1 + * falsifies that premise outright: a bare list where `{ items }` belongs, an envelope whose + * `items` is a string, and a row that is not an object all raise a bare `TypeError` from inside + * the vendor's own transformers, before any code here is reached. So the classification was + * inverted — a vendor fault escaped the loop as a bug of ours, abandoning every account after it + * unasked, and the reader was told nothing at all. + * + * WHICH IS WHY THE GUARD MOVED RATHER THAN BEING RETUNED. `askVendor` wraps the `await vendor.*` + * and nothing else, so "the fault surfaced inside the vendor's code" is a fact about the call + * stack there rather than a guess from an error class; the loop no longer has to tell the two + * apart, because by the time an error reaches it the question has been answered one layer down. + */ + test("a shape the SDK could not read is Composio refusing, and the loop goes on", async () => { + const deleted: string[] = []; + const { broker } = buildComposioClient( + fakeVendor({ + authConfigs: { list: ourGmailConfig }, + connectedAccounts: { + list: async () => ({ + items: [{ id: "ca_1" }, { id: "ca_2" }, { id: "ca_3" }], + }), + delete: async (id: string) => { + if (id === "ca_2") { + throw new TypeError( + "undefined is not an object (evaluating 'x')", + ); + } + deleted.push(id); + return WITHDRAWN; + }, + }, + }), + () => 1_000_000, + ); + + const failure = await failureOf( + broker.revoke({ userId: "user_1", toolkit: "gmail" }), + ); + + // The third account is the one the old classification abandoned: a `TypeError` at the second + // escaped the loop, so a live grant was never asked about and nothing in the answer said so. + expect(deleted).toEqual(["ca_1", "ca_3"]); + expect(brokerSentence(failure)).toMatch(/2 of this person's 3 accounts/); + // And the crash is carried rather than quoted: the sentence a person reads is this + // deployment's, and the vendor's own words are on `cause` for whoever is reading a log. + expect(failure.message).not.toMatch(A_CRASH); + expect((failure.cause as Error).cause).toBeInstanceOf(TypeError); + }); + + test("the same two promises hold for the loop that removes an app's configs", async () => { + const { broker } = buildComposioClient( + fakeVendor({ + authConfigs: { + list: async () => ({ items: [OURS, OURS_SPARE] }), + delete: async (id: string) => { + throw new Error(`Composio refused ${id}.`); + }, + }, + }), + () => 1_000_000, + ); + + const failure = await failureOf(broker.deleteAuthConfig("linear")); + + expect(brokerSentence(failure)).toMatch( + /0 of this deployment's 2 authorization configs/, + ); + expect( + (failure.cause as AggregateError).errors.map( + (one) => (one as Error).message, + ), + ).toEqual(["Composio refused ac_ours.", "Composio refused ac_ours_spare."]); + }); +}); + +/** + * WHAT EACH VENDOR LISTING IS ALLOWED TO BE, given that this file's types only assert its shape. + * + * A TYPESCRIPT INTERFACE OVER A WIRE VALUE IS AN ASSERTION AND NOT A CHECK, which is the reason + * this table exists — but only for the fields, and that distinction is the whole of what a round of + * running `@composio/core` 0.18.1 established. Three of these four listings go through the SDK's + * warn-only `transform()`, which logs a `safeParse` failure and returns the unvalidated object, so a + * toolkit whose name is null, an auth config whose id is missing and a connected account with no id + * all arrive here exactly as they came off the wire. Those are the shapes below, and each one was + * OBSERVED arriving rather than reasoned about. + * + * WHAT IS NOT BELOW ANY MORE IS THE CONTAINER, AND THAT IS A CORRECTION RATHER THAN A GAP. This + * table used to open each listing with "nothing at all" and "a bare list where an envelope belongs", + * on the claim that those had been observed too. They had not, and they cannot be: every one of the + * SDK's list transformers dereferences the answer before returning it — `response.items.map(...)` + * for the two paged listings and for the tool list, `item.meta.categories` for the catalogue — so a + * container of the wrong shape dies inside the vendor's code, as a bare `TypeError` on three paths + * and as `ComposioToolkitFetchError` on the catalogue, before any reader in the adapter is reached. + * That failure is a vendor-shape fault and is answered as one, which is asserted next door in the + * seam table rather than pretended at here. + * + * WHAT A MALFORMED ANSWER MUST NOT BECOME IS A CRASH REPORT. `f.toLowerCase is not a function` and + * `null is not an object` are what these produce without a reader: the first as an unhandled 500 on + * a live route, the rest as a 502 telling an administrator to check a key that is perfectly good. So + * each case asserts that the call refuses, and that the refusal is a sentence rather than the name + * of a method that was not there. + * + * ASSERTED AS A PROPERTY AND NOT AS A WORDING, because what is required is that a reader be told + * something, and that nothing be sent to the vendor on the strength of a field the answer did not + * carry. The second half is the one that had rotted: `sent` was a literal empty array in half these + * cases and no stub ever wrote to it, so the assertion holding it was a tautology dressed as a + * check. It is now every vendor call the adapter made, in order, and each listing says which single + * call that should be. + */ +describe("a vendor listing that is not the shape it is declared to be", () => { + /** Every vendor method the adapter reached, named, in the order it was called. */ + type Probe = { + parts: Parameters[0]; + sent: string[]; + }; + + const MALFORMED_LISTINGS: { + listing: string; + answers: { shape: string; answer: unknown }[]; + probe: (answer: unknown) => Probe; + /** The one call this listing's fault is allowed to have made before it refused. */ + asked: string[]; + ask: (client: ReturnType) => Promise; + /** Whether the refusal has to be one a route passes through as this deployment's own. */ + authored: boolean; + }[] = [ + { + listing: "the app catalogue", + answers: [ + { + shape: "a row whose slug is null", + answer: [{ slug: null, name: "Gmail", meta: {} }], + }, + { + shape: "a row whose name is null", + answer: [{ slug: "gmail", name: null, meta: {} }], + }, + { + shape: "a category the vendor named with nothing", + answer: [ + { slug: "gmail", name: "Gmail", meta: { categories: [{}] } }, + ], + }, + ], + probe: (answer) => { + const sent: string[] = []; + return { + parts: { + toolkits: { + list: async () => { + sent.push("toolkits.get"); + return answer; + }, + }, + }, + sent, + }; + }, + asked: ["toolkits.get"], + ask: ({ broker }) => broker.listApps(), + authored: true, + }, + { + listing: "this app's auth configs", + answers: [ + { + shape: "a row with no name", + answer: { items: [{ id: "ac_ours", status: "ENABLED" }] }, + }, + { + shape: "a row whose id is null", + answer: { + items: [{ id: null, name: "Linear (OpenBot)", status: "ENABLED" }], + }, + }, + ], + probe: (answer) => { + const sent: string[] = []; + return { + parts: { + authConfigs: { + list: async () => { + sent.push("authConfigs.list"); + return answer; + }, + }, + connectedAccounts: { + link: async () => { + sent.push("connectedAccounts.link"); + return { redirectUrl: "https://backend.composio.dev/s/a-link" }; + }, + }, + }, + sent, + }; + }, + asked: ["authConfigs.list"], + ask: ({ broker }) => + broker.authorize({ + userId: "user_1", + toolkit: "linear", + returnUrl: RETURN_URL, + }), + authored: true, + }, + { + listing: "this person's accounts", + answers: [ + { shape: "a row with no id", answer: { items: [{}] } }, + { shape: "a row whose id is null", answer: { items: [{ id: null }] } }, + { shape: "a row whose id is a number", answer: { items: [{ id: 7 }] } }, + ], + probe: (answer) => { + const sent: string[] = []; + return { + parts: { + authConfigs: { + list: async () => { + sent.push("authConfigs.list"); + return { items: [OUR_GMAIL] }; + }, + }, + connectedAccounts: { + list: async () => { + sent.push("connectedAccounts.list"); + return answer; + }, + delete: async () => { + sent.push("connectedAccounts.delete"); + }, + }, + }, + sent, + }; + }, + // The whole listing is unreadable here, so there is no readable grant to withdraw and the + // delete must not be reached at all — a withdrawal of `undefined` is the request this refusal + // exists to stop being made. Where SOME rows are readable the answer is different and the + // test for it sits beside `revoke`: those go, and the sentence counts what was left. + // + // The config listing goes out first because the withdrawal is scoped to this deployment's own + // configs before it asks for an account at all; it is named here rather than left out so that + // the assertion stays a statement about the whole conversation with the vendor. + asked: ["authConfigs.list", "connectedAccounts.list"], + ask: ({ broker }) => + broker.revoke({ userId: "user_1", toolkit: "gmail" }), + authored: true, + }, + { + listing: "this app's actions", + answers: [ + // The one row fault `ToolSchema` admits: `z.string()` is satisfied by the empty string, and + // an action's slug is `mcp_tools.name` — NOT NULL and half the primary key — as well as + // what a later call sends back to Composio. + { + shape: "an action whose slug is empty", + answer: [{ slug: "", name: "Fetch emails" }], + }, + ], + probe: (answer) => { + const sent: string[] = []; + return { + parts: { + tools: { + list: async () => { + sent.push("tools.getRawComposioTools"); + return answer; + }, + }, + }, + sent, + }; + }, + asked: ["tools.getRawComposioTools"], + ask: ({ actions }) => + actions.listActions("gmail", { limit: WHOLE_LISTING }), + // `./composio` authors this one's sentence, not `./broker`: a listing failure is recorded in + // the app's `lastError` rather than answered to a route, so what is required here is a + // sentence and not a class. + authored: false, + }, + ]; + + for (const listing of MALFORMED_LISTINGS) { + for (const { shape, answer } of listing.answers) { + test(`${listing.listing} answered with ${shape} is refused in a sentence`, async () => { + const probe = listing.probe(answer); + const client = buildComposioClient( + fakeVendor(probe.parts), + () => 1_000_000, + ); + + const failure = await failureOf(listing.ask(client)); + + expect(failure.message).not.toMatch(A_CRASH); + expect(failure.message.trim()).not.toBe(""); + if (listing.authored) { + expect(brokerSentence(failure)).not.toBeNull(); + } + // Nothing was sent to the vendor on the strength of a field the answer did not carry: an + // id-less account reaching the delete is a request to withdraw `undefined`, which the + // vendor is free to read as anything at all. The listing itself is what SHOULD have gone + // out, so the assertion names it rather than asking for silence — an adapter that stopped + // asking at all would satisfy an empty expectation perfectly. + expect(probe.sent).toEqual(listing.asked); + }); + } + } +}); + +/** + * A listing that came back at the page ceiling, and what a caller may conclude from it. + * + * THE TWO LISTINGS HERE CARRY A CURSOR AND THE CATALOGUE DOES NOT, which is why they are answered + * differently from the fragment refusal next door. `AuthConfigListParamsSchema` and + * `ConnectedAccountListParamsSchema` both name a `cursor` (`@composio/core` 0.18.1, + * `src/types/authConfigs.types.ts:124-131` and `src/types/connectedAccounts.types.ts:259-266`), + * both models forward it (`src/models/AuthConfigs.ts:95`, `src/models/ConnectedAccounts.ts:118`), + * and both transformers fill `nextCursor` in from the response + * (`src/utils/transformers/authConfigs.ts:80`, `connectedAccounts.ts:116`). The toolkit listing has + * none of that — its response is a bare array with the cursor dropped before any caller sees it — + * so there the only honest answer is to refuse, and here it is to go and read the rest. + * + * WHAT IS ACTUALLY BEING PROTECTED IS `revoke`'s `true`. It means "this person's access has ended", + * and `store.ts` writes that into the audit trail and then deletes the one row naming which app they + * had connected. One page of their accounts is not the set of their accounts, so the assertions + * below are on WHAT WENT OUT — every account, from every page — rather than on what came back: an + * implementation that read one page answers `true` just as confidently. + */ +describe("a listing that arrived with a cursor still outstanding", () => { + /** The statuses the revoke asks about, spelled once for the two queries asserted below. */ + const REVOCABLE_STATUSES = [ + "INITIALIZING", + "INITIATED", + "ACTIVE", + "FAILED", + "EXPIRED", + "INACTIVE", + ]; + + test("every page of this person's accounts is read, and every account on them withdrawn", async () => { + const asked: unknown[] = []; + const deleted: string[] = []; + const { broker } = buildComposioClient( + fakeVendor({ + authConfigs: { list: ourGmailConfig }, + connectedAccounts: { + list: async (query: unknown) => { + asked.push(query); + return (query as { cursor?: string }).cursor === undefined + ? { items: [{ id: "ca_1" }], nextCursor: "page_2" } + : { items: [{ id: "ca_2" }], nextCursor: null }; + }, + delete: async (id: string) => { + deleted.push(id); + return WITHDRAWN; + }, + }, + }), + ); + + expect(await broker.revoke({ userId: "user_1", toolkit: "gmail" })).toBe( + true, + ); + + // `ca_2` is the whole test. It is on the second page, so a reader that stopped at the first + // deletes `ca_1`, answers `true`, and leaves a live grant behind an audit row saying this + // person's access ended. + expect(deleted).toEqual(["ca_1", "ca_2"]); + // The first request carries no cursor at all, and the second carries the vendor's own word for + // where it left off — which is the half a reader cannot infer from the rows that came back. + expect(asked).toEqual([ + { + userIds: ["user_1"], + toolkitSlugs: ["gmail"], + statuses: REVOCABLE_STATUSES, + accountType: "ALL", + authConfigIds: [OUR_GMAIL.id], + limit: WHOLE_LISTING, + }, + { + userIds: ["user_1"], + toolkitSlugs: ["gmail"], + statuses: REVOCABLE_STATUSES, + accountType: "ALL", + authConfigIds: [OUR_GMAIL.id], + limit: WHOLE_LISTING, + cursor: "page_2", + }, + ]); + }); + + test("an account on a later page still decides whether somebody is connected", async () => { + const { broker } = buildComposioClient( + fakeVendor({ + connectedAccounts: { + list: async (query: unknown) => + (query as { cursor?: string }).cursor === undefined + ? { items: [], nextCursor: "page_2" } + : { items: [{ id: "ca_2" }], nextCursor: null }, + }, + }), + ); + + // A first page that is empty with a cursor still outstanding is exactly the shape that reads as + // "this person has no account", which then tells them to connect an app they already hold — and + // tells the gate in `./access` that they may not act through one they can. + expect( + await broker.isConnected({ userId: "user_1", toolkit: "gmail" }), + ).toBe(true); + }); + + /** + * A YES/NO QUESTION THAT PAGING MADE FAILABLE, WHICH IS THE OTHER HALF OF THE TEST ABOVE. + * + * Reading every page is what makes `isConnected`'s `false` honest, and it is also what put the + * two cursor refusals and the page ceiling in front of a person whose ACTIVE account had already + * been found. `true` is settled the moment one row arrives — no cursor Composio sends next and no + * fiftieth page can turn it into anything else — so a fault on a page nobody needed was deciding + * the answer to a question nobody still had. And the consequence is not a wasted request: + * `store.ts` DELETES this person's `composio_connections` row on anything other than a `true`, + * and the route turns the refusal into "Composio's answer could not be read" over an account that + * is right there on page one. + * + * THE THREE FAULTS ARE ASSERTED SEPARATELY, because they are three different branches of + * `everyRowOf` and an early stop that closed one of them would be green on a test that only asked + * about another. + */ + for (const { fault, pages } of [ + { + fault: "a cursor that is not a cursor", + pages: () => async () => ({ items: [{ id: "ca_1" }], nextCursor: 7 }), + }, + { + fault: "a cursor that never advances", + pages: () => async () => ({ + items: [{ id: "ca_1" }], + nextCursor: "page_2", + }), + }, + { + fault: "a cursor that advances for ever", + pages: () => { + let page = 0; + return async () => ({ + items: [{ id: `ca_${++page}` }], + nextCursor: `page_${page + 1}`, + }); + }, + }, + ]) { + test(`${fault} cannot unanswer a connection the first page proved`, async () => { + let asked = 0; + const page = pages(); + const { broker } = buildComposioClient( + fakeVendor({ + connectedAccounts: { + list: async () => { + asked += 1; + return page(); + }, + }, + }), + () => 1_000_000, + ); + + expect( + await broker.isConnected({ userId: "user_1", toolkit: "gmail" }), + ).toBe(true); + // One request, because the first answer settled it. Counted rather than left implicit: an + // implementation that read on and happened not to throw would answer `true` as well, and the + // whole point is that the later pages are never reached. + expect(asked).toBe(1); + }); + } + + /** + * AND THE `false` IS STILL NOT ALLOWED TO BE A GUESS, which is what stops the fix above from + * collapsing into "read one page and answer". + * + * With no row in hand the question is genuinely unsettled, so a cursor this deployment cannot + * follow means it does not know — and saying `false` there would delete the person's row and tell + * a gate they may not act through an app they hold. The refusal is the honest answer, and it is + * the one an early stop is most likely to take away by accident. + */ + test("a cursor fault before any account has been seen is still a refusal", async () => { + const { broker } = buildComposioClient( + fakeVendor({ + connectedAccounts: { + list: async () => ({ items: [], nextCursor: 7 }), + }, + }), + () => 1_000_000, + ); + + const failure = await failureOf( + broker.isConnected({ userId: "user_1", toolkit: "gmail" }), + ); + expect(failure).toBeInstanceOf(BrokerRefusalError); + expect(failure.message).not.toMatch(A_CRASH); + }); + + test("every page of this app's configs is read, so none is left standing", async () => { + const asked: unknown[] = []; + const deleted: string[] = []; + const { broker } = buildComposioClient( + fakeVendor({ + authConfigs: { + list: async (query: unknown) => { + asked.push(query); + return (query as { cursor?: string }).cursor === undefined + ? { items: [OURS], nextCursor: "page_2" } + : { items: [OURS_SPARE], nextCursor: null }; + }, + delete: async (id: string) => { + deleted.push(id); + }, + }, + }), + ); + + await broker.deleteAuthConfig("linear"); + + // The spare from a lost enable race is on page two. Left behind, it is a config the removal was + // supposed to drop, holding every grant made against it, with the app's row deleted after this + // returns and nothing left in this deployment pointing at it. + expect(deleted).toEqual(["ac_ours", "ac_ours_spare"]); + expect(asked).toEqual([ + { toolkit: "linear", limit: WHOLE_LISTING, showDisabled: true }, + { + toolkit: "linear", + limit: WHOLE_LISTING, + showDisabled: true, + cursor: "page_2", + }, + ]); + }); + + test("a config of ours on a later page is the one a connection is begun against", async () => { + const linked: unknown[] = []; + const { broker } = buildComposioClient( + fakeVendor({ + authConfigs: { + list: async (query: unknown) => + (query as { cursor?: string }).cursor === undefined + ? { items: [BY_HAND], nextCursor: "page_2" } + : { items: [OURS], nextCursor: null }, + }, + connectedAccounts: { + link: async (...call: unknown[]) => { + linked.push(call); + return { redirectUrl: "https://backend.composio.dev/s/a-link" }; + }, + }, + }), + ); + + await broker.authorize({ + userId: "user_1", + toolkit: "linear", + returnUrl: RETURN_URL, + }); + + // Reading one page here answers "this deployment has no config for linear" and sends an + // administrator to remove and re-add an app whose config is sitting on page two. + expect(linked).toEqual([ + ["user_1", "ac_ours", { callbackUrl: RETURN_URL }], + ]); + }); + + test("a cursor that is not a cursor is refused rather than read as the end of the list", async () => { + let calls = 0; + const deleted: string[] = []; + const { broker } = buildComposioClient( + fakeVendor({ + authConfigs: { list: ourGmailConfig }, + connectedAccounts: { + list: async () => { + calls += 1; + return { items: [{ id: "ca_1" }], nextCursor: 42 }; + }, + delete: async (id: string) => { + deleted.push(id); + return WITHDRAWN; + }, + }, + }), + ); + + const refusal = await failureOf( + broker.revoke({ userId: "user_1", toolkit: "gmail" }), + ); + + // Read as absent, a cursor this deployment cannot follow is a truncated page wearing the + // clothes of a complete answer — which is the one thing this guard exists to make impossible. + expect(refusal).toBeInstanceOf(BrokerRefusalError); + expect(refusal.message).not.toMatch(A_CRASH); + expect(deleted).toEqual([]); + + /* + * THE REFUSAL HAS TO BE THIS ONE AND NOT THE LOOP GUARD NEXT DOOR, which is what these two + * assertions are for and what a mutation run proved they had to be. Coerce the unreadable + * cursor to "" and the paging sends a SECOND request carrying it, gets the same page back, and + * refuses — with a sentence saying Composio answered the same page twice, which is a complaint + * about the vendor for something this deployment did. One request went out, and the sentence + * names what arrived where a cursor belongs. + */ + expect(calls).toBe(1); + expect(refusal.message).toMatch(/where the cursor to the next page/); + }); + + /** + * A CURSOR WITH NOTHING IN IT IS THE END OF THE LISTING, NOT A REFUSAL — AND THE VENDOR'S OWN + * TYPES PERMIT IT. + * + * All four list responses in the installed client declare `next_cursor?: string | null` + * (`@composio/client` 0.1.0-alpha.76, `resources/auth-configs.d.ts:248`, + * `connected-accounts.d.ts:4987`, `toolkits.d.ts:326`, `tools.d.ts:204`), so `""` is type-legal + * on the wire; both transformers write `response.next_cursor ?? null`, which does not catch it; + * and it therefore reached the guard and became a hard refusal. What that refusal takes down is + * not one call: `revoke`, `authorize`, `ensureAuthConfig`, `deleteAuthConfig` and `isConnected` + * all read one of these two listings, so every app in the deployment stops working at once, for + * as long as the vendor sends it — over a field whose whole content is that it has none. + * + * AND THERE IS NO SECOND REQUEST IT COULD HAVE MEANT. An empty cursor is exactly what this loop + * sends when it has no position: the first request omits the field. Following it asks for page + * one again, which is why the alternative reading ends in the loop guard next door accusing + * Composio of answering the same page twice — a complaint about the vendor for something this + * deployment did. + * + * THE ASSERTION IS ON WHAT WENT OUT AS WELL AS ON WHAT CAME BACK. An implementation that read the + * empty cursor as the end and ALSO sent a second request would answer identically here without + * the call count, and it is the second request that is the defect. + */ + for (const { shape, cursor } of [ + { shape: "an empty string", cursor: "" }, + { shape: "a string of blank space", cursor: " " }, + ]) { + test(`a cursor Composio sent as ${shape} ends the listing rather than refusing it`, async () => { + let calls = 0; + const deleted: string[] = []; + const { broker } = buildComposioClient( + fakeVendor({ + authConfigs: { list: ourGmailConfig }, + connectedAccounts: { + list: async () => { + calls += 1; + return { items: [{ id: "ca_1" }], nextCursor: cursor }; + }, + delete: async (id: string) => { + deleted.push(id); + return WITHDRAWN; + }, + }, + }), + ); + + expect(await broker.revoke({ userId: "user_1", toolkit: "gmail" })).toBe( + true, + ); + expect(deleted).toEqual(["ca_1"]); + expect(calls).toBe(1); + }); + } + + /** + * AND THE SAME ON THE CONFIG LISTING, WHICH IS THE HALF THIS FILE KEEPS FORGETTING. + * + * One cursor guard serves both listings, so a fix written against the accounts path is a fix + * everywhere — and a test written only against the accounts path is a test that cannot tell the + * difference. Removing an app reads this listing and nothing else, so an empty cursor refused + * here is an app nobody can withdraw. + */ + test("an empty cursor ends the config listing too, so the app can still be removed", async () => { + let calls = 0; + const deleted: string[] = []; + const { broker } = buildComposioClient( + fakeVendor({ + authConfigs: { + list: async () => { + calls += 1; + return { items: [OURS], nextCursor: "" }; + }, + delete: async (id: string) => { + deleted.push(id); + }, + }, + }), + ); + + await broker.deleteAuthConfig("linear"); + + expect(deleted).toEqual(["ac_ours"]); + expect(calls).toBe(1); + }); + + test("a cursor that never advances is refused rather than followed for ever", async () => { + let calls = 0; + const deleted: string[] = []; + const { broker } = buildComposioClient( + fakeVendor({ + authConfigs: { list: ourGmailConfig }, + connectedAccounts: { + list: async () => { + calls += 1; + if (calls > 20) throw new Error("The paging did not terminate."); + return { items: [{ id: "ca_1" }], nextCursor: "page_2" }; + }, + // The delete ANSWERS rather than refusing, which is what makes this test able to fail: a + // reader that follows no cursor withdraws `ca_1`, reports a completed disconnection, and + // would satisfy any assertion that only asked for a refusal of some kind. + delete: async (id: string) => { + deleted.push(id); + return WITHDRAWN; + }, + }, + }), + ); + + const refusal = await failureOf( + broker.revoke({ userId: "user_1", toolkit: "gmail" }), + ); + + // A vendor answering the same cursor for ever is a hung request rather than a long one, and the + // caller here is a person waiting on a page they pressed disconnect from. + expect(refusal).toBeInstanceOf(BrokerRefusalError); + expect(refusal.message).not.toMatch(A_CRASH); + expect(calls).toBeLessThanOrEqual(3); + expect(deleted).toEqual([]); + }); + + test("a cursor that advances for ever is stopped at a ceiling, and stopping is a refusal", async () => { + let calls = 0; + const deleted: string[] = []; + const { broker } = buildComposioClient( + fakeVendor({ + authConfigs: { list: ourGmailConfig }, + connectedAccounts: { + list: async () => { + calls += 1; + // A cursor that is different every time defeats the same-page guard above, so this is + // the one shape only the ceiling catches. The throw is the test's own stop: without a + // ceiling in the adapter this listing has no end at all. + if (calls > 200) throw new Error("The paging did not terminate."); + // One row a page, so the row count in the refusal is a figure somebody counted rather + // than the page size this deployment asked for. + return { + items: [{ id: `ca_${calls}` }], + nextCursor: `page_${calls}`, + }; + }, + delete: async (id: string) => { + deleted.push(id); + return WITHDRAWN; + }, + }, + }), + ); + + const refusal = await failureOf( + broker.revoke({ userId: "user_1", toolkit: "gmail" }), + ); + + // Stopping is the easy half; the half that matters is that stopping is not answering. A ceiling + // that returned the rows it had would be the page ceiling again, further out and harder to see. + expect(refusal).toBeInstanceOf(BrokerRefusalError); + expect(deleted).toEqual([]); + + // The number of pages that happened, and the number the sentence states, are the same number. + expect(calls).toBe(PAGES_BEFORE_REFUSING); + expect(refusal.message).toMatch( + new RegExp(`answered ${PAGES_BEFORE_REFUSING} pages`), + ); + + // And the sentence asserts no page size nobody measured. `at 1000 rows each` was the limit this + // deployment ASKED for, stated as a fact about what Composio sent — these pages carry one row. + expect(refusal.message).not.toMatch(new RegExp(`${WHOLE_LISTING} rows`)); + expect(refusal.message).toMatch( + new RegExp(`${PAGES_BEFORE_REFUSING} rows in all`), + ); + }); +}); + +/** + * The two vendor answers that are single objects, and what is actually in doubt about each. + * + * THE CONTAINER IS NOT, WHICH IS A CORRECTION TO WHAT THESE USED TO ASSERT. Both answers were once + * fed a bare `null` on the argument that a single object read straight off an `await` has a declared + * type that only looks settled. Running `@composio/core` 0.18.1 settles it for real, on both paths + * and for different reasons: `getRawComposioToolBySlug` ends in a throwing `ToolSchema.parse` + * (`src/models/Tools.ts:719`), and `connectedAccounts.link` builds its answer with + * `createConnectionRequest(...)` inside a try that turns everything else into + * `ComposioFailedToCreateConnectedAccountLink` (`src/models/ConnectedAccounts.ts:420-453`). So + * neither can hand over something that is not an object, and the refusals that stood for that were + * branches no answer could reach. + * + * WHAT IS IN DOUBT IS EACH ONE'S ONE COPIED FIELD. The tool's `toolkit.slug` is a required string + * that `z.string()` lets be empty, and the request's `redirectUrl` is `response.redirect_url` + * carried across untouched by a builder that validates nothing. Those are what these tests are + * about now, and both are shapes Composio can actually send. + */ +describe("a vendor answer that is one object rather than a listing", () => { + /** The call `execute` is made with in this section, which is a mismatch test's whole setup. */ + const GMAIL_CALL = { + slug: "GMAIL_FETCH_EMAILS", + toolkit: "gmail", + userId: "user_1", + version: "20260903_00", + }; + + /** + * A RESOLVE THAT FAILED IS A CALL THAT MUST NOT RUN, WHICH IS THE HALF WORTH KEEPING. + * + * This test used to hand the resolve a bare `null` and assert that reading a field off it was + * refused rather than crashed. `@composio/core` 0.18.1 cannot answer that: `getRawComposioToolBySlug` + * ends in `transformToolCases`, whose last act is a throwing `ToolSchema.parse` + * (`src/models/Tools.ts:719`, `:193`), so a null answer raises `TypeError: null is not an object + * (evaluating 'tool.input_parameters')` and anything else that is not a tool raises a `ZodError` — + * neither of which reaches a reader in the adapter. The guard it covered is gone; the property + * underneath it is not, and this is that property asked of an input the vendor can actually + * produce. + */ + test("a resolve the SDK could not read refuses, and nothing is run", async () => { + const ran: unknown[] = []; + const { actions } = buildComposioClient( + fakeVendor({ + tools: { + getRawComposioToolBySlug: async () => { + throw new TypeError( + "null is not an object (evaluating 'tool.input_parameters')", + ); + }, + execute: async (...call: unknown[]) => { + ran.push(call); + return { successful: true, data: {} }; + }, + }, + }), + ); + + const refusal = await failureOf(actions.execute(GMAIL_CALL, {})); + + // `./composio` puts whatever comes out of here into a model's context and an audit row, so the + // crash is carried rather than quoted and the sentence names the one act that changes anything. + expect(refusal.message).not.toMatch(A_CRASH); + expect(refusal.message).toMatch(/GMAIL_FETCH_EMAILS/); + expect(refusal.message).toMatch(/@composio\/core/); + expect(ran).toEqual([]); + }); + + test("an app the vendor named with nothing is not reported as no app at all", async () => { + const ran: unknown[] = []; + const { actions } = buildComposioClient( + fakeVendor({ + tools: { + getRawComposioToolBySlug: async () => ({ + slug: "GMAIL_FETCH_EMAILS", + name: "Fetch emails", + // The one toolkit fault a passing `ToolSchema.parse` still admits: `ToolkitSchema` + // spells the slug required and `z.string()` is satisfied by the empty string. A bare + // string where `{ slug }` belongs — what this fixture used to be — raises a `ZodError` + // inside the SDK and never arrives. + toolkit: { slug: "", name: "Gmail" }, + }), + execute: async (...call: unknown[]) => { + ran.push(call); + return { successful: true, data: {} }; + }, + }, + }), + ); + + const refusal = await failureOf(actions.execute(GMAIL_CALL, {})); + + /* + * THE TWO FACTS ARE NOT THE SAME AND THEIR REMEDIES ARE NOT EITHER. "Composio resolves that + * action to no app at all" is a statement about the action, and the sentence carrying it tells + * an administrator to refresh the app's tools — right for a slug recorded against a url that + * has since changed, and useless for an SDK that has begun answering a different shape. The + * toolkit here is PRESENT and its name is blank, which is neither of those. + */ + expect(refusal.message).not.toMatch(A_CRASH); + expect(refusal.message).not.toMatch(/no app at all/); + expect(ran).toEqual([]); + }); + + test("a redirect that is not a url is refused rather than handed to a browser", async () => { + const { broker } = buildComposioClient( + fakeVendor({ + authConfigs: { list: async () => ({ items: [OURS] }) }, + connectedAccounts: { + link: async () => ({ + redirectUrl: { href: "https://backend.composio.dev/s/a-link" }, + }), + }, + }), + ); + + /* + * PRESENT, TRUTHY AND NOT A URL, which is the one shape the `if (!redirectUrl)` guard beside it + * cannot see. What is on the other side of that return is a `Location` header and a person's + * browser, so `[object Object]` would be a page nobody can visit, reported as the consent + * screen they were sent to. + */ + const refusal = await failureOf( + broker.authorize({ + userId: "user_1", + toolkit: "linear", + returnUrl: RETURN_URL, + }), + ); + + expect(refusal).toBeInstanceOf(BrokerRefusalError); + expect(refusal.message).not.toMatch(A_CRASH); + }); +}); + +/** + * The field guards, held to the behaviour each was written for. + * + * EVERY TEST HERE COVERS A GUARD THAT WAS ALREADY IN THE FILE AND HAD NOTHING HOLDING IT. That is + * worse than an untested new behaviour rather than better: a guard nothing reddens for is read by + * the next person as ceremony over a field the SDK's own types already promise, and deleting it + * leaves a green suite. Each one below was therefore checked by removing the guard it covers and + * watching this test fail. + */ +describe("what a malformed field of a row actually costs", () => { + const CATALOGUE_ROWS: { fault: string; row: unknown; names: RegExp }[] = [ + { + fault: "a slug that arrived as null", + // The slug is the only name this deployment has for an app: it is what enabling one writes + // into a url and what every later call names, so a row without one is an app whose Add button + // records something nothing can act on. Deleting this guard left the suite green. + row: { slug: null, name: "Gmail", meta: {} }, + names: /slug/, + }, + { + fault: "a logo that is not an address", + // This value is put in an image address on an administrator's picker. Deleting this guard + // also left the suite green: the object went into `src` and the page showed a broken image. + row: { + slug: "gmail", + name: "Gmail", + meta: { logo: { url: "https://example.test/gmail.png" } }, + }, + names: /logo/, + }, + { + fault: "a name that arrived as null", + // `?? ""` here is an app in an administrator's picker with nothing written on it, and `gmail` + // is a slug rather than a title. + row: { slug: "gmail", name: null, meta: {} }, + names: /name/, + }, + { + fault: "a description that is not text", + row: { slug: "gmail", name: "Gmail", meta: { description: 12 } }, + names: /description/, + }, + { + fault: "a category with no name", + // The categories are the words a person chooses an app by, so a blank one is a filter nobody + // can use rather than a cosmetic gap. + row: { + slug: "gmail", + name: "Gmail", + meta: { categories: [{ id: "productivity" }] }, + }, + names: /categor/, + }, + { + fault: "an action count that arrived as a string", + // `Number("63")` is the defect this whole sweep is about: a vendor change turned into a + // plausible figure, shown BEFORE anybody enables an app, that nobody would think to question. + row: { slug: "gmail", name: "Gmail", meta: { tools_count: "63" } }, + names: /count/, + }, + ]; + + for (const { fault, row, names } of CATALOGUE_ROWS) { + test(`a catalogue row with ${fault} stops the directory`, async () => { + const { broker } = buildComposioClient( + fakeVendor({ toolkits: { list: async () => ({ items: [row] }) } }), + () => 1_000_000, + ); + + const refusal = await failureOf(broker.listApps()); + + expect(refusal).toBeInstanceOf(BrokerRefusalError); + expect(refusal.message).not.toMatch(A_CRASH); + expect(refusal.message).toMatch(names); + }); + } + + /** + * THE ACTION ROW HAS ONE FAULT LEFT, AND FOUR TESTS HERE WERE ABOUT SHAPES IT CANNOT HAVE. + * + * A description that is not text, an `inputParameters` that is a string of JSON, tags that are + * not all labels and a version that is a number each had a row in a table beside this one, and + * each was answered by a refusal in the adapter. Running `@composio/core` 0.18.1 shows all four + * dying one layer earlier: `transformToolCases` ends in `ToolSchema.parse(...)` — a throwing + * parse, not the warn-only `transform()` the other three listings go through + * (`src/models/Tools.ts:193`) — and both calls this adapter makes run through it (`:561`, `:719`). + * Every one of those four fixtures produces a `ZodError` from inside the SDK, which `./composio`'s + * `isSchemaMismatch` already recognises and answers with the same package remedy. So the tests + * were green over guards nothing could reach, which is the worst of the three states: a reader + * takes both the guard and the test as proof the path is watched. + * + * WHAT `ToolSchema` LEAVES OPEN IS THE ONE BELOW. `slug: z.string()` is satisfied by the empty + * string, and this slug is not a label: it becomes `mcp_tools.name`, which is NOT NULL and half + * that table's primary key, it is what a grant points at, and it is what a later call sends back + * to Composio. That is the fault this listing still has to refuse, and it is the only one. + */ + test("an action whose slug is blank stops the listing rather than being written", async () => { + const { actions } = buildComposioClient( + fakeVendor({ + tools: { + list: async () => ({ + items: [ + { slug: "GMAIL_FETCH_EMAILS", name: "Fetch emails" }, + { slug: " ", name: "Send mail" }, + ], + }), + }, + }), + ); + + const refusal = await failureOf( + actions.listActions("gmail", { limit: WHOLE_LISTING }), + ); + + expect(refusal.message).not.toMatch(A_CRASH); + expect(refusal.message).toMatch(/slug/); + // Which row, because an administrator reading this off a Plugins page has a listing of sixty + // actions and no other way to tell which of them Composio named with nothing. + expect(refusal.message).toMatch(/row 2/); + // And what it cost them, which is nothing: a refusal here leaves the actions already recorded + // for the app in place rather than replacing them with a listing one row short. + expect(refusal.message).toMatch(/tools already held are untouched/); + }); + + /** + * A CATEGORY THAT IS NOT A CATEGORY IS DESCRIBED AS WHAT ARRIVED, NOT AS A MISSING NAME. + * + * The non-object guard here was deleted on the stated ground that the SDK dereferences a category + * and dies before this file sees one. Running `@composio/core` 0.18.1 says otherwise: `("crm").id` + * is `undefined` and not a throw, so a string, a number or a boolean in that list survives + * `transformToolkitListResponse` — only null and undefined die there. What the refusal then said + * was "Composio sent nothing where the name of gmail's category 1 belongs", about a value that + * was the string "crm", which sends an operator looking in a dashboard for a category with a + * missing name. There is no such category. + * + * THE ASSERTION IS ON THE TWO SENTENCES BEING DIFFERENT, because a test that only required a + * refusal was green over the whole defect: the catalogue was refused either way, and what was + * wrong was what the operator was told. + */ + for (const { fault, entry, names } of [ + { fault: "a string", entry: "crm", names: /a string/ }, + { fault: "a number", entry: 7, names: /a number/ }, + ]) { + test(`a category that arrived as ${fault} is named as one rather than as a missing name`, async () => { + const { broker } = buildComposioClient( + fakeVendor({ + toolkits: { + list: async () => ({ + items: [ + { slug: "gmail", name: "Gmail", meta: { categories: [entry] } }, + ], + }), + }, + }), + () => 1_000_000, + ); + + const refusal = await failureOf(broker.listApps()); + + expect(refusal).toBeInstanceOf(BrokerRefusalError); + expect(refusal.message).not.toMatch(A_CRASH); + expect(refusal.message).toMatch(/gmail's category 1/); + expect(refusal.message).toMatch(names); + // And NOT the sentence for a category object whose name Composio omitted, which is the other + // fault and the other thing to go looking at. + expect(refusal.message).not.toMatch(/Composio sent nothing/); + }); + } + + /** + * A BLANK IDENTIFIER IS DESCRIBED AS BLANK RATHER THAN AS "A STRING". + * + * {@link textOf} decides emptiness on the TRIMMED value and `sent` tested `value === ""`, so the + * two disagreed about exactly one shape — the padded blank, which is the one a wire value + * actually arrives in. A config id of three spaces was refused for being empty and then described + * as "a string", which is a sentence with no finding in it: a string is what an id IS, so the + * reader is told the field was right and the call refused anyway. + */ + test("a config id that is nothing but spaces is not reported as a string", async () => { + const { broker } = buildComposioClient( + fakeVendor({ + authConfigs: { + list: async () => ({ + items: [{ id: " ", name: "Linear (OpenBot)", status: "ENABLED" }], + }), + }, + }), + ); + + const refusal = await failureOf(broker.deleteAuthConfig("linear")); + expect(refusal).toBeInstanceOf(BrokerRefusalError); + expect((refusal.cause as Error).message).toMatch(/blank space/); + expect((refusal.cause as Error).message).not.toMatch( + /Composio sent a string where/, + ); + }); + + /** + * THE THREE ACTION FIELDS THIS FILE HANDS ON, AND WHAT EACH BECOMES WHERE IT LANDS. + * + * The four deleted guards next door were deleted correctly — `ToolSchema.parse` really does raise + * a `ZodError` for them — and the argument was then taken one step too far, past the fields + * {@link ComposioAction} promises to `./composio` and `./store`. The parse is a fact about + * `getRawComposioTools` in one version of one package; the declaration is what the seam a test + * satisfies with a literal, and what the next version is read against, actually rest on. + * + * WHAT THAT COSTS IS NOT A REFUSAL, WHICH IS WHY IT BELONGS HERE. `storableTools` writes + * `(tool.description ?? "").replaceAll(NUL, "")` and `tool.version?.replaceAll(NUL, "")`, so a + * description or a version that is not a string is a bare "42.replaceAll is not a function" + * thrown from outside every vendor `try` in the adapter — a crash where this file's whole + * contract is a sentence. An `inputParameters` that is not an object is quieter: it is stored as + * the action's input schema and shown to a model as Composio's own. + * + * EACH ASSERTS THE FIELD IT IS ABOUT, because a table of refusals that only required a refusal + * would pass with one guard standing in for three. + */ + for (const { fault, row, names } of [ + { + fault: "a description that is not text", + row: { slug: "GMAIL_FETCH_EMAILS", description: 42 }, + names: /description/, + }, + { + fault: "an input schema that is not an object", + row: { slug: "GMAIL_FETCH_EMAILS", input_parameters: "not-a-schema" }, + names: /input schema/, + }, + { + fault: "a version that is not text", + row: { slug: "GMAIL_FETCH_EMAILS", version: 20_260_903 }, + names: /version/, + }, + ]) { + test(`an action with ${fault} stops the listing rather than crossing the seam`, async () => { + const { actions } = buildComposioClient( + fakeVendor({ tools: { list: async () => ({ items: [row] }) } }), + ); + + const failure = await failureOf( + actions.listActions("gmail", { limit: WHOLE_LISTING }), + ); + + expect(failure.message).not.toMatch(A_CRASH); + expect(failure.message).toMatch(names); + // The tools already recorded for the app are untouched, which is what makes refusing the + // right answer rather than a worse outage than the one being avoided. + expect(failure.message).toMatch(/tools already held are untouched/); + }); + } + + /** + * AND AN ACTION THAT PUBLISHES NONE OF THEM IS ORDINARY. Composio genuinely ships actions with no + * description and no version, and one with no parameters at all arrives with none — the SDK + * normalizes `{}` to absent before parsing. Absence is a fact about the action; present-and-wrong + * is a fact about the answer. A guard that could not tell them apart would refuse most of the + * catalogue. + */ + test("an action that publishes no description, schema or version is still listed", async () => { + const { actions } = buildComposioClient( + fakeVendor({ + tools: { + list: async () => ({ items: [{ slug: "GMAIL_FETCH_EMAILS" }] }), + }, + }), + ); + + expect( + await actions.listActions("gmail", { limit: WHOLE_LISTING }), + ).toEqual([ + { + slug: "GMAIL_FETCH_EMAILS", + description: undefined, + inputParameters: undefined, + tags: undefined, + version: undefined, + }, + ]); + }); + + test("a nameless config stops the removal rather than being left standing", async () => { + const deleted: unknown[] = []; + const { broker } = buildComposioClient( + fakeVendor({ + authConfigs: { + list: async () => ({ + items: [OURS, { id: "ac_nameless", status: "ENABLED" }], + }), + delete: async (...call: unknown[]) => { + deleted.push(call); + }, + }, + }), + ); + + /* + * WITHOUT THE GUARD THIS IS A REPORTED SUCCESS. A name read as "" fails the suffix test, so the + * row is sorted into somebody else's dashboard work and left standing — and `deleteAuthConfig` + * returns quietly, after which `removeServer` deletes the app's row. The config and every grant + * made against it outlive the removal with nothing in this deployment naming them. + */ + const refusal = await failureOf(broker.deleteAuthConfig("linear")); + expect(refusal).toBeInstanceOf(BrokerRefusalError); + expect(refusal.message).toMatch(/name/); + /* + * AND THE CONFIG THAT WAS READABLE IS GONE, WHICH THIS ASSERTED THE OPPOSITE OF ON PURPOSE AND + * IS CHANGED ON PURPOSE. It required `[]` — not even the row that WAS readable — on the ground + * that a half-done removal reported as done is the state being avoided. The first half of that + * is right and the second half does not describe this: the call still refuses, so `removeServer` + * never deletes the app's row and nothing is reported as done. What the old shape actually + * bought was a permanent block. The unreadable row is unreadable on every retry, so the app + * could never be removed at all while a config of ours held live grants the whole time — which + * is the defect the accounts path was corrected for one wave earlier, sitting one function + * away. Every config this deployment CAN name goes, and the row it cannot is what the refusal + * counts. + */ + expect(deleted).toEqual([["ac_ours", { revoke_on_delete: true }]]); + // And the remedy for the row that is left is the dashboard rather than the button just pressed, + // because the next press meets exactly the same unreadable row. + expect(refusal.message).toMatch(/Composio's own dashboard/); + }); + + test("a config with no id stops the removal, and no delete is sent", async () => { + const deleted: unknown[] = []; + const { broker } = buildComposioClient( + fakeVendor({ + authConfigs: { + list: async () => ({ + items: [{ name: "Linear (OpenBot)", status: "ENABLED" }], + }), + delete: async (...call: unknown[]) => { + deleted.push(call); + }, + }, + }), + ); + + /* + * A DELETE WITHOUT AN ID IS A REQUEST COMPOSIO IS FREE TO READ AS ANYTHING, answered however it + * likes, after which this deployment records that the app was withdrawn. The assertion is on + * what went out rather than on what came back for exactly that reason. + */ + const refusal = await failureOf(broker.deleteAuthConfig("linear")); + expect(refusal).toBeInstanceOf(BrokerRefusalError); + expect(refusal.message).toMatch(/id/); + expect(deleted).toEqual([]); + }); + + test("an unreadable status names every config's own, not the first one's for all of them", async () => { + const { broker } = buildComposioClient( + fakeVendor({ + authConfigs: { + list: async () => ({ + items: [ + { id: "ac_a", name: "Linear (OpenBot)", status: "PENDING" }, + { id: "ac_b", name: "Linear (OpenBot)", status: "SUSPENDED" }, + ], + }), + }, + }), + () => 1_000_000, + ); + + const refusal = await failureOf( + broker.authorize({ + userId: "user_1", + toolkit: "linear", + returnUrl: RETURN_URL, + }), + ); + + /* + * TWO CONFIGS, TWO STATUSES, AND THE SENTENCE COUNTED BOTH AND QUOTED ONE — asserting of the + * pair a fact it had established of the first. The status is quoted because it is the one + * thing an operator can search a dashboard and a changelog for, and an operator given PENDING + * would never find the config that says SUSPENDED. + */ + expect(refusal.message).toMatch(/"PENDING"/); + expect(refusal.message).toMatch(/"SUSPENDED"/); + expect(refusal.message).toMatch(/2 of this deployment's 2/); + }); + + test("a status that is not a vendor enum name is described rather than repeated", async () => { + /* + * THIS BRANCH IS REACHED PRECISELY BECAUSE THE VALUE IS NOT ONE OF THE WORDS THE CODE EXPECTS, + * so "it is a closed set of enum names" — the whole argument for quoting it — is the one thing + * that cannot be assumed here. What arrives is whatever came off the wire, and the refusal it + * lands in is read off an admin page, written into an app's `lastError` and put in front of a + * model. + */ + const WIRE = `\n502 Bad Gateway\n${"x".repeat(20_000)}`; + const { broker } = buildComposioClient( + fakeVendor({ + authConfigs: { + list: async () => ({ + items: [{ id: "ac_a", name: "Linear (OpenBot)", status: WIRE }], + }), + }, + }), + () => 1_000_000, + ); + + const refusal = await failureOf( + broker.authorize({ + userId: "user_1", + toolkit: "linear", + returnUrl: RETURN_URL, + }), + ); + + expect(refusal.message).not.toContain("502 Bad Gateway"); + expect(refusal.message).not.toContain("x".repeat(64)); + expect(refusal.message.length).toBeLessThan(1000); + // Still a refusal, and still one naming the state: what the value IS remains the finding even + // where the value itself is not safe to repeat. + expect(refusal.message).toMatch(/neither ENABLED nor DISABLED/); + expect(refusal.message).toMatch(/a string/); + }); + + test("the config a connection is begun against is chosen in one order everywhere", async () => { + const linked: unknown[] = []; + const { broker } = buildComposioClient( + fakeVendor({ + authConfigs: { + list: async () => ({ + items: [ + { id: "ac_a", name: "Linear (OpenBot)", status: "ENABLED" }, + { id: "ac_B", name: "Linear (OpenBot)", status: "ENABLED" }, + ], + }), + }, + connectedAccounts: { + link: async (...call: unknown[]) => { + linked.push(call); + return { redirectUrl: "https://backend.composio.dev/s/a-link" }; + }, + }, + }), + () => 1_000_000, + ); + + await broker.authorize({ + userId: "user_1", + toolkit: "linear", + returnUrl: RETURN_URL, + }); + + /* + * `ac_B` BECAUSE "B" IS 0x42 AND "a" IS 0x61, which is the same answer on every machine. Under + * `localeCompare` with no locale it is the HOST that decides — an English collation puts + * `ac_a` first — and the two callers this order exists to keep in step are a person pressing + * Connect and an administrator pressing Remove, who need not be answered by the same process, + * container or build of ICU. This pair is the one that tells the two orders apart. + */ + expect(linked).toEqual([["user_1", "ac_B", { callbackUrl: RETURN_URL }]]); + }); + + test("a created config the answer does not name is reported as possibly standing", async () => { + const { broker } = buildComposioClient( + fakeVendor({ + authConfigs: { + list: async () => ({ items: [] }), + // The create was accepted; what came back carries no id. The answer used to be awaited + // and dropped, so this was a successful enable of an app whose config nothing here could + // show existed. + create: async () => ({}), + }, + }), + () => 1_000_000, + ); + + const refusal = await failureOf( + broker.ensureAuthConfig({ + toolkit: "linear", + name: "Linear", + connection: { kind: "consent" }, + }), + ); + + expect(refusal).toBeInstanceOf(BrokerRefusalError); + expect(refusal.message).not.toMatch(A_CRASH); + expect(refusal.message).toMatch(/may well be standing/); + expect(refusal.message).toMatch(/enabling linear again finds it/); + }); + + test("a create whose reply the SDK could not read does not claim nothing was created", async () => { + const { broker } = buildComposioClient( + fakeVendor({ + authConfigs: { + list: async () => ({ items: [] }), + /* + * What `transformCreateAuthConfigResponse` does to an answer with no `auth_config`: it + * reads `response.auth_config.id` (`@composio/core` 0.18.1, + * `src/utils/transformers/authConfigs.ts:96-106`) and raises inside the vendor's own + * package — AFTER the create has been sent and answered. + */ + create: async (): Promise => { + throw new TypeError( + "undefined is not an object (evaluating 'response.auth_config.id')", + ); + }, + }, + }), + () => 1_000_000, + ); + + const refusal = await failureOf( + broker.ensureAuthConfig({ + toolkit: "linear", + name: "Linear", + connection: { kind: "consent" }, + }), + ); + + /* + * THE ONE CONDITION MOST LIKELY TO MEAN THE CONFIG EXISTS WAS THE ONE SAYING IT DID NOT. The + * `TypeError` row translates a fault raised inside `@composio/core` while it READ a reply, so + * by the time this sentence is composed the request has gone out and Composio has answered it + * — and the outcome clause read "no authorization config was created for linear". + */ + expect(refusal).toBeInstanceOf(BrokerRefusalError); + expect(refusal.message).not.toMatch(/no authorization config was created/); + expect(refusal.message).toMatch( + /whether an authorization config for linear now stands at Composio is not something this deployment can tell/, + ); + }); + + const UNSETTLED: { fault: string; status: unknown }[] = [ + { fault: "a status this deployment has never heard of", status: "PENDING" }, + { fault: "no status at all", status: undefined }, + ]; + + for (const { fault, status } of UNSETTLED) { + test(`a config of ours with ${fault} refuses in its own words`, async () => { + const linked: unknown[] = []; + const { broker } = buildComposioClient( + fakeVendor({ + authConfigs: { + list: async () => ({ + items: [{ id: "ac_ours", name: "Linear (OpenBot)", status }], + }), + }, + connectedAccounts: { + link: async (...call: unknown[]) => { + linked.push(call); + return { redirectUrl: "https://backend.composio.dev/s/a-link" }; + }, + }, + }), + ); + + const refusal = await failureOf( + broker.authorize({ + userId: "user_1", + toolkit: "linear", + returnUrl: RETURN_URL, + }), + ); + + /* + * NEITHER OF THE OTHER TWO REMEDIES, which is the whole of what this asserts. Telling an + * operator the config is disabled sends them to a dashboard to enable something that may + * already be enabled, and leaves them with a page insisting on a fact they can see is false; + * telling them there is no config sends them to remove and re-add an app whose config is + * sitting right there. The state is UNKNOWN, and only a sentence saying so is honest. + */ + expect(refusal).toBeInstanceOf(BrokerRefusalError); + expect(refusal.message).not.toMatch(DISABLED_REMEDY); + expect(refusal.message).not.toMatch(NO_CONFIG_REMEDY); + // And still a refusal: consent spent against a config this deployment cannot show is enabled + // attaches nothing, and cannot be spent again without sending the person round a second time. + expect(linked).toEqual([]); + }); + } +}); + +/** + * WHAT A FIELD COMPOSIO PADDED IS WORTH, WHICH IS THE FIELD AND NOT THE FIELD PLUS ITS PADDING. + * + * `textOf` decided emptiness on the TRIMMED string and answered the PADDED one — a guard that + * checked one value and passed along another — so every identifier this adapter reads travelled + * with whatever whitespace the wire wrapped it in. Not one of the four below is cosmetic: three of + * them are what a later REQUEST names, and the fourth is both sides of the one comparison this + * file refuses a call on. + * + * WHY A VENDOR WOULD SEND ONE AT ALL is the same reason `madeHere` tolerates a trailing space in a + * config's name: these values pass through dashboards where people type, paste and edit them. + */ +describe("a field Composio padded with whitespace", () => { + test("a padded slug and title reach the picker as the app's own name", async () => { + const { broker } = buildComposioClient( + fakeVendor({ + toolkits: { + list: async () => ({ + items: [ + { slug: " gmail ", name: " Gmail ", meta: { tools_count: 63 } }, + ], + }), + }, + }), + () => 1_000_000, + ); + + const [app] = await broker.listApps(); + + /* + * THE SLUG IS THE ONE THAT COSTS SOMETHING. `addBrokeredApp` composes `composio://` from + * exactly this value, and `toolkitOf` reads the app back out of that url through a character + * class that admits no spaces — so a padded slug here is an enabled app whose url names no app + * at all, and every later call through it refuses. + */ + expect(app?.slug).toBe("gmail"); + expect(app?.name).toBe("Gmail"); + }); + + test("a padded config id is what the delete names, without the padding", async () => { + const deleted: unknown[] = []; + const { broker } = buildComposioClient( + fakeVendor({ + authConfigs: { + list: async () => ({ + items: [ + { id: " ac_ours ", name: "Linear (OpenBot)", status: "ENABLED" }, + ], + }), + delete: async (...call: unknown[]) => { + deleted.push(call); + }, + }, + }), + ); + + await broker.deleteAuthConfig("linear"); + + // The id is the whole of what a deletion names, and this one was sent verbatim: Composio is + // asked to remove an object with a name nobody holds, and this deployment records a withdrawal. + expect(deleted).toEqual([["ac_ours", { revoke_on_delete: true }]]); + }); + + test("a padded account id is what the withdrawal names", async () => { + const deleted: unknown[] = []; + const { broker } = buildComposioClient( + fakeVendor({ + authConfigs: { list: ourGmailConfig }, + connectedAccounts: { + list: async () => ({ items: [{ id: " ca_1 " }] }), + delete: async (...call: unknown[]) => { + deleted.push(call); + return WITHDRAWN; + }, + }, + }), + ); + + expect(await broker.revoke({ userId: "user_1", toolkit: "gmail" })).toBe( + true, + ); + expect(deleted).toEqual([["ca_1", { revoke_on_delete: true }]]); + }); + + test("a padded action slug is recorded as the action rather than as one nothing can call", async () => { + const { actions } = buildComposioClient( + fakeVendor({ + tools: { + list: async () => ({ + items: [{ slug: " GMAIL_FETCH_EMAILS ", version: "20260903_00" }], + }), + }, + }), + ); + + const [action] = await actions.listActions("gmail", { + limit: WHOLE_LISTING, + }); + + // This becomes `mcp_tools.name`, which is half that table's primary key, what a grant points at + // and what the next call sends back to Composio. + expect(action?.slug).toBe("GMAIL_FETCH_EMAILS"); + }); + + test("a padded app slug on the vendor's answer is not a mismatch", async () => { + const ran: unknown[] = []; + const { actions } = buildComposioClient( + fakeVendor({ + tools: { + getRawComposioToolBySlug: async () => ({ + slug: "GMAIL_FETCH_EMAILS", + toolkit: { slug: " gmail " }, + }), + execute: async (...call: unknown[]) => { + ran.push(call); + return { successful: true, data: {}, error: null }; + }, + }, + }), + ); + + /* + * THE MISMATCH REFUSAL IS THE ONE PLACE THIS FILE REFUSES TO RUN SOMETHING, and it exists for a + * url edited between a refresh and a call. A padded slug is not that: it is the same app, + * refused with a sentence naming a remedy — refresh this app's tools — that cannot change what + * Composio pads. + */ + await actions.execute( + { + toolkit: "gmail", + slug: "GMAIL_FETCH_EMAILS", + userId: "user_1", + version: "20260903_00", + }, + {}, + ); + + expect(ran).toHaveLength(1); + }); +}); + +/** + * WHICH `delete` THE VENDOR OBJECT ACTUALLY CARRIES, asserted without a network and without a key. + * + * THIS IS THE ONE DECISION IN {@link createComposioClient} AND NOTHING REACHED IT. That function is + * described in its own comment as too thin to have a bug in, and it is — except for two lines. + * `Composio`'s own `authConfigs.delete` and `connectedAccounts.delete` hard-code the request body + * they send (`@composio/core` 0.18.1, `src/models/AuthConfigs.ts:303-311`, + * `src/models/ConnectedAccounts.ts:532-540`), so through them `revoke_on_delete` cannot be passed at + * all and every delete soft-deletes while the grant at Google or Slack stands. Both are therefore + * satisfied from `composio.getClient()` instead, and that routing is the whole reason this + * deployment's withdrawals withdraw anything. + * + * EVERY OTHER TEST IN THIS FILE DRIVES {@link buildComposioClient}, which takes the vendor object + * already assembled — so the four that assert `revoke_on_delete` went out assert it about a double, + * and none of them can see which function `createComposioClient` put behind it. Swapping those two + * lines back to `composio.authConfigs.delete` and `composio.connectedAccounts.delete` type-checks + * and leaves this suite green while restoring the exact defect the flag was added for. The only + * thing that ever exercised the real wiring was `composio-live.test.ts`, which is skipped in every + * run that has no key — which is every ordinary run. + * + * WHAT MAKES IT OBSERVABLE OFFLINE IS THAT BOTH CLIENTS ARE CONSTRUCTIBLE WITHOUT DIALLING + * ANYTHING. `new Composio({ apiKey })` opens no socket once tracking and the npm version check are + * off, `getClient()` hands back the underlying `@composio/client` it already holds, and the methods + * of both live on their classes' prototypes. So a prototype replaced BEFORE `createComposioClient` + * runs is what the client it builds will call: the SDK's telemetry wrapper copies each method off + * the prototype at construction (`src/telemetry/Telemetry.ts:95-116`), and the raw client's + * resources carry no own properties at all. Four recorders — one on each side of each delete — turn + * "which function was reached" into a list, which is a fact about the wiring rather than about a + * request that was never made. + * + * THE IMPORT OF `@composio/core` HERE DOES NOT BREAK THE ONE-IMPORT-SITE RULE. That rule is about + * `server/src`, so that a version bump has exactly one FILE of product code to be read against; + * this is a test, and the version it is written against is the whole of what it asserts. + */ +describe("the key becoming a vendor, and which delete that vendor carries", () => { + /** One class's methods, as the object a replacement is written onto. */ + type Methods = Record Promise>; + + const methodsOf = (instance: object): Methods => + Object.getPrototypeOf(instance) as Methods; + + test("both deletes are the raw client's, and neither is the SDK's own wrapper", async () => { + /* + * A SECOND CLIENT, BUILT ONLY TO REACH THE CLASSES. Nothing is called on it: it exists because + * the prototypes are not exported, and the way to a prototype is an instance. The key is a + * string nobody will ever send anywhere, which is the point of asserting this without one. + */ + const seed = new Composio({ + apiKey: "never-dialled", + allowTracking: false, + disableVersionCheck: true, + }); + const sdkAccounts = methodsOf(seed.connectedAccounts); + const sdkConfigs = methodsOf(seed.authConfigs); + const rawAccounts = methodsOf(seed.getClient().connectedAccounts); + const rawConfigs = methodsOf(seed.getClient().authConfigs); + + const restore: { on: Methods; name: string; was: Methods[string] }[] = []; + const replace = (on: Methods, name: string, answer: Methods[string]) => { + restore.push({ on, name, was: on[name] }); + on[name] = answer; + }; + + const reached: string[] = []; + const recorder = + (whose: string): Methods[string] => + async (...call: unknown[]) => { + reached.push(`${whose} ${JSON.stringify(call)}`); + return { success: true }; + }; + + try { + /* + * BOTH SIDES OF BOTH DELETES ARE RECORDED, which is what makes the list an assertion rather + * than a spy. A recorder on the raw client alone would still fire if the SDK's wrapper were + * used, because the wrapper calls straight through to it — so what tells the two wirings + * apart is whose recorder answered, and the only way to see that is to have one on each. + */ + replace( + rawAccounts, + "delete", + recorder("the raw client's connectedAccounts.delete"), + ); + replace( + sdkAccounts, + "delete", + recorder("the SDK's own connectedAccounts.delete"), + ); + replace( + rawConfigs, + "delete", + recorder("the raw client's authConfigs.delete"), + ); + replace( + sdkConfigs, + "delete", + recorder("the SDK's own authConfigs.delete"), + ); + + // The two listings that carry each delete to its argument. Answered from memory, so this test + // reaches the network exactly as often as it reaches the live Composio account: never. + replace(sdkAccounts, "list", async () => ({ + items: [{ id: "ca_1" }], + nextCursor: null, + })); + replace(sdkConfigs, "list", async () => ({ + items: [OUR_GMAIL], + nextCursor: null, + })); + + const { broker } = createComposioClient("never-dialled"); + expect(await broker.revoke({ userId: "user_1", toolkit: "gmail" })).toBe( + true, + ); + await broker.deleteAuthConfig("gmail"); + } finally { + // Restored whatever happened above, because these are the SDK's own classes and every later + // test in this process would otherwise be running against a patched vendor. + for (const { on, name, was } of restore.reverse()) on[name] = was; + } + + /* + * THE WHOLE OF WHAT WAS REACHED, IN ORDER. An implementation that routed either delete through + * the SDK's wrapper puts that wrapper's name in this list, and the equality says so; one that + * dropped the flag puts a different argument list in it. Both are the same defect seen from + * two sides, and neither is visible to any other test in this file. + */ + expect(reached).toEqual([ + `the raw client's connectedAccounts.delete ["ca_1",{"revoke_on_delete":true}]`, + `the raw client's authConfigs.delete ["${OUR_GMAIL.id}",{"revoke_on_delete":true}]`, + ]); + }); +}); + +/** + * THE TWO LISTINGS THAT USED TO REFUSE A FULL PAGE, NOW READ TO THE END OF THEIR CURSORS. + * + * These were the last two places in this file where a request the vendor can answer was described + * as one this deployment cannot express. It can: `@composio/client` 0.1.0-alpha.76 declares + * `cursor` on both `ToolkitListParams` (`resources/toolkits.d.ts:467-478`) and `ToolListParams` + * (`resources/tools.d.ts:421-432`), and `next_cursor` on both responses (`:322-326` and + * `:200-204`). What had no cursor was the WRAPPER around them — `transformToolkitListResponse` + * returns `response.items.map(...)`, a bare array with the cursor dropped, and + * `ToolListParamsSchema` names no cursor field at all — and the two refusals blamed the vendor for + * the wrapper's shape. + * + * IT MATTERED MOST WHERE IT COST MOST. Composio publishes more than {@link WHOLE_LISTING} toolkits, + * so the catalogue refusal fired on the FIRST call every time and an operator opening the app + * picker saw no apps at all — not a truncated directory, none. The tests below are about the rows + * on the SECOND page, because a reader that stops at the first is exactly as wrong as the old + * refusal was and answers far more plausibly. + */ +describe("listings Composio pages, read to the end", () => { + /** One catalogue row, complete, so a test about paging is not also a test about a field. */ + const app = (slug: string) => ({ + slug, + name: slug.toUpperCase(), + meta: { + description: `The ${slug} app.`, + logo: `https://logo.test/${slug}.png`, + categories: [{ id: "productivity", name: "Productivity" }], + tools_count: 3, + }, + }); + + /** One action row as the raw client hands it over, which is snake_case and unparsed. */ + const action = (slug: string) => ({ + slug, + description: `Does ${slug}.`, + input_parameters: { type: "object", properties: {} }, + tags: ["readOnlyHint"], + version: "20260903_00", + }); + + test("an app on the catalogue's second page is an app the picker can find", async () => { + const asked: unknown[] = []; + const { broker } = buildComposioClient( + fakeVendor({ + toolkits: { + list: async (query: unknown) => { + asked.push(query); + return (query as { cursor?: string }).cursor === undefined + ? { items: [app("slack")], next_cursor: "page_2" } + : { items: [app("gmail")], next_cursor: null }; + }, + }, + }), + () => 1_000_000, + ); + + // `gmail` is the whole test, and it is the app the operator waiting on this actually typed. + // Under the old refusal this call answered nothing at all; under a pager that stopped at page + // one it answers an app short and says so nowhere. + expect((await broker.listApps()).map((one) => one.slug)).toEqual([ + "slack", + "gmail", + ]); + // The first request carries no cursor FIELD rather than an undefined one, and the second + // carries the vendor's own word for where it left off. + expect(asked).toEqual([ + { limit: WHOLE_LISTING, sort_by: "usage" }, + { limit: WHOLE_LISTING, sort_by: "usage", cursor: "page_2" }, + ]); + }); + + test("an action on the second page is an action the refresh records", async () => { + const asked: unknown[] = []; + const { actions } = buildComposioClient( + fakeVendor({ + tools: { + list: async (query: unknown) => { + asked.push(query); + return (query as { cursor?: string }).cursor === undefined + ? { items: [action("GMAIL_FETCH_EMAILS")], next_cursor: "page_2" } + : { items: [action("GMAIL_SEND_EMAIL")], next_cursor: null }; + }, + }, + }), + ); + + const listed = await actions.listActions("gmail", { limit: WHOLE_LISTING }); + + expect(listed.map((one) => one.slug)).toEqual([ + "GMAIL_FETCH_EMAILS", + "GMAIL_SEND_EMAIL", + ]); + // `refreshTools` commits a listing as the complete truth about an app — the write is a delete + // and an insert — so an action left on page two is an action DELETED from `mcp_tools` under a + // refresh that reported success, taking every grant pointing at it. + expect(listed[1]).toEqual({ + slug: "GMAIL_SEND_EMAIL", + description: "Does GMAIL_SEND_EMAIL.", + inputParameters: { type: "object", properties: {} }, + tags: ["readOnlyHint"], + version: "20260903_00", + }); + /* + * THE QUERY IS ASSERTED BECAUSE THREE OF ITS FIELDS ARE THINGS THE WRAPPER USED TO DO FOR US. + * `toolkit_versions` is the SDK's own default, forwarded on every listing it made + * (`@composio/core` 0.18.1, `src/models/Tools.ts:548`), and it decides which `version` each + * action comes back with — the value a later call sends back to Composio. `limit` is the page, + * and its absence is what used to let the vendor apply twenty. And `important` is named + * NOWHERE, deliberately: the wrapper set it to "true" whenever a toolkit query gave no limit + * (`:505-515`), which narrows the answer to a featured subset that nothing in the answer + * declares. + */ + expect(asked).toEqual([ + { + toolkit_slug: "gmail", + limit: WHOLE_LISTING, + toolkit_versions: "latest", + }, + { + toolkit_slug: "gmail", + limit: WHOLE_LISTING, + toolkit_versions: "latest", + cursor: "page_2", + }, + ]); + }); + + test("a catalogue cursor this deployment cannot follow refuses rather than truncates", async () => { + const { broker } = buildComposioClient( + fakeVendor({ + toolkits: { + list: async () => ({ items: [app("slack")], next_cursor: 7 }), + }, + }), + () => 1_000_000, + ); + + const refusal = await failureOf(broker.listApps()); + + // A number is a position this deployment cannot express and cannot rule out being real, so it + // is the fault it always was. Coercing it to "7" would be the fragment-read-as-whole mistake + // the pager exists to prevent, wearing a default's clothes. + expect(refusal).toBeInstanceOf(BrokerRefusalError); + expect(refusal.message).not.toMatch(A_CRASH); + expect(refusal.message).toMatch( + /sent a number where the cursor to the next page/, + ); + expect(refusal.message).toMatch(/Composio's app catalogue/); + }); + + test("an action cursor this deployment cannot follow refuses rather than truncates", async () => { + const { actions } = buildComposioClient( + fakeVendor({ + tools: { + list: async () => ({ + items: [action("GMAIL_FETCH_EMAILS")], + next_cursor: { page: 2 }, + }), + }, + }), + ); + + const refusal = await failureOf( + actions.listActions("gmail", { limit: WHOLE_LISTING }), + ); + + expect(refusal.message).not.toMatch(A_CRASH); + expect(refusal.message).toMatch(/an object/); + expect(refusal.message).toMatch(/gmail's actions/); + }); + + test("a catalogue page that is not a page of rows refuses", async () => { + const { broker } = buildComposioClient( + fakeVendor({ + toolkits: { list: async () => ({ items: "gmail" }) }, + }), + () => 1_000_000, + ); + + const refusal = await failureOf(broker.listApps()); + + /* + * THE CONTAINER IS THIS FILE'S TO CHECK NOW, WHICH IT WAS NOT BEFORE. The wrapper dereferenced + * every answer on its way out — `response.items.map(...)` — so a malformed envelope died inside + * the vendor's package and reached a reader as the translated `TypeError`. Reading the raw + * client means nothing dereferences it before this file does, and `rows.push(...items)` over a + * string is "string is not iterable" with nothing in it a person can act on. + */ + expect(refusal).toBeInstanceOf(BrokerRefusalError); + expect(refusal.message).not.toMatch(A_CRASH); + expect(refusal.message).toMatch(/sent a string where the rows/); + expect(refusal.message).toMatch(/Composio's app catalogue/); + }); + + test("an action page that is not a page of rows refuses", async () => { + const { actions } = buildComposioClient( + fakeVendor({ tools: { list: async () => null } }), + ); + + const refusal = await failureOf( + actions.listActions("gmail", { limit: WHOLE_LISTING }), + ); + + expect(refusal.message).not.toMatch(A_CRASH); + expect(refusal.message).toMatch(/gmail's actions/); + }); + + test("the catalogue stops at the page ceiling rather than reading a listing that never ends", async () => { + let pages = 0; + const { broker } = buildComposioClient( + fakeVendor({ + toolkits: { + list: async () => { + pages += 1; + return { + items: [app(`app_${pages}`)], + next_cursor: `page_${pages}`, + }; + }, + }, + }), + () => 1_000_000, + ); + + const refusal = await failureOf(broker.listApps()); + + // The number of pages READ and the number the refusal STATES, both against the literal. + expect(pages).toBe(PAGES_BEFORE_REFUSING); + expect(refusal.message).toMatch( + new RegExp(`answered ${PAGES_BEFORE_REFUSING} pages`), + ); + expect(refusal.message).toMatch(/Composio's app catalogue/); + }); + + test("a catalogue row whose metadata is not an object refuses", async () => { + const { broker } = buildComposioClient( + fakeVendor({ + toolkits: { + list: async () => ({ + items: [{ slug: "gmail", name: "Gmail", meta: "productivity" }], + }), + }, + }), + () => 1_000_000, + ); + + const refusal = await failureOf(broker.listApps()); + + /* + * A GUARANTEE THAT CAME FROM THE WRAPPER AND LEAVES WITH IT. `transformToolkitListResponse` + * built each row's meta itself, spreading it into a fresh literal, so every meta arriving here + * was an object however the wire had spelled it. Nothing does that now, and `meta.description` + * off a string is `undefined` rather than a throw — so the app would have shown no description, + * no logo, no categories and no count, indistinguishable from an app that published none. + */ + expect(refusal).toBeInstanceOf(BrokerRefusalError); + expect(refusal.message).not.toMatch(A_CRASH); + expect(refusal.message).toMatch( + /sent a string where gmail's description, logo, categories and action count belong/, + ); + }); + + test("a catalogue row whose categories are not a list refuses", async () => { + const { broker } = buildComposioClient( + fakeVendor({ + toolkits: { + list: async () => ({ + items: [ + { + slug: "gmail", + name: "Gmail", + meta: { categories: "productivity" }, + }, + ], + }), + }, + }), + () => 1_000_000, + ); + + const refusal = await failureOf(broker.listApps()); + + // The wrapper's `item.meta.categories?.map(...)` died on this one; nothing maps it now, so a + // string would be read as an app that publishes no categories at all. + expect(refusal).toBeInstanceOf(BrokerRefusalError); + expect(refusal.message).not.toMatch(A_CRASH); + expect(refusal.message).toMatch( + /sent a string where gmail's categories belong/, + ); + }); +}); + +/** + * What an app asks a person to type, read off the vendor rather than written down here. + * + * THE FIXTURES ARE MEASURED ANSWERS RATHER THAN INVENTED ONES. The first is `perplexityai`'s + * `API_KEY` mode as Composio publishes it, down to the `legacy_template_name` this deployment does + * not read — because the value of asking the vendor at all is that the form follows what the app + * actually wants, and a fixture composed of the four fields the mapping happens to touch would + * assert that mapping against a shape no app sends. + */ +describe("the fields an app asks a person to fill in", () => { + test("the fields an app wants come back as the app describes them", async () => { + const { broker } = buildComposioClient( + fakeVendor({ + toolkits: { + retrieve: async () => ({ + auth_config_details: [ + { + mode: "API_KEY", + fields: { + connected_account_initiation: { + required: [ + { + name: "generic_api_key", + displayName: "API Key", + description: + "Your secret Perplexity API key, starting with 'pplx-'.", + type: "string", + required: true, + is_secret: true, + user_visible: true, + }, + ], + optional: [], + }, + }, + }, + ], + }), + }, + }), + ); + + expect( + await broker.connectionFields({ + toolkit: "perplexityai", + authScheme: "API_KEY", + }), + ).toEqual([ + { + name: "generic_api_key", + label: "API Key", + help: "Your secret Perplexity API key, starting with 'pplx-'.", + required: true, + secret: true, + }, + ]); + }); + + test("a field of a type this deployment cannot draw is refused rather than drawn blind", async () => { + const { broker } = buildComposioClient( + fakeVendor({ + toolkits: { + retrieve: async () => ({ + auth_config_details: [ + { + mode: "API_KEY", + fields: { + connected_account_initiation: { + required: [ + { + name: "cert", + displayName: "Certificate", + type: "file", + required: true, + is_secret: true, + user_visible: true, + }, + ], + optional: [], + }, + }, + }, + ], + }), + }, + }), + ); + + const refusal = await failureOf( + broker.connectionFields({ toolkit: "mystery", authScheme: "API_KEY" }), + ); + + /* + * A TEXT BOX DRAWN FOR A FILE IS THE FAILURE THIS PREVENTS: somebody types a path into a box + * labelled Certificate, the connection is made, and the first tool call is what discovers it. + * Every required field measured across the catalogue is a plain string, so this is a guard + * against the vendor changing rather than a routine case. + */ + expect(refusal).toBeInstanceOf(BrokerRefusalError); + expect(refusal.message).toMatch(/cannot be filled in here/); + }); + + /** + * "NO SUCH MODE" AND "THIS MODE ASKS FOR NOTHING" ARE TWO ANSWERS, AND THEY USED TO BE ONE `[]`. + * + * The scheme handed in is the RECORDED one and is never re-derived — that is the entire point of + * the column — so a mode Composio has stopped publishing for this app is exactly the drift + * recording it anticipates. As an empty form it is invisible: the person presses submit, a + * connection is created carrying no credential at all, Composio answers `ACTIVE` because it does + * not grade what it is given, and the later probe is the first thing that notices. From their end + * it is a box they cannot fill in. + */ + test("a mode this app no longer publishes is refused rather than drawn as an empty form", async () => { + const { broker } = buildComposioClient( + fakeVendor({ + toolkits: { + // The app publishes OAuth2 today; the row here was enabled as API_KEY and still says so. + retrieve: async () => ({ + auth_config_details: [ + { + mode: "OAUTH2", + fields: { + connected_account_initiation: { required: [], optional: [] }, + }, + }, + ], + }), + }, + }), + ); + + const refusal = await failureOf( + broker.connectionFields({ toolkit: "linear", authScheme: "API_KEY" }), + ); + + expect(refusal).toBeInstanceOf(BrokerRefusalError); + expect(refusal.message).not.toMatch(A_CRASH); + // The app and the RECORDED scheme, because those two are the whole of the finding — and the one + // act that rewrites the recorded scheme, which is an administrator's rather than this person's. + expect(refusal.message).toMatch(/linear/); + expect(refusal.message).toMatch(/API_KEY/); + expect(refusal.message).toMatch(/Plugins page/); + }); + + /** + * AND THE NAME IS THE ONE FIELD THAT TRAVELS, WHICH IS WHY IT IS GUARDED LIKE THE TYPE. + * + * `label`, `help` and `default` all pass through `textOf` and are read by a person. The name is + * sent back to Composio verbatim and is the key `connectWithFields` spreads into the connection's + * `val`. Coerced with `String(...)`, this row drew a box literally called "undefined" and then + * submitted whatever was typed into it under that key: a value no app reads, inside a connection + * Composio accepts. + */ + test("a field with no name is refused rather than drawn as a box called undefined", async () => { + const { broker } = buildComposioClient( + fakeVendor({ + toolkits: { + retrieve: async () => ({ + auth_config_details: [ + { + mode: "API_KEY", + fields: { + connected_account_initiation: { + required: [ + { + displayName: "API Key", + description: "", + type: "string", + required: true, + is_secret: true, + user_visible: true, + }, + ], + optional: [], + }, + }, + }, + ], + }), + }, + }), + ); + + const refusal = await failureOf( + broker.connectionFields({ toolkit: "nameless", authScheme: "API_KEY" }), + ); + + expect(refusal).toBeInstanceOf(BrokerRefusalError); + expect(refusal.message).not.toMatch(A_CRASH); + expect(refusal.message).toMatch(/cannot be filled in here/); + // And never the coercion itself, which is what the form used to be handed. + expect(refusal.message).not.toMatch(/"undefined"/); + }); + + test("a field Composio marks invisible is not shown", async () => { + const { broker } = buildComposioClient( + fakeVendor({ + toolkits: { + retrieve: async () => ({ + auth_config_details: [ + { + mode: "API_KEY", + fields: { + connected_account_initiation: { + required: [ + { + name: "internal_tenant", + displayName: "Tenant", + description: "", + type: "string", + required: true, + is_secret: false, + user_visible: false, + }, + ], + optional: [], + }, + }, + }, + ], + }), + }, + }), + ); + + expect( + await broker.connectionFields({ + toolkit: "hidden", + authScheme: "API_KEY", + }), + ).toEqual([]); + }); + + test("a default Composio padded arrives in the box without its padding", async () => { + const { broker } = buildComposioClient( + fakeVendor({ + toolkits: { + retrieve: async () => ({ + auth_config_details: [ + { + mode: "API_KEY", + fields: { + connected_account_initiation: { + required: [], + optional: [ + { + name: "base_url", + displayName: "Base URL", + description: "", + default: " https://api.example.com ", + type: "string", + required: false, + is_secret: false, + user_visible: true, + }, + ], + }, + }, + }, + ], + }), + }, + }), + ); + + /* + * THE GUARD AND THE ANSWER READ THE SAME VALUE, which is what this is about rather than the + * whitespace. The method tested `textOf(row.default)` — which trims — and emitted + * `String(row.default)` — which does not — so a padded default passed a judgement made about + * one string and reached the form as another. What a person then submits is whatever is in the + * box, so the padding would travel on into the connection as part of the value. + */ + expect( + await broker.connectionFields({ + toolkit: "padded", + authScheme: "API_KEY", + }), + ).toEqual([ + { + name: "base_url", + label: "Base URL", + help: "", + required: false, + secret: false, + default: "https://api.example.com", + }, + ]); + }); +}); + +/** + * THE ONE CALL IN THIS SEAM THAT DROPS THE VENDOR'S ERROR RATHER THAN CARRYING IT. + * + * Every other refusal below this adapter keeps the original as `cause`, deliberately, because the + * object holds the request it was made for and whoever is reading a log rather than a page deserves + * it. On every other call that request is a link mint or a delete. On this one it is somebody's API + * key — so the rule reverses here, and the reversal is worth a test rather than a comment because + * nothing about it is visible in a type or in a passing happy path. + * + * THE LEAK TEST ASKS THE WHOLE THROWN OBJECT AND NOT ITS MESSAGE. `JSON.stringify` of an `Error` is + * `{}` — its fields are non-enumerable — so a check written against that would stay green over a + * `cause` carrying the entire request body, which is exactly the defect this is about. What is + * asserted is that a recognisable secret planted on the vendor's error reaches none of the message, + * the `cause`, or any own property of what escapes, at any depth. + */ +describe("connecting one person with the secret they typed", () => { + /** Shaped like the thing a person pastes into the form, and recognisable wherever it surfaces. */ + const TYPED_SECRET = "pplx-LEAK-CANARY-3f9a2c"; + + /** + * Every string the thrown object carries, its own property names included, at every depth. + * + * `JSON.stringify(error)` answers `{}` for an `Error`, because `message`, `stack` and `cause` are + * all non-enumerable — so a leak test written against it would pass over a `cause` holding the + * whole request. This asks for the own property names at each level and follows whatever hangs + * off them, which is where the secret would be if this call ever carried the vendor's object out. + */ + function everythingCarriedBy(value: unknown, depth = 0): string { + // Deep enough to reach a typed value at the bottom of a request body hanging off a `cause`, + // which is seven levels down from the thrown error and is the whole thing this is looking for. + if (depth > 12) return ""; + if (typeof value === "string") return value; + if (typeof value !== "object" || value === null) return String(value); + const held = value as Record; + return Object.getOwnPropertyNames(held) + .map((name) => `${name}=${everythingCarriedBy(held[name], depth + 1)}`) + .join(" "); + } + + /** + * The thrown object as a handler further out would serialize it, hidden fields expanded. + * + * `JSON.stringify(error)` IS `{}` AND THE OBVIOUS FIX IS BARELY BETTER. An `Error`'s `message`, + * `stack` and `cause` are all non-enumerable, so the first form sees none of them — and + * `JSON.stringify(error, Object.getOwnPropertyNames(error))` passes a replacer ARRAY, which is a + * key allow-list applied at EVERY depth: it names the top error's three fields and then filters + * the request body out of the very `cause` it just let through. Measured against the version of + * this adapter that attached the cause, that assertion passed while the key was two levels below + * it. The replacer below expands each error into its own property names instead and lets the + * plain objects under them through whole, which is the serialization this call has to survive. + */ + function serializedWith(error: Error): string { + return JSON.stringify(error, (_key, value: unknown) => { + if (!(value instanceof Error)) return value; + const own = value as unknown as Record; + return Object.fromEntries( + Object.getOwnPropertyNames(own).map((name) => [name, own[name]]), + ); + }); + } + + test("what the person typed is sent as this connection's state, and the account comes back", async () => { + const asked: unknown[] = []; + const { broker } = buildComposioClient( + fakeVendor({ + authConfigs: { list: async () => ({ items: [OURS] }) }, + connectedAccounts: { + create: async (body: unknown) => { + asked.push(body); + return { id: "ca_new", status: "ACTIVE" }; + }, + }, + }), + ); + + expect( + await broker.connectWithFields({ + userId: "user_1", + toolkit: "linear", + authScheme: "API_KEY", + values: { generic_api_key: TYPED_SECRET, subdomain: "acme" }, + }), + ).toEqual({ accountId: "ca_new" }); + + /* + * THE WHOLE BODY, because every part of it decides something. The config is this deployment's + * own rather than whichever row the vendor listed first; the user id is what every later call + * names the account by; and the state is the scheme the form was drawn for with the typed + * values under it, which is the shape `AuthScheme.APIKey` builds (`@composio/core` 0.18.1, + * `src/models/AuthScheme.ts:84-94`) and the one the raw create declares. + */ + expect(asked).toEqual([ + { + auth_config: { id: OURS.id }, + connection: { + user_id: "user_1", + state: { + authScheme: "API_KEY", + val: { + status: "ACTIVE", + generic_api_key: TYPED_SECRET, + subdomain: "acme", + }, + }, + }, + }, + ]); + }); + + test("a failure on that call carries no vendor object, because the object holds the key", async () => { + /* + * THE VENDOR'S ERROR AS IT ARRIVES FROM A CREATE THAT WAS REFUSED. `@composio/client` hangs the + * response body on `.error` — which is the shallower of the two depths `vendorSentence` reads — + * and the object also carries the request it was made for. On this one call that request is the + * form somebody just filled in, which is why the secret below is planted there and nowhere in + * the sentence: what must survive is Composio's own words, and what must not is everything else. + */ + const raised = Object.assign( + new Error(`400 {"error":{"message":"Invalid credential"}}`), + { + error: { + error: { + message: + "Composio could not use that credential for linear (request req_9f3c).", + }, + }, + request: { + body: { + auth_config: { id: OURS.id }, + connection: { + user_id: "user_1", + state: { + authScheme: "API_KEY", + val: { status: "ACTIVE", generic_api_key: TYPED_SECRET }, + }, + }, + }, + }, + }, + ); + + const { broker } = buildComposioClient( + fakeVendor({ + authConfigs: { list: async () => ({ items: [OURS] }) }, + connectedAccounts: { + create: async () => { + throw raised; + }, + }, + }), + ); + + const refusal = await failureOf( + broker.connectWithFields({ + userId: "user_1", + toolkit: "linear", + authScheme: "API_KEY", + values: { generic_api_key: TYPED_SECRET }, + }), + ); + + // Composio's own sentence is what an operator is left with, request id and all, because that is + // the string their dashboard searches on and the only diagnostic this call agrees to keep. + expect(refusal).toBeInstanceOf(BrokerRefusalError); + expect(refusal.message).toMatch(/req_9f3c/); + + // And the object it came out of is gone: not rethrown, not attached, not reachable from what a + // handler further out will serialize. + expect(refusal).not.toBe(raised); + expect(refusal.cause).toBeUndefined(); + expect(everythingCarriedBy(refusal)).not.toContain(TYPED_SECRET); + expect(serializedWith(refusal)).not.toContain(TYPED_SECRET); + expect(everythingSaidBy(refusal).join("\n")).not.toContain(TYPED_SECRET); + }); + + test("a reply with no account id is refused rather than answered as a connection", async () => { + const { broker } = buildComposioClient( + fakeVendor({ + authConfigs: { list: async () => ({ items: [OURS] }) }, + connectedAccounts: { create: async () => ({ status: "ACTIVE" }) }, + }), + ); + + const refusal = await failureOf( + broker.connectWithFields({ + userId: "user_1", + toolkit: "linear", + authScheme: "API_KEY", + values: { generic_api_key: TYPED_SECRET }, + }), + ); + + /* + * AN ACCOUNT MAY BE STANDING AT COMPOSIO OVER A REFUSAL HERE, which is the one thing this + * sentence has to carry: the id is what a caller undoes its own work with, so without one there + * is a connection nothing on this deployment can name or take back. The dashboard is where it + * can be seen and removed, and it is the only remedy there is. + */ + expect(refusal).toBeInstanceOf(BrokerRefusalError); + expect(refusal.message).toMatch(/dashboard/); + expect(refusal.message).not.toContain(TYPED_SECRET); + }); + + test("an app with no config of this deployment's is refused before anything is sent", async () => { + const { broker } = buildComposioClient( + fakeVendor({ + // The create is left at `fakeVendor`'s refusal, which is what says the value typed in went + // nowhere: a call that was made would name itself here rather than answering. + authConfigs: { list: async () => ({ items: [BY_HAND] }) }, + }), + ); + + const refusal = await failureOf( + broker.connectWithFields({ + userId: "user_1", + toolkit: "linear", + authScheme: "API_KEY", + values: { generic_api_key: TYPED_SECRET }, + }), + ); + + expect(refusal).toBeInstanceOf(BrokerRefusalError); + expect(refusal.message).toMatch(NO_CONFIG_REMEDY); + expect(refusal.message).not.toContain(TYPED_SECRET); + }); + + /** + * THE TWO BELOW ASSERT AN ORDER RATHER THAN A SENTENCE, AND THE ORDER IS THE WHOLE STANCE. + * + * A create that ANSWERS is what makes them able to fail. Left at {@link fakeVendor}'s refusal, an + * implementation that sent the key would throw the double's own error, {@link failureOf} would + * hand back a refusal, and a test asking only "did this refuse" would be green over the exact + * defect — the secret having travelled. So the double here succeeds like the real vendor does on + * a key it has not graded, and what is asserted is that it was never called: `created` empty is + * the statement that the person's credential did not leave this process. + */ + test("a config of ours that is disabled is refused before the key is sent", async () => { + const created: unknown[] = []; + const { broker } = buildComposioClient( + fakeVendor({ + /* + * `configsFor` LISTS WITH `showDisabled: true`, so this row is one this read can meet and + * `ours[0]` cannot tell it from a working config. Composio does not grade a submitted key, + * so a create against it is a request that carries the secret out of this process and comes + * back with an account that cannot work. + */ + authConfigs: { + list: async () => ({ + items: [ + { id: "ac_ours", name: "Linear (OpenBot)", status: "DISABLED" }, + ], + }), + }, + connectedAccounts: { + create: async (body: unknown) => { + created.push(body); + return { id: "ca_new", status: "ACTIVE" }; + }, + }, + }), + ); + + const refusal = await failureOf( + broker.connectWithFields({ + userId: "user_1", + toolkit: "linear", + authScheme: "API_KEY", + values: { generic_api_key: TYPED_SECRET }, + }), + ); + + expect(refusal).toBeInstanceOf(BrokerRefusalError); + expect(refusal.message).not.toMatch(A_CRASH); + expect(refusal.message).toMatch(DISABLED_REMEDY); + expect(refusal.message).not.toMatch(NO_CONFIG_REMEDY); + expect(refusal.message).not.toContain(TYPED_SECRET); + // The point of the test: the refusal arrived before the credential did, not after Composio had + // been handed it and answered. + expect(created).toEqual([]); + }); + + test("a listing this deployment cannot read does not send an administrator round a loop", async () => { + const created: unknown[] = []; + const { broker } = buildComposioClient( + fakeVendor({ + authConfigs: { + list: async () => ({ items: [{ id: "ac_nameless" }] }), + }, + connectedAccounts: { + create: async (body: unknown) => { + created.push(body); + return { id: "ca_new", status: "ACTIVE" }; + }, + }, + }), + ); + + const refusal = await failureOf( + broker.connectWithFields({ + userId: "user_1", + toolkit: "linear", + authScheme: "API_KEY", + values: { generic_api_key: TYPED_SECRET }, + }), + ); + + /* + * NOT THE NO-CONFIG REMEDY, which is the finding. The unreadable row may itself BE ours, in + * which case removing the app meets `deleteAuthConfig`'s refusal over the same row and adding + * it again meets `ensureAuthConfig`'s — an administrator sent round a loop that cannot close. + * `authorize` tells the two states apart against this same listing; this path handed out the + * wrong one of the two. + */ + expect(refusal).toBeInstanceOf(BrokerRefusalError); + expect(refusal.message).not.toMatch(A_CRASH); + expect(refusal.message).not.toMatch(NO_CONFIG_REMEDY); + expect(refusal.message).not.toMatch(DISABLED_REMEDY); + expect(refusal.message).toMatch( + /upgrading this deployment's @composio\/core/, + ); + expect(refusal.message).not.toContain(TYPED_SECRET); + // And no `cause`, because this method's refusals carry none — see the describe above. + expect(refusal.cause).toBeUndefined(); + expect(created).toEqual([]); + }); +}); + +/** + * ENDING ONE ACCOUNT BY ID, WHICH IS A DIFFERENT QUESTION FROM ENDING A PERSON'S ACCESS. + * + * `revoke` above is asked "this person is done with this app" and has to go and find out what that + * means: it lists, it matches, and it deletes everything it found. This one is asked "take back the + * account you just made", and the id it is handed is the whole of the question — so the listing + * that makes the other method correct is, here, both a call nothing needs and a set of accounts + * nobody asked about. The two differ exactly where the local row and Composio have drifted apart, + * which is the state a failed verification is standing in: a connection that works beside the + * attempt that did not. The absence of the listing is therefore asserted rather than assumed. + */ +describe("taking back the one account a verification just made", () => { + test("the delete names that account and asks for the grant behind it, with nothing listed first", async () => { + const deleted: unknown[] = []; + /* + * THE LISTINGS ANSWER RATHER THAN REFUSE, deliberately, and that is what makes this an + * assertion about the method instead of about {@link fakeVendor}. Left at the refusals, a + * sweeping implementation would fail here on a thrown fixture and the failure would read like + * an unrelated vendor error; answering means a sweep gets everything it needs and is caught by + * the one thing that is actually wrong with it — that it went looking at all. + */ + const listed: string[] = []; + const { broker } = buildComposioClient( + fakeVendor({ + authConfigs: { + list: async () => { + listed.push("authConfigs.list"); + return { items: [OURS] }; + }, + }, + connectedAccounts: { + list: async () => { + listed.push("connectedAccounts.list"); + return { items: [{ id: "ca_new" }] }; + }, + delete: async (id: unknown, params: unknown) => { + deleted.push([id, params]); + return WITHDRAWN; + }, + }, + }), + ); + + await broker.revokeAccount("ca_new"); + + /* + * THE ID AS IT WAS HANDED OVER, AND THE FLAG BESIDE IT. Without `revoke_on_delete` the account + * stops being visible to this deployment and the credential at the far end stands — which is + * worse here than anywhere else in this file, because the secret left live is one somebody + * typed into a form minutes ago that then told them the connection had not been kept. + */ + expect(deleted).toEqual([["ca_new", { revoke_on_delete: true }]]); + expect(listed).toEqual([]); + }); + + /** + * AND WHAT COMPOSIO ANSWERED IS READ, BECAUSE THE CALLER BRANCHES ON THIS CALL RETURNING CLEANLY. + * + * The await used to drop the reply, so a 200 carrying `success: false` — Composio saying it did + * NOT delete the account and started no revocation — resolved like a withdrawal that happened. + * The verification step above this reads exactly that: a key that fails verification has its + * account withdrawn, and where the WITHDRAWAL fails the row is written unverified so the account + * stays named on a screen and disconnectable. A `success: false` resolving normally takes the + * other branch, writes no row, and leaves a live account holding a credential that nothing in + * this deployment names and nobody can press disconnect on. + * + * THE SENTENCES ARE THIS METHOD'S OWN, which is why they are asserted rather than assumed from + * {@link withdrawalDeclined}. That function's two are written around a toolkit and around + * pressing disconnect again; this call was handed an id, and the second press it would invite is + * a button on no page. + */ + test("the one account's delete answered `success: false` is not a withdrawal", async () => { + const { broker } = buildComposioClient( + fakeVendor({ + connectedAccounts: { + // A 200 whose body says the account was not deleted: the flag went out, Composio read the + // request and answered it. This is the vendor declining rather than failing. + delete: async () => ({ success: false }), + }, + }), + ); + + const refusal = await failureOf(broker.revokeAccount("ca_new")); + + expect(refusal).toBeInstanceOf(BrokerRefusalError); + expect(refusal.message).not.toMatch(A_CRASH); + expect(refusal.message).toMatch(/success: false/); + // The two facts the caller has to be able to act on: the account stands, and the credential + // behind it was never withdrawn. + expect(refusal.message).toMatch(/still standing at Composio/); + expect(refusal.message).not.toMatch( + /upgrading this deployment's @composio\/core/, + ); + }); + + test("the one account's delete with no verdict in it is not counted as one either", async () => { + const { broker } = buildComposioClient( + fakeVendor({ + connectedAccounts: { + /* + * A DOCUMENT THAT ARRIVED WITHOUT ITS VERDICT, which is not the same thing as no document + * at all. `success` is required in the declaration and absent on this wire, and the + * generated client parses the body and hands it over — so the schema's "required" is a + * promise about what Composio means to send rather than a fact about what came. + */ + delete: async () => ({}), + }, + }), + ); + + const refusal = await failureOf(broker.revokeAccount("ca_new")); + + expect(refusal).toBeInstanceOf(BrokerRefusalError); + expect(refusal.message).not.toMatch(A_CRASH); + /* + * A DIFFERENT SENTENCE FROM THE ONE ABOVE. "Composio said no" is a fact about this account; + * "Composio answered something where its verdict belongs" is a fact about the package, which + * nobody holding an admin page can correct — so it carries the remedy that names the upgrade + * and the other one must not. + */ + expect(refusal.message).toMatch( + /upgrading this deployment's @composio\/core/, + ); + expect(refusal.message).not.toMatch(/success: false/); + }); + + /** + * AND NO DOCUMENT AT ALL IS COMPOSIO SAYING IT DID DELETE, which is the opposite mistake and the + * one {@link withdrawalDeclined} was corrected for once already. The installed client resolves a + * 204 to `null` and a JSON reply carrying `content-length: 0` to `undefined`, and neither can be + * a rejection: every `!response.ok` is thrown as an `APIError` before parsing, so an answer + * arriving here at all is Composio having accepted the request. Reading those as "no verdict" + * would turn a completed withdrawal into a failure — which, on this path, has the caller write + * the account's row as unverified over an account that is already gone. + */ + for (const { shape, answer } of [ + { shape: "a 204 carrying no content", answer: null }, + { shape: "a JSON reply of content-length zero", answer: undefined }, + ]) { + test(`the one account's withdrawal answered with ${shape} is a withdrawal`, async () => { + const deleted: unknown[] = []; + const { broker } = buildComposioClient( + fakeVendor({ + connectedAccounts: { + delete: async (...call: unknown[]) => { + deleted.push(call); + return answer; + }, + }, + }), + ); + + expect(await broker.revokeAccount("ca_new")).toBeUndefined(); + // And the flag still went out, so what resolved is a delete that asked for the grant behind + // the account to be withdrawn rather than one that quietly filed the account away. + expect(deleted).toEqual([["ca_new", { revoke_on_delete: true }]]); + }); + } +}); diff --git a/server/tests/composio-broker-seam.test.ts b/server/tests/composio-broker-seam.test.ts new file mode 100644 index 000000000..0c8a72bdd --- /dev/null +++ b/server/tests/composio-broker-seam.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, test } from "bun:test"; +import { + BrokerReturnUrlError, + BrokerUnconfiguredError, + brokerReturnUrl, + brokerSentence, +} from "../src/plugins/broker"; + +/** + * The broker seam's three decisions, asserted with no network and no database. + * + * Most of `./broker` is a type, which a type checker settles and a test cannot. What is left to + * assert is the runtime facts the module exists to fix: what an unconfigured deployment says about + * itself, what it refuses to say about anything else, and which return addresses it will not begin + * a consent against. + * + * The second of those is the one worth a file. `brokerSentence` is what a caller reaches for when a + * broker call throws, and the tempting shape — a fallback sentence for anything it does not + * recognise — would report a missing setting for a vendor outage: an operator told to set + * `COMPOSIO_API_KEY` when the key is set and Composio's socket hung up. Null is how the function + * declines to guess, and the caller is left to say something true about the failure it actually has. + */ + +describe("brokerSentence", () => { + test("reports the unconfigured deployment in the error's own words", () => { + const error = new BrokerUnconfiguredError(); + + expect(error.message).toContain("COMPOSIO_API_KEY"); + expect(brokerSentence(error)).toBe(error.message); + }); + + test("declines to explain a failure that is not about configuration", () => { + expect(brokerSentence(new Error("socket hang up"))).toBeNull(); + }); +}); + +/** + * The return address, which is the one value on this seam a type cannot settle. + * + * `authorize`'s `returnUrl` is documented as required and spelled `string`, and `""`, `" "` and + * `openbot.example.com/settings/...` all satisfy that: they reach Composio as a callback nobody + * returns through, and the person who finds out is the one who has just granted a third party + * access to their mailbox. The route assembles the address at run time from `OPENBOT_APP_URL`, an + * unvalidated environment string, so `string` really is the strongest promise it can make and the + * check belongs here rather than in the type. + * + * Each refusal is asserted by the half of its sentence that only it says. Both name + * `OPENBOT_APP_URL`, because both are fixed there, so a test that asked only for the setting would + * pass just as well if the two branches collapsed into one — and they are two different mistakes: + * an address nobody built, and a configured one that cannot work. + */ +describe("brokerReturnUrl", () => { + test("refuses an address that was never built", () => { + expect(() => brokerReturnUrl("")).toThrow(/built no address/); + expect(() => brokerReturnUrl(" ")).toThrow(/built no address/); + expect(() => brokerReturnUrl("")).toThrow(BrokerReturnUrlError); + }); + + test("refuses an address no browser could come back through", () => { + for (const unusable of [ + "openbot.example.com/settings/connected-accounts/x", + "localhost:3001/settings/connected-accounts/x", + "/settings/connected-accounts/x", + "javascript:alert(1)", + ]) { + expect(() => brokerReturnUrl(unusable)).toThrow(/not a web address/); + } + }); + + /** + * THE ADDRESS HANDED IN WAS BUILT ON THE HOST THE REFUSAL QUOTES ON PURPOSE, which left this + * assertion verifying one segment of it. + * + * The sentence names `openbot.example.com` in its own example of the fix — "https:// + * openbot.example.com rather than openbot.example.com" — and the value this test passed was + * `openbot.example.com/settings/...`. So `not.toContain` could only ever have been answered by + * the path: a refusal that echoed the host back would have matched the message's own example and + * looked, to this test, exactly like one that had not. + * + * The host is the half that carries the most. `OPENBOT_APP_URL` is an environment string and an + * environment string carries whatever was put in it: a customer's name in a tenant subdomain, an + * internal hostname that says how this deployment is reached, a preview host with a token in it. + * This refusal goes to whoever asked — it is a `BrokerRefusalError`, which is a promise + * that the message is safe to show them — so the address must be absent from it as a whole and + * in its parts, and the value asked about has to be one no sentence here mentions for its own + * reasons. + */ + test("says which setting fixes it, and never quotes the address", () => { + const address = + "openbot-tenant-42.internal.corp/settings/connected-accounts/9f3c"; + + let thrown: unknown; + try { + brokerReturnUrl(address); + } catch (error) { + thrown = error; + } + + const sentence = brokerSentence(thrown); + expect(sentence).toContain("OPENBOT_APP_URL"); + expect(sentence).not.toContain(address); + expect(sentence).not.toContain("openbot-tenant-42.internal.corp"); + expect(sentence).not.toContain("/settings/connected-accounts/9f3c"); + }); + + test("hands back the address a configured deployment built", () => { + expect( + brokerReturnUrl("https://openbot.test/settings/connected-accounts/x"), + ).toBe("https://openbot.test/settings/connected-accounts/x"); + expect(brokerReturnUrl("http://localhost:3001/admin/plugins/x")).toBe( + "http://localhost:3001/admin/plugins/x", + ); + }); + + /** + * The address handed back is the one that was checked, which is the whole of what the check is + * worth. + * + * A guard that reads one value and returns another has approved nothing. `OPENBOT_APP_URL` is an + * environment string, and an environment string carries whatever was pasted into it: a leading + * space from a copied address, a trailing newline from a file read line by line, a tab or a + * carriage return from a variable assembled by a shell. Every one of those is invisible where it + * is set and every one reaches Composio as part of the callback if the guard hands the raw string + * back — which is the same person on the same hosted page the guard exists to keep them off. + * + * Each case below is the same intended address wearing a different disguise, and the disguises are + * split deliberately: the first three sit at the ends, where trimming would find them, and the + * last three sit in the middle of the host and the path, where it would not. A fix that only + * trimmed would pass the first half of this table and strand somebody on the second. + */ + test("hands back the address it checked rather than the padding around it", () => { + const intended = "https://openbot.test/settings/connected-accounts/x"; + + for (const disguised of [ + " https://openbot.test/settings/connected-accounts/x", + "https://openbot.test/settings/connected-accounts/x\n", + "\thttps://openbot.test/settings/connected-accounts/x ", + "https://openbot\n.test/settings/connected-accounts/x", + "https://openbot.test/settings/\tconnected-accounts/x", + "https://openbot.test/settings\r/connected-accounts/x", + ]) { + expect(brokerReturnUrl(disguised)).toBe(intended); + } + }); +}); diff --git a/server/tests/composio-classify.test.ts b/server/tests/composio-classify.test.ts new file mode 100644 index 000000000..a0d6ab9a4 --- /dev/null +++ b/server/tests/composio-classify.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, test } from "bun:test"; +import { catalogueEntry, classifyTool } from "../src/plugins/catalogue"; + +/** + * What an action does, when the vendor said so and when nobody did. + * + * The property under test is the direction of the failure. Exactly two things can earn a read, and + * both require somebody to have said so: the vendor recorded exactly `read`, or a curated entry + * advertised the action and a reviewed list declined to call it a write. Everything else — a + * recorded write, an unrecognised value, a different case, an empty string, a name no server + * advertised, a server nobody reviewed — is a write. That asymmetry is the point: an action wrongly + * gated as a write costs a confirmation, and one wrongly waved through as a read costs somebody's + * mailbox. + * + * THE RECORDED VALUE MAY NARROW WHAT A BOT MAY DO AND MAY NEVER WIDEN IT. It arrives from a vendor + * listing into a plain `text` column with no constraint on its contents, so it outranks nothing that + * a human reviewed. Both halves are pinned below: a recorded write settles an action the curated + * list forgot, and a recorded read cannot unsettle one the curated list named. + * + * The first block passes a null entry throughout, which is the brokered shape — a Composio app has + * no curated entry behind it. The second block passes a real one, because a null entry is itself a + * blanket write and would let the precedence cases below pass without exercising the precedence. + */ +describe("classifyTool with a recorded effect", () => { + test("a recorded read is a read", () => { + expect(classifyTool(null, "GMAIL_FETCH_EMAILS", true, "read")).toBe("read"); + }); + + test("a recorded write is a write", () => { + expect(classifyTool(null, "GMAIL_SEND_EMAIL", true, "write")).toBe("write"); + }); + + test("no recorded effect is a write, not a read", () => { + expect(classifyTool(null, "GMAIL_SEND_EMAIL", true, null)).toBe("write"); + expect(classifyTool(null, "GMAIL_SEND_EMAIL", true, undefined)).toBe( + "write", + ); + expect(classifyTool(null, "GMAIL_SEND_EMAIL", true, "")).toBe("write"); + }); + + test("a value nothing recognises is a write", () => { + // A future label, a typo, or a column somebody wrote by hand. None is a licence to read. + expect(classifyTool(null, "GMAIL_SEND_EMAIL", true, "readonly")).toBe( + "write", + ); + expect(classifyTool(null, "GMAIL_SEND_EMAIL", true, "destructive")).toBe( + "write", + ); + expect(classifyTool(null, "GMAIL_SEND_EMAIL", true, "READ")).toBe("write"); + }); + + test("a recorded read cannot rescue an action the server never advertised", () => { + // The name came from somewhere other than a listing, so no recorded effect is about it. + expect(classifyTool(null, "GMAIL_INVENTED", false, "read")).toBe("write"); + }); +}); + +describe("a recorded effect against a curated entry", () => { + const notion = catalogueEntry("notion"); + + /* + * The two names every case below is built on, asserted once. + * + * `notion-update-page` has to be ON the write list and `notion-fetch` has to be OFF it, or the + * expectations stop meaning what they say: a reviewed write whose name drifted off the list would + * turn the precedence cases into ordinary unlisted-tool cases and they would keep passing. + */ + test("the entry these cases are about names one of them a write and not the other", () => { + expect(notion).not.toBeNull(); + expect(notion?.writeTools).toContain("notion-update-page"); + expect(notion?.writeTools).not.toContain("notion-fetch"); + }); + + test("a recorded read cannot override a curated entry's write list", () => { + /* + * THE CASE THIS WHOLE BLOCK EXISTS FOR. `effect` is vendor-supplied text in a column with no + * check constraint and no product writer other than the refresh path, so a `read` in it is + * reachable by a hand edit or a restore. `writeTools` was reviewed by a person. Letting the + * column win here would buy an action LESS scrutiny than review already gave it, which is the + * one direction this classifier must never move in. + */ + expect(classifyTool(notion, "notion-update-page", true, "read")).toBe( + "write", + ); + expect(classifyTool(notion, "notion-create-pages", true, "read")).toBe( + "write", + ); + expect(classifyTool(notion, "notion-move-pages", true, "read")).toBe( + "write", + ); + }); + + test("a recorded read still settles an action the write list does not name", () => { + // The permitted direction, and the reason the column is consulted at all: where review said + // nothing, the vendor's own label is the better source and is taken at its word. + expect(classifyTool(notion, "notion-fetch", true, "read")).toBe("read"); + }); + + test("a recorded write settles an action the write list forgot", () => { + // The other permitted direction. The write list is known-incomplete, so a vendor saying an + // action writes narrows what a Bot may do and is honoured. + expect(classifyTool(notion, "notion-fetch", true, "write")).toBe("write"); + }); + + test("an empty recorded effect is a write, not an absence of opinion", () => { + /* + * A `text` column holding the empty string is a value, not a null. Reading it as "nothing was + * recorded" sends an advertised action that no reviewed list names down the read branch, which + * is the widening this classifier exists to refuse. Both sides of the write list are pinned so + * the answer cannot depend on which one the name falls on. + */ + expect(classifyTool(notion, "notion-fetch", true, "")).toBe("write"); + expect(classifyTool(notion, "notion-update-page", true, "")).toBe("write"); + }); + + test("a recorded value nothing recognises is a write", () => { + // A label a vendor invents later, a typo, or the wrong case. None of them is `read`, so none of + // them earns a read, whether or not the reviewed list names the action. + for (const value of ["readonly", "READ", "Read", "destructive", "none"]) { + expect(classifyTool(notion, "notion-fetch", true, value)).toBe("write"); + expect(classifyTool(notion, "notion-update-page", true, value)).toBe( + "write", + ); + } + }); + + test("nothing recorded leaves the curated write list deciding", () => { + // The behaviour that shipped before the column existed, unchanged for every row written before + // it. Null and undefined are the column saying nothing, which is not a value. + for (const value of [null, undefined]) { + expect(classifyTool(notion, "notion-update-page", true, value)).toBe( + "write", + ); + expect(classifyTool(notion, "notion-fetch", true, value)).toBe("read"); + } + }); + + test("a recorded read cannot rescue a name the entry's server never advertised", () => { + // Checked before either source, so the model-invented name is refused whatever the column says + // and whatever the reviewed list says. + expect(classifyTool(notion, "notion-fetch", false, "read")).toBe("write"); + expect(classifyTool(notion, "notion-invented", false, "read")).toBe( + "write", + ); + }); +}); diff --git a/server/tests/composio-connection-kinds.test.ts b/server/tests/composio-connection-kinds.test.ts new file mode 100644 index 000000000..2828dbe8d --- /dev/null +++ b/server/tests/composio-connection-kinds.test.ts @@ -0,0 +1,106 @@ +import { expect, test } from "bun:test"; +import { connectionOf } from "../src/plugins/composio-adapter"; + +/** + * Which flow an app gets, decided from what Composio's own catalogue publishes. + * + * Every case here is a real row measured against the live catalogue on 2026-09-13. The ordering + * is the whole of the logic: `no_auth` wins outright because Composio REFUSES an auth config for + * such a toolkit, managed OAuth beats everything else because it asks the person for nothing, and + * a scheme this deployment cannot drive is named as unsupported rather than attempted. + */ +test("an app Composio holds credentials for gets the consent flow", () => { + expect( + connectionOf({ + slug: "gmail", + auth_schemes: ["OAUTH2"], + composio_managed_auth_schemes: ["OAUTH2"], + }), + ).toEqual({ kind: "consent" }); +}); + +test("managed OAuth wins over a key the app also accepts", () => { + expect( + connectionOf({ + slug: "linear", + auth_schemes: ["OAUTH2", "API_KEY"], + composio_managed_auth_schemes: ["OAUTH2"], + }), + ).toEqual({ kind: "consent" }); +}); + +test("OAuth that registers itself needs nobody's credentials", () => { + expect( + connectionOf({ + slug: "linear_mcp", + auth_schemes: ["DCR_OAUTH"], + composio_managed_auth_schemes: [], + }), + ).toEqual({ kind: "self-registering" }); +}); + +/** + * A consent screen asks the person for nothing and a key asks them to go and find one, so an app + * offering both should never send them looking. + */ +test("an app offering both a self-registering consent and a key prefers the consent", () => { + expect( + connectionOf({ + slug: "x", + auth_schemes: ["DCR_OAUTH", "API_KEY"], + composio_managed_auth_schemes: [], + }), + ).toEqual({ kind: "self-registering" }); +}); + +test("an app the person holds a key for asks for fields", () => { + expect( + connectionOf({ + slug: "perplexityai", + auth_schemes: ["API_KEY"], + composio_managed_auth_schemes: [], + }), + ).toEqual({ kind: "fields", authScheme: "API_KEY" }); +}); + +test("no_auth beats every scheme beside it, because Composio refuses a config for one", () => { + expect( + connectionOf({ + slug: "gemini", + no_auth: true, + auth_schemes: ["NO_AUTH", "API_KEY"], + composio_managed_auth_schemes: [], + }), + ).toEqual({ kind: "no-auth" }); +}); + +/** + * Two of the thirty-four no-auth apps publish a managed scheme beside the flag, and they are the + * only rows this precedence decides. Read as managed, they get a config Composio refuses outright. + */ +test("no_auth beats managed OAuth, because a config for one is refused however it is asked for", () => { + expect( + connectionOf({ + slug: "hackernews", + no_auth: true, + auth_schemes: ["NO_AUTH", "OAUTH2"], + composio_managed_auth_schemes: ["OAUTH2"], + }), + ).toEqual({ kind: "no-auth" }); +}); + +test("an app wanting this deployment's own OAuth client is unsupported, and says so", () => { + const connection = connectionOf({ + slug: "docusign", + auth_schemes: ["OAUTH2"], + composio_managed_auth_schemes: [], + }); + expect(connection.kind).toBe("unsupported"); + expect(connection.kind === "unsupported" && connection.reason).toContain( + "its own OAuth client", + ); +}); + +test("an app publishing nothing readable is unsupported rather than guessed at", () => { + expect(connectionOf({ slug: "mystery" }).kind).toBe("unsupported"); +}); diff --git a/server/tests/composio-connections.test.ts b/server/tests/composio-connections.test.ts new file mode 100644 index 000000000..867a0e062 --- /dev/null +++ b/server/tests/composio-connections.test.ts @@ -0,0 +1,2811 @@ +import { afterAll, afterEach, beforeEach, expect, test } from "bun:test"; +import { randomUUID } from "node:crypto"; +import { and, asc, eq, inArray } from "drizzle-orm"; +import { createAuditStore } from "../src/audit"; +import type { ActionPolicy } from "../src/computer/policy"; +import type { + CredentialSecretReader, + CredentialStore, +} from "../src/credentials"; +import { createDatabase } from "../src/db/client"; +import { + agents, + auditEvents, + composioConnections, + mcpServers, + mcpTools, + pluginGrants, + users, +} from "../src/db/schema"; +import type { ComposioBroker } from "../src/plugins/broker"; +import type { ComposioActions, ComposioResult } from "../src/plugins/composio"; +import { useComposioClient } from "../src/plugins/composio"; +import { createPluginStore } from "../src/plugins/store"; +import { TEST_POOL } from "./support/database"; + +/** + * What ends a brokered connection, and what the trail says when nobody was asking. + * + * `composio_connections` is the sole gate on a brokered call: the row `(toolkit, user_id)` is the + * whole of the permission, it points at no vault secret, and it references neither `users` nor + * `mcp_servers`. Nothing therefore cascades it away, which is deliberate — the row has to outlive + * the person so offboarding can still find it — and it means an explicit retirement is the ONLY + * thing that can ever end one. Three store methods perform that retirement: `retireConnectionsFor` + * when somebody is offboarded, `removeServer` when the app itself is taken away, and + * `disconnectBrokered` when a person ends their own account. This file is about the two an + * administrator performs on somebody else's behalf, and about the trail those two leave; + * `disconnectBrokered` has its coverage in `plugin-store.integration.test.ts`, which is why the two + * here are described throughout as the two ACTS AN ADMINISTRATOR PERFORMS and never as all the ways + * a connection can end. It is asked about here in one place only — the test at the foot of this + * file, where what the trail may claim is decided by the app's recorded scheme, and so needs an app + * that arrived the way Add makes one arrive and a key that arrived the way a person types one. + * + * WHY THIS FILE OWNS ITS IDS OUTRIGHT, AND SO NEEDS NO REFUSE-TO-RUN GUARD. + * `plugin-store.integration.test.ts` inserts at `gmail`, `notion` and `bot_helper` and refuses to + * run when a database already holds them: it asserts things about a real vendor's own action + * classification, so its ids are forced to be the spellings production uses, and a fixture at a + * forced id cannot coexist with a real row at that id. Nothing here asserts anything about a real + * vendor — `accessFor` answers `brokered` for ANY row whose provenance column says composio, and + * reads the app slug straight off the url — so every id below carries a run-unique suffix and every + * delete is keyed on one. That makes each row this file removes provably one it inserted, which is + * the property that guard buys the other way round, and it also lets this file run beside that one. + * + * The production deletes under test are keyed the same way: `removeServer` deletes by toolkit and + * `retireConnectionsFor` by user id, and both of those values are suite-scoped here, so neither can + * reach another run's rows either. + */ + +const database = createDatabase( + process.env.DATABASE_URL ?? + "postgres://openbot:openbot@localhost:5432/openbot", + TEST_POOL, +); + +const suite = randomUUID().slice(0, 8); +/** The app: its `mcp_servers.id`, and the slug in its url, which is what a connection is keyed on. */ +const toolkit = `revocable-${suite}`; +const actionName = "APP_FETCH_ITEMS"; +const ref = `${toolkit}/${actionName}`; +const botId = `agent_revoke_${suite}`; +/** Somebody who connected the app. */ +const askerId = `user_asker_${suite}`; +/** Somebody who connected it and whose `users` row is then deleted out from under the connection. */ +const leaverId = `user_leaver_${suite}`; +/** + * The same app under a display id that is NOT its slug, which is a legal row and an ordinary one. + * + * `mcp_servers.id` is what an operator sees and what a grant is written against; the slug in the + * url is what the broker is asked about. Nothing holds the two equal, and every fixture above + * spells them the same — which is exactly why a defect that only shows when they differ survived. + */ +const renamedId = `renamed-${suite}`; +/** + * A SECOND app the same person connected, which is what makes an offboarding's answer per-app. + * + * Spelled as an extension of {@link toolkit} rather than as an independent name, so that `toolkit` + * sorts before it under every collation a database might be running: one string is a strict prefix + * of the other, and no locale reorders that pair. The offboarding path reads its apps + * `order by toolkit`, and an assertion about that order is worth nothing if the order it expects is + * itself a guess about the server's locale. + */ +const secondToolkit = `${toolkit}-more`; +/** + * The app this file ENABLES rather than inserts, and so the only one whose row it did not write. + * + * Every other fixture here is an `mcp_servers` insert made by hand, because what those tests are + * about is what a removal does to a row that already stands. The two tests at the foot of this file + * are about the row `addBrokeredApp` writes itself — its `auth_scheme` in particular — so the app + * has to arrive the way an administrator's press of Add makes it arrive, id and all. + */ +const enabledToolkit = `enablable-${suite}`; +/** What `addBrokeredApp` spells that app's row, which is the id the two tests below read back. */ +const enabledId = `composio-${enabledToolkit}`; +/** + * The app somebody CONNECTS TWICE, which is the only shape that can tell a first key from a second. + * + * Its own name rather than {@link enabledToolkit}'s, because `audit_events` is append-only and no + * cleanup in this file can reach it: the test below that asserts ONE `mcp.account_connected` row + * under that app would be reading this test's rows too, and the two would pass or fail on whichever + * order the runner happened to pick. + */ +const rekeyedToolkit = `rekeyable-${suite}`; +/** What `addBrokeredApp` spells that app's row, so {@link clean} can take it back. */ +const rekeyedId = `composio-${rekeyedToolkit}`; +/** + * THE APP WHOSE TYPED KEY IS ACTUALLY SPENT ON A CALL, which no other fixture here is. + * + * Its own name rather than {@link enabledToolkit}'s, because that app is the one the secrecy test + * connects and it publishes no actions at all — which is the whole reason it stays unverified. An + * app that gets probed has to hold an action a probe may use, and seeding one on that app would + * change what that test is about. Added to {@link ownedToolkits} so its connection rows are swept, + * and its `mcp_servers` row goes out with {@link probedId} in {@link clean}. + */ +const probedToolkit = `probed-${suite}`; +/** What `addBrokeredApp` spells that app's row, which is also the id the probe chooser is asked. */ +const probedId = `composio-${probedToolkit}`; +/** The one action the chooser can pick for it: a read, asking for nothing, at a recorded version. */ +const probeAction = "PROBED_GET_ME"; +/** + * The version the listing recorded for that action, and the reason it is on the fixture at all. + * + * Composio refuses a call without a specific version and the transport refuses one before dialling, + * so an action recorded with no version is an action nothing here can call. A fixture that left it + * null would have every probe below fail for this deployment's reason rather than the vendor's — + * and the failure tests would pass while asserting nothing about a key. + */ +const probeVersion = "20260903_00"; +/** The account Composio answers with when the key that was just typed is attached. */ +const madeAccountId = `ca_${suite}`; +/** + * AN ACCOUNT THE VENDOR HOLDS THAT THIS DEPLOYMENT NEVER MADE, which is the drift a sweep destroys. + * + * `revoke` ends every account a person holds for an app; `revokeAccount` ends the one it is handed. + * The two differ only when Composio holds an account no row here names — an earlier connection this + * deployment lost the row for, one made in Composio's own dashboard — and that is somebody's + * WORKING connection. This id stands in for it, so "the undo was narrow" is an assertion about what + * the vendor still holds afterwards rather than about how a call was spelled. + */ +const strandedAccountId = `ca_working_${suite}`; +/** Every app this run owns, which is the scope of every read and every delete below. */ +const ownedToolkits = [ + toolkit, + secondToolkit, + enabledToolkit, + rekeyedToolkit, + probedToolkit, +]; +/** + * An app this file does NOT own, standing in for another run's fixture — or another file's. + * + * It carries this run's suffix so it cannot collide with a real row, and it is deliberately absent + * from {@link ownedToolkits} so {@link clean} cannot reach it. Its whole purpose is to be the row + * that a sweep keyed on `user_id` alone would take by mistake. + */ +const foreignToolkit = `foreign-${suite}`; +/** + * The app the probe chooser reads, which holds actions and nothing else. + * + * Its own `mcp_servers` id rather than {@link toolkit}'s, because every other fixture here seeds + * `APP_FETCH_ITEMS` — an argument-less read — and a chooser asked about that app would answer that + * action whatever it did with the rows a probe test cares about. Named as an app id and not added to + * {@link ownedToolkits}: nobody connects it, so it has no `composio_connections` row to sweep, and + * its actions go out with the server rows in {@link clean}. + */ +const probeAppId = `probe-${suite}`; +const admin = "admin@openbot.local"; +/** + * THE SECRET A PERSON TYPES, which the test below looks for everywhere it must not be. + * + * A RUN-UNIQUE SPELLING, for the same reason every id here carries one and for one more. The + * assertions about it are ABSENCE assertions read out of two shared tables, and `audit_events` is + * append-only — nothing in this file can sweep it — so a fixed spelling would have one run's rows + * answering another run's question. With the suffix, "this string is nowhere" is a sentence about + * rows this run wrote. + * + * AND IT IS A KEY THE REDACTOR WOULD NOT SAVE, WHICH IS WHAT MAKES THE TEST WORTH RUNNING. + * `redactAuditPayload` masks a value by the NAME of the key holding it, and neither `values` nor + * `generic_api_key` — the name Composio publishes for Perplexity's key — is on its list. So a + * payload that carried what somebody typed would carry it verbatim into the trail, and the absence + * asserted below is the implementation's doing rather than the redactor's. + */ +const typedKey = `pplx-secret-value-${suite}`; +/** + * THE SECOND KEY, the one somebody types when the first has been rotated or typed wrong. + * + * A different spelling from {@link typedKey} rather than the same value sent twice, because what + * the reconnect test is about is a grant being REPLACED: two sends of one string would be a shape + * an idempotent no-op could also produce. + */ +const rotatedKey = `pplx-rotated-value-${suite}`; + +const policy: ActionPolicy = { mode: "enforce", deny: [], allow: ["true"] }; + +/** + * The vault, and every method loud. + * + * A brokered call reaches no credential at all — the deployment's Composio key belongs to the + * transport and never travels through the store — and neither of the removals under test has a + * secret of this suite's to retire, because no `mcp_user_token` is ever minted here. So any call to + * any of these means this file has started exercising something it does not claim to, and a silent + * stub would hide that. + * + * Typed as the interface rather than left to inference, so the shape being stood in for is stated + * where a reader meets it instead of being inferred from the methods below. That annotation is + * documentation TODAY AND NOT A CHECK: `tests` is outside `server/tsconfig.json`'s `include`, so + * `tsc` never reads this file and a method added to the vault goes unremarked here — nothing in + * this directory would fail, and neither would the assignment further down. It is written anyway so + * that the day that directory is type-checked, this is already right. + */ +const credentialsStub: CredentialSecretReader & CredentialStore = { + readSecret: async () => { + throw new Error("a brokered call reads no credential"); + }, + create: async () => { + throw new Error("this suite does not write credentials"); + }, + updateSecret: async () => { + throw new Error("this suite does not write credentials"); + }, + rotate: async () => { + throw new Error("this suite does not write credentials"); + }, + revoke: async () => { + throw new Error("this suite mints no credential to revoke"); + }, + isLive: async () => { + throw new Error("this suite holds no credential to ask about"); + }, + findLiveByKey: async () => { + throw new Error("this suite holds no credential to ask about"); + }, +}; + +/** + * A store over the real database, keeping every event it writes. + * + * Recorded ALONGSIDE the real insert rather than instead of it: the payloads are what these tests + * assert about, and a store whose audit insert never touched the database would not be exercising + * the one it has. + * + * AND THOSE ROWS OUTLIVE THE RUN, WHICH {@link clean} CANNOT CHANGE. Every other table this file + * touches is swept on the way out; `audit_events` is not, and the omission is the database's rule + * rather than an oversight here. The trail is append-only, enforced by a trigger rather than by the + * application (`0007_audit_retention_window.sql`): a plain `delete` raises "Audit events are + * append-only", and the one exemption — a session that sets `openbot.audit_retention_days` to a + * positive whole number — still refuses any row younger than that many days. The rows this file + * writes are seconds old at the moment it would sweep them, so NO setting makes them deletable; + * `3650` is refused for the same reason `1` is. A cleanup here would be a statement that always + * throws. + * + * What that leaves is bounded rather than unbounded. Every row this file writes is keyed on an id + * carrying this run's suffix — `targetId` is {@link toolkit}, {@link secondToolkit}, + * {@link renamedId} or {@link ref} on every one of them — so they are findable, they belong to no + * other run, and the retention sweep removes them on its ordinary schedule once they age past the + * deployment's window. That is the same treatment every audit row in the product gets, and the + * guarantee that forbids the shortcut is the one the product sells. + */ +const events: Parameters["insert"]>[0][] = + []; +const persisting = createAuditStore(database); +const auditStore = { + insert: async (event: (typeof events)[number]) => { + events.push(event); + await persisting.insert(event); + }, +}; + +/** + * Every connection row THIS RUN owns, as `/`, in a fixed order. + * + * SCOPED TO THIS RUN'S APPS AND ORDERED, both load-bearing. Every name in {@link ownedToolkits} + * carries this run's suffix, so this reads nothing another run inserted — which matters most for + * the anonymous actor, whose half of the key names nobody and is therefore the one pair another run + * legitimately holds too. A read filtered on the person alone would take in every app's anonymous + * row at once, and a run that died before its cleanup would leave one standing that no cleanup here + * can reach: these tests run against the shared development database, so that row would redden this + * file for everybody until somebody edited the database by hand. The ordering is the same argument + * one step down — Postgres promises none without one, so an unordered read of several rows is + * compared against whichever order the plan happened to produce. + */ +async function connectionsHeld(): Promise { + const rows = await database + .select({ + toolkit: composioConnections.toolkit, + userId: composioConnections.userId, + }) + .from(composioConnections) + .where(inArray(composioConnections.toolkit, ownedToolkits)) + .orderBy(asc(composioConnections.toolkit), asc(composioConnections.userId)); + return rows.map((row) => `${row.toolkit}/${row.userId}`); +} + +/** + * THE BROKER, ASKED FOR REAL, because what these tests name is its own answer. + * + * Every `mcp.account_disconnected` row here carries `vendorRevocationRequested`, and the whole + * value of that field is that a reader can tell an account this deployment ended at Composio from + * one that outlives it there. A store built with NO broker cannot produce anything but `false` for + * it: `removeServer` and `retireConnectionsFor` both spell the absent-broker case as that constant. + * So a suite asserting `false` against a brokerless store was asserting the missing dependency and + * never the implementation — and the same absence hid the revokes themselves and the auth config, + * because with nothing to call, deleting all three call sites changed nothing this file could see. + * + * WHAT IS NOT NAMED THROWS, the discipline `plugin-store.integration.test.ts`'s own spy keeps, and + * for its reason. "The removal asked the broker to revoke" is worth little beside "and asked it + * nothing else": a removal that also listed the catalogue or began somebody's connection would be + * acting on somebody's behalf in a way nothing here has reasoned about, and a stub answering + * plausibly would let that pass unremarked. Nothing in this file lists the catalogue or begins + * somebody's connection, so those two methods have no caller here and say so. + * + * `ensureAuthConfig` and `isConnected` are the exceptions, and both are recorded rather than + * answered silently: the tests at the foot of this file enable an app for real and confirm a + * connection for real, so each has a caller — and every assertion above compares {@link asksMade} + * whole, so recording them keeps "and asked it nothing else" true of the removals as well. + */ +const unasked = (what: string) => async (): Promise => { + throw new Error(`this suite's path asked the broker to ${what}`); +}; + +/** + * Each ask that reached the vendor, in order, with what this run's table held at the moment of it. + * + * `held` IS HOW "REVOKE BEFORE DELETE" BECOMES AN ASSERTION, and that order is the whole of both + * removals: the row is the only thing in this deployment naming which app a person connected, so a + * delete that ran first would leave a failed revoke with nothing to revoke under — a live grant on + * somebody's mailbox that no operation here could reach. A spy that only counted calls would see + * the two orders identically, so each handler reads the table itself rather than recording its own + * arguments. + */ +const asks: { ask: string; held: string[] }[] = []; + +/** + * What the vendor was handed to connect somebody with, which is the ONE place it belongs. + * + * Kept beside {@link asks} rather than folded into it, because the two record opposite things. An + * ask is a sentence safe to compare and to print; this holds a person's own credential, and the + * only reason it is held at all is that "the secret is nowhere else" is worth nothing unless + * something also asserts it ARRIVED. A test that only looked for the absence would pass just as + * well against a method that sent Composio nothing. + */ +const valuesSent: Record[] = []; + +/** The asks alone, which is what an ordering assertion is about. */ +function asksMade(): string[] { + return asks.map((entry) => entry.ask); +} + +/** + * Whether the vendor finds an account to withdraw, which is the answer the trail has to carry. + * + * A function of the request rather than a flag, so one act can be given a different answer per + * person — the shape that tells a passed-through answer from a constant of either polarity. + */ +let vendorFinds: (request: { userId: string; toolkit: string }) => boolean = + () => true; + +/** + * Whether the vendor REFUSES TO ANSWER AT ALL, which is a different event from answering "none". + * + * `false` from {@link ComposioBroker.revoke} is a fact the vendor asserts — it looked and there was + * no account — and a retirement may finish on it. A throw asserts nothing: the account may be alive + * and untouched. The two must therefore end the act differently, and a seam that could only vary + * the boolean could never say so. Separate from {@link vendorFinds} for exactly that reason: one + * knob spelling both would read as though a refusal were a shade of "no". + */ +let vendorRefuses: (request: { userId: string; toolkit: string }) => boolean = + () => false; + +/** + * EVERY ACCOUNT THE VENDOR STILL HOLDS, which is what an undo has to be judged against. + * + * A list rather than a counter, because the question the narrow undo answers is WHICH account went: + * a sweep and a by-id withdrawal both leave "one fewer ask made" behind them, and they differ only + * in what Composio is still holding afterwards. {@link connectWithFields} adds the account it made, + * {@link ComposioBroker.revokeAccount} takes back the one it is handed, and {@link + * ComposioBroker.revoke} empties it for the app — so a test can seed {@link strandedAccountId} and + * assert it survived. + */ +let vendorHolds: string[] = []; + +/** + * Whether the vendor REFUSES TO TAKE ONE ACCOUNT BACK, which is the worst state this feature has. + * + * Separate from {@link vendorRefuses} rather than folded into it for the reason that knob is + * separate from {@link vendorFinds}: that one is about the sweep a retirement makes, this is about + * the by-id withdrawal a failed verification makes, and one flag spelling both would let a test + * about an undo pass on a stub that only ever refused a retirement. + */ +let vendorKeepsAccount = false; + +const broker: ComposioBroker = { + listApps: unasked("list the catalogue"), + ensureAuthConfig: async (config) => { + // Named by app AND kind, because the kind is what decides which config is created: an enable + // that forwarded nothing would record an ask whose second half is missing rather than one that + // merely differs. + asks.push({ + ask: `ensureAuthConfig:${config.toolkit}/${config.connection.kind}`, + held: await connectionsHeld(), + }); + }, + authorize: unasked("begin somebody's connection"), + isConnected: async (request) => { + asks.push({ + // Named by app AND person for the reason `revoke` is: a confirm is about one person's account + // at one app, and "a connection was checked" names neither. + ask: `isConnected:${request.toolkit}/${request.userId}`, + held: await connectionsHeld(), + }); + // Constant, and deliberately not a knob like {@link vendorFinds}. The no-answer is the branch + // that DELETES a row, which is somebody else's coverage; what this file asks of the confirm is + // what the yes-answer writes down, so a second polarity here would be a seam with no test + // behind it pretending the other branch were covered. + return true; + }, + revoke: async (request) => { + asks.push({ + // Named by app AND person: "two revokes happened" says nothing about who they were for, and + // for `removeServer` who they were for is the whole of what makes a removal repeatable. + ask: `revoke:${request.toolkit}/${request.userId}`, + held: await connectionsHeld(), + }); + // Recorded before it throws, so a refusal is still an ask that was made: the assertions about a + // refused act are about what reached the vendor before it stopped, and what did not. + if (vendorRefuses(request)) { + throw new Error( + `the vendor would not withdraw ${request.toolkit}/${request.userId}`, + ); + } + // A SWEEP, which is the whole difference from `revokeAccount` and the reason it is modelled + // here at all: this ends every account the person holds for the app, including one this + // deployment never made. See {@link strandedAccountId}. + vendorHolds = []; + return vendorFinds(request); + }, + deleteAuthConfig: async (forToolkit) => { + // Named by app as well, because the app and the `mcp_servers` id are allowed to differ and the + // config belongs to the app. A removal that dropped the config for the row id would be deleting + // a shape this deployment never made and leaving standing the one it did. + asks.push({ + ask: `deleteAuthConfig:${forToolkit}`, + held: await connectionsHeld(), + }); + }, + // Nothing here draws a connect form, so nobody asks what an app wants typed. + connectionFields: unasked("ask what an app wants typed"), + connectWithFields: async (request) => { + asks.push({ + // Named by app AND person, for `isConnected`'s reason: a connection is one person's account + // at one app, and "a connection was made" names neither. The values are deliberately NOT in + // this string — it is compared, printed on failure, and read by whoever is debugging. + ask: `connectWithFields:${request.toolkit}/${request.userId}`, + held: await connectionsHeld(), + }); + valuesSent.push(request.values); + // Held from here, so that what the vendor is left with afterwards is a fact about the act + // rather than about the fixture: the account exists because this call made it. + vendorHolds.push(madeAccountId); + return { accountId: madeAccountId }; + }, + revokeAccount: async (accountId) => { + asks.push({ + // Named by the ACCOUNT ID and by nothing else, because that is the whole of what this method + // is handed and the whole of what makes it narrow. An ask spelled by app and person would be + // indistinguishable from `revoke`'s, which is the call this one exists not to be. + ask: `revokeAccount:${accountId}`, + held: await connectionsHeld(), + }); + // Recorded before it refuses, for `revoke`'s reason: an undo that failed is still an undo that + // was attempted, and the tests about that state assert both halves. + if (vendorKeepsAccount) { + throw new Error(`the vendor would not take ${accountId} back`); + } + vendorHolds = vendorHolds.filter((held) => held !== accountId); + }, +}; + +const store = createPluginStore({ + database, + auditStore, + broker, + credentials: credentialsStub, + encryptionKey: "x".repeat(44), + policy: () => policy, +}); + +/** Every action Composio was asked to run, so "was this call made" is an assertion and not a guess. */ +const reached: string[] = []; + +const answered: ComposioResult = { data: {}, error: null, successful: true }; + +/** + * A client that answers everything, so a refusal in these tests is always this deployment's. + * + * The vendor is a process-wide registry, so `afterEach` takes it back out: a stub outliving its test + * would be answering another file's calls. + */ +function useAnsweringClient(actions: Partial = {}) { + useComposioClient({ + listActions: async () => [], + execute: async ({ slug }) => { + reached.push(slug); + return answered; + }, + ...actions, + }); +} + +/** Only this run's rows, and every one of them keyed on an id this run invented. */ +async function clean() { + await database.delete(pluginGrants).where(eq(pluginGrants.agentId, botId)); + await database.delete(agents).where(eq(agents.id, botId)); + await database + .delete(mcpTools) + .where( + inArray(mcpTools.serverId, [ + toolkit, + renamedId, + enabledId, + rekeyedId, + probeAppId, + probedId, + ]), + ); + await database + .delete(mcpServers) + .where( + inArray(mcpServers.id, [ + toolkit, + renamedId, + enabledId, + rekeyedId, + probeAppId, + probedId, + ]), + ); + await database + .delete(composioConnections) + .where(inArray(composioConnections.toolkit, ownedToolkits)); + await database.delete(users).where(inArray(users.id, [askerId, leaverId])); +} + +/** + * The stand-in for somebody else's fixture, taken back by hand. + * + * Deliberately NOT part of {@link clean}, because a test below asserts that `clean` leaves this row + * standing: folding it in would make that assertion agree with itself. Run beside `clean` from + * `beforeEach` and `afterAll` instead, so the row cannot outlive the run even if the test that + * inserts it dies partway — the same shared database that makes the row worth protecting makes a + * leaked one everybody's problem. + */ +async function cleanForeign() { + await database + .delete(composioConnections) + .where(eq(composioConnections.toolkit, foreignToolkit)); +} + +/** Which of this run's app rows the deployment still holds, so "the app survived" is an assertion. */ +async function appsHeld(): Promise { + const rows = await database + .select({ id: mcpServers.id }) + .from(mcpServers) + .where(inArray(mcpServers.id, [toolkit, renamedId])) + .orderBy(asc(mcpServers.id)); + return rows.map((row) => row.id); +} + +/** The app's row and its one granted action. Separated from the Bot, so a re-add can reuse the Bot. */ +async function addApp() { + await database.insert(mcpServers).values({ + id: toolkit, + title: "Revocable App", + vendor: "Composio", + url: `composio://${toolkit}`, + provenance: "composio", + }); + await database.insert(mcpTools).values({ + serverId: toolkit, + name: actionName, + description: "Fetch some items.", + effect: "read", + version: "20260903_00", + }); + await store.grant("mcp", ref, botId, admin); +} + +/** The app, a Bot holding its one action, and optionally somebody who has connected it. */ +async function seedApp(options: { connect?: boolean } = {}) { + await database.insert(agents).values({ + id: botId, + name: "Helper", + type: "built_in", + configuration: {}, + }); + await addApp(); + if (options.connect !== false) { + await database + .insert(composioConnections) + .values({ toolkit, userId: askerId }); + } +} + +/** + * The probed app as an administrator's press of Add leaves it, plus the action a probe may use. + * + * ENABLED FOR REAL RATHER THAN INSERTED BY HAND, because what the connect path reads off the row is + * the `auth_scheme` — and a fixture that wrote it itself would be asserting this file's idea of what + * Add records instead of `addBrokeredApp`'s. The action is inserted directly afterwards, the way the + * two chooser tests above insert theirs: nothing here is about how a listing turns Composio's tags + * into an effect, and a stub that had to spell those tags would make every test below depend on it. + * + * `withProbe: false` LEAVES THE APP WITH NO ACTIONS AT ALL, which is an ordinary app and not a + * broken one: most key-based apps in the live catalogue publish some argument-less read and PostHog + * publishes none. + */ +async function addProbedApp(options: { withProbe?: boolean } = {}) { + await store.addBrokeredApp({ + slug: probedToolkit, + title: "Probed App", + by: admin, + connection: { kind: "fields", authScheme: "API_KEY" }, + }); + if (options.withProbe === false) return; + await database.insert(mcpTools).values({ + serverId: probedId, + name: probeAction, + description: "Says who the key belongs to.", + effect: "read", + version: probeVersion, + }); +} + +/** + * Which of THIS RUN'S apps this deployment still believes somebody has connected. + * + * Narrowed to named apps and ordered for the reason {@link connectionsHeld} gives, which is the + * same reason and matters for the same row: the anonymous actor. `notNull` admits the empty string, + * so `(toolkit, "")` is a legal pair and every run of this file inserts one — and the only half of + * it that is this run's is the app. Asking what `""` has connected across the whole table therefore + * reads every other run's anonymous row too, including one left behind by a run that was + * interrupted before its cleanup; against the shared development database that row is permanent, + * unreachable by the cleanup here, and reddens this file for everybody until the database is edited + * by hand. Narrowing to named apps is what makes the assertion about this run. + * + * `within` DEFAULTS TO THIS RUN'S OWN APPS and is passed explicitly only to ask about + * {@link foreignToolkit} — the one row this file holds that it deliberately does not own, and + * therefore the one it has to be able to ask about separately. + */ +async function connectedToolkitsFor( + userId: string, + within: string[] = ownedToolkits, +): Promise { + const rows = await database + .select({ toolkit: composioConnections.toolkit }) + .from(composioConnections) + .where( + and( + eq(composioConnections.userId, userId), + inArray(composioConnections.toolkit, within), + ), + ) + .orderBy(asc(composioConnections.toolkit)); + return rows.map((row) => row.toolkit); +} + +function recordedOfType(eventType: string) { + return events.filter((event) => event.eventType === eventType); +} + +// Cleaning BEFORE each test as well as after the run, so a run that dies halfway leaves the next +// one nothing to trip over. +beforeEach(async () => { + await clean(); + await cleanForeign(); + events.length = 0; + reached.length = 0; + asks.length = 0; + valuesSent.length = 0; + // The vendor finding an account is the ordinary case — somebody connected, so there is a grant to + // withdraw. The one test about the answer itself says otherwise for itself. + vendorFinds = () => true; + // And answering at all is the ordinary case too. A vendor that will not answer is the subject of + // its own two tests and of nothing else. + vendorRefuses = () => false; + // The vendor holding nothing is where every test starts, so an account in the list below is one + // the test under way either made or seeded on purpose. + vendorHolds = []; + // And taking an account back when asked is the ordinary case, for {@link vendorRefuses}' reason: + // the refusal is the subject of exactly one test. + vendorKeepsAccount = false; +}); + +afterEach(() => useComposioClient(null)); + +afterAll(async () => { + await clean(); + await cleanForeign(); +}); + +/** + * OFFBOARDING. The act an administrator is told removes somebody's access. + * + * The call is made first, so what follows is an assertion about the retirement rather than about the + * fixture. Reaching the vendor a second time would be the person's mailbox being opened after they + * were removed. + */ +test("offboarding somebody retires the app they connected, and the next call is refused", async () => { + await seedApp(); + useAnsweringClient(); + + await store.callTool({ ref, args: {}, botId, actorId: askerId }); + expect(reached).toEqual([actionName]); + + const { retired } = await store.retireConnectionsFor(askerId, admin); + + // Counted, because the number is what "we removed their access" claims. Reporting the vault's + // tally alone would say nothing was retired for somebody whose only connector was brokered. + expect(retired).toBe(1); + expect(await connectedToolkitsFor(askerId)).toEqual([]); + + /* + * THE ACCOUNT ENDED AT THE VENDOR, not merely forgotten here, which is the half an administrator + * was actually promised. Deleting the row shuts the gate this deployment owns and does nothing to + * the grant: the person's mailbox stays attached at Composio and the offboarding was a lie about + * the only thing that matters. So the ask is asserted, and asserted WHILE THE ROW STILL STOOD — + * the row is the only thing naming which app to revoke, so the other order leaves a failed revoke + * with nothing to revoke under. + */ + expect(asksMade()).toEqual([`revoke:${toolkit}/${askerId}`]); + expect(asks[0].held).toEqual([`${toolkit}/${askerId}`]); + + await expect( + store.callTool({ ref, args: {}, botId, actorId: askerId }), + ).rejects.toThrow(/have not connected/i); + expect(reached).toEqual([actionName]); + + const disconnected = recordedOfType("mcp.account_disconnected"); + expect(disconnected).toHaveLength(1); + expect(disconnected[0].payload).toMatchObject({ + actor: admin, + server: toolkit, + owner: askerId, + // An administrator removing somebody, never somebody changing their own mind. And true because + // the broker answered that it had found this person's account and asked for its withdrawal: + // the field is the vendor's own answer passed through, not that a call was made. + reason: "person_removed", + vendorRevocationRequested: true, + }); +}); + +/** + * THE GATE, AFTER THE PERSON IS GONE. + * + * `composio_connections.user_id` carries no foreign key by design, so deleting somebody's `users` + * row leaves their connection standing — and the gate reads nothing but `(toolkit, user_id)`, so it + * goes on passing for an id no person answers to. That is the state offboarding exists to end, and + * it is the one the vault-based retirement cannot reach: there is no secret here to scan for, + * because Composio holds the account. + */ +test("a connection whose person is already deleted is retired, and stops passing the gate", async () => { + await seedApp({ connect: false }); + useAnsweringClient(); + + await database + .insert(users) + .values({ id: leaverId, email: `${leaverId}@example.com`, name: "Leaver" }); + await database + .insert(composioConnections) + .values({ toolkit, userId: leaverId }); + await database.delete(users).where(eq(users.id, leaverId)); + + // The design fact this rests on: the row outlives the person, which is what leaves anything to + // find. Asserted rather than assumed, because the retirement below is pointless without it. + expect(await connectedToolkitsFor(leaverId)).toEqual([toolkit]); + + const { retired } = await store.retireConnectionsFor(leaverId, admin); + expect(retired).toBe(1); + expect(await connectedToolkitsFor(leaverId)).toEqual([]); + // The grant is withdrawn for somebody who no longer exists here, which is the point of the row + // outliving the person: nothing else in this deployment still names the app they connected. + expect(asksMade()).toEqual([`revoke:${toolkit}/${leaverId}`]); + expect(asks[0].held).toEqual([`${toolkit}/${leaverId}`]); + + await expect( + store.callTool({ ref, args: {}, botId, actorId: leaverId }), + ).rejects.toThrow(/have not connected/i); + expect(reached).toEqual([]); +}); + +/** Retiring twice is something an administrator may legitimately do, and the second time is quiet. */ +test("retiring the same person twice retires nothing the second time", async () => { + await seedApp(); + useAnsweringClient(); + + expect((await store.retireConnectionsFor(askerId, admin)).retired).toBe(1); + expect(asksMade()).toEqual([`revoke:${toolkit}/${askerId}`]); + + expect((await store.retireConnectionsFor(askerId, admin)).retired).toBe(0); + // Quiet at the vendor too, and not only in the count. The rows are gone, so there is no app left + // to name — a second pass that asked Composio again would be this deployment guessing. + expect(asksMade()).toEqual([`revoke:${toolkit}/${askerId}`]); +}); + +/** + * A REFUSAL AT THE VENDOR MUST NOT BECOME A RETIREMENT HERE. + * + * CRITERION. When the broker will not withdraw the grant, `retireConnectionsFor` fails, the row + * stands, the gate still passes, and nothing is written to the trail. + * + * REASON. The row is the only thing in this deployment naming which app this person connected. A + * retirement that swallowed the refusal would delete it and report success, and what is left is the + * worst state the design admits: a live grant on a departed person's mailbox that no operation here + * can reach any more, under an administrator who has been told their access was removed. Dead and + * reachable beats live and unreachable, so the failure has to be loud and the row has to survive it. + * Repeating the act is the recovery, and repeating it is only possible while the row is there. + * + * THE GATE IS ASKED AFTERWARDS, not merely the table. "The row exists" and "the row still works" + * come apart if a retirement ever clears part of the state before failing, and it is the second + * that describes the person's access. + */ +test("an offboarding the vendor refuses leaves the connection standing", async () => { + await seedApp(); + vendorRefuses = () => true; + + await expect(store.retireConnectionsFor(askerId, admin)).rejects.toThrow( + /would not withdraw/i, + ); + + // The ask was made and the answer never came, which is the state the row has to survive. + expect(asksMade()).toEqual([`revoke:${toolkit}/${askerId}`]); + expect(await connectedToolkitsFor(askerId)).toEqual([toolkit]); + // No trail row either: `mcp.account_disconnected` says an account ended, and none did. + expect(recordedOfType("mcp.account_disconnected")).toHaveLength(0); + + useAnsweringClient(); + await store.callTool({ ref, args: {}, botId, actorId: askerId }); + expect(reached).toEqual([actionName]); +}); + +/** + * THE OFFBOARDING TRAIL CARRIES THE VENDOR'S ANSWER PER APP, AND IN A FIXED ORDER. + * + * CRITERION. One act, two of this person's apps, the vendor finding an account for one and none for + * the other: each row's `vendorRevocationRequested` is the answer about ITS app, and both the asks + * and the rows come out in `toolkit` order. + * + * REASON. This is the same criterion `removeServer` already has a two-person fixture for, on the + * other act that ends a brokered connection — and the two paths are separate code with separate + * maps, so a fixture on one says nothing about the other. Until now this one was only ever run with + * a single connection, which a hardcoded `true` satisfies exactly as well as a passed-through + * answer; the field then reads as evidence about every row while describing none of them, which is + * what it was renamed away from. + * + * TWO APPS RATHER THAN TWO PEOPLE, because an offboarding is one person by definition. The map this + * path keeps is keyed on the app for the same reason, so the app is where a constant would show. + * + * INSERTED IN THE WRONG ORDER DELIBERATELY. The expected order is the sorted one, and a read with no + * `order by` most often hands back what was inserted — so a fixture inserted in sorted order agrees + * with an unordered read by accident and the ordering assertion proves nothing. Inserting the later + * name first is what makes the sort the only thing that could have produced the expected answer. + */ +test("an offboarding carries the vendor's answer per app, in a fixed order", async () => { + await seedApp({ connect: false }); + await database + .insert(composioConnections) + .values({ toolkit: secondToolkit, userId: askerId }); + await database + .insert(composioConnections) + .values({ toolkit, userId: askerId }); + vendorFinds = ({ toolkit: asked }) => asked === toolkit; + + expect((await store.retireConnectionsFor(askerId, admin)).retired).toBe(2); + + expect(asksMade()).toEqual([ + `revoke:${toolkit}/${askerId}`, + `revoke:${secondToolkit}/${askerId}`, + ]); + // Both asks made while both rows still stood: the apps are read off the rows, so a delete between + // the two would leave the second revoke with nothing to name. + const bothHeld = [`${toolkit}/${askerId}`, `${secondToolkit}/${askerId}`]; + expect(asks[0].held).toEqual(bothHeld); + expect(asks[1].held).toEqual(bothHeld); + expect(await connectedToolkitsFor(askerId)).toEqual([]); + + const disconnected = recordedOfType("mcp.account_disconnected"); + expect(disconnected).toHaveLength(2); + // Compared in order rather than as a set, because the order is half the criterion. Not sorted + // here either: sorting the answer before comparing it is how an ordering assertion stops being one. + expect( + disconnected + .map( + (event) => + event.payload as { + server: string; + vendorRevocationRequested: boolean; + }, + ) + .map(({ server, vendorRevocationRequested }) => ({ + server, + vendorRevocationRequested, + })), + ).toEqual([ + { server: toolkit, vendorRevocationRequested: true }, + { server: secondToolkit, vendorRevocationRequested: false }, + ]); +}); + +/** + * THE ANONYMOUS ACTOR OWNS NOTHING, and `notNull` does not exclude the empty string, so a row at + * `(toolkit, "")` is legal. Retiring "nobody" must not be what deletes it — that would be an + * unattributed offboarding reaching a row it cannot possibly own. + * + * WHOSE ROW THIS IS, since the actor half of the key names nobody. The app half does: {@link + * toolkit} carries this run's suffix, so the sweep in `clean` takes this row by the same clause it + * takes the asker's by, and no other file can arrive at the pair by guessing. That is the whole of + * the ownership — a delete keyed on `user_id = ''` alone would reach every app's anonymous row at + * once, which is how this fixture came to be removed mid-run by another file, and how a run of + * this file that died before its cleanup came to refuse every test in that one. + */ +test("retiring nobody retires nothing and leaves the anonymous row alone", async () => { + await seedApp({ connect: false }); + await database.insert(composioConnections).values({ toolkit, userId: "" }); + + expect((await store.retireConnectionsFor("", admin)).retired).toBe(0); + expect(await connectedToolkitsFor("")).toEqual([toolkit]); + // And nothing reached Composio either. An unattributed offboarding has no account to name, so a + // revoke sent under an empty user id would be this deployment asking the vendor about nobody. + expect(asksMade()).toEqual([]); +}); + +/** + * The fixture above is taken back by the same sweep every other row here is, and by nothing wider. + * + * CRITERION. Two halves, and the second is the one that has teeth. After the sweep this run holds + * no `composio_connections` row at all — the one at the anonymous actor included, which none of the + * person ids that sweep names would reach — AND an anonymous row belonging to somebody else is + * still standing. + * + * REASON. Brokered connections are removed here by toolkit, so the anonymous row is already + * covered and needs no second, broader delete to reach it. Asserted rather than read off the code, + * because the tempting spelling for "take the anonymous row too" is `user_id = ''`, which is every + * app at once: the sweep that lands on another file's fixture. + * + * WHY THE SECOND HALF IS NOT OPTIONAL. "This run's rows are gone" is satisfied just as well by the + * wider delete as by the narrow one — a `user_id = ''` sweep takes this run's anonymous row too, + * and every assertion about absence goes on passing while the defect it forbids is present. Only a + * row the correct sweep must LEAVE BEHIND can tell the two deletes apart, so {@link foreignToolkit} + * stands in for one: a row this file inserted, deliberately outside {@link ownedToolkits}, at the + * pair another run legitimately holds. It is cleaned up by {@link cleanForeign} rather than by the + * sweep under test, for the reason given there. + */ +test("the sweep takes this run's anonymous row without reaching by actor", async () => { + await seedApp({ connect: false }); + await database.insert(composioConnections).values({ toolkit, userId: "" }); + // Somebody else's anonymous row, at an app this file's sweep does not name. + await database + .insert(composioConnections) + .values({ toolkit: foreignToolkit, userId: "" }); + expect(await connectedToolkitsFor("")).toEqual([toolkit]); + + await clean(); + + expect(await connectionsHeld()).toEqual([]); + // And the row that was never this sweep's to take is exactly where it was. This is the assertion + // a delete keyed on `user_id = ''` fails, and the only one here that it fails. + expect(await connectedToolkitsFor("", [foreignToolkit])).toEqual([ + foreignToolkit, + ]); +}); + +/** + * REMOVING THE APP. The second act that has to end a brokered connection. + * + * Nothing else can: the table references `mcp_servers` no more than it references `users`, so the + * rows simply stand there once the app's row is gone. + */ +test("removing the app takes every brokered connection to it", async () => { + await seedApp(); + useAnsweringClient(); + + await store.callTool({ ref, args: {}, botId, actorId: askerId }); + + await store.removeServer(toolkit, admin); + + expect(await connectedToolkitsFor(askerId)).toEqual([]); + + /* + * THE THREE ASKS THIS ACT OWES THE VENDOR, IN THIS ORDER. + * + * Every connected person revoked first, while the rows naming the app still stand, for the reason + * offboarding revokes first. Then the auth config, LAST OF ALL: an orphaned config grants nobody + * anything, while a live account whose config has already been deleted is access nothing left + * here can end. And the config is dropped for the APP, which the `deleteAuthConfig:` half of the + * entry carries. + */ + expect(asksMade()).toEqual([ + `revoke:${toolkit}/${askerId}`, + `deleteAuthConfig:${toolkit}`, + ]); + expect(asks[0].held).toEqual([`${toolkit}/${askerId}`]); + expect(asks[1].held).toEqual([]); + + const disconnected = recordedOfType("mcp.account_disconnected"); + expect(disconnected).toHaveLength(1); + expect(disconnected[0].payload).toMatchObject({ + actor: admin, + server: toolkit, + owner: askerId, + // An administrator took the whole app away and the person did nothing. Distinct from both + // "they disconnected" and "they were removed", which is what an auditor is trying to tell apart. + reason: "mcp_server_removed", + // And the vendor's own answer about this person's account, passed through. + vendorRevocationRequested: true, + }); +}); + +/** + * THE SAME REFUSAL, ON THE OTHER ACT, WHERE MORE IS AT STAKE. + * + * CRITERION. When the broker will not withdraw a grant, `removeServer` fails, the connection rows + * stand, the auth config is not dropped, and the app's own row is still there. + * + * REASON. The app row is the load-bearing extra. The toolkit is readable in exactly one place — the + * slug in `mcp_servers.url` — so an app deleted with its connections still standing is a set of live + * grants that nothing in this deployment can name, let alone end. A removal that swallowed the + * refusal would do precisely that and report the connector gone. Failing with everything in place + * costs a repeat of an administrative act nobody minds repeating. + * + * AND THE CONFIG STAYS, which is the ordering argument from the other side. The auth config is + * dropped last because a live account whose config has already been deleted is access nothing left + * here can end; a refusal partway through must not reach that step either. + */ +test("an app removal the vendor refuses leaves the app and its connections standing", async () => { + await seedApp(); + vendorRefuses = () => true; + + await expect(store.removeServer(toolkit, admin)).rejects.toThrow( + /would not withdraw/i, + ); + + // The revoke was attempted; nothing after it ran. Asserted as the whole list, because what makes + // this pass is as much the `deleteAuthConfig:` that is absent as the `revoke:` that is present. + expect(asksMade()).toEqual([`revoke:${toolkit}/${askerId}`]); + expect(await connectedToolkitsFor(askerId)).toEqual([toolkit]); + expect(recordedOfType("mcp.account_disconnected")).toHaveLength(0); + expect(await appsHeld()).toEqual([toolkit]); +}); + +/** + * REMOVING AN APP NOBODY EVER CONNECTED. + * + * CRITERION. No revoke reaches the vendor, and the auth config is dropped all the same. + * + * REASON. The two halves fail in opposite directions and neither had a test. A revoke sent with + * nobody to name would be this deployment asking Composio about a person who never connected — the + * same defect the anonymous-actor tests forbid on the other act, reached from the other end. And + * skipping the vendor entirely because the connection table happened to be empty would strand the + * auth config: it is a shape this deployment created at Composio when the app was added, it belongs + * to the app and not to anybody's account, and this is the only act that takes it. An app added and + * removed without a single person connecting is an ordinary sequence — a trial, a mistake, a + * rename — so the config it leaves behind is the ordinary case and not the rare one. + */ +test("removing an app nobody connected asks about nobody and still drops the config", async () => { + await seedApp({ connect: false }); + + await store.removeServer(toolkit, admin); + + expect(asksMade()).toEqual([`deleteAuthConfig:${toolkit}`]); + // Nobody's account ended, so nothing claims one did. + expect(recordedOfType("mcp.account_disconnected")).toHaveLength(0); + expect(await appsHeld()).toEqual([]); +}); + +/** + * WHAT WAS ASKED OF THE VENDOR, NOT THAT A CALL WAS MADE. + * + * CRITERION. `vendorRevocationRequested` on each row is the broker's own answer about THAT person. + * + * REASON. The field exists so a reader can tell an account this deployment ended at Composio from + * one that outlives it somewhere else — a gate cleared here with no grant left at the vendor, and a + * grant the vendor really held and was asked to withdraw. A constant is worse than no field at all, + * because it reads as evidence about every row while describing none of them; it is how the field + * came to be renamed from `vendorRevoked`, when every row saying a grant had been withdrawn was + * describing one still live at Google. + * + * TWO PEOPLE IN ONE ACT, the vendor finding an account for one and none for the other, is the + * smallest shape that tells a passed-through answer from a constant of EITHER polarity: one row + * alone is satisfied by a hardcoded `true` just as the brokerless store satisfied a hardcoded + * `false`. + */ +test("the trail carries the vendor's answer per person, not one answer for the act", async () => { + await seedApp(); + await database + .insert(composioConnections) + .values({ toolkit, userId: leaverId }); + vendorFinds = ({ userId }) => userId === askerId; + + await store.removeServer(toolkit, admin); + + expect(asksMade()).toEqual([ + `revoke:${toolkit}/${askerId}`, + `revoke:${toolkit}/${leaverId}`, + `deleteAuthConfig:${toolkit}`, + ]); + + const disconnected = recordedOfType("mcp.account_disconnected"); + expect(disconnected).toHaveLength(2); + expect( + disconnected + .map( + (event) => + event.payload as { + owner: string; + vendorRevocationRequested: boolean; + }, + ) + .map(({ owner, vendorRevocationRequested }) => ({ + owner, + vendorRevocationRequested, + })) + .sort((left, right) => left.owner.localeCompare(right.owner)), + ).toEqual([ + { owner: askerId, vendorRevocationRequested: true }, + { owner: leaverId, vendorRevocationRequested: false }, + ]); +}); + +/** + * ONE KEY FOR "WHAT HAPPENED TO THIS PERSON'S ACCESS", across both acts that can end it. + * + * CRITERION. Every `mcp.account_disconnected` row a brokered connection produces names the APP at + * the broker — in `targetId` and in `payload.server` — whichever act produced it. + * + * REASON. The two acts were written in different waves and keyed differently. Offboarding files + * under `connection.toolkit`, which is all a connection row records and all that is left once the + * server row is gone. Removing the app filed under the `mcp_servers` id. Where the two spellings + * agree — which they do in every other fixture in this file, and in the product whenever nobody + * renamed anything — the disagreement is invisible; where they differ, no single query answers + * what happened to one person's access, because half the rows are filed under a name the other + * half never mentions. + * + * THE APP IS THE RIGHT KEY, not the row id. A brokered connection is consent to an app: the gate + * is `(toolkit, user_id)`, `removeServer` clears it by toolkit, and the row outlives the + * `mcp_servers` row entirely — so the id is not always available and is never what was consented + * to. Which server row was removed is not lost either: the `configuration.changed` row written in + * the same call names it. + */ +test("both acts that end a brokered connection file it under the app", async () => { + await database.insert(agents).values({ + id: botId, + name: "Helper", + type: "built_in", + configuration: {}, + }); + // The row id and the app slug deliberately different, which is the only shape that can tell the + // two keys apart. + await database.insert(mcpServers).values({ + id: renamedId, + title: "Revocable App", + vendor: "Composio", + url: `composio://${toolkit}`, + provenance: "composio", + }); + await database.insert(composioConnections).values([ + { toolkit, userId: askerId }, + { toolkit, userId: leaverId }, + ]); + + // Offboarding one person, then removing the app out from under the other. + expect((await store.retireConnectionsFor(leaverId, admin)).retired).toBe(1); + await store.removeServer(renamedId, admin); + + // The broker is asked about the APP in both acts, and the auth config dropped for the app too — + // never for the row id, which is a display key the vendor has never heard of. This is the one + // fixture where the two spellings differ, so it is the only one that can tell them apart. + expect(asksMade()).toEqual([ + `revoke:${toolkit}/${leaverId}`, + `revoke:${toolkit}/${askerId}`, + `deleteAuthConfig:${toolkit}`, + ]); + + const disconnected = recordedOfType("mcp.account_disconnected"); + expect(disconnected).toHaveLength(2); + // Both rows, under one key. Asked as the set of keys rather than row by row, because what the + // criterion is about is a query finding all of them at once. + expect(new Set(disconnected.map((event) => event.targetId))).toEqual( + new Set([toolkit]), + ); + expect( + new Set( + disconnected.map((event) => (event.payload as { server: string }).server), + ), + ).toEqual(new Set([toolkit])); + + // And each still says which person and which of the three things happened to them, which is the + // other half of the question and was never the part that was wrong. + expect( + disconnected + .map((event) => event.payload as { owner: string; reason: string }) + .map(({ owner, reason }) => ({ owner, reason })) + .sort((left, right) => left.owner.localeCompare(right.owner)), + ).toEqual([ + { owner: askerId, reason: "mcp_server_removed" }, + { owner: leaverId, reason: "person_removed" }, + ]); +}); + +/** + * CONSENT MUST NOT REATTACH. + * + * Removing an app and adding it back is two ordinary administrative acts. If the connection rows + * survive them, the second act silently restores everybody's brokered access without anybody being + * asked again — and the only visible difference between an app nobody has connected and an app + * everybody is still connected to is whether a call goes out. + */ +test("adding the app back does not restore a connection nobody re-granted", async () => { + await seedApp(); + useAnsweringClient(); + + await store.callTool({ ref, args: {}, botId, actorId: askerId }); + expect(reached).toEqual([actionName]); + + await store.removeServer(toolkit, admin); + expect(asksMade()).toEqual([ + `revoke:${toolkit}/${askerId}`, + `deleteAuthConfig:${toolkit}`, + ]); + // The same app at the same id, added again. Only the server and its action: the Bot's grant + // survived the removal on its own, which is a separate defect about `plugin_grants` and not this + // one. Added by insert rather than through `addBrokeredApp`, so nothing asks the broker again — + // the refusal below is the consent being gone and not an auth config that was never remade. + await addApp(); + + await expect( + store.callTool({ ref, args: {}, botId, actorId: askerId }), + ).rejects.toThrow(/have not connected/i); + expect(reached).toEqual([actionName]); +}); + +/** + * THE TRAIL, WHERE NOBODY WAS ASKING. + * + * An empty string in a field whose purpose is to name who did something is worse than an absent + * field: it reads as a value, and a reader counting rows by actor gets a person called "". + * + * `reachedAs` and `actor` are the two on this row, and both are the run's actor verbatim. A brokered + * app is reached AS THE PERSON, so a run nobody could be attributed to has no name to put in either + * — and the refusal is recorded, which is exactly when the trail matters. + */ +test("an unattributed run is recorded as unattributed rather than as a blank", async () => { + await seedApp(); + useAnsweringClient(); + + await expect( + store.callTool({ ref, args: {}, botId, actorId: "" }), + ).rejects.toThrow(/not attributed to anybody/i); + expect(reached).toEqual([]); + + const failed = recordedOfType("mcp.call_failed"); + expect(failed).toHaveLength(1); + // Both fields, exactly. `reachedAs` is "unattributed" and so by that very assertion is not + // "deployment": this call did not go out on a shared credential, it did not go out at all, and + // saying the deployment reached the app would assert an attribution that never happened. A + // separate `not.toBe("deployment")` below this would be that same claim restated more weakly, + // green for every wrong value but one. + expect(failed[0].payload).toMatchObject({ + actor: "unattributed", + reachedAs: "unattributed", + }); +}); + +/** + * THE TRAIL, WHERE THE DEPLOYMENT WAS THE ONE ACTING. + * + * `refreshTools` defaults its actor to the empty string, and `addServer` and `addCustomServer` both + * take that default — deliberately, because that argument doubles as the credential to list with and + * nobody can have connected an app in the moment it is added. So the absence is real and permanent, + * and what the trail owes a reader is the distinction: not a person, and not nobody either, but the + * deployment refreshing on its own behalf. `reachedAs` already spells that "deployment". + */ +test("the refresh that follows an add is attributed to the deployment", async () => { + await seedApp(); + // A different action, so the granted one is left held and not advertised — which is the audit row + // under test. + useAnsweringClient({ + listActions: async () => [ + { + slug: "APP_SOMETHING_ELSE", + description: "Not the one anybody holds.", + version: "20260903_00", + }, + ], + }); + + // No actor, which is exactly what the add path passes. + await store.refreshTools(toolkit); + + const stranded = events.filter( + (event) => + (event.payload as { change?: string }).change === "grants_not_advertised", + ); + expect(stranded).toHaveLength(1); + expect(stranded[0].payload).toMatchObject({ + actor: "deployment", + refs: [ref], + }); +}); + +/** + * ENABLING AN APP THAT IS ALREADY HERE, which is what pressing Add a second time is. + * + * `addBrokeredApp` is idempotent by design — two administrators can press Add together, and an app + * can be removed and added again — so the second press takes the upsert's update branch. Everything + * on that branch is a display fact the vendor is allowed to restate: the title, the url, who added + * it. `auth_scheme` is not. It is what this deployment's authorization config was created AS, and + * every connection anybody has made against that config depends on it, so re-enabling has to leave + * it standing: a vendor that starts publishing managed OAuth for an app somebody connected by key + * would otherwise, one press of Add later, have this deployment minting consent links against a + * config full of keys. + */ +test("re-enabling never moves a connected app onto a different flow", async () => { + useAnsweringClient(); + await store.addBrokeredApp({ + slug: enabledToolkit, + title: "Enablable App", + by: admin, + connection: { kind: "fields", authScheme: "API_KEY" }, + }); + await database + .insert(composioConnections) + .values({ toolkit: enabledToolkit, userId: askerId }); + + await store.addBrokeredApp({ + slug: enabledToolkit, + title: "Enablable App", + by: admin, + connection: { kind: "consent" }, + }); + + const [row] = await database + .select({ authScheme: mcpServers.authScheme }) + .from(mcpServers) + .where(eq(mcpServers.id, enabledId)); + expect(row.authScheme).toBe("API_KEY"); +}); + +/** + * AND THE ONE CASE WHERE THE REWRITE IS BOTH SAFE AND THE POINT. + * + * The rule above is about not stranding connections, so where there are none there is nothing to + * strand. Re-enabling is then how an operator picks up a vendor's change — without it the only way + * to record a new scheme would be removing the app and adding it back, which takes its grants with + * it. So the column is write-once EXCEPT here, and this test is the half of that sentence the test + * above cannot state. + */ +test("re-enabling an app nobody has connected picks up the vendor's change", async () => { + useAnsweringClient(); + await store.addBrokeredApp({ + slug: enabledToolkit, + title: "Enablable App", + by: admin, + connection: { kind: "fields", authScheme: "API_KEY" }, + }); + + await store.addBrokeredApp({ + slug: enabledToolkit, + title: "Enablable App", + by: admin, + connection: { kind: "consent" }, + }); + + const [row] = await database + .select({ authScheme: mcpServers.authScheme }) + .from(mcpServers) + .where(eq(mcpServers.id, enabledId)); + expect(row.authScheme).toBe("OAUTH2"); +}); + +/** + * CONFIRMING A CONNECTION RECORDS IT VERIFIED, BECAUSE A CONSENT SCREEN IS A VERIFICATION. + * + * CRITERION. After a confirm the vendor answers yes to, the row reads `verified` true and carries a + * `verified_at` no earlier than the moment the confirm was made. + * + * REASON. `verified` is what the settings page dates its sentence from — "connected, last checked + * 13 Sep" rather than a present tense this deployment has not earned — and the pair separates a + * connection whose liveness somebody established from one nobody ever checked. A consent connection + * belongs on the checked side by construction: it exists at all only because the person + * authenticated at the vendor's own screen and Composio then answered that the account is attached, + * which is the same evidence a probe goes and asks for. Writing it on the defaults instead left + * every consent connection made since migration 0030 reading `false` with a null `verified_at` — + * byte-identical to a key somebody typed in and nobody has tested — so the page had to describe the + * two the same way, and the backfilled rows were the only ones in the table telling the truth. + * + * THE TIMESTAMP IS HALF THE CRITERION AND NOT A DETAIL. `verified` true beside a null `verified_at` + * is a claim with no date on it, and the page has nothing to print; the two are written together by + * one writer or the row is a shape no reader here has reasoned about. + */ +test("a confirmed connection is recorded verified, at the moment it was earned", async () => { + await seedApp({ connect: false }); + // Taken before the call, so the comparison below is against a moment that cannot postdate the + // write. Both this and the column are written in this process, so no clock but one is involved. + const before = new Date(); + + expect( + await store.confirmBrokeredConnection({ toolkit, userId: askerId }), + ).toEqual({ connected: true }); + // And the vendor was asked, which is what makes the row a record of Composio's answer rather than + // of a browser arriving back on a page. + expect(asksMade()).toEqual([`isConnected:${toolkit}/${askerId}`]); + + const [row] = await database + .select({ + verified: composioConnections.verified, + verifiedAt: composioConnections.verifiedAt, + }) + .from(composioConnections) + .where( + and( + eq(composioConnections.toolkit, toolkit), + eq(composioConnections.userId, askerId), + ), + ); + expect(row.verified).toBe(true); + expect(row.verifiedAt).not.toBeNull(); + expect(row.verifiedAt?.getTime()).toBeGreaterThanOrEqual(before.getTime()); +}); + +/** + * CHOOSING THE PROBE: READ EFFECT AND ZERO REQUIRED INPUTS, AND NEITHER ALONE WILL DO. + * + * CRITERION. Given an app whose alphabetically first argument-less action is a WRITE, the chosen + * probe is the argument-less READ that sorts after it, and never the write. + * + * REASON. The action this picks is the one that will be called with somebody's just-typed API key + * to find out whether the key works, so a wrong pick is an unrequested write on a stranger's + * account. The fixture is Stripe's own list and not an invention: the first action Composio + * publishes for Stripe that requires no arguments is `STRIPE_CREATE_BILLING_METER_EVENT_SESSION`, + * so a chooser written on "takes no arguments" — the condition that looks sufficient, because it is + * the one that makes a call possible at all — would open a billing meter event session on the + * account of every person who typed a key into this deployment. Read effect is what stands between + * those two names, and it is a fact the vendor asserted rather than a guess: `effectOf` answers + * `read` only where Composio sent `readOnlyHint`, so everything unlabelled is already recorded here + * as a write. + */ +test("the probe skips an argument-less write for the read that sorts after it", async () => { + await database.insert(mcpServers).values({ + id: probeAppId, + title: "Stripe", + vendor: "Composio", + url: `composio://${probeAppId}`, + provenance: "composio", + }); + await database.insert(mcpTools).values([ + { + serverId: probeAppId, + // Sorts first, asks for nothing, and charges somebody money. The whole test. + name: "STRIPE_CREATE_BILLING_METER_EVENT_SESSION", + description: "Creates a billing meter event session.", + effect: "write", + version: "20260903_00", + }, + { + serverId: probeAppId, + name: "STRIPE_RETRIEVE_BALANCE", + description: "Retrieves the balance.", + effect: "read", + version: "20260903_00", + }, + ]); + + // The name, which is what this test is about; the version beside it is asserted by the test below + // that is about the version. + expect((await store.probeActionFor(probeAppId))?.name).toBe( + "STRIPE_RETRIEVE_BALANCE", + ); +}); + +/** + * AND AN APP WHOSE ONLY SAFE ACTION WANTS AN ARGUMENT HAS NO PROBE AT ALL. + * + * CRITERION. Where every read this deployment recorded for an app declares a required input, the + * answer is null rather than that action. + * + * REASON. There is nothing to invent an argument from. A probe is made before anybody has told this + * deployment anything about the account beyond the key itself, so a required customer id, project + * id or query has no honest value to carry — and a guessed one turns "is this key good" into a + * question about whether some made-up identifier exists, which fails for a perfectly good key. + * NULL IS A REAL ANSWER AND NOT AN ERROR: sampling the key-based apps in the live catalogue, most + * publish some argument-less read and PostHog publishes none, so every caller of this has to have + * an answer for an app that cannot be probed. + */ +test("an app whose only read takes an argument has no probe", async () => { + await database.insert(mcpServers).values({ + id: probeAppId, + title: "Needs An Argument", + vendor: "Composio", + url: `composio://${probeAppId}`, + provenance: "composio", + }); + await database.insert(mcpTools).values({ + serverId: probeAppId, + name: "APP_GET_PROJECT", + description: "Reads one project, by id.", + effect: "read", + inputSchema: { + type: "object", + properties: { project_id: { type: "string" } }, + required: ["project_id"], + }, + version: "20260903_00", + }); + + expect(await store.probeActionFor(probeAppId)).toBeNull(); +}); + +/** + * AND AN ACTION WITH NO RECORDED VERSION IS NOT A CANDIDATE, ON THE READ AS WELL AS ON THE PROBE. + * + * CRITERION. Where the only safe read this deployment recorded for an app carries no version, the + * chooser answers null — and the connections listing, which derives its `probe` from that same + * chooser, says null too. Neither spends a call at the vendor. + * + * REASON. Composio refuses an execution without a specific version, so the transport refuses before + * dialling where none travels with the call: an action recorded with no version is an action + * nothing here can call, which is the whole of what "can this be used to check a key" asks. While + * that condition lived in the PROBE and not in the CHOOSER the two disagreed, and the listing was + * the one that lied. Connecting such an app wrote `verified: false` and answered `probe: null` — + * the honest pair, "the key was accepted without being checked" — and then a reload derived a NAMED + * probe beside the same `false` and drew the worst sentence this feature has: your key was checked + * and rejected, and the account it was checked in is still standing at Composio. For a person whose + * key has never been tried at all, every clause of that is false. + * + * BOTH HALVES IN ONE TEST, because the point is that they AGREE. Asserting either alone would leave + * the pair free to come apart again in the direction that was wrong the first time. + */ +test("an app whose only safe read has no recorded version has no probe", async () => { + useAnsweringClient(); + await addProbedApp({ withProbe: false }); + await database.insert(mcpTools).values({ + serverId: probedId, + name: probeAction, + description: "Says who the key belongs to, at no version anybody recorded.", + effect: "read", + version: null, + }); + + expect(await store.probeActionFor(probedId)).toBeNull(); + + await database + .insert(composioConnections) + .values({ toolkit: probedToolkit, userId: askerId, verified: false }); + + const listed = await store.brokeredConnectionsFor(askerId); + expect(listed).toHaveLength(1); + expect(listed[0]?.serverId).toBe(probedId); + expect(listed[0]?.probe).toBeNull(); + // And nothing was spent learning it: both answers come out of recorded metadata. + expect(reached).toEqual([]); +}); + +/** + * AND A VERSIONLESS ACTION IS PASSED OVER RATHER THAN ENDING THE SEARCH. + * + * CRITERION. Given an app whose identity read carries no version and whose other safe read does, + * the chosen probe is the one that carries a version — even though the versionless one is the shape + * {@link IDENTITY_ACTION} prefers and sorts first. + * + * REASON. The version belongs in the same filter as the effect and the required inputs because it + * answers the same question — can this action be called at all — and a filter is what lets the next + * candidate be considered. The condition used to live downstream of the choice, where a versionless + * winner short-circuited the whole app to "there is nothing here to try" even when the app publishes + * another read this deployment could have called. Passing over it is strictly better: an app that + * can be checked gets checked, and the null answer is kept for an app that really has nothing. + */ +test("the chooser passes over a versionless read for the one it could call", async () => { + await database.insert(mcpServers).values({ + id: probeAppId, + title: "Thin Listing", + vendor: "Composio", + url: `composio://${probeAppId}`, + provenance: "composio", + }); + await database.insert(mcpTools).values([ + { + serverId: probeAppId, + // Sorts first, is the preferred shape, and carries nothing to call it at. + name: "THIN_GET_ME", + description: "Says who the key belongs to.", + effect: "read", + version: null, + }, + { + serverId: probeAppId, + name: "THIN_LIST_PROJECTS", + description: "Lists the projects.", + effect: "read", + version: "20260903_00", + }, + ]); + + expect(await store.probeActionFor(probeAppId)).toEqual({ + name: "THIN_LIST_PROJECTS", + version: "20260903_00", + }); +}); + +/** + * AND THE LISTING CARRIES THE ACTION THE CHECK ACTUALLY SPENT, WHICH IS WHAT SURVIVES A RELOAD. + * + * CRITERION. A brokered connection whose row records an action is listed with that action's name in + * `probe` — and it is, EVEN WHERE THE APP NO LONGER PUBLISHES IT. Nothing is spent at the vendor to + * find that out. + * + * REASON. `probe` was only ever a field of an ANSWER — to a key handed over, or to a re-check — so + * a page that reloaded lost it, and the row fell back to the sentence that says a key was accepted + * without being checked. For the worst state this feature has that sentence is FALSE: a named probe + * beside `verified: false` means the check ran, the vendor refused the key, and the account it ran + * in could not be withdrawn. The one person with a live account and a bad key behind it was told + * nothing was wrong, and the Re-check button was taken away from them at the same moment — on the + * page load where they would reach for it. + * + * WHICH IS RECORDED RATHER THAN DERIVED, and the de-listed app above is what makes that an + * assertion instead of a wording. The field was once answered by the chooser, from the app's action + * listing as it stood at the moment of the READ; the row's own verdict comes from the listing as it + * stood at the moment of the CHECK, and an administrator's press of Refresh moves one and not the + * other in either direction. Here the action the check spent has since left the app's listing — + * Composio publishes what it publishes — and the connection still reports what was tried on it, + * because that is what happened and no later listing can unhappen it. + */ +test("a listed brokered connection names the action it was checked with", async () => { + useAnsweringClient(); + // The app WITHOUT the action, so the only place the name below can come from is the row. + await addProbedApp({ withProbe: false }); + await database.insert(composioConnections).values({ + toolkit: probedToolkit, + userId: askerId, + verified: false, + probeAction: probeAction, + }); + + const listed = await store.brokeredConnectionsFor(askerId); + expect(listed).toHaveLength(1); + expect(listed[0]?.serverId).toBe(probedId); + expect(listed[0]?.verified).toBe(false); + // The name, and not merely "something": it is the name that separates a key the vendor refused + // from a connection nothing was ever tried on. + expect(listed[0]?.probe).toBe(probeAction); + // And the chooser has nothing to offer, which is what makes the line above about the record. + expect(await store.probeActionFor(probedId)).toBeNull(); + // Nothing was spent finding any of that out: both answers are read out of this deployment's own + // tables. + expect(reached).toEqual([]); +}); + +/** + * AND A CONNECTION NOTHING WAS EVER SPENT ON IS LISTED AS EXACTLY THAT. + * + * CRITERION. Where the row records no action, the listed connection's `probe` is null rather than a + * name. + * + * REASON. Null is the first of the states and the only one that is a fact about the CHECK rather + * than about the key: nothing was tried. Most key-based apps in the live catalogue publish some + * argument-less read and PostHog publishes none, so a connection made to such an app is honestly + * unchecked and stays so. A listing that could not say null would leave the screen unable to tell + * that apart from a refused key, which is the distinction the whole field exists for — and it would + * draw the accusation written for a bad key over somebody whose key was never tried. + */ +test("a listed brokered connection nothing was spent on says there is no probe", async () => { + useAnsweringClient(); + await addProbedApp({ withProbe: false }); + await database + .insert(composioConnections) + .values({ toolkit: probedToolkit, userId: askerId, verified: false }); + + const listed = await store.brokeredConnectionsFor(askerId); + expect(listed).toHaveLength(1); + expect(listed[0]?.serverId).toBe(probedId); + expect(listed[0]?.probe).toBeNull(); +}); + +/** + * AND AN APP THAT LATER STARTS PUBLISHING ONE DOES NOT ACCUSE A KEY NOBODY EVER TRIED. + * + * CRITERION. A key connected to an app that published nothing safe to spend it on is listed with a + * null probe; the app's action listing then gains a safe versioned read, the chooser names it from + * that moment on, and the SAME connection is still listed with a null probe. + * + * REASON. `verified` is a fact about a check made against the listing as it stood THEN; a probe + * derived on read is a fact about the listing as it stands NOW, and nothing holds the two together. + * `POST /servers/:id/refresh` is a generic administrator's route keyed on a server id, and + * `composio-` is a server id, so a brokered app's actions are re-listed by an ordinary press + * of Refresh — which is exactly what the transport's own comment tells an operator to press when an + * action appears, or when one it had already listed gains the version that makes it callable. The + * instant that happened, a row honestly recording "your key was accepted without being checked" + * began reading as a NAMED probe beside `verified: false`, and the page drew the worst sentence + * this feature has: the key was checked and rejected, the account it ran in still stands, disconnect + * it. Every clause of that is false for somebody whose key was never tried, and it tells them to + * take down a connection that works — on every page load until they press Re-check. + * + * WHICH IS WHY THE COLUMN EXISTS. The pair is a record of the check that was made, written by the + * one writer that knows what it spent, and no metadata arriving afterwards can talk a listing out + * of it. The chooser is asked in the same breath below, so this is a test about the record rather + * than a test about an app that still has nothing to publish. + */ +test("an action listed after the fact does not rewrite what a key was checked with", async () => { + useAnsweringClient(); + await addProbedApp({ withProbe: false }); + + expect( + await store.connectBrokeredWithFields({ + toolkit: probedToolkit, + userId: askerId, + values: { generic_api_key: typedKey }, + }), + ).toEqual({ connected: true, verified: false, probe: null }); + + // THE REFRESH, as its only lasting effect: the app's actions re-listed, now carrying a read the + // chooser will take. Inserted directly for {@link addProbedApp}'s reason — nothing here is about + // how a listing turns Composio's tags into an effect. + await database.insert(mcpTools).values({ + serverId: probedId, + name: probeAction, + description: "Says who the key belongs to.", + effect: "read", + version: probeVersion, + }); + + const listed = await store.brokeredConnectionsFor(askerId); + expect(listed).toHaveLength(1); + expect(listed[0]?.serverId).toBe(probedId); + expect(listed[0]?.verified).toBe(false); + // Still null, because still nothing was ever spent on this key. + expect(listed[0]?.probe).toBeNull(); + // While the chooser now names one, which is the whole of what changed and the reason the two + // answers have to come from two different questions. + expect((await store.probeActionFor(probedId))?.name).toBe(probeAction); + // And no call was made to find any of that out. + expect(reached).toEqual([]); +}); + +/** + * AND WHETHER THERE IS ANYTHING TO CHECK TODAY IS ANSWERED BESIDE IT, NEVER OUT OF IT. + * + * CRITERION. The connection of the test above — a key the deployment spent nothing on, under an app + * that has since started publishing a safe versioned read — is listed with `probe` still null AND + * `checkable` true. Nothing is spent at the vendor to find that out either. + * + * REASON. THIS IS THE DEADLOCK THE RECORDED COLUMN CREATED, and it is the exact price of the fix + * above. `probe` became a record so that a refresh could not accuse a key nobody had tried; the + * settings page's Re-check button went on reading it, and that button asks a different question — + * not "what did the check spend" but "is there anything to spend now". The two answers agreed while + * the field was derived and part company the moment it is stored, in one direction that does not + * recover: a key connected to an app with nothing to try reads null FOR GOOD, the button stays + * withheld however many actions the app later publishes, and pressing that button is the only thing + * in the product that could ever record an action on the row. The state is stable, wrong, and + * unreachable from inside itself. + * + * SO THE LISTING ANSWERS BOTH QUESTIONS AND COLLAPSES NEITHER. `probe` is the past — read off the + * row, unmoved by anything the catalogue does afterwards — and `checkable` is the present, asked of + * {@link createPluginStore.probeActionFor} about the app. The pair below is what a single field can + * never be: the check spent nothing AND there is something to spend now. + */ +test("a key nothing was spent on becomes checkable when the app publishes something", async () => { + useAnsweringClient(); + await addProbedApp({ withProbe: false }); + await database + .insert(composioConnections) + .values({ toolkit: probedToolkit, userId: askerId, verified: false }); + + const before = await store.brokeredConnectionsFor(askerId); + expect(before).toHaveLength(1); + // Nothing was tried, and there is nothing to try: the two agree here, which is why one field + // could ever pass for both. + expect(before[0]?.probe).toBeNull(); + expect(before[0]?.checkable).toBe(false); + + // THE REFRESH. An administrator's press re-lists the app's actions and one of them is a safe + // versioned read — the very press the transport tells an operator to make when an action appears. + await database.insert(mcpTools).values({ + serverId: probedId, + name: probeAction, + description: "Says who the key belongs to.", + effect: "read", + version: probeVersion, + }); + + const after = await store.brokeredConnectionsFor(askerId); + expect(after).toHaveLength(1); + // The record does not move, because nothing happened to the key: this is the guard on the fix + // that stands above, and a listing that let the catalogue write here would re-open it. + expect(after[0]?.probe).toBeNull(); + expect(after[0]?.verified).toBe(false); + // And the present-tense answer does move, which is what puts the button back within reach. + expect(after[0]?.checkable).toBe(true); + // Both answers are read out of this deployment's own tables. + expect(reached).toEqual([]); +}); + +/** + * A CONSENT CONNECTION RECORDS NO ACTION, BECAUSE NONE WAS SPENT. + * + * CRITERION. A connection confirmed at the vendor is written verified with no action beside it, and + * listed that way, even where the app publishes one the chooser would happily take. + * + * REASON. Null in this column is not "unchecked" — `verified` is what says that — it is "this + * deployment spent no action of the app's to know what it knows". For a consent row that is + * permanently and exactly true: the evidence is the vendor's own yes at the end of its own screen, + * which {@link confirmBrokeredConnection} goes and asks for, and no call is ever made against the + * account. The derived field could not say so. It answered with whatever the app happened to + * publish, so a consent connection was listed as checked with an action nothing had ever called, + * and the rows whose date is the moment of consent read exactly like the rows that had answered a + * call. + */ +test("a consent connection records no action, because none was spent", async () => { + useAnsweringClient(); + await store.addBrokeredApp({ + slug: probedToolkit, + title: "Probed App", + by: admin, + connection: { kind: "consent" }, + }); + // The action a derived field would have named, so the null below is this path's doing and not the + // app having nothing to offer. + await database.insert(mcpTools).values({ + serverId: probedId, + name: probeAction, + description: "Says who the account belongs to.", + effect: "read", + version: probeVersion, + }); + + expect( + await store.confirmBrokeredConnection({ + toolkit: probedToolkit, + userId: askerId, + }), + ).toEqual({ connected: true }); + + const [row] = await database + .select() + .from(composioConnections) + .where( + and( + eq(composioConnections.toolkit, probedToolkit), + eq(composioConnections.userId, askerId), + ), + ); + expect(row.verified).toBe(true); + expect(row.probeAction).toBeNull(); + // Nothing was called to earn that flag, which is the fact the null records. + expect(reached).toEqual([]); + + const listed = await store.brokeredConnectionsFor(askerId); + expect(listed[0]?.probe).toBeNull(); +}); + +/** + * CONNECTING WITH A KEY SOMEBODY TYPED: THE VALUES REACH COMPOSIO AND NOTHING ELSE. + * + * CRITERION. After a connection made from typed values, the secret is in the vendor's hands and in + * no row this deployment wrote — not in the `mcp.account_connected` payload, not in + * `composio_connections` — and what the trail carries instead is the NAMES of the fields that were + * filled in. + * + * REASON. This is the only flow in the product where a person hands this deployment a credential of + * their own, and the whole design of it is that the credential travels in one direction: off the + * request, into `connectWithFields`, out to Composio. Every other participant here is a long-lived, + * widely-readable record. `composio_connections` is read by offboarding, by disconnect and by the + * gate on every brokered call; `audit_events` is append-only by trigger, exported, and kept for as + * long as a deployment's retention window says — so a key that lands in either is not a leak + * somebody can clean up afterwards, it is a leak with a schedule. + * + * WHICH IS WHY THIS ASSERTS ABSENCE OUT OF THE TABLES RATHER THAN OFF THE RETURN VALUE. A method + * can be read for what it puts in a payload; what a reviewer cannot read is what some later writer + * on the same path adds. Stringifying the rows themselves is the assertion that survives that, and + * it is not saved by the redactor: neither `values` nor `generic_api_key` is on `sensitiveKeys`, so + * a payload carrying what somebody typed would carry it through verbatim. See {@link typedKey}. + * + * AND IT ASSERTS THE ARRIVAL TOO. "The secret is nowhere" is true of a method that sends Composio + * nothing at all, so {@link valuesSent} is checked in the same breath: the values went to the one + * place they are for. + * + * `verified: false` HERE IS "NOTHING TO TRY", WHICH `probe: null` IS WHAT SAYS. This app publishes + * no actions at all, so the chooser has nothing safe to spend the key on and the connection is + * honestly unchecked — the pair `composio_connections.verified` documents for exactly this row. The + * same `false` beside a NAMED probe would mean the opposite thing about the key, which is why the + * two travel together; see {@link connectBrokeredWithFields} and the test at the foot of this file. + */ +test("the values reach Composio and nothing else", async () => { + useAnsweringClient(); + await store.addBrokeredApp({ + slug: enabledToolkit, + title: "Enablable App", + by: admin, + connection: { kind: "fields", authScheme: "API_KEY" }, + }); + + expect( + await store.connectBrokeredWithFields({ + toolkit: enabledToolkit, + userId: askerId, + values: { generic_api_key: typedKey }, + }), + ).toEqual({ connected: true, verified: false, probe: null }); + + // The vendor was asked, and asked with what the person typed. Without this the absences below + // would be satisfied by a method that connected nobody. + expect(asksMade()).toEqual([ + `ensureAuthConfig:${enabledToolkit}/fields`, + `connectWithFields:${enabledToolkit}/${askerId}`, + ]); + expect(valuesSent).toEqual([{ generic_api_key: typedKey }]); + + /* + * THE TRAIL, READ OUT OF THE TABLE RATHER THAN OFF THE RECORDING STORE. The rows are what a + * reader of the trail will actually see — after the redactor, after the insert — and this file's + * `auditStore` keeps a copy of the input beside it, not instead of it. Narrowed to this app + * because `audit_events` is append-only: no cleanup here can reach it, so the other tests in this + * run have already written `mcp.account_connected` rows under {@link toolkit}. + */ + const trail = await database + .select() + .from(auditEvents) + .where( + and( + eq(auditEvents.eventType, "mcp.account_connected"), + eq(auditEvents.targetId, enabledToolkit), + ), + ); + expect(trail).toHaveLength(1); + expect(trail[0].payload).toMatchObject({ + actor: askerId, + server: enabledToolkit, + reconnected: false, + // The names, sorted, because a reader needs to know what the app asked this person for — and + // that is the whole of what a credential may contribute to a record like this one. + fields: ["generic_api_key"], + }); + expect(JSON.stringify(trail)).not.toContain(typedKey); + + const rows = await database + .select() + .from(composioConnections) + .where(inArray(composioConnections.toolkit, ownedToolkits)); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + toolkit: enabledToolkit, + userId: askerId, + verified: false, + }); + expect(rows[0].verifiedAt).toBeNull(); + expect(JSON.stringify(rows)).not.toContain(typedKey); +}); + +/** + * CONNECTING A SECOND TIME: THE TRAIL SAYS A GRANT WAS REPLACED. + * + * CRITERION. Somebody who types a key for an app they had already connected leaves a second + * `mcp.account_connected` row saying `reconnected: true`, while the first one they left says + * `false` — and there is still ONE connection row, because the second key replaced the first. + * + * REASON. `recordBrokeredConnection` is an upsert, so the row it leaves behind is byte-identical + * whether it was the first grant or the fourth; `reconnected` is the only thing in the record that + * tells those apart, and a reader chasing "whose key is on this account" has nothing else to go on. + * A constant `false` there does not merely omit the fact — it asserts the opposite of it, about a + * row that really did replace one. + * + * AND THE ROUTE'S GUARD IS NOT A SUBSTITUTE, which is why this asks the store directly. The one + * caller today refuses a second account for the same app, so in production the constant happened to + * be true; but the guard lives in another file, nothing in this method points at it, and a method + * that is honest only because of a check somewhere else is one refactor away from filing a false + * record. What is asserted here is that the store looks. + */ +test("a second key for the same app is recorded as a reconnection", async () => { + useAnsweringClient(); + await store.addBrokeredApp({ + slug: rekeyedToolkit, + title: "Rekeyable App", + by: admin, + connection: { kind: "fields", authScheme: "API_KEY" }, + }); + + await store.connectBrokeredWithFields({ + toolkit: rekeyedToolkit, + userId: askerId, + values: { generic_api_key: typedKey }, + }); + await store.connectBrokeredWithFields({ + toolkit: rekeyedToolkit, + userId: askerId, + values: { generic_api_key: rotatedKey }, + }); + + // Both keys reached the vendor, in order. Without this the trail assertion below would be + // satisfied by a second call that refused before it connected anybody. + expect(valuesSent).toEqual([ + { generic_api_key: typedKey }, + { generic_api_key: rotatedKey }, + ]); + + // ONE ROW FOR TWO CONNECTS, which is the whole of why the flag cannot be inferred later: the + // upsert left nothing behind saying there had been two. + expect(await connectedToolkitsFor(askerId)).toEqual([rekeyedToolkit]); + + // Read in the order the acts happened, which the recorder keeps and `created_at` does not + // promise to: two inserts a millisecond apart are two rows an ordered read may return either way + // round, and the whole assertion is about which of them said what. + const connected = recordedOfType("mcp.account_connected").filter( + (event) => event.targetId === rekeyedToolkit, + ); + expect( + connected.map( + (event) => (event.payload as { reconnected: boolean }).reconnected, + ), + ).toEqual([false, true]); +}); + +/** + * A KEY THAT DOES NOT WORK LEAVES NOTHING BEHIND, NOT EVEN THE ACCOUNT THE CHECK ITSELF MADE. + * + * CRITERION. When the probe comes back an error, the call refuses with the vendor's own sentence, + * the account Composio just made is withdrawn by id, and this deployment holds no connection row. + * + * REASON. Composio accepts a key without ever trying it, so "connected" at the vendor says nothing + * about whether the credential works — and a row written on that acceptance is a gate every later + * brokered call passes for a key that cannot answer. The first anybody would hear of it is the + * vendor's own error in the middle of a Bot doing something. So the verification is the whole point + * of this path, and a failure that left the account standing would be worse than no check at all: + * the live-but-useless connection would have been created BY the check. + * + * AND THE PROBE HAD TO REALLY RUN, which is what {@link reached} asserts beside it. Every absence + * below is also true of a method that refused before it dialled — for want of a version, say — so + * without it this test would pass against a deployment that never spent the key at all. + */ +test("a key that does not work leaves nothing behind", async () => { + useAnsweringClient({ + execute: async ({ slug }) => { + reached.push(slug); + // The vendor reporting a failure in a 200, which is how Composio says a credential is wrong. + return { + data: {}, + error: "Invalid API key provided.", + successful: false, + }; + }, + }); + await addProbedApp(); + + await expect( + store.connectBrokeredWithFields({ + toolkit: probedToolkit, + userId: askerId, + values: { generic_api_key: typedKey }, + }), + ).rejects.toThrow(/Invalid API key provided\./); + + // The key was spent on the action the chooser picked, and on nothing else. + expect(reached).toEqual([probeAction]); + // Nothing was saved, which is the sentence the refusal ends on. + expect(await connectedToolkitsFor(askerId)).toEqual([]); + // And the account the check created is gone from the vendor, by the id it was made under. + expect(vendorHolds).toEqual([]); + expect(asksMade()).toEqual([ + `ensureAuthConfig:${probedToolkit}/fields`, + `connectWithFields:${probedToolkit}/${askerId}`, + `revokeAccount:${madeAccountId}`, + ]); +}); + +/** + * AN UNDO THAT FAILS LEAVES THE ACCOUNT REACHABLE RATHER THAN INVISIBLE. + * + * CRITERION. When the probe fails AND Composio will not take the account back, the row is written + * unverified, the refusal says all three things — the key did not work, the account could not be + * withdrawn, it is recorded here unchecked — and the account is still the vendor's to see. + * + * REASON. This is the worst state the feature admits, and the rule that governs every other failure + * here is not available in it. "Leave nothing behind" assumes the account can be ended; when it + * cannot, leaving no row does not mean nothing was left behind — it means a LIVE account that + * nothing in this deployment names, that no screen draws and that the person cannot disconnect, + * because disconnect works off the row. A row saying "unchecked" is worse than a clean failure and + * far better than an invisible account. What makes the row honest is that the refusal carries what + * happened: the person is not told their key is fine, and they are given the one step that helps. + */ +test("an undo that fails leaves the account reachable rather than invisible", async () => { + useAnsweringClient({ + execute: async ({ slug }) => { + reached.push(slug); + return { + data: {}, + error: "Invalid API key provided.", + successful: false, + }; + }, + }); + vendorKeepsAccount = true; + await addProbedApp(); + + await expect( + store.connectBrokeredWithFields({ + toolkit: probedToolkit, + userId: askerId, + values: { generic_api_key: typedKey }, + }), + ).rejects.toThrow(/would not take the account back/); + + // The undo was attempted and refused, rather than skipped: the row below is the consequence of a + // vendor that would not act, and an implementation that never asked would leave the same row. + expect(asksMade()).toEqual([ + `ensureAuthConfig:${probedToolkit}/fields`, + `connectWithFields:${probedToolkit}/${askerId}`, + `revokeAccount:${madeAccountId}`, + ]); + expect(vendorHolds).toEqual([madeAccountId]); + + const [row] = await database + .select() + .from(composioConnections) + .where( + and( + eq(composioConnections.toolkit, probedToolkit), + eq(composioConnections.userId, askerId), + ), + ); + expect(row).toMatchObject({ + toolkit: probedToolkit, + userId: askerId, + verified: false, + }); + // No date on a claim nobody made, which is the pair `recordBrokeredConnection` writes together. + expect(row.verifiedAt).toBeNull(); + + // AND THE TRAIL CARRIES THE STATE, not just the person who happened to be at the screen. This is + // the one outcome here that leaves a live account nobody can reach through this deployment, and + // an operator reading the trail later is exactly who needs to find it — so the refusal being + // thorough is not a substitute for a row. + const checked = recordedOfType("mcp.connection_verified"); + expect(checked).toHaveLength(1); + expect(checked[0].payload).toMatchObject({ + actor: askerId, + // The action that was TRIED, which is the whole of what separates this row from the unchecked + // one: both say `verified: false`, and only the name says the vendor was asked and said no. + action: probeAction, + verified: false, + }); + // And the key is in none of it, on the path that fails as much as on the one that works. + expect(JSON.stringify(checked)).not.toContain(typedKey); +}); + +/** + * A FAILED KEY TAKES DOWN THE ACCOUNT IT MADE AND NOTHING BESIDE IT. + * + * CRITERION. With the vendor holding a second account this deployment has no row for, the undo + * names the id it was just handed, and the other account is still there afterwards. + * + * REASON. The drift case, and it is ordinary rather than exotic: an account made in Composio's own + * dashboard, or one whose row this deployment lost, is a WORKING connection the vendor holds and + * nothing here names. `revoke` sweeps every account a person holds for an app, so an undo written + * that way ends somebody's working connection because somebody mistyped a key — a second person's + * access destroyed by the first one's typo, by a call made to clean up after a check. The id is the + * whole of what makes the narrow call narrow, and it is the only thing a test can hold it to. + */ +test("a failed key takes down the account it made and nothing beside it", async () => { + useAnsweringClient({ + execute: async ({ slug }) => { + reached.push(slug); + return { + data: {}, + error: "Invalid API key provided.", + successful: false, + }; + }, + }); + await addProbedApp(); + // Seeded before the connect, so it is an account that predates this act rather than one of its + // making — which is the whole of what a sweep cannot tell apart. + vendorHolds.push(strandedAccountId); + + await expect( + store.connectBrokeredWithFields({ + toolkit: probedToolkit, + userId: askerId, + values: { generic_api_key: typedKey }, + }), + ).rejects.toThrow(); + + // The working account survived the failure of somebody else's key. + expect(vendorHolds).toEqual([strandedAccountId]); + // And the sweep was never asked for, which is the other half of the same statement: a `revoke` + // here would have emptied the list above. + expect(asksMade()).toEqual([ + `ensureAuthConfig:${probedToolkit}/fields`, + `connectWithFields:${probedToolkit}/${askerId}`, + `revokeAccount:${madeAccountId}`, + ]); +}); + +/** + * AN APP WITH NO PROBE CONNECTS UNVERIFIED RATHER THAN NOT AT ALL. + * + * CRITERION. Where the app publishes nothing safe to call, the connection is made, the row says + * unverified with no date, `probe` comes back null, and no action ran at the vendor. + * + * REASON. Null from the chooser is an answer and not a failure — most key-based apps publish some + * argument-less read and PostHog publishes none — so a verification that refused what it could not + * check would make this deployment's ability to connect an app depend on that app's action list. + * + * AND `probe: null` IS WHAT KEEPS THAT ROW'S SENTENCE TRUE. `verified: false` now has three + * possible meanings, and only one of them is this one: nothing was tried. Where a probe ran and + * failed the same flag means the key is bad, and a browser inferring a sentence from the flag alone + * would tell one of those two people the opposite of what happened. The audit row carries the same + * distinction under `action`. + */ +test("an app with no probe connects unverified rather than not at all", async () => { + useAnsweringClient(); + await addProbedApp({ withProbe: false }); + + expect( + await store.connectBrokeredWithFields({ + toolkit: probedToolkit, + userId: askerId, + values: { generic_api_key: typedKey }, + }), + ).toEqual({ connected: true, verified: false, probe: null }); + + // Nothing was called, which is what "nothing to try" means at the vendor. + expect(reached).toEqual([]); + // And nothing was taken back either: there was no failure to undo. + expect(vendorHolds).toEqual([madeAccountId]); + + const [row] = await database + .select() + .from(composioConnections) + .where( + and( + eq(composioConnections.toolkit, probedToolkit), + eq(composioConnections.userId, askerId), + ), + ); + expect(row.verified).toBe(false); + expect(row.verifiedAt).toBeNull(); + // Null in the column too, and it is the row's own sentence rather than the listing's: NOTHING WAS + // SPENT on this key. What the app publishes today, or comes to publish tomorrow, cannot move it. + expect(row.probeAction).toBeNull(); + + const checked = recordedOfType("mcp.connection_verified"); + expect(checked).toHaveLength(1); + expect(checked[0].payload).toMatchObject({ + actor: askerId, + // Null rather than a name, for the same reason the response field is: the trail must be able to + // say that no action ran, which "verified: false" alone cannot. + action: null, + verified: false, + }); +}); + +/** + * A KEY THAT WORKS IS RECORDED VERIFIED, WITH THE ACTION IT WAS CHECKED WITH. + * + * CRITERION. When the probe answers, the row is verified with a date, the response names the action + * that was called, the trail carries the same name, and what reached the vendor was that action, in + * the asking person's account, at the version the listing recorded, WITH NO ARGUMENTS. + * + * REASON. The three failure tests above all end in a refusal, so every one of them would pass + * against a probe that could never succeed — and the transport refuses before dialling unless the + * call carries the version its listing recorded, which is exactly the shape a probe sent with a + * bare `{}` would have. Without this test "the key was checked and it passed" is a state the suite + * never reaches, and a verification that fails for this deployment's own reason would look from + * every other test here exactly like a vendor saying the key is bad — while withdrawing the account + * of every person who typed a good one. + * + * THE ARGUMENTS ARE ASSERTED EMPTY, because that is half of what makes this the one vendor call in + * the deployment that runs outside `callTool`'s grant, policy and content checks. The version + * travels under the transport's reserved key and is stripped before anything reaches Composio, so + * what the vendor is handed is an action chosen from recorded metadata and nothing else. + */ +test("a key that works is recorded verified, with the action it was checked with", async () => { + const sent: { + slug: string; + userId: string; + version: string; + args: unknown; + }[] = []; + useAnsweringClient({ + execute: async (call, args) => { + reached.push(call.slug); + sent.push({ + slug: call.slug, + userId: call.userId, + version: call.version, + args, + }); + return answered; + }, + }); + await addProbedApp(); + // Taken before the call, so the comparison below is against a moment that cannot postdate the + // write. Both this and the column are written in this process, so no clock but one is involved. + const before = new Date(); + + expect( + await store.connectBrokeredWithFields({ + toolkit: probedToolkit, + userId: askerId, + values: { generic_api_key: typedKey }, + }), + ).toEqual({ connected: true, verified: true, probe: probeAction }); + + expect(sent).toEqual([ + { + slug: probeAction, + // The person's own account, which is the only account a probe could be a check on. + userId: askerId, + version: probeVersion, + args: {}, + }, + ]); + // The account stands, because there was nothing to undo. + expect(vendorHolds).toEqual([madeAccountId]); + expect(asksMade()).toEqual([ + `ensureAuthConfig:${probedToolkit}/fields`, + `connectWithFields:${probedToolkit}/${askerId}`, + ]); + + const [row] = await database + .select() + .from(composioConnections) + .where( + and( + eq(composioConnections.toolkit, probedToolkit), + eq(composioConnections.userId, askerId), + ), + ); + expect(row.verified).toBe(true); + expect(row.verifiedAt).not.toBeNull(); + expect(row.verifiedAt?.getTime()).toBeGreaterThanOrEqual(before.getTime()); + // AND THE ROW RECORDS WHICH ACTION EARNED THE FLAG, which is the half `verified` cannot hold. + // The listing reads this column rather than asking today's metadata what it WOULD spend, so what + // a page says about this connection stays what happened to it. + expect(row.probeAction).toBe(probeAction); + + const checked = recordedOfType("mcp.connection_verified"); + expect(checked).toHaveLength(1); + expect(checked[0].payload).toMatchObject({ + actor: askerId, + action: probeAction, + verified: true, + }); + // The row names the person and no Bot, which is what `mcp.connection_verified` documents: nothing + // ran on a Bot's behalf here, and borrowing one to make the row look uniform would be a lie. + expect(JSON.stringify(checked[0].payload)).not.toContain(botId); + /* + * BOTH ROWS THIS METHOD WRITES COME BACK UNDER ONE ID, which is the question anybody asks this + * trail: what happened to this person's access to this app. The two used to be filed under + * different keys — the connection under the app slug, the verification under the server row id — + * so a reader got half the story depending on which one they asked with, and neither half said + * it was a half. {@link probedId} is named first because it is the id that was used and the one + * a regression puts back; the set is asked after it, because the criterion is a single query + * finding all of these rows and that is true of no other id either. + */ + const forApp = events.filter( + (event) => + event.eventType === "mcp.connection_verified" || + event.eventType === "mcp.account_connected", + ); + expect(forApp).toHaveLength(2); + expect(forApp.map((event) => event.targetId)).not.toContain(probedId); + expect(new Set(forApp.map((event) => event.targetId))).toEqual( + new Set([probedToolkit]), + ); + // And the key itself is in none of it, the promise every write on this path keeps. + expect(JSON.stringify(checked)).not.toContain(typedKey); +}); + +/** + * The account a re-check runs against, which is one that ALREADY EXISTS. + * + * Inserted by hand rather than made through `connectBrokeredWithFields`, and that is the whole + * point of the fixture: a re-check is not a connect. It is pressed days later, by somebody who has + * just fixed a key at the vendor, against a row and an account that were already here — so a test + * that reached this state by connecting would be asserting about a row this run had just written + * with a probe of its own, and could not tell a method that re-checks from one that reconnects. + * + * `verifiedAt` IS A DATE FROM THE PAST WHERE ONE IS ASKED FOR, so "the timestamp was left alone" is + * an assertion about a value rather than about whether a column is null. + */ +async function holdProbedApp(verifiedAt: Date | null = null) { + await database.insert(composioConnections).values({ + toolkit: probedToolkit, + userId: askerId, + verified: verifiedAt !== null, + verifiedAt, + }); + // The vendor's side of that row: an account it is holding before this run's act, which is what + // makes "nothing was withdrawn" an assertion about what Composio still has afterwards. + vendorHolds.push(madeAccountId); +} + +/** + * A RE-CHECK THAT ANSWERS RECORDS THE CONNECTION VERIFIED, WITH THE ACTION IT WAS CHECKED WITH. + * + * CRITERION. Against a connection that already exists, the probe runs in the asking person's + * account, the row is written verified with a fresh date, the answer carries that date and the name + * of the action, and the trail records the same check. + * + * REASON. This is the button somebody presses having just rotated a key that had stopped working. + * Nothing else in the product will ever re-check it: Composio accepts a key once and never tests it + * again, and every other path that writes `verified` is a connect or a consent — so without this + * the row's sentence is frozen at whatever was true the day the key was typed, and a person who has + * fixed their key has no way to make this deployment agree. + * + * AND IT IS A BUTTON AND NEVER A PAGE-LOAD EFFECT, which is why nothing here calls it twice. The + * call is spent against the VENDOR'S rate limit on the person's own account, so verifying on every + * render would burn somebody's quota at Linear to redraw one word on a settings page. + */ +test("a re-check that answers records the connection verified, with the action it was checked with", async () => { + const sent: { + slug: string; + userId: string; + version: string; + args: unknown; + }[] = []; + useAnsweringClient({ + execute: async (call, args) => { + reached.push(call.slug); + sent.push({ + slug: call.slug, + userId: call.userId, + version: call.version, + args, + }); + return answered; + }, + }); + await addProbedApp(); + await holdProbedApp(); + // Taken before the call, so the comparison below is against a moment that cannot postdate the + // write. Both this and the column are written in this process, so no clock but one is involved. + const before = new Date(); + + const answer = await store.recheckBrokeredConnection({ + toolkit: probedToolkit, + userId: askerId, + }); + + expect(answer.verified).toBe(true); + expect(answer.probe).toBe(probeAction); + expect(new Date(answer.verifiedAt ?? "").getTime()).toBeGreaterThanOrEqual( + before.getTime(), + ); + + // The same call the connect path makes, in the same shape: the person's own account, the version + // the listing recorded, and no arguments at all. + expect(sent).toEqual([ + { slug: probeAction, userId: askerId, version: probeVersion, args: {} }, + ]); + + const [row] = await database + .select() + .from(composioConnections) + .where( + and( + eq(composioConnections.toolkit, probedToolkit), + eq(composioConnections.userId, askerId), + ), + ); + expect(row.verified).toBe(true); + expect(row.verifiedAt?.toISOString()).toBe(answer.verifiedAt); + // And the action this press spent, written down beside the flag it earned. A re-check is the + // second of the two writers that can put a name here, and a row must not be able to tell which of + // them wrote it apart from by its date. + expect(row.probeAction).toBe(probeAction); + + // NOTHING WAS CONNECTED AND NOTHING WAS WITHDRAWN. The only ask in this run is the one the app's + // own enablement made; a re-check that reached `connectWithFields` would be making a second + // account for somebody who has one, and one that reached either revoke would be ending the + // account it was asked to check. + expect(asksMade()).toEqual([`ensureAuthConfig:${probedToolkit}/fields`]); + expect(vendorHolds).toEqual([madeAccountId]); + + const checked = recordedOfType("mcp.connection_verified"); + expect(checked).toHaveLength(1); + expect(checked[0].targetId).toBe(probedToolkit); + expect(checked[0].payload).toMatchObject({ + actor: askerId, + action: probeAction, + verified: true, + }); +}); + +/** + * A PROBE THAT RAN AND FAILED IS A FAILURE, AND NOT AN ANSWER SAYING "NOT VERIFIED". + * + * CRITERION. When the vendor rejects the key, the call raises with Composio's own sentence in it, + * the row is left standing and written unverified, the account at the vendor is untouched, and the + * trail carries the action that was tried. + * + * REASON. `verified: false` is the same flag an app that publishes nothing safe to call produces, + * so a re-check that RETURNED it would hand the row two states it cannot tell apart — and the one + * it would get wrong is the person who has just fixed their key and pressed the button. The row + * would drop the Re-check button in exactly the state somebody needs it, while telling them nothing + * was ever checked. A raise carries the vendor's sentence, which is the whole of what they can act + * on. + * + * AND THE ACCOUNT STAYS, which is the line between this and a connect. `connectBrokeredWithFields` + * withdraws the account it just made, because it made it and the key is bad — the undo is of its own + * act. Here the account predates the press by days and the person did not ask to disconnect + * anything; their key is wrong, and taking their account away to tell them so would destroy the + * thing they are trying to repair. + */ +test("a re-check whose probe fails raises rather than answering unverified", async () => { + useAnsweringClient({ + execute: async ({ slug }) => { + reached.push(slug); + // The vendor reporting a failure in a 200, which is how Composio says a credential is wrong. + return { + data: {}, + error: "Invalid API key provided.", + successful: false, + }; + }, + }); + await addProbedApp(); + await holdProbedApp(); + + await expect( + store.recheckBrokeredConnection({ + toolkit: probedToolkit, + userId: askerId, + }), + ).rejects.toThrow(/Invalid API key provided\./); + + // The key was spent on the action the chooser picked, and on nothing else. + expect(reached).toEqual([probeAction]); + // The account is still the vendor's to see, and nothing here asked it to be otherwise. + expect(vendorHolds).toEqual([madeAccountId]); + expect(asksMade()).toEqual([`ensureAuthConfig:${probedToolkit}/fields`]); + + // The row SURVIVES the failure — it is their key that is wrong, not their account — and it stops + // claiming a verification, with no date left standing on a claim nobody is making. + const [row] = await database + .select() + .from(composioConnections) + .where( + and( + eq(composioConnections.toolkit, probedToolkit), + eq(composioConnections.userId, askerId), + ), + ); + expect(row).toMatchObject({ toolkit: probedToolkit, verified: false }); + expect(row.verifiedAt).toBeNull(); + + // AND THE TRAIL SAYS WHICH ACTION WAS TRIED, which is what separates this row from an app that + // had nothing to try: both say `verified: false`, and only the name says the vendor was asked. + const checked = recordedOfType("mcp.connection_verified"); + expect(checked).toHaveLength(1); + expect(checked[0].payload).toMatchObject({ + actor: askerId, + action: probeAction, + verified: false, + }); +}); + +/** + * AN APP WITH NOTHING TO PROBE COMES BACK SAYING SO, AND THE ROW IS LEFT EXACTLY AS IT WAS. + * + * CRITERION. Where the app publishes no action a probe may use, no call is made, the answer carries + * `probe: null`, the row's `verified` and `verified_at` are the values they already held, and + * nothing reaches the trail. + * + * REASON. Null from the chooser is an ordinary answer — most key-based apps publish some + * argument-less read and PostHog publishes none — and `probe: null` is what tells the row that + * nothing was tried, which `verified: false` alone cannot. + * + * THE UNTOUCHED ROW IS THE HALF THAT WOULD BE EASY TO GET WRONG. Writing `false` here because the + * check produced no evidence would take the date off a connection that was verified at a consent + * screen — a press of a button erasing a fact nothing else in this deployment records, and telling + * the person their working connection is now unchecked. A check that could try nothing has learned + * nothing, and the honest write is no write at all. + */ +test("an app with nothing to probe leaves the verification exactly as it was", async () => { + useAnsweringClient(); + await addProbedApp({ withProbe: false }); + // Verified a fortnight ago, at a consent screen or by a probe this app has since stopped + // publishing. Either way it is a fact, and this press must not be what takes it off the row. + const earned = new Date("2026-08-30T09:00:00.000Z"); + await holdProbedApp(earned); + + expect( + await store.recheckBrokeredConnection({ + toolkit: probedToolkit, + userId: askerId, + }), + ).toEqual({ + verified: true, + verifiedAt: earned.toISOString(), + probe: null, + }); + + // Nothing was called, which is what "nothing to try" means at the vendor. + expect(reached).toEqual([]); + const [row] = await database + .select() + .from(composioConnections) + .where( + and( + eq(composioConnections.toolkit, probedToolkit), + eq(composioConnections.userId, askerId), + ), + ); + expect(row.verified).toBe(true); + expect(row.verifiedAt?.toISOString()).toBe(earned.toISOString()); + // And nothing is on the trail: `mcp.connection_verified` records an account exercised with a real + // call, and no call was made. A row filed for a press that changed nothing would make the one + // event that means "a key was tried" also mean "somebody looked at a page". + expect(recordedOfType("mcp.connection_verified")).toEqual([]); +}); + +/** + * A RE-CHECK WITH NO CONNECTION TO CHECK REFUSES, AND MAKES NEITHER A ROW NOR A CALL. + * + * CRITERION. Where this person holds no account for the app, the call raises, no row is written, + * and nothing is spent at the vendor. + * + * REASON. The single writer this path records through is an UPSERT, so a re-check that probed + * first and wrote the answer would INSERT a connection for somebody who has none — a row that is + * the whole of the gate every later brokered call passes through, created by a button that claims + * to check one. And the probe itself would be spent on an account the vendor does not have, coming + * back as "no connected account found": a sentence about this deployment's own state, shown to + * somebody as though their key had been rejected. + */ +test("a re-check with no connection refuses rather than making one", async () => { + useAnsweringClient(); + await addProbedApp(); + + await expect( + store.recheckBrokeredConnection({ + toolkit: probedToolkit, + userId: askerId, + }), + ).rejects.toThrow(); + + expect(await connectedToolkitsFor(askerId)).toEqual([]); + expect(reached).toEqual([]); + expect(recordedOfType("mcp.connection_verified")).toEqual([]); +}); + +/** + * AND A RE-CHECK AGAINST A CONSENT CONNECTION IS REFUSED IN THE STORE, NOT MERELY IN THE BROWSER. + * + * CRITERION. Where the app's recorded scheme is a consent scheme, the call raises, no action is + * called at the vendor, nothing reaches the trail, and the row keeps the `verified` and + * `verified_at` it already held — with an app that HAS a probe, so the refusal is the scheme's doing + * and not the nothing-to-try branch's. + * + * REASON. A consent connection has no key here to re-check: what it has is a date earned at the + * vendor's own screen, which is a fact nothing else in this deployment records. Without this gate a + * direct POST — the browser's own button is not the only caller a route has — would spend a call on + * somebody's account and, on the failure that call is likely to be, write `verified: false` with a + * null timestamp: a button that claims to check a connection, destroying the only evidence that one + * was ever checked. {@link connectBrokeredWithFields} sets the precedent it is read off — the scheme + * on the app's row decides, in the store, rather than the caller being trusted to have looked. + */ +test("a re-check against a consent connection refuses rather than spending its date", async () => { + useAnsweringClient(); + // Added on the consent flow, and then given an action a probe could otherwise have used: without + // that action this test would pass on the nothing-to-probe branch and assert nothing about the + // scheme. + await store.addBrokeredApp({ + slug: probedToolkit, + title: "Probed App", + by: admin, + connection: { kind: "consent" }, + }); + await database.insert(mcpTools).values({ + serverId: probedId, + name: probeAction, + description: "Says who the account belongs to.", + effect: "read", + version: probeVersion, + }); + // The date the consent screen earned, which is the thing this refusal protects. + const earned = new Date("2026-08-30T09:00:00.000Z"); + await holdProbedApp(earned); + + await expect( + store.recheckBrokeredConnection({ + toolkit: probedToolkit, + userId: askerId, + }), + ).rejects.toThrow(); + + expect(reached).toEqual([]); + expect(recordedOfType("mcp.connection_verified")).toEqual([]); + const [row] = await database + .select() + .from(composioConnections) + .where( + and( + eq(composioConnections.toolkit, probedToolkit), + eq(composioConnections.userId, askerId), + ), + ); + expect(row.verified).toBe(true); + expect(row.verifiedAt?.toISOString()).toBe(earned.toISOString()); +}); + +/** + * DISCONNECTING A KEY CLAIMS NO REVOCATION, BECAUSE A KEY HAS NO GRANT BEHIND IT TO WITHDRAW. + * + * CRITERION. Ending a connection somebody typed a key into answers `vendorRevocationRequested: + * false` and files a trail row saying false — while the vendor is still asked, still finds the + * account, and still ends it; and while the SAME app under a consent scheme, disconnected the same + * way against the same vendor answer, says true. + * + * REASON. `revoke_on_delete` asks the PROVIDER to end a grant. For a consent connection that is a + * real request Google or Slack acts on, and the field says a withdrawal was asked for. For a key + * there is no grant: the value is still valid at the app and still works for anyone holding it, so + * the account ends at Composio and nothing was asked of anybody else. `ComposioBroker.revoke`'s own + * doc argues that "requested" is as far as any implementation can honestly go and that the whole + * worth of the boolean is letting a reader tell an account this deployment acted on from one that + * outlives it somewhere else — a key reporting true would be the one row in that trail nobody could + * rely on, a withdrawal recorded for something nobody ever granted. The person is already told the + * other half of this fact in their own words on the disconnect row: their key still works at the + * app, and rotating it there is what ends it. + * + * WHY BOTH SCHEMES IN ONE TEST. The vendor here answers `true` to every revoke, so the false above + * is the implementation's doing and not the stub's — and the second half is what separates a field + * this deployment computes from a constant of either polarity. One app rather than two, re-enabled + * onto the other flow with nobody connected, so the ONLY thing that differs between the two acts is + * the scheme recorded on the row. + */ +test("disconnecting a key claims no revocation", async () => { + useAnsweringClient(); + await store.addBrokeredApp({ + slug: enabledToolkit, + title: "Enablable App", + by: admin, + connection: { kind: "fields", authScheme: "API_KEY" }, + }); + await store.connectBrokeredWithFields({ + toolkit: enabledToolkit, + userId: askerId, + values: { generic_api_key: typedKey }, + }); + + expect( + await store.disconnectBrokered({ + toolkit: enabledToolkit, + userId: askerId, + by: askerId, + reason: "self", + }), + ).toEqual({ vendorRevocationRequested: false }); + + /* + * AND THE ACCOUNT DID END AT COMPOSIO, which is the half of the sentence the false must not be + * allowed to swallow. The broker was asked, it answered that it found an account — {@link + * vendorFinds} is true for everybody here — and it is holding nothing afterwards. Without these + * the assertion above would be satisfied just as well by a disconnect that skipped the revoke + * and left somebody's key attached at the vendor with no row here pointing at it. + */ + expect(asksMade()).toEqual([ + `ensureAuthConfig:${enabledToolkit}/fields`, + `connectWithFields:${enabledToolkit}/${askerId}`, + `revoke:${enabledToolkit}/${askerId}`, + ]); + expect(vendorHolds).toEqual([]); + expect(await connectedToolkitsFor(askerId)).toEqual([]); + + // THE SAME APP AND THE SAME ACT, with the scheme moved underneath it. Re-enabling may rewrite the + // column because the disconnect above left nobody connected to be stranded by it. + await store.addBrokeredApp({ + slug: enabledToolkit, + title: "Enablable App", + by: admin, + connection: { kind: "consent" }, + }); + await database + .insert(composioConnections) + .values({ toolkit: enabledToolkit, userId: askerId }); + + expect( + await store.disconnectBrokered({ + toolkit: enabledToolkit, + userId: askerId, + by: askerId, + reason: "self", + }), + ).toEqual({ vendorRevocationRequested: true }); + + // Read in the order the two acts happened, which is what makes the pair an assertion about the + // scheme rather than two separate assertions about a boolean. + expect( + recordedOfType("mcp.account_disconnected").map( + (event) => + (event.payload as { vendorRevocationRequested: boolean }) + .vendorRevocationRequested, + ), + ).toEqual([false, true]); +}); diff --git a/server/tests/composio-live.test.ts b/server/tests/composio-live.test.ts new file mode 100644 index 000000000..8d25fcc18 --- /dev/null +++ b/server/tests/composio-live.test.ts @@ -0,0 +1,271 @@ +import { describe, expect, test } from "bun:test"; +import { + Composio, + ComposioError, + ComposioToolVersionRequiredError, + type ToolExecuteResponse, +} from "@composio/core"; +import { effectOf, vendorSentence } from "../src/plugins/composio"; +import { createComposioClient } from "../src/plugins/composio-adapter"; + +/** + * One real call to Composio, so the shapes this transport is written against are the shapes it gets. + * + * WHY THIS EXISTS AT ALL, when everything else here runs against a stub. Three separate assumptions in + * an earlier draft were wrong — a call needs a specific version, failures throw rather than resolving + * with an error field, and the useful sentence is nested two levels inside the cause — and every one of + * them passed the whole stubbed suite. A stub asserts what its author believed. This asserts what the + * vendor does. + * + * WRITTEN AGAINST THE VENDOR'S TYPES, WITH NO `as never`. `server/tsconfig.json` does not include + * `tests`, so nothing in the build type-checks this file — which means a cast here erases the only + * place in the repo where the real SDK surface is named, and erasing it defeats the one job the file + * has. Every call below is typed by `@composio/core` itself. Where a value has to be narrowed, it is + * narrowed by a runtime check that says what was missing, not by a cast that asserts it was there. + * + * SKIPPED WITHOUT A KEY, so CI and a contributor with no Composio account are unaffected. Run it + * deliberately: `OPENBOT_LIVE_COMPOSIO=1 COMPOSIO_API_KEY=... bun test tests/composio-live.test.ts`. + * + * IT READS AND IT FAILS ON PURPOSE. Every action it calls is a read; the user id it calls for is one + * nobody has connected, and the one action it calls that is allowed to reach a third party is a + * no-auth public lookup of a record that does not exist. So the calls cannot touch anybody's data — + * the failure is the assertion. + */ +const key = process.env.COMPOSIO_API_KEY?.trim(); +const live = process.env.OPENBOT_LIVE_COMPOSIO === "1" && Boolean(key); + +/** A user id nobody has connected, so no call below can reach an account that belongs to somebody. */ +const NOBODY = "openbot-live-test-nobody"; + +/** + * The vendor's own no-auth example action, and a record it cannot find. + * + * `@composio/core` 0.18.1 uses `HACKERNEWS_GET_USER` in three of its own `tools.execute` doc examples + * with no connected account in sight, which is why it is the action picked to provoke a failure the + * vendor reports rather than throws. The test below re-checks the vendor's `isNoAuth` label before it + * calls, so a toolkit that stops being no-auth says so instead of quietly asking for a connection. + */ +const NO_AUTH_ACTION = "HACKERNEWS_GET_USER"; +const NO_SUCH_RECORD = "openbot-live-test-no-such-hacker-news-user"; + +describe.skipIf(!live)("Composio, for real", () => { + // Constructed inside each test rather than here, because Bun evaluates the body of a skipped + // describe: the constructor throws without a key, which would make this file fail rather than skip. + const client = () => + new Composio({ + apiKey: key, + // Their default telemetry installs its own interrupt handlers, and this is a self-hosted product + // whose operator never opted into a third party's analytics. + allowTracking: false, + disableVersionCheck: true, + // Pinned so the result is the same on every machine. `getToolkitVersionsFromEnv` folds any + // exported `COMPOSIO_TOOLKIT_VERSION_` into this config, and a version test whose answer + // depends on the operator's shell reports the operator's environment as a vendor change. + // Config wins over the environment, so naming the toolkits this file touches settles it. + toolkitVersions: { gmail: "latest", hackernews: "latest" }, + }); + + test("a listing carries a version and a behaviour label for every action", async () => { + const composio = client(); + const actions = await composio.tools.getRawComposioTools({ + toolkits: ["gmail"], + // Explicit, because their default page is 20 and Gmail has 63. + limit: 500, + }); + + expect(actions.length).toBeGreaterThan(50); + expect(actions.every((action) => Boolean(action.version))).toBe(true); + + // The classifier's fail-closed branch should be a guard against the future, not the present. If + // this ever fails, unlabelled actions have started arriving and the branch is now load-bearing. + const unlabelled = actions.filter( + (action) => + !(action.tags ?? []).some( + (tag) => tag === "readOnlyHint" || tag === "destructiveHint", + ), + ); + expect(unlabelled).toEqual([]); + + const reads = actions.filter( + (action) => effectOf(action.tags).effect === "read", + ); + expect(reads.length).toBeGreaterThan(10); + }); + + test("calling for somebody with no connection fails with a sentence naming that", async () => { + const composio = client(); + const [action] = await composio.tools.getRawComposioTools({ + toolkits: ["gmail"], + limit: 1, + }); + // Stated rather than destructured into a crash. A vendor that lists nothing has not disagreed + // with the assertion below, it has left the assertion unmade, and the two want opposite reactions. + if (!action) { + throw new Error( + "Precondition not met: Composio listed no Gmail action, so there was nothing to call. Nothing below was exercised.", + ); + } + // `Tool.version` is optional in `ToolSchema`, so the concrete version this call needs is a + // precondition and not something to assert into existence with a cast. + const version = action.version; + if (!version) { + throw new Error( + `Precondition not met: Composio listed ${action.slug} with no version, so no versioned call could be made. Nothing below was exercised.`, + ); + } + + let thrown: unknown; + try { + await composio.tools.execute(action.slug, { + userId: NOBODY, + arguments: {}, + version, + }); + } catch (error) { + thrown = error; + } + + expect(thrown).toBeDefined(); + // The whole point: the transport's error path depends on this shape, and the top-level message + // ("Error executing the tool X") names nothing anybody could act on. + expect(vendorSentence(thrown)).toMatch(/no connected account/i); + }); + + /** + * NOT A CHECK ON COMPOSIO, and named that way now because it used to be named as though it were. + * + * `executeComposioTool` throws `ComposioToolVersionRequiredError` on the statement directly above + * its own `try` — `@composio/core` 0.18.1, `dist/index.mjs:1728` — before the request body is built + * and before anything is sent. So no answer from Composio is involved and none can drift; what this + * watches is the SDK's local guard. + * + * Kept rather than deleted, because that guard is the reason the transport carries a version column + * at all. If the SDK ever stops refusing, `latest` becomes reachable and the column, and the refusal + * this transport inherits from it, are both worth revisiting. + */ + test("the SDK refuses locally, before dialling, when a version resolves to 'latest'", async () => { + const composio = client(); + const [action] = await composio.tools.getRawComposioTools({ + toolkits: ["gmail"], + limit: 1, + }); + if (!action) { + throw new Error( + "Precondition not met: Composio listed no Gmail action, so there was nothing to call. Nothing below was exercised.", + ); + } + + // Both spellings of "no concrete version": omitted, which the SDK resolves through the config to + // `latest`, and `latest` asked for by name. `body.version ?? getToolkitVersion(...)` reads an + // explicit `undefined` exactly as it reads an absent key, so passing it is the omitted case. + for (const version of [undefined, "latest"] as const) { + let thrown: unknown; + try { + await composio.tools.execute(action.slug, { + userId: NOBODY, + arguments: {}, + version, + }); + } catch (error) { + thrown = error; + } + expect(thrown).toBeInstanceOf(ComposioToolVersionRequiredError); + // Read off a narrowed instance rather than cast onto `unknown`: `ComposioError.code` is + // declared `string | undefined`, and the `TS-SDK::` prefix is the SDK stamping its own errors. + expect(thrown instanceof ComposioError ? thrown.code : null).toBe( + "TS-SDK::TOOL_VERSION_REQUIRED", + ); + } + }); + + /** + * The one that mattered, and the one that was missing. + * + * A 200 carrying `successful: false` came back from this transport as `isError: false`, was audited + * as `mcp.call_succeeded`, and was handed to the model as though the failure were content. The fix + * reads the field; nothing here checked that the real API still sends it, so the exact drift the fix + * was about was unguarded against the vendor. + * + * `ToolExecuteResponseSchema` makes `successful` required and `transformToolExecuteResponse` parses + * every answer through it, so an SDK resolution is guaranteed to carry the field. What only a live + * call can show is the other half: that the vendor reports a failure by RESOLVING with that field + * set to false, and not only by throwing. If this ever throws instead, `reportedFailure` in the + * transport has become dead code and the throw path is carrying the whole load. + */ + test("a failure the vendor reports arrives as a resolution, not only as a throw", async () => { + const composio = client(); + const [action] = await composio.tools.getRawComposioTools({ + tools: [NO_AUTH_ACTION], + }); + if (!action) { + throw new Error( + `Precondition not met: Composio does not list ${NO_AUTH_ACTION}, so no unauthenticated call could be made. Nothing below was exercised.`, + ); + } + if (action.isNoAuth !== true) { + throw new Error( + `Precondition not met: Composio no longer marks ${NO_AUTH_ACTION} as no-auth, so calling it would need somebody's connected account. Pick another no-auth action rather than connecting one.`, + ); + } + const version = action.version; + if (!version) { + throw new Error( + `Precondition not met: Composio listed ${action.slug} with no version, so no versioned call could be made. Nothing below was exercised.`, + ); + } + + let thrown: unknown; + let answer: ToolExecuteResponse | undefined; + try { + answer = await composio.tools.execute(action.slug, { + userId: NOBODY, + // A public lookup of a record that does not exist: the request reaches the vendor, and the + // vendor has nothing of anybody's to return. + arguments: { userId: NO_SUCH_RECORD }, + version, + }); + } catch (error) { + thrown = error; + } + + expect(thrown).toBeUndefined(); + expect(answer?.successful).toBe(false); + // `ComposioResult` in the transport spells `error` as `string | null`, and a reported failure that + // says nothing is what `unexplained()` exists for — so null is legal here and a third type is drift. + expect(answer?.error === null || typeof answer?.error === "string").toBe( + true, + ); + }); + + /** + * The catalogue an administrator picks an app out of, asked for the way the product asks for it. + * + * THROUGH `createComposioClient`, not through a hand-built `Composio` like the tests above. This + * one is about the broker rather than about a vendor shape, and the broker's whole listing is one + * request: `listApps` asks for a single page AT THE CEILING sorted by usage, because the SDK has + * no cursor to follow and a page is therefore all there is. So the ceiling is what makes one + * request the entire catalogue, and the count below is what shows it held — several hundred apps + * rather than the vendor's default page of twenty, which would read exactly like a full list. + * + * A read of the public catalogue: no user id is involved and nobody's account is touched. + */ + test("the broker lists the whole catalogue in one request", async () => { + if (!key) { + throw new Error( + "Precondition not met: no COMPOSIO_API_KEY was set, so no client could be built. Nothing below was exercised.", + ); + } + const apps = await createComposioClient(key).broker.listApps(); + + // Several hundred, not a page of twenty. + expect(apps.length).toBeGreaterThan(50); + + // Not stated as a precondition: the ceiling is what makes this one request the whole listing, + // so an absent Gmail is a truncated answer rather than an assertion left unmade. The count is + // the field `listApps` reads out of `meta.toolsCount` and zeroes when the vendor publishes + // none, which is the one absence that would go unnoticed on the screen. + const gmail = apps.find((app) => app.slug === "gmail"); + expect(gmail?.slug).toBe("gmail"); + expect(gmail?.actionCount).toBeGreaterThan(0); + }); +}); diff --git a/server/tests/composio-transport.test.ts b/server/tests/composio-transport.test.ts new file mode 100644 index 000000000..983ebb288 --- /dev/null +++ b/server/tests/composio-transport.test.ts @@ -0,0 +1,2848 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { BrokerRefusalError } from "../src/plugins/broker"; +import { + type ComposioAction, + type ComposioActions, + type ComposioResult, + callTool, + effectOf, + LISTING_LIMIT, + listNeedsCredential, + listTools, + toolkitOf, + useComposioClient, + vendorSentence, +} from "../src/plugins/composio"; +import { MAX_RESULT_CHARS } from "../src/plugins/mcp"; + +/** + * The Composio transport's boundary, asserted with no network and no database. + * + * What is under test is the boundary rather than the SDK: which app a connection names, whose id a + * call is attributed to, what a label means, which version is sent, and what a refusal reads as. The + * client arrives through {@link useComposioClient}, which is the only seam the module has — + * `transportFor` resolves a kind to a MODULE, so there is no constructor to pass one to. Same shape + * as `builtin-routines`. + * + * The security property this file exists for is the attribution one: the user id comes off the + * connection and never out of the arguments a model produced. A model that could name a user id + * could open somebody else's mailbox. + */ + +afterEach(() => useComposioClient(null)); + +/** + * One call as the vendor received it: the four fields that decide what it MEANS, and the arguments. + * + * THE ARGUMENTS ARE HERE BECAUSE THE ATTRIBUTION PROPERTY IS ABOUT BOTH HALVES. The recorder used to + * take the call record and drop `args` on the floor, so the one test this file exists for — a user + * id that comes off the connection and never out of a model's arguments — could only ever see the + * half that was already right. `@composio/client` resolves the connected account from the execute + * body's `user_id` and carries the model's own arguments beside it in `arguments` + * (0.1.0-alpha.76, `resources/tools.d.ts:480-493`), so what a transport sends into the second of + * those is as much of the call as what it sends into the first. + */ +type Recorded = { + toolkit: string; + slug: string; + userId: string; + version: string; + args: Record; +}; + +/** + * An answer in the shape `ToolExecuteResponseSchema` actually permits. + * + * Every stub here goes through this rather than returning a shape of its own, because the SDK's + * schema makes `data`, `error` and `successful` all REQUIRED — so a stub that resolves `null`, or a + * bare string, is testing a case the library cannot produce, and a test built on an impossible input + * proves nothing about the code that reads a real one. + */ +function answered( + data: Record, + outcome: { error?: string | null; successful?: boolean } = {}, +) { + return { + data, + error: outcome.error ?? null, + successful: outcome.successful ?? true, + }; +} + +/** + * The two numbers this file reasons about, WRITTEN OUT rather than imported. + * + * AN ASSERTION THAT IMPORTS THE CONSTANT IT IS ABOUT CANNOT FAIL WHEN THAT CONSTANT MOVES, because + * both sides move together. The previous version of this file replaced a loose bound with an + * equality against `MAX_RESULT_CHARS` itself and called the number pinned; it was not. Applied to + * the modules, `MAX_RESULT_CHARS` 20,000 → 40,000 and `LISTING_LIMIT` 1000 → 20 both left all 44 + * tests passing: the cap tests measured the answer against whatever the cap had just become, and + * the listing test asked for whatever page the module had just decided to ask for. + * + * So the literals live here, and one test below is the only place the imported constants are read. + * Changing either constant now reddens exactly that test, which is where the argument for the + * number belongs: 20,000 is how much of a model's context one tool result may spend, and 1000 is + * the vendor's stated page ceiling and therefore the whole listing. + */ +const RESULT_CAP = 20_000; +const WHOLE_LISTING = 1000; +const TRUNCATION_MARKER = "\n\n[truncated]"; +const CAPPED_LENGTH = RESULT_CAP + TRUNCATION_MARKER.length; + +/** The nesting `vendorSentence` reaches through, with whatever the vendor left at the bottom of it. */ +function nested(message: unknown): unknown { + return { cause: { error: { error: { message } } } }; +} + +/** + * The SAME failure with nothing wrapped around it, which is how several of these calls arrive. + * + * `@composio/client`'s `APIError` hangs the response body on `.error` and sets no `cause` at all, + * and builds its own `message` as the status code followed by that body JSON-stringified whole + * where the body has no top-level `message` — `${"${status}"} ${"${JSON.stringify(error)}"}` + * (`@composio/client` 0.1.0-alpha.76, `src/core/error.ts:9-45`). Composio's body puts its sentence + * at `error.message`, so that fallback is what every one of these throws carries in `.message`. + * + * It reaches this transport unwrapped from five calls: `@composio/core` 0.18.1 awaits + * `this.client.authConfigs.list`, `this.client.connectedAccounts.list` and `this.client.tools.list` + * with no try around them (`src/models/AuthConfigs.ts`, `src/models/ConnectedAccounts.ts`, + * `src/models/Tools.ts:552-555`), and `./composio-adapter` calls both raw deletes on the client + * itself. So the sentence sits one level shallower here than in {@link nested}, and the dump is + * what escaped in its place. + */ +function unwrapped(message: unknown): Error { + const body = { error: { message } }; + return Object.assign(new Error(`404 ${JSON.stringify(body)}`), { + status: 404, + headers: { "x-request-id": "must-not-appear" }, + error: body, + }); +} + +/** The same client error for a body with no sentence anywhere in it: status code, then the lot. */ +function dumped(body: Record): Error { + return Object.assign(new Error(`502 ${JSON.stringify(body)}`), { + status: 502, + headers: { "x-request-id": "must-not-appear" }, + error: body, + }); +} + +/** + * The dump AS A BARE STRING, for the places one arrives already unwrapped from its Error. + * + * Taken off {@link dumped} rather than written out a second time, so the two cannot drift: what the + * envelope's `error` field and a candidate sentence are tested against is byte-for-byte the string + * `@composio/client` builds. A vendor gateway that proxies an upstream status and body into the + * field its own schema spells a sentence produces exactly this, and so does one that puts its + * client's message there — the shape is what is refused, not the route it took. + */ +const RESPONSE_DUMP = dumped({ + detail: [{ loc: ["body", "arguments"], msg: "unrecognised" }], + request_id: "must-not-appear", +}).message; + +function recording(answers: Partial = {}): { + client: ComposioActions; + calls: Recorded[]; +} { + const calls: Recorded[] = []; + return { + calls, + client: { + listActions: answers.listActions ?? (async () => []), + execute: + answers.execute ?? + (async (call, args) => { + // SNAPSHOTTED RATHER THAN HELD BY REFERENCE, for the reason the schema test snapshots: + // `toEqual` against a live reference holds whatever happened to the object afterwards, so + // a transport that handed its arguments over and then edited them would be recorded as + // having sent whatever it edited them into. + calls.push({ ...call, args: structuredClone(args) }); + return answered({ ok: true }); + }), + }, + }; +} + +const GMAIL_READ = { + slug: "GMAIL_FETCH_EMAILS", + description: "Fetch emails.", + inputParameters: { + type: "object", + properties: { query: { type: "string" } }, + }, + tags: ["readOnlyHint", "important"], + version: "20260903_00", +}; + +/** A parameter that stages a file, in the shape `JSONSchemaPropertySchema` keeps it. */ +const FILE_PROPERTY = { type: "string", file_uploadable: true }; + +/** + * One subschema keyword carrying `sub`, hung off a property so the root stays a `ParametersSchema`. + * + * A COMPUTED KEY, for `then` and for nothing else: biome refuses a literal `then` key on an object + * literal, and the conditional trio has to be reachable here or the branch that walks it is being + * asserted by nothing. `if`, `then`, `else`, `items` and `$ref` live on `JSONSchemaPropertySchema` + * and not on the parameters root (`@composio/core` 0.18.1, `src/types/tool.types.ts:77-131` against + * `:134-175`), so a case for one of them has to nest to be a shape the vendor could send. + */ +function underProperty(keyword: string, sub: unknown): Record { + return { + type: "object", + properties: { field: { type: "object", [keyword]: sub } }, + }; +} + +/** One action whose whole schema is the case under test, listed beside a plain one. */ +function listing(inputParameters: Record) { + return recording({ + listActions: async () => [ + GMAIL_READ, + { slug: "GMAIL_STAGES_A_FILE", version: "20260903_00", inputParameters }, + ], + }).client; +} + +describe("the numbers these assertions are about", () => { + test("the modules hold the numbers this file has written out", () => { + // The only reads of the imported constants in this file. Every other assertion measures + // against the literals above, so a constant that moves reddens this one test — which states + // the number — instead of quietly redefining what all the others are checking. + expect(MAX_RESULT_CHARS).toBe(RESULT_CAP); + expect(LISTING_LIMIT).toBe(WHOLE_LISTING); + }); +}); + +describe("which app a connection names", () => { + test("the app slug comes off the url", () => { + expect(toolkitOf("composio://gmail")).toBe("gmail"); + expect(toolkitOf("composio://gmail/")).toBe("gmail"); + }); + + test("anything that is not a composio url names no app", () => { + expect(toolkitOf("https://mcp.notion.com/mcp")).toBeNull(); + expect(toolkitOf("composio://")).toBeNull(); + expect(toolkitOf("")).toBeNull(); + }); + + test("anything past the app slug means the url does not name one app", () => { + // This answer is the app a person's connection is checked against — `accessFor` puts it on + // `ServerAccess.toolkit` (`access.ts:133`) and the brokered gate looks `composio_connections` + // up by it. A url this function reads loosely is a check performed against the wrong app, so + // anything it cannot read as exactly one slug has to be no app rather than a best guess. + expect(toolkitOf("composio://gmail/messages")).toBeNull(); + expect(toolkitOf("composio://gmail?scope=read")).toBeNull(); + expect(toolkitOf("composio://gmail#inbox")).toBeNull(); + expect(toolkitOf("composio://gmail slack")).toBeNull(); + }); + + test("surrounding space is taken off before the trailing slash, not after", () => { + // The strip ran first and the trim second, so a slash that was not the last character survived + // it: `composio://gmail/ ` answered `"gmail/"`, which matches no row in `composio_connections` + // and is not the app anybody meant. + expect(toolkitOf("composio://gmail/ ")).toBe("gmail"); + expect(toolkitOf("composio://gmail ")).toBe("gmail"); + expect(toolkitOf("composio://google_drive//")).toBe("google_drive"); + }); +}); + +describe("what a label means", () => { + test("read-only is a read", () => { + expect(effectOf(["readOnlyHint", "openWorldHint", "gmail"])).toEqual({ + effect: "read", + destructive: false, + }); + }); + + test("destructive is a destructive write", () => { + expect(effectOf(["destructiveHint", "important"])).toEqual({ + effect: "write", + destructive: true, + }); + }); + + test("create and update are writes that are not destructive", () => { + expect(effectOf(["createHint", "openWorldHint"])).toEqual({ + effect: "write", + destructive: false, + }); + expect(effectOf(["updateHint", "labels", "inbox"])).toEqual({ + effect: "write", + destructive: false, + }); + }); + + test("idempotent is not a read, because deleting is idempotent", () => { + expect(effectOf(["idempotentHint", "openWorldHint"])).toEqual({ + effect: "write", + destructive: false, + }); + }); + + test("no label at all is a write", () => { + // Measured across five apps and never seen, so this branch guards the future rather than the + // present: an app that labels nothing, or a label added later, must land on write. + expect(effectOf([])).toEqual({ effect: "write", destructive: false }); + expect(effectOf(undefined)).toEqual({ + effect: "write", + destructive: false, + }); + expect(effectOf(["gmail", "inbox"])).toEqual({ + effect: "write", + destructive: false, + }); + }); + + test("destructive wins over read-only when both are present", () => { + // Contradictory labels are somebody else's bug, and the safe reading is the strict one. + expect(effectOf(["readOnlyHint", "destructiveHint"])).toEqual({ + effect: "write", + destructive: true, + }); + }); +}); + +describe("finding the vendor's own sentence", () => { + test("the sentence nested inside the cause is what comes out", () => { + // The real shape, copied from a live failure. The top-level message is useless. + const error = Object.assign(new Error("Error executing the tool X"), { + cause: { + status: 404, + headers: { "x-request-id": "must-not-appear" }, + error: { + error: { + message: + "No connected account found for user ID u1 for toolkit gmail", + code: 1810, + }, + }, + }, + }); + + expect(vendorSentence(error)).toBe( + "No connected account found for user ID u1 for toolkit gmail", + ); + }); + + test("an error with no such sentence yields nothing rather than a guess", () => { + expect(vendorSentence(new Error("boom"))).toBeNull(); + expect(vendorSentence({ cause: { error: {} } })).toBeNull(); + expect(vendorSentence(undefined)).toBeNull(); + }); + + test("a sentence made only of whitespace is not a sentence", () => { + // The `.trim()` on the return had nothing asserting it. A blank message that counted as a + // sentence is worse than none: `callTool` and `listingSentence` both prefer it over their + // fallbacks, so the reader gets an empty refusal instead of the one line naming what to do. + expect(vendorSentence(nested(""))).toBeNull(); + expect(vendorSentence(nested(" "))).toBeNull(); + expect(vendorSentence(nested("\n\t "))).toBeNull(); + }); + + test("the sentence comes back as it was measured, without its padding", () => { + // The guard trimmed and the return did not, so the one thing the function had already decided + // about the string was thrown away again. What comes out is a refusal in a model's context and + // a sentence in an audit row; leading newlines in both are this module's own untidiness, and + // the cap that measures the string measures the padding with it. + expect(vendorSentence(nested(" Gmail rejected the query.\n"))).toBe( + "Gmail rejected the query.", + ); + }); + + test("a message that is not a string is not read as one", () => { + // Nothing asserted the type guard either. Composio's payloads are somebody else's JSON, so the + // field can be a number, an object or null; handed on unchecked, each of those reaches a model's + // context and an audit row as `[object Object]` or `1810`. + expect(vendorSentence(nested(1810))).toBeNull(); + expect(vendorSentence(nested(null))).toBeNull(); + expect(vendorSentence(nested({ text: "a nested sentence" }))).toBeNull(); + expect(vendorSentence(nested(["a sentence in a list"]))).toBeNull(); + }); + + test("the vendor's placeholder is not a sentence, however deep it arrives", () => { + /* + * THE GUARD COVERED THE BRANCH THAT THROWS AND NOT THE ONE THIS FUNCTION READS, which is the + * half that matters: `listingSentence` and `callTool` both prefer this answer over their own + * fallbacks, so "Error executing the tool X" found nested inside `cause` walked straight past a + * check written for exactly that string. This module's own comment calls it the one sentence + * never worth passing on; a reader who asked for that tool learns from it only that they asked. + */ + expect( + vendorSentence(nested("Error executing the tool GMAIL_FETCH_EMAILS")), + ).toBeNull(); + expect(vendorSentence(nested(" error executing the tool X\n"))).toBeNull(); + + // Matched on its opening and not looked for anywhere in the string, because a real sentence + // that goes on to mention the phrase is still a real sentence. + expect( + vendorSentence( + nested("No connected account found; error executing the tool X."), + ), + ).toBe("No connected account found; error executing the tool X."); + }); + + test("the sentence is found where a client error with no wrapper puts it", () => { + /* + * ONE DEPTH WAS READ AND TWO ARE THROWN. See {@link unwrapped}: five of this transport's calls + * surface `@composio/client`'s own `APIError`, which carries the body on `.error` and no + * `cause`, so `cause.error.error.message` found nothing on any of them and the vendor's + * readable sentence was skipped in favour of a status code and the whole response body. + */ + expect( + vendorSentence( + unwrapped( + "No connected account found for user ID u1 for toolkit gmail", + ), + ), + ).toBe("No connected account found for user ID u1 for toolkit gmail"); + }); + + test("every judgement this function makes applies at the shallower depth too", () => { + /* + * THE POINT OF THE JUDGEMENT LIVING HERE. It was moved into this function so that no caller + * could reach a sentence without it; a second depth read without it would be that bypass + * rebuilt. Each of these is the assertion its {@link nested} sibling above makes, asked of the + * place a client error actually puts the field. + */ + expect( + vendorSentence(unwrapped("Error executing the tool GMAIL_FETCH_EMAILS")), + ).toBeNull(); + expect( + vendorSentence(unwrapped(" error executing the tool X\n")), + ).toBeNull(); + expect(vendorSentence(unwrapped(""))).toBeNull(); + expect(vendorSentence(unwrapped(" "))).toBeNull(); + expect(vendorSentence(unwrapped(1810))).toBeNull(); + expect(vendorSentence(unwrapped(null))).toBeNull(); + expect(vendorSentence(unwrapped({ text: "a nested sentence" }))).toBeNull(); + expect(vendorSentence(unwrapped(["a sentence in a list"]))).toBeNull(); + expect(vendorSentence(unwrapped(" Gmail rejected the query.\n"))).toBe( + "Gmail rejected the query.", + ); + }); + + test("a body with no sentence in it yields nothing rather than the body", () => { + // The shape `dumped` is named for: nothing at `error.message`, so there is no sentence to find + // and the function must say so rather than reaching for whatever else the body holds. + expect( + vendorSentence(dumped({ detail: "something went wrong" })), + ).toBeNull(); + expect(vendorSentence(dumped({}))).toBeNull(); + }); + + test("a response dump found where the sentence belongs is not a sentence either", () => { + /* + * THE OTHER HALF OF THE JUDGEMENT, WHICH ONLY ONE OF THE THREE READERS WAS MAKING. The dump + * refusal was added to the thrown-message guard and to nothing else, so a status code followed + * by a whole response body arriving AT `error.message` — the field this function reaches for — + * was passed on as the vendor's own explanation, at both depths. The test above only covers a + * body with no `message` at all; it says nothing about a `message` that is itself a dump, which + * is what a gateway proxying an upstream reply puts there. + * + * Refused for the reason the placeholder is: it is not an explanation, and it costs a request + * id in a model's context and an audit row the size of a response body to say so. + */ + expect(vendorSentence(nested(RESPONSE_DUMP))).toBeNull(); + expect(vendorSentence(unwrapped(RESPONSE_DUMP))).toBeNull(); + }); + + test("a status code with a real sentence after it is still a sentence", () => { + /* + * THE LIMIT ON THE REFUSAL ABOVE, asserted so that widening it reddens something. What the + * module refuses is a three-digit status followed by the OPENING OF A JSON DOCUMENT, because + * that is the string the client builds and nothing a person writes. A status code followed by + * words is the client's other branch — the body's own `message` came through — and it is the + * most useful sentence on this path; refusing it would replace a named cause with advice to + * check a connection that is fine. + */ + expect(vendorSentence(nested("400 Invalid auth config id"))).toBe( + "400 Invalid auth config id", + ); + expect(vendorSentence(unwrapped("404 status code (no body)"))).toBe( + "404 status code (no body)", + ); + }); +}); + +describe("listing an app's actions", () => { + test("listing needs no credential", () => { + expect(listNeedsCredential).toBe(false); + }); + + test("the listing asks for a page, and for one big enough to be the whole list", async () => { + const asked: unknown[] = []; + useComposioClient( + recording({ + listActions: async (toolkit, page) => { + asked.push({ toolkit, page }); + return [GMAIL_READ]; + }, + }).client, + ); + + await listTools({ url: "composio://gmail" }); + + // Composio's default page is 20 and Gmail publishes 63 actions, so an omitted limit truncates. + // It also NARROWED, through the wrapper this listing used to go through: `getRawComposioTools` + // auto-applied `important=true` when no limit, no tags and no search were given + // (`@composio/core` 0.18.1, `src/models/Tools.ts:505-515`), and nothing in the short answer + // said a filter had been applied. Asking for a page is therefore not an optimisation, and the + // seam must not let a caller forget to — what it sizes now is each request rather than the + // answer, because `./composio-adapter` reads on until Composio's cursor stops. + expect(asked).toEqual([ + { toolkit: "gmail", page: { limit: WHOLE_LISTING } }, + ]); + }); + + /** + * THIS TEST ASSERTED A REFUSAL AND WAS CHANGED ON PURPOSE. + * + * It was "a listing that filled the biggest page the SDK can ask for is not called complete", and + * it pinned the refusal this module made of any listing at {@link WHOLE_LISTING} rows. The reason + * it gave was true of the WRAPPER: "`ToolListParamsSchema` accepts no cursor and + * `getRawComposioTools` drops the response's `next_cursor`, so one page at the API's stated + * maximum is the largest listing expressible through this SDK". + * + * IT IS NOT TRUE OF THE VENDOR. `@composio/client`'s `ToolListParams` carries a `cursor` and its + * `ToolListResponse` carries `next_cursor` (0.1.0-alpha.76, `resources/tools.d.ts:421-432`, + * `:200-204`), and `./composio-adapter` now reads that client and follows the cursor to the end. + * A listing this long is an app with a lot of actions, and refusing it would be refusing a + * complete answer — which is what the same ceiling, one file over, was doing to the app picker + * on every single call. + * + * SO WHAT IS ASSERTED IS THE INVERSE, AT THE SAME BOUNDARY. The seam below hands back a full page + * because that is what the adapter's pager resolves to once it has read every page; this module's + * job is to commit it rather than to second-guess its length. The refusals this module still + * makes — an empty listing, an unreadable row, a listing that did not answer — are unmoved, and + * each has its own test above. + */ + test("a listing as long as the biggest page is committed rather than called a fragment", async () => { + useComposioClient( + recording({ + listActions: async () => + Array.from({ length: WHOLE_LISTING }, (_unused, index) => ({ + ...GMAIL_READ, + slug: `GMAIL_ACTION_${index}`, + })), + }).client, + ); + + const listed = await listTools({ url: "composio://gmail" }); + + expect(listed).toHaveLength(WHOLE_LISTING); + }); + + test("an action arrives with its schema, its effect and its version", async () => { + // RECORDED AND ASSERTED AFTERWARDS, NOT CHECKED INSIDE THE STUB. `listTools` wraps this call in + // the try that turns a throw into `listingSentence`'s sentence, so a failed `expect` in there + // is not a failed test: it is swallowed and re-emerges as a refusal about Composio, which this + // test would then report as a listing failure rather than as the wrong app being asked for. + const asked: string[] = []; + const { client } = recording({ + listActions: async (toolkit) => { + asked.push(toolkit); + return [GMAIL_READ]; + }, + }); + useComposioClient(client); + + expect(await listTools({ url: "composio://gmail" })).toEqual([ + { + name: "GMAIL_FETCH_EMAILS", + description: "Fetch emails.", + inputSchema: { + type: "object", + properties: { query: { type: "string" } }, + }, + effect: "read", + destructive: false, + version: "20260903_00", + }, + ]); + expect(asked).toEqual(["gmail"]); + }); + + test("an action that stages a file is not offered at all", async () => { + useComposioClient( + recording({ + listActions: async () => [ + GMAIL_READ, + { + slug: "GMAIL_SEND_EMAIL", + description: "Send an email.", + tags: ["createHint"], + version: "20260903_00", + inputParameters: { + type: "object", + properties: { + recipient: { type: "string" }, + // What the SDK hands on under its default file handling: the vendor's own staging + // descriptor, untouched. `dangerouslyAllowAutoUploadDownloadFiles` is off unless a + // client asks for it (`src/models/Tools.ts:136`, `:242-248`), and only that flag + // collapses the shape. An `s3key` is issued by an upload nothing here performs. + attachment: { + type: "object", + file_uploadable: true, + properties: { + name: { type: "string" }, + mimetype: { type: "string" }, + s3key: { type: "string" }, + }, + }, + }, + required: ["recipient", "attachment"], + }, + }, + ], + }).client, + ); + + const listed = await listTools({ url: "composio://gmail" }); + + // Dropped rather than offered with a field the model can only invent. Offering it guarantees a + // hallucinated key and a rejection at the vendor's staging lookup, and a grant recorded against + // a name that can never work. + expect(listed.map((tool) => tool.name)).toEqual(["GMAIL_FETCH_EMAILS"]); + }); + + test("a file parameter reached through $defs and a variant is found too", async () => { + useComposioClient( + recording({ + listActions: async () => [ + GMAIL_READ, + { + slug: "GMAIL_GET_ATTACHMENT", + version: "20260903_00", + inputParameters: { + type: "object", + properties: { body: { $ref: "#/$defs/upload" } }, + $defs: { + upload: { + anyOf: [ + { type: "null" }, + { type: "string", file_uploadable: true }, + ], + }, + }, + }, + }, + ], + }).client, + ); + + // Composio toolkits routinely express the flag through a `$ref`/`$defs` indirection, which is + // why the SDK's own predicate walks `$defs` and every composed variant + // (`src/utils/modifiers/FileToolModifier.utils.neutral.ts:77-134`). A walk that stopped at + // `properties` would answer false for every ref-based schema and offer it anyway. + const listed = await listTools({ url: "composio://gmail" }); + expect(listed.map((tool) => tool.name)).toEqual(["GMAIL_FETCH_EMAILS"]); + }); + + test("a file parameter is found down every subschema keyword the SDK keeps", async () => { + /* + * ONE CASE PER KEYWORD, because a keyword the walk does not descend is an action offered to a + * model under BOTH auto-upload settings — the parameter is either a bucket key nobody here can + * issue or a server-side path nobody should promise — so every call against it fails. + * + * `additionalProperties` is the one that was missing, and it is not exotic: both + * `ParametersSchema` and `JSONSchemaPropertySchema` keep it as a full subschema + * (`@composio/core` 0.18.1, `src/types/tool.types.ts:154` and `:111`), which is precisely how a + * toolkit spells "a bag of attachments". The rest were already walked and asserted by nothing. + */ + const hidden: { where: string; schema: Record }[] = [ + { + where: "additionalProperties at the root", + schema: { type: "object", additionalProperties: FILE_PROPERTY }, + }, + { + where: "additionalProperties under a property", + schema: underProperty("additionalProperties", FILE_PROPERTY), + }, + { + where: "patternProperties at the root", + schema: { + type: "object", + patternProperties: { "^attachment_": FILE_PROPERTY }, + }, + }, + { + where: "patternProperties under a property", + schema: underProperty("patternProperties", { any: FILE_PROPERTY }), + }, + { + where: "not at the root", + schema: { type: "object", not: FILE_PROPERTY }, + }, + { where: "not", schema: underProperty("not", FILE_PROPERTY) }, + { where: "if", schema: underProperty("if", FILE_PROPERTY) }, + { where: "then", schema: underProperty("then", FILE_PROPERTY) }, + { where: "else", schema: underProperty("else", FILE_PROPERTY) }, + { where: "items", schema: underProperty("items", FILE_PROPERTY) }, + { + where: "items as a tuple", + schema: underProperty("items", [{ type: "string" }, FILE_PROPERTY]), + }, + { where: "oneOf", schema: underProperty("oneOf", [FILE_PROPERTY]) }, + { where: "allOf", schema: underProperty("allOf", [FILE_PROPERTY]) }, + { + where: "definitions at the root", + schema: { + type: "object", + properties: { body: { $ref: "#/definitions/upload" } }, + definitions: { upload: FILE_PROPERTY }, + }, + }, + ]; + + for (const { where, schema } of hidden) { + useComposioClient(listing(schema)); + const listed = await listTools({ url: "composio://gmail" }); + // The keyword is carried into the comparison so a failure names which one escaped. + expect({ where, offered: listed.map((tool) => tool.name) }).toEqual({ + where, + offered: ["GMAIL_FETCH_EMAILS"], + }); + } + }); + + test("an action is dropped only where the flag is actually set", async () => { + /* + * THE OTHER HALF OF THE WALK, which decides what stays offered. `file_uploadable` is + * `z.boolean().optional()` (`src/types/tool.types.ts:89`), so `false` is a value the vendor + * really sends and the comparison against `true` rather than against truthiness is what keeps + * it from dropping an action nobody has to stage anything for. `additionalProperties` is a + * union with `boolean` (`:111`, `:154`), so `true` and `false` arrive there as values and the + * walk has to read them as "not a subschema" instead of tripping over them. + */ + const offered: { where: string; schema: Record }[] = [ + { + where: "the flag is explicitly false", + schema: { + type: "object", + properties: { note: { type: "string", file_uploadable: false } }, + }, + }, + { + where: "additionalProperties is open", + schema: { type: "object", additionalProperties: true }, + }, + { + where: "additionalProperties is closed", + schema: { type: "object", additionalProperties: false }, + }, + ]; + + for (const { where, schema } of offered) { + useComposioClient(listing(schema)); + const listed = await listTools({ url: "composio://gmail" }); + expect({ where, offered: listed.map((tool) => tool.name) }).toEqual({ + where, + offered: ["GMAIL_FETCH_EMAILS", "GMAIL_STAGES_A_FILE"], + }); + } + }); + + test("the schema a model is shown is the one the SDK handed over, unaltered", async () => { + /* + * A CHARACTERIZATION TEST, and it passed before the claim beside `inputParameters` was + * corrected — the correction is to a comment, because the loss it describes happens inside + * `ToolSchema.parse` and there is no key left here to restore. + * + * What it pins is the narrower promise that replaced the false one: this module adds nothing to + * the schema and removes nothing from it. The keys below are ones `ParametersSchema` and + * `JSONSchemaPropertySchema` would have stripped, so a real client never delivers them — which + * is exactly why they are the right probe for whether anything HERE also strips. `listTools` + * now walks the schema looking for a file parameter, and a walk that rebuilt what it read + * would silently narrow every schema in the listing. + */ + const schema = { + type: "object", + properties: { + query: { type: "string", deprecated: true, contentEncoding: "utf-8" }, + }, + if: { required: ["query"] }, + // No `then` beside it: biome bans a `then` key on an object literal, and the point of these + // is only that they are root keywords `ParametersSchema` does not name. + else: { required: [] }, + examples: [{ query: "is:unread" }], + "x-openbot-probe": "kept", + }; + /* + * SNAPSHOTTED BEFORE THE CALL, because comparing the answer to `schema` compares it to the very + * object the stub handed over. `toEqual` between two references to one object holds whatever + * happened in between, so an in-place `delete` inside `listTools` — a walk that pruned what it + * read — passed this test unchanged. Proven: one added `delete` of a root keyword in the map + * left all 44 tests green. + * + * The snapshot is what the SDK handed over. The first assertion is that a model is shown that; + * the second is that the vendor's own object still IS that, because a module returning a + * faithful copy while wrecking the original would corrupt every later reader of one listing. + */ + const asHandedOver = structuredClone(schema); + useComposioClient( + recording({ + listActions: async () => [{ ...GMAIL_READ, inputParameters: schema }], + }).client, + ); + + const [tool] = await listTools({ url: "composio://gmail" }); + expect(tool?.inputSchema).toEqual(asHandedOver); + expect(schema).toEqual(asHandedOver); + }); + + test("an action with no schema is still listed, with an open one", async () => { + useComposioClient( + recording({ + listActions: async () => [ + { slug: "GMAIL_ODD", tags: ["updateHint"], version: "20260903_00" }, + ], + }).client, + ); + + const [tool] = await listTools({ url: "composio://gmail" }); + + // Offered rather than dropped: the vendor is the right party to reject a bad argument, and a + // silently missing action reads to an administrator as an app that does not have it. + expect(tool?.name).toBe("GMAIL_ODD"); + expect(tool?.inputSchema).toEqual({}); + expect(tool?.effect).toBe("write"); + }); + + test("an action Composio published no version for is listed with no version key", async () => { + useComposioClient( + recording({ + listActions: async () => [ + { slug: "GMAIL_UNVERSIONED", tags: ["readOnlyHint"] }, + ], + }).client, + ); + + const [tool] = await listTools({ url: "composio://gmail" }); + + // The branch that spreads the key only when the vendor sent one had nothing exercising it: + // every listing stub above carries a version. What it guards is not cosmetic. `store.ts` + // writes `tool.version ?? null`, so a key present and empty would be recorded as a version this + // deployment believes it has, and `callTool` would send `""` to a vendor that rejects it — + // instead of the refusal that names what the reader can and cannot do about it. + expect(Object.keys(tool ?? {})).not.toContain("version"); + expect(tool?.name).toBe("GMAIL_UNVERSIONED"); + expect(tool?.effect).toBe("read"); + }); + + test("a listing nobody was asked for throws rather than answering empty", async () => { + // `[]` means "the vendor was asked and advertises none" everywhere else in this codebase, and + // `refreshTools` commits it as a healthy refresh. No client installed is the SHIPPED state — + // nothing under `server/src` calls `useComposioClient` — so `[]` here was the only answer a + // real Composio refresh could produce, and committing it deleted every recorded action. + const refused = listTools({ url: "composio://gmail" }); + + await expect(refused).rejects.toThrow( + /not configured for this deployment/i, + ); + + const thrown = (await refused.catch((error: unknown) => error)) as Error; + expect(thrown.message).toContain("gmail"); + // Not a crash report. No deployment installs a client yet, so an operator reading this has to + // recognise a state rather than go hunting for a fault. + expect(thrown.message).toMatch(/expected/i); + }); + + test("a url that names no app throws about the url, and asks nobody", async () => { + /* + * THE REFUSAL IS ONLY HALF THE CLAIM, and this test used to make only that half. + * + * With the default stub answering `[]`, nothing here noticed whether Composio had been asked + * at all — so the guard could be moved to after the dial and every assertion below still + * passed, while the transport handed `https://example.com` to the vendor as an app slug. That + * is the failure the guard exists to prevent: `toolkitOf` is what keeps a url this deployment + * cannot read from becoming a request, and a test that cannot tell a refusal from a round trip + * is not testing the guard. + */ + const asked: unknown[] = []; + useComposioClient( + recording({ + listActions: async (toolkit, page) => { + asked.push({ toolkit, page }); + return []; + }, + }).client, + ); + + const refused = listTools({ url: "https://example.com" }); + + // The two refusals send an operator to different places — one to this deployment's + // configuration, one to the row — so they must not share a sentence. + await expect(refused).rejects.toThrow(/does not name a Composio app/i); + + const thrown = (await refused.catch((error: unknown) => error)) as Error; + expect(thrown.message).not.toMatch(/not configured/i); + expect(thrown.message).toContain("https://example.com"); + expect(asked).toEqual([]); + }); + + test("a listing the vendor's own schema rejects throws a sentence, not a Zod dump", async () => { + const issues = [ + { + code: "invalid_type", + expected: "string", + received: "number", + path: ["slug"], + message: "Expected string, received number", + }, + ]; + useComposioClient( + recording({ + listActions: async () => { + // What `ToolSchema` throws: `message` is the issue array as JSON, which is what would land + // in `lastError` and, before `refreshTools` existed, in a model's context. + throw Object.assign(new Error(JSON.stringify(issues, null, 2)), { + name: "ZodError", + issues, + }); + }, + }).client, + ); + + const refused = listTools({ url: "composio://gmail" }); + + // Propagated rather than answered empty, because `refreshTools` records a throw in `lastError` + // and leaves the tools it already holds alone. An empty answer would read as an app that has no + // actions, and every grant would point at a name nothing advertises. + await expect(refused).rejects.toThrow(/did not match/i); + + const thrown = await refused.catch((error: unknown) => error); + expect(String((thrown as Error).message)).not.toContain("invalid_type"); + expect(String((thrown as Error).message)).toContain("gmail"); + }); + + test("a listing that failed with nothing said still names the app it was about", async () => { + // The other arm of `listingSentence`'s fallback, which nothing reached. `refreshTools` puts + // this string in the row's `lastError` and an administrator reads it off the Plugins page, so + // a blank one is a refresh that reports having failed and declines to say about what. + for (const thrown of [{ status: 502 }, new Error(""), new Error(" ")]) { + useComposioClient( + recording({ + listActions: async () => { + throw thrown; + }, + }).client, + ); + + const message = await listTools({ url: "composio://gmail" }).then( + () => "", + (error: unknown) => (error as Error).message, + ); + + expect(message.trim()).not.toBe(""); + expect(message).toContain("gmail"); + } + }); + + test("a listing failure carrying only the vendor's placeholder says something else", async () => { + /* + * `callTool` already refuses to pass "Error executing the tool X" on, and the listing path did + * not. Same string, same reader: `refreshTools` writes this sentence into the row's + * `lastError` and an administrator reads it off the Plugins page, where the name of the thing + * they asked to refresh is the one fact they already have. + */ + useComposioClient( + recording({ + listActions: async () => { + throw new Error("Error executing the tool GMAIL_FETCH_EMAILS"); + }, + }).client, + ); + + const message = await listTools({ url: "composio://gmail" }).then( + () => "", + (error: unknown) => (error as Error).message, + ); + + expect(message).not.toMatch(/error executing the tool/i); + expect(message).toContain("gmail"); + }); + + test("a listing refusal this deployment authored beats whatever the vendor said", async () => { + /* + * THE RULE THIS PATH NEVER ASKED ABOUT. `routes.ts` reads `brokerSentence` first and + * `vendorSentence` second, and `callTool`'s own catch was corrected to the same order; this one + * consulted `brokerSentence` nowhere at all. `./composio-adapter`'s `askVendor` wraps the raw + * tool listing exactly as it wraps the execute, so a `BrokerRefusalError` arrives here as + * readily as it arrives there — and an authored sentence is the only one that names the step + * that clears the condition. + * + * WHAT IT LOST TO IS A READ ONE LEVEL SHALLOW, the same way it did on the call path. + * `vendorRefusal` authors only where `vendorSentence` of the ORIGINAL was null, so asking the + * WRAPPER the same question lands somewhere the adapter never judged and comes back with + * whatever sits there — below, two words naming nothing, in place of a remedy. + */ + const authored = + 'Composio refuses a call whose toolkit version is "latest", and that is the version travelling with this one, so gmail\'s action list was not refreshed and the tools already held are untouched. A dated version is recorded when an app\'s actions are listed, so refreshing gmail\'s tools on its Plugins page replaces "latest" with a version Composio will accept.'; + + useComposioClient( + recording({ + listActions: async () => { + throw new BrokerRefusalError(authored, { + cause: { error: { error: { message: "Invalid request" } } }, + }); + }, + }).client, + ); + + const message = await listTools({ url: "composio://gmail" }).then( + () => "", + (error: unknown) => (error as Error).message, + ); + + // Pinned whole rather than by a fragment, for the reason its sibling on the call path is: a + // substring check passes on a sentence joined to the vendor's or cut short of the remedy. + expect(message).toBe(authored); + expect(message).not.toContain("Invalid request"); + }); + + test("a listing that failed with no wrapper still carries the vendor's sentence", async () => { + /* + * `./composio-adapter`'s listing calls `client.tools.list` directly, with no try around it, so + * what lands here is `@composio/client`'s own error — see {@link unwrapped}. `refreshTools` writes this string into the row's + * `lastError` for an administrator to read off the Plugins page, and what it used to write was + * a status code followed by the entire response body. + */ + useComposioClient( + recording({ + listActions: async () => { + throw unwrapped("Composio holds no auth config for gmail."); + }, + }).client, + ); + + const message = await listTools({ url: "composio://gmail" }).then( + () => "", + (error: unknown) => (error as Error).message, + ); + + expect(message).toBe("Composio holds no auth config for gmail."); + // The error carries the whole HTTP response beside the sentence. None of it belongs on an + // admin page, in an audit row, or in a model's context. + expect(message).not.toContain("must-not-appear"); + expect(message).not.toContain("x-request-id"); + }); + + test("a listing failure whose body says nothing is not answered with the body", async () => { + /* + * WHAT ESCAPES WHEN THERE IS NO SENTENCE HAS TO BE WORTH SHOWING. `APIError` builds its message + * by JSON-stringifying the whole body behind the status code where the body has no top-level + * `message`, so the fallback to the thrown message handed an operator a response dump — the + * same thing the Zod-dump refusal above exists to stop, arriving through a different door. + */ + useComposioClient( + recording({ + listActions: async () => { + throw dumped({ + detail: [{ loc: ["body", "toolkit"], msg: "unrecognised" }], + request_id: "must-not-appear", + }); + }, + }).client, + ); + + const message = await listTools({ url: "composio://gmail" }).then( + () => "", + (error: unknown) => (error as Error).message, + ); + + expect(message).not.toContain("must-not-appear"); + expect(message).not.toContain("unrecognised"); + expect(message).not.toContain("{"); + expect(message).toContain("gmail"); + }); + + test("a destructive action is listed as destructive", async () => { + /* + * THE ONE CLASSIFICATION THAT MATTERS MOST, AND NOTHING STOOD OVER IT. `effectOf` is asserted + * on both answers directly, but every assertion that reached this mapping asserted + * `destructive: false` — so hardcoding `destructive: false` here left the whole suite green, + * and the field a Bot is gated on before it runs a dangerous action was pinned by nobody. + * + * `store.ts` records it on the `mcp_tools` row and a grant is what a person approves against + * it: an action recorded as safe that deletes a mailbox is approved once and run for ever. + */ + useComposioClient( + recording({ + listActions: async () => [ + GMAIL_READ, + { + slug: "GMAIL_DELETE_MESSAGE", + description: "Delete a message.", + tags: ["destructiveHint"], + version: "20260903_00", + }, + ], + }).client, + ); + + const listed = await listTools({ url: "composio://gmail" }); + + // Both rows, so the assertion fails on a mapping that hardcodes EITHER answer rather than + // reading the labels. + expect( + listed.map((tool) => `${tool.name}: ${tool.effect}/${tool.destructive}`), + ).toEqual([ + "GMAIL_FETCH_EMAILS: read/false", + "GMAIL_DELETE_MESSAGE: write/true", + ]); + }); + + test("an action Composio described in no words is listed with an empty description", async () => { + /* + * `description` is optional on the vendor's tool and `ListedTool`'s is not, so the mapping has + * to supply something. Nothing asserted which: `?? "no description available"`, `?? tool.name` + * and `?? null` all left the suite green, and the last of those is a row `store.ts` writes as + * NULL against a NOT NULL column. + * + * The empty string is the honest answer — the vendor said nothing, so this deployment says + * nothing rather than inventing a sentence a model will read as the action's own. + */ + useComposioClient( + recording({ + listActions: async () => [ + { slug: "GMAIL_ODD", tags: ["readOnlyHint"], version: "20260903_00" }, + ], + }).client, + ); + + const [tool] = await listTools({ url: "composio://gmail" }); + + expect(tool?.description).toBe(""); + // Asserted separately from `toEqual`, which treats a key holding `undefined` as a key that is + // not there and would accept the fallback being dropped altogether. + expect(Object.keys(tool ?? {})).toContain("description"); + }); + + test("an answer that is not a list of actions throws a sentence, not a TypeError", async () => { + /* + * `ComposioActions` is OUR projection of the vendor, implemented by an adapter nobody has + * written yet, and TypeScript polices none of what a promise actually resolves to at runtime. + * A client that answers `null` — a 204, an SDK path that returns before assigning, a mock in + * somebody's staging deployment — used to reach `actions.length` and `actions.filter` outside + * the try that wraps the vendor's call, so what propagated was `null is not an object`. That + * lands verbatim in `lastError` on the Plugins page and tells an administrator nothing about + * which app or what to do, which is the whole reason this path throws sentences. + */ + // `[null]` is the same failure one level down: it clears `Array.isArray` and then reaches + // `action.inputParameters` in the filter, which is outside that try as well. + // NOT `"gmail"` AS THE SCALAR, which is what this loop used to carry: the assertion below is + // that the sentence names the app, and a shape that IS the app's name satisfies it whether the + // name came from the url or from the malformed answer being echoed back. + for (const shape of [ + null, + undefined, + { items: [] }, + "an action list", + [null], + ]) { + useComposioClient( + recording({ + listActions: async () => shape as unknown as ComposioAction[], + }).client, + ); + + const message = await listTools({ url: "composio://gmail" }).then( + () => "", + (error: unknown) => (error as Error).message, + ); + + expect(message).toContain("gmail"); + expect(message).not.toMatch(/is not an object|is not a function/i); + } + }); + + test("an action with no slug breaks the listing rather than being dropped from it", async () => { + /* + * The slug is the action's whole identity: `name` in `mcp_tools`, which is NOT NULL and half + * the primary key, the string a grant records, and the `slug` `callTool` sends back to call it. + * An element without one reached the map and became a tool named `undefined`, so one malformed + * action in a listing of sixty took the entire app's refresh down on the insert. + * + * REFUSED RATHER THAN SKIPPED, which is what this asserts. Skipping would hand `refreshTools` a + * SHORT listing, and it commits a listing as the complete truth about the app: the replace is a + * delete and an insert, so every recorded action missing from it is deleted along with its + * `version`, under a refresh that reported success. Nobody could even be told which action went + * missing, because the thing that names it is the thing that is not there. + */ + for (const slug of [undefined, null, "", " ", 7]) { + useComposioClient( + recording({ + listActions: async () => + [ + GMAIL_READ, + { slug, description: "Nameless.", tags: ["readOnlyHint"] }, + ] as unknown as ComposioAction[], + }).client, + ); + + const outcome = await listTools({ url: "composio://gmail" }).then( + () => "the listing was committed", + (error: unknown) => (error as Error).message, + ); + + // The success arm says nothing about a slug, so this is the refusal and not a message match. + expect(outcome).toContain("slug"); + expect(outcome).toContain("gmail"); + } + }); + + test("a version made only of whitespace is recorded as no version at all", async () => { + /* + * TRIMMED ON THE WAY IN BECAUSE IT IS TRIMMED ON THE WAY OUT. `callTool` trims the recorded + * version and refuses an empty one, so a blank string that counts as a version here is written + * to `mcp_tools` as a version this deployment believes it has and is then permanently + * unusable — and the refusal the caller gets names a refresh, which rewrites the same blank. + * That is exactly the loop the test above reasons about, reached by recording rather than by + * the vendor publishing nothing. + */ + useComposioClient( + recording({ + listActions: async () => [ + { slug: "GMAIL_BLANK", tags: ["readOnlyHint"], version: " " }, + { + slug: "GMAIL_PADDED", + tags: ["readOnlyHint"], + version: " 20260903_00\n", + }, + ], + }).client, + ); + + const [blank, padded] = await listTools({ url: "composio://gmail" }); + + expect(Object.keys(blank ?? {})).not.toContain("version"); + // Recorded as the version `callTool` will actually send, rather than as one it has to repair. + expect(padded?.version).toBe("20260903_00"); + }); + + test("the name recorded is the slug the guard measured, without its padding", async () => { + /* + * The guard above admits an action by measuring `slug.trim()`, and the version beside it is + * recorded trimmed for a reason this file writes down — so the name went to `mcp_tools` and on + * to Composio with the padding still on it. That name is NOT NULL and half the primary key, it + * is what a grant records, and it is the `slug` `callTool` sends back; a row keyed on + * " GMAIL_FETCH_EMAILS " is a different action from the one anybody granted. + */ + useComposioClient( + recording({ + listActions: async () => [ + { ...GMAIL_READ, slug: " GMAIL_FETCH_EMAILS\n" }, + ], + }).client, + ); + + const [tool] = await listTools({ url: "composio://gmail" }); + + expect(tool?.name).toBe("GMAIL_FETCH_EMAILS"); + }); + + test("a slug listed twice is listed twice, because the collision is settled downstream", async () => { + /* + * WHY THIS IS NOT A FIFTH REFUSAL, written down because it was proposed as one. `mcp_tools` + * holds one row per name, so a vendor naming an action twice IS a collision — and + * `storableTools` in `./store` already resolves it before a transaction is opened, keying the + * insert by name and keeping the first occurrence. "a vendor naming one action twice records it + * once" in `plugin-store.integration.test.ts` pins that end: one row, and `lastError` null. + * Refusing here would replace a healthy refresh with a total failure and strand every grant on + * the app — the loss the refusals around this one exist to prevent, caused by one of them. + * + * WHAT THIS PATH DOES OWE THAT DE-DUPLICATION is the name it de-duplicates by. The map records + * the trimmed slug, so two spellings of one action arrive downstream as one name rather than + * as two rows the database will happily keep apart. + */ + useComposioClient( + recording({ + listActions: async () => [ + GMAIL_READ, + { + ...GMAIL_READ, + slug: " GMAIL_FETCH_EMAILS ", + description: "Fetch emails, listed again.", + version: "20260101_00", + }, + ], + }).client, + ); + + const listed = await listTools({ url: "composio://gmail" }); + + expect(listed.map((tool) => tool.name)).toEqual([ + "GMAIL_FETCH_EMAILS", + "GMAIL_FETCH_EMAILS", + ]); + }); + + test("tags that are not a list of labels break the listing rather than the runtime", async () => { + /* + * `effectOf` builds a Set out of whatever is in this field, and the element guard above only + * settles that the action is an object. A `tags` that is not iterable throws + * `{} is not iterable` out of a map that sits OUTSIDE the try wrapping the vendor's call, so + * that string is what `refreshTools` writes into `lastError` for a person to read. + * + * A STRING IS THE WORSE HALF, because it does not throw at all: `new Set("readOnlyHint")` + * yields its characters, no hint matches, and a read-only action is recorded as a write. That + * is the silent wrong answer a refusal exists to prevent, so both shapes are refused rather + * than repaired. + */ + for (const tags of [{}, "readOnlyHint", 7, { 0: "readOnlyHint" }]) { + useComposioClient( + recording({ + listActions: async () => + [ + GMAIL_READ, + { slug: "GMAIL_ODD", description: "Odd labels.", tags }, + ] as unknown as ComposioAction[], + }).client, + ); + + const outcome = await listTools({ url: "composio://gmail" }).then( + () => "the listing was committed", + (error: unknown) => (error as Error).message, + ); + + expect(outcome).not.toBe("the listing was committed"); + expect(outcome).toContain("GMAIL_ODD"); + expect(outcome).toContain("gmail"); + expect(outcome).not.toMatch(/is not iterable|is not a function/i); + } + }); + + test("a list whose labels are not labels records no action as safe", async () => { + /* + * THE CONTAINER WAS CHECKED AND ITS CONTENTS WERE TRUSTED, which is the third distinct way this + * one field has been found unheld. `Array.isArray` settles that `tags` can be iterated and says + * nothing about what comes out, and `effectOf` decides by `Set.has("destructiveHint")` — an + * identity comparison that no non-string element can ever satisfy. So a destructive action + * whose labels arrive as objects, as nested arrays or as numbers is recorded with + * `destructive: false`. + * + * WHICH IS THE SAME SILENT WRONG ANSWER THE STRING CASE ABOVE IS REFUSED FOR, and worse in the + * direction it fails. `new Set("readOnlyHint")` at least loses a READ, and an action wrongly + * called a write is only asked about too often. This loses the DESTRUCTIVE flag, and that field + * is what decides whether a Bot is stopped before it runs the action at all — nobody sees it is + * wrong, because a row that says "not destructive" looks exactly like an action that is not. + * + * `["readOnlyHint", 7]` IS HERE BECAUSE THE HOLE IS PER-ELEMENT. A list that is mostly labels is + * the shape a vendor actually sends when one entry is malformed, and a guard written as "are + * any of these strings" rather than "are all of these strings" would pass it. + */ + for (const tags of [ + [["destructiveHint"]], + [{ name: "destructiveHint" }], + [123], + [null], + ["readOnlyHint", 7], + ]) { + useComposioClient( + recording({ + listActions: async () => + [ + GMAIL_READ, + { slug: "GMAIL_DELETE_DRAFT", description: "Delete.", tags }, + ] as unknown as ComposioAction[], + }).client, + ); + + const outcome = await listTools({ url: "composio://gmail" }).then( + (listed) => + `the listing was committed, as ${JSON.stringify( + listed.map((tool) => [tool.name, tool.destructive]), + )}`, + (error: unknown) => (error as Error).message, + ); + + const named = JSON.stringify(tags); + // NAMED IN THE SUCCESS ARM, so the failure message says what was recorded rather than only + // that something was. The wrong answer this test exists for is a specific pair — + // ["GMAIL_DELETE_DRAFT",false] — and a reader of a red run should not have to go and find it. + expect(`${named}: ${outcome}`).not.toContain("the listing was committed"); + expect(outcome).toContain("GMAIL_DELETE_DRAFT"); + expect(outcome).toContain("gmail"); + expect(outcome).not.toMatch(/is not iterable|is not a function/i); + } + }); + + test("a version that is not a version breaks the listing rather than the runtime", async () => { + /* + * THE FIELD BESIDE THE LABELS, WITH THE FAILURE THE LABELS' GUARD EXISTS TO PREVENT. `version` + * is read as `action.version?.trim()` in a map that sits OUTSIDE the try wrapping the vendor's + * call, and `?.` guards nothing but null and undefined — so a number, an object or a boolean + * throws `action.version?.trim is not a function` out of `listTools`, and that engine sentence + * is what `refreshTools` writes into the row's `lastError` for an administrator to read. + * + * REFUSED RATHER THAN TREATED AS NO VERSION, which is the other candidate repair and the wrong + * one. An action recorded with no version is permanently uncallable and its refusal names a + * refresh — which would write the same unreadable field back — and committing the listing + * deletes the version already held for every OTHER action on the app, which no later refresh + * reconstructs where Composio publishes none. Keeping what is held is what every sibling + * refusal on this path does. + */ + for (const version of [5, { major: 1 }, ["20260903_00"], true]) { + useComposioClient( + recording({ + listActions: async () => + [ + GMAIL_READ, + { slug: "GMAIL_ODD_VERSION", tags: ["readOnlyHint"], version }, + ] as unknown as ComposioAction[], + }).client, + ); + + const outcome = await listTools({ url: "composio://gmail" }).then( + () => "the listing was committed", + (error: unknown) => (error as Error).message, + ); + + const named = JSON.stringify(version); + expect(`${named}: ${outcome}`).not.toBe( + `${named}: the listing was committed`, + ); + expect(outcome).toContain("GMAIL_ODD_VERSION"); + expect(outcome).toContain("gmail"); + expect(outcome).not.toMatch(/is not a function|undefined is not/i); + } + }); + + test("an absent version is still absent rather than unreadable", async () => { + /* + * THE LIMIT ON THE REFUSAL ABOVE. "Composio published no version" is a real and common state + * this file already has an answer for — the action is listed with no version key — and `null` + * is how JSON spells it. The `?.` in the map has always read null that way, so refusing it + * would turn a healthy refresh into a total failure for every app that publishes one, which is + * exactly the loss the refusal above is written to avoid. + */ + useComposioClient( + recording({ + listActions: async () => + [ + { slug: "GMAIL_NO_VERSION", tags: ["readOnlyHint"] }, + { + slug: "GMAIL_NULL_VERSION", + tags: ["readOnlyHint"], + version: null, + }, + ] as unknown as ComposioAction[], + }).client, + ); + + const listed = await listTools({ url: "composio://gmail" }); + + expect(listed.map((tool) => tool.name)).toEqual([ + "GMAIL_NO_VERSION", + "GMAIL_NULL_VERSION", + ]); + for (const tool of listed) { + expect(Object.keys(tool)).not.toContain("version"); + } + }); + + test("a vendor complaint that merely carries issues is not answered with an upgrade", async () => { + /* + * `isSchemaMismatch` duck-types on the PRESENCE of an `issues` array, and an array under that + * name is not the vendor's SDK refusing its own answer. A gateway's validation payload carries + * one, and so does any error somebody built with a list of complaints in it. Read as a schema + * mismatch, the one sentence saying what actually went wrong is replaced by an instruction to + * upgrade a package that is working perfectly. + */ + for (const thrown of [ + Object.assign( + new Error( + "Composio's gmail gateway rejected the query: from: is not a search operator.", + ), + { issues: ["from: is not a search operator."] }, + ), + Object.assign(new Error("Composio rejected the request for gmail."), { + issues: [{ field: "query", reason: "required" }], + }), + ]) { + useComposioClient( + recording({ + listActions: async () => { + throw thrown; + }, + }).client, + ); + + const message = await listTools({ url: "composio://gmail" }).then( + () => "", + (error: unknown) => (error as Error).message, + ); + + // PINNED WHOLE RATHER THAN BY THE ONE WORD EVERY CANDIDATE ANSWER CONTAINS. "rejected" is in + // the fixture, so it survived the sentence being wrapped, prefixed or cut short — and both + // of the answers this branch must not give, the upgrade advice and "Composio did not answer + // with an action list for gmail.", are sentences a fragment check cannot tell from this one. + expect(message).toBe(thrown.message); + // And it names the row the operator is looking at, which is what every sibling refusal on + // this path asserts and this one did not: `refreshTools` writes this string into `lastError` + // and an administrator reads it off the Plugins page beside a list of apps. + expect(message).toContain("gmail"); + expect(message).not.toMatch(/upgrad/i); + } + }); + + test("a listing left empty by the file filter is not committed as an app with no actions", async () => { + /* + * THE FILTER CAN EMPTY A LISTING, and an empty listing is the one thing this function's four + * other refusals exist to prevent. `refreshTools` commits a listing as the complete truth + * about the app — the replace is a delete and an insert — so every recorded action goes, with + * its `effect`, `destructive` and `version`, under a refresh that reported success. The + * versions are the loss no later refresh repairs where Composio publishes none. + * + * An app whose actions all stage files is not an app with no actions, and the sentence has to + * say which of the two this is. + */ + const onlyFiles = [ + { + slug: "GMAIL_SEND_EMAIL", + description: "Send an email.", + tags: ["createHint"], + version: "20260903_00", + inputParameters: { + type: "object", + properties: { attachment: FILE_PROPERTY }, + }, + }, + { + slug: "GMAIL_REPLY_TO_THREAD", + description: "Reply to a thread.", + tags: ["createHint"], + version: "20260903_00", + inputParameters: { + type: "object", + properties: { attachment: FILE_PROPERTY }, + }, + }, + ]; + useComposioClient(recording({ listActions: async () => onlyFiles }).client); + + const outcome = await listTools({ url: "composio://gmail" }).then( + (listed) => `the listing was committed with ${listed.length} actions`, + (error: unknown) => (error as Error).message, + ); + + expect(outcome).not.toContain("committed"); + expect(outcome).toContain("gmail"); + expect(outcome).toMatch(/file/i); + // The COUNT, which is the only thing separating this refusal from the empty answer asserted + // below. Both are listings with nothing left in them; a sentence that did not say how many + // actions the app really published would send an operator looking for the wrong fault. + expect(outcome).toContain("2"); + + /* + * AND THE VENDOR'S OWN EMPTY ANSWER IS STILL AN ANSWER, which is a decision this file shares + * with `store.ts` rather than one it makes alone. + * + * `@composio/core` used to manufacture an empty listing out of a response it could not read — + * `getRawComposioTools` ends `if (!tools) { return []; }` (0.18.1, + * `src/models/Tools.ts:553-557`) — and that half is gone with the wrapper, because + * `./composio-adapter` refuses an answer that is not a page rather than emptying it. What is + * left is a listing Composio really did answer with no rows, and it is answered at the layer + * that does the committing: `refreshTools` has its own empty-listing guard, which + * keeps every recorded action with its `effect`, `destructive` and `version` whenever an app + * that holds actions lists none, and stamps no refresh + * (`plugin-store.integration.test.ts`, "a refresh the vendor answered with no actions at all"). + * + * Refusing here as well would buy nothing that guard does not hold, and would cost the case it + * is careful to allow: an app that genuinely advertises nothing stays recordable instead of + * reading as broken for good. The refusal above is about a listing this deployment emptied, + * not one that arrived empty. + */ + useComposioClient(recording({ listActions: async () => [] }).client); + expect(await listTools({ url: "composio://gmail" })).toEqual([]); + }); +}); + +describe("calling one action", () => { + test("the call runs as the connection's actor, at the recorded version", async () => { + const { client, calls } = recording(); + useComposioClient(client); + + const result = await callTool( + { url: "composio://gmail", actorId: "user_asker" }, + "GMAIL_FETCH_EMAILS", + { query: "is:unread", __version: "20260903_00" }, + ); + + expect(calls).toEqual([ + { + toolkit: "gmail", + slug: "GMAIL_FETCH_EMAILS", + userId: "user_asker", + version: "20260903_00", + args: { query: "is:unread" }, + }, + ]); + expect(result.isError).toBe(false); + }); + + test("the app goes out with the call, and follows the url when the url changes", async () => { + /* + * THE DEEPEST HOLE THIS TRANSPORT HAD. `toolkitOf` resolved the app, `accessFor` gated the + * person's `composio_connections` row on it, and then the call went out as the slug alone — + * and a slug is what a LISTING recorded, not what the url says now. A url edited between a + * refresh and a call was therefore gated on the app it names today and run against the app it + * named when the tools were last read: a person who connected Slack satisfying the gate for a + * Gmail action that still runs in their Gmail. + * + * Composio's wire cannot carry the pair — `ToolExecuteParams` has no toolkit field and + * `tools.execute(toolSlug, params)` takes the slug alone (`@composio/client` 0.1.0-alpha.76, + * `resources/tools.d.ts:480-493` and `:41`) — so what binds them here is that the app is an + * argument of the call this module makes and an implementation has to reconcile it with the + * tool it resolves. Asserting it is passed asserts the implementation was handed the fact it + * needs; asserting it FOLLOWS the url is the part a check performed and then discarded could + * never show, and discarding it was the defect. + */ + const { client, calls } = recording(); + useComposioClient(client); + + for (const app of ["gmail", "slack"]) { + await callTool( + { url: `composio://${app}`, actorId: "user_asker" }, + "GMAIL_FETCH_EMAILS", + { __version: "20260903_00" }, + ); + } + + expect(calls.map((call) => call.toolkit)).toEqual(["gmail", "slack"]); + }); + + test("the version is not passed on to the vendor as an argument", async () => { + const seen: Record[] = []; + useComposioClient( + recording({ + execute: async (_call, args) => { + seen.push(args); + return answered({}); + }, + }).client, + ); + + // Held in a variable rather than written inline, because `seen` showing the version absent + // shows it only of whatever object the module chose to pass on. Deleting the key from the + // CALLER'S object and forwarding that satisfies the assertion below while destroying the + // record the call path still holds — the same identity-for-value mistake the schema test had. + const args = { query: "is:unread", __version: "20260903_00" }; + await callTool( + { url: "composio://gmail", actorId: "user_asker" }, + "GMAIL_FETCH_EMAILS", + args, + ); + + expect(seen).toEqual([{ query: "is:unread" }]); + expect(args).toEqual({ query: "is:unread", __version: "20260903_00" }); + }); + + test("a call with no recorded version refuses rather than guessing one", async () => { + const { client, calls } = recording(); + useComposioClient(client); + + const result = await callTool( + { url: "composio://gmail", actorId: "user_asker" }, + "GMAIL_FETCH_EMAILS", + {}, + ); + + // Composio refuses a call without a specific version and refuses "latest" too. A guessed version + // is a call against some other revision of the action, which is worse than not calling. + expect(result.isError).toBe(true); + expect(result.text).toMatch(/version/i); + expect(calls).toEqual([]); + + // The refusal used to name a refresh as THE fix, unconditionally. It is not one where the + // vendor published no version: `listTools` sets the field only when Composio sent one, so a + // refresh writes the same nothing back and the reader presses the button again. The sentence + // has to make the remedy conditional on the vendor, which is the part nobody here controls. + expect(result.text).not.toContain( + "Refresh this app's tools on its Plugins page and try again.", + ); + expect(result.text).toMatch(/only if Composio publishes/i); + }); + + test("the version goes out without its padding, and padding alone is no version", async () => { + /* + * THE CALL SIDE OF A TRIM THE LISTING SIDE ALREADY TESTS. "a version made only of whitespace is + * recorded as no version at all" pins what `listTools` writes; nothing pinned what `callTool` + * sends, so the `.trim()` here could be deleted with the whole suite green. + * + * BOTH HALVES OF IT MATTER AND THEY FAIL DIFFERENTLY. A padded version forwarded as it arrived + * is a string Composio does not match to any revision of the action — the call fails at the + * vendor, wearing the vendor's words, for a fault that is this deployment's. A version made + * only of padding is not a version at all, and read as one it sends `" "` where the module's + * own refusal says there is nothing to fall back on; the recorded-version guard is the thing + * that keeps that off the wire, and it is the trim that lets the guard see it. + * + * The same two shapes the listing side uses, reached from the other end: `mcp_tools` holds what + * `listTools` wrote, and `store.ts` hands it back through `__version` on the next call. + */ + const { client, calls } = recording(); + useComposioClient(client); + + const padded = await callTool( + { url: "composio://gmail", actorId: "user_asker" }, + "GMAIL_FETCH_EMAILS", + { __version: " 20260903_00\n" }, + ); + + expect(padded.isError).toBe(false); + expect(calls.map((call) => call.version)).toEqual(["20260903_00"]); + + const blank = await callTool( + { url: "composio://gmail", actorId: "user_asker" }, + "GMAIL_FETCH_EMAILS", + { __version: " " }, + ); + + expect(blank.isError).toBe(true); + expect(blank.text).toMatch(/no recorded version/i); + // Refused before dialling, which is the half a message match cannot show: nothing was added to + // the record above. + expect(calls).toHaveLength(1); + }); + + test("an actor named in the arguments is ignored, whichever way it is spelled", async () => { + /* + * THE HEADLINE PROPERTY OF THIS FILE, AND IT WAS ASSERTED OF HALF THE CALL. The recorder's + * default `execute` dropped the arguments, so what this test could see was the call record's + * own `userId` — the half a transport gets right by construction, because it is the field it + * fills from the connection. What it could not see was the object actually forwarded to the + * vendor, which is where a model's three spellings of an identity live and where the vendor + * reads `arguments` from. Both halves go out on one request, so both halves are the claim. + * + * ASSERTED AS THE WHOLE CALL rather than field by field, the way the adapter suite's own + * attribution test is ("a call that runs carries the person, the version and the arguments"): + * an EXTRA field on this request is as much a finding as a wrong one, and a per-field check is + * blind to every one of them. + * + * THE THREE SPELLINGS TRAVEL ON AS ARGUMENTS, which is the correct outcome and not an + * oversight. They are the model's own arguments; the vendor is the party that decides what an + * action's `userId` parameter means, and stripping keys by name would be this module reading + * `args` for an identity — the very thing that makes the property structural rather than + * merely checked. What must never happen is one of them reaching the field beside them. + * + * THE PERSON IS NAMED AFTER NOTHING ELSE IN THIS FILE, which is what makes the assertion able + * to tell attribution from coincidence: `user_asker` is the house id every other call here + * uses, so it is exactly the literal a transport that had stopped reading the connection would + * most plausibly be hard-coded to. + */ + const { client, calls } = recording(); + useComposioClient(client); + + const spelled = { + userId: "user_victim", + user_id: "user_victim", + entityId: "user_victim", + }; + + await callTool( + { url: "composio://gmail", actorId: "user_whose_mailbox_this_is" }, + "GMAIL_FETCH_EMAILS", + { ...spelled, query: "is:unread", __version: "20260903_00" }, + ); + + // The identity is not a field a model fills. This is the defect OpenTag got wrong three times, + // and the only structural defence is that the argument name is never read. + expect(calls).toEqual([ + { + toolkit: "gmail", + slug: "GMAIL_FETCH_EMAILS", + userId: "user_whose_mailbox_this_is", + version: "20260903_00", + args: { ...spelled, query: "is:unread" }, + }, + ]); + }); + + test("a call with nobody attributed refuses and reaches nothing", async () => { + /* + * AN ACTOR MADE OF PADDING IS NOBODY, and that is the half of this guard nothing was asking. + * Only the absent key was tested, so the `.trim()` in front of the check could be deleted with + * the suite green — and what it keeps out is worse than an absent id, not better: `" "` is a + * user id Composio will happily look up, find no connected account for, and refuse. The call + * would then read as somebody's lapsed connection rather than as a run this deployment never + * attributed to anybody, which sends the person who reads it to the wrong page. + * + * The shapes are the ones an identifier arrives as when something upstream had nothing to put + * in it: a column read back empty, a header that was sent blank, a value assembled by a shell. + */ + for (const actorId of [undefined, "", " ", "\n\t "]) { + const { client, calls } = recording(); + useComposioClient(client); + + const result = await callTool( + { url: "composio://gmail", actorId }, + "GMAIL_FETCH_EMAILS", + { + __version: "20260903_00", + }, + ); + + const named = JSON.stringify(actorId ?? null); + expect(`${named}: ${result.isError}`).toBe(`${named}: true`); + expect(result.text).toMatch(/not attributed to anybody/i); + expect(calls).toEqual([]); + } + }); + + test("a thrown failure is reported with the vendor's own sentence", async () => { + useComposioClient( + recording({ + execute: async () => { + throw Object.assign( + new Error("Error executing the tool GMAIL_FETCH_EMAILS"), + { + cause: { + status: 404, + headers: { "x-request-id": "must-not-appear" }, + error: { + error: { + message: + "No connected account found for user ID u1 for toolkit gmail", + }, + }, + }, + }, + ); + }, + }).client, + ); + + const result = await callTool( + { url: "composio://gmail", actorId: "user_asker" }, + "GMAIL_FETCH_EMAILS", + { __version: "20260903_00" }, + ); + + expect(result.isError).toBe(true); + expect(result.text).toContain("No connected account found"); + // The error also carries the whole HTTP response. None of it belongs in a model's context or an + // audit row. + expect(result.text).not.toContain("must-not-appear"); + expect(result.text).not.toContain("x-request-id"); + }); + + test("a failure with no vendor sentence falls back to the thrown message, whole", async () => { + /* + * WHY THIS ONE REFUSAL NAMES NEITHER THE ACTION NOR A REMEDY, which every sibling around it + * does and which this test used to accept without saying anything about. + * + * `toContain` accepted the current answer and would have accepted any of the wrong ones too: a + * sentence with this deployment's generic advice bolted on, one cut short of the vendor's + * words, one with the action's name prefixed. So what the fallback actually IS was pinned by + * nobody, and the difference between the branches is the whole subject of this describe. + * + * THE RULE THE MODULE FOLLOWS IS THAT THE WORDS THAT WERE SAID BEAT THE WORDS WE WOULD INVENT, + * and it is the same rule `listingSentence` follows on the other path. `unexplained` — which + * names the action and sends the reader to the Plugins page — is what the two sibling tests + * below assert, and it is reached only where the vendor said NOTHING usable: an empty message, + * or the placeholder. A transport fault that came with a diagnosis is not that case, and + * replacing "composio unreachable" with "check that this app is still connected" would be + * exactly the wrong advice at exactly the wrong moment — the connection is fine and Composio + * is down. The action's name is in `store.ts`'s audit row beside this sentence either way, and + * the model reading it has just called the action. + * + * So this refusal carrying neither is deliberate, and the test now says so by pinning the + * answer WHOLE rather than by looking for a fragment inside whatever arrived. + * + * THE PADDED ONE IS HERE BECAUSE THE TRIM IS. What comes back is measured by the cap and read + * by a person, and the module already decided this string was worth passing on — with the + * padding dropped, the way `vendorSentence` drops it. + */ + for (const thrown of ["composio unreachable", " composio unreachable\n"]) { + useComposioClient( + recording({ + execute: async () => { + throw new Error(thrown); + }, + }).client, + ); + + const result = await callTool( + { url: "composio://gmail", actorId: "user_asker" }, + "GMAIL_FETCH_EMAILS", + { __version: "20260903_00" }, + ); + + expect(result.isError).toBe(true); + expect(result.text).toBe("composio unreachable"); + expect(result.truncated).toBe(false); + } + }); + + test("a thrown client error with no wrapper is reported with its sentence", async () => { + /* + * The same correction as the listing's, on the path where the string reaches a model rather + * than an admin page. See {@link unwrapped} for which calls throw this shape; what used to be + * handed on for them was a status code followed by the whole response body, which is both the + * request id this file already refuses to pass on and somebody's context window spent on it. + */ + useComposioClient( + recording({ + execute: async () => { + throw unwrapped("Gmail rejected the query: invalid search syntax."); + }, + }).client, + ); + + const result = await callTool( + { url: "composio://gmail", actorId: "user_asker" }, + "GMAIL_FETCH_EMAILS", + { __version: "20260903_00" }, + ); + + expect(result.isError).toBe(true); + expect(result.text).toBe( + "Gmail rejected the query: invalid search syntax.", + ); + expect(result.text).not.toContain("must-not-appear"); + expect(result.text).not.toContain("x-request-id"); + }); + + test("a failure whose body says nothing is not answered with the body", async () => { + // The dump reaching a model, rather than an operator. `unexplained` is the right answer here + // for the reason it is the right answer to the placeholder: neither says anything the reader + // can act on, and one of them costs a context window to say it. + useComposioClient( + recording({ + execute: async () => { + throw dumped({ + detail: [{ loc: ["body", "arguments"], msg: "unrecognised" }], + request_id: "must-not-appear", + }); + }, + }).client, + ); + + const result = await callTool( + { url: "composio://gmail", actorId: "user_asker" }, + "GMAIL_FETCH_EMAILS", + { __version: "20260903_00" }, + ); + + expect(result.isError).toBe(true); + expect(result.text).not.toContain("must-not-appear"); + expect(result.text).not.toContain("unrecognised"); + expect(result.text).not.toContain("{"); + expect(result.text).toMatch(/Plugins page/); + expect(result.text).toContain("GMAIL_FETCH_EMAILS"); + }); + + test("a result the exact size of the cap is not cut, and one character more is", async () => { + /* + * THE BOUNDARY, WHICH IS THE ONLY PLACE A CAP CAN BE WRONG. Every other test here measures a + * string far over the limit or far under it, so `<=` and `<` answered both of them the same + * way — and the off-by-one is the version that reports `truncated: true` beside text nothing + * was taken from, which is a lie in the field whose whole job is telling a model that what it + * is reading stops early. + */ + for (const [length, cut] of [ + [RESULT_CAP, false], + [RESULT_CAP + 1, true], + ] as const) { + useComposioClient( + recording({ + execute: async () => { + throw new Error("x".repeat(length)); + }, + }).client, + ); + + const result = await callTool( + { url: "composio://gmail", actorId: "user_asker" }, + "GMAIL_FETCH_EMAILS", + { __version: "20260903_00" }, + ); + + expect(`${length}: ${result.truncated}`).toBe(`${length}: ${cut}`); + expect(`${length}: ${result.text.length}`).toBe( + `${length}: ${cut ? CAPPED_LENGTH : RESULT_CAP}`, + ); + expect(result.text.endsWith(TRUNCATION_MARKER)).toBe(cut); + } + }); + + test("a result is capped visibly rather than silently", async () => { + useComposioClient( + recording({ + execute: async () => answered({ body: "x".repeat(60_000) }), + }).client, + ); + + const result = await callTool( + { url: "composio://gmail", actorId: "user_asker" }, + "GMAIL_FETCH_EMAILS", + { __version: "20260903_00" }, + ); + + // VISIBLY is the marker and RATHER THAN SILENTLY is the flag, and this test asserted only the + // flag. `truncated: true` beside text that just stops is exactly the silent cut the name + // promises against: the model reads a JSON document that ends mid-token and completes it from + // memory, because nothing in what it was handed says the ending is ours. + expect(result.isError).toBe(false); + expect(result.truncated).toBe(true); + expect(result.text.slice(-TRUNCATION_MARKER.length)).toBe( + TRUNCATION_MARKER, + ); + expect(result.text.length).toBe(CAPPED_LENGTH); + }); + + test("an empty answer says so in words rather than being empty", async () => { + useComposioClient(recording({ execute: async () => answered({}) }).client); + + const result = await callTool( + { url: "composio://gmail", actorId: "user_asker" }, + "GMAIL_FETCH_EMAILS", + { __version: "20260903_00" }, + ); + + // An empty string in front of a model reads as "the action had nothing to say" rather than "there + // is nothing there", and the model closes the gap from memory. Same reasoning as `resultText`. + // `data` is a required record, so the empty answer the SDK can actually produce is `{}` — if that + // did not count, this branch would be unreachable and its promise would be a fiction. + expect(result.text).toMatch(/returned nothing/i); + // Nothing to say is not a failure and is not a truncation. Both fields were unasserted, so this + // branch could have started reporting an error and the test would not have noticed. + expect(result.isError).toBe(false); + expect(result.truncated).toBe(false); + }); + + test("an answer the vendor marked unsuccessful is a failure, not content", async () => { + // `ToolExecuteResponseSchema` makes `successful` REQUIRED and resolves `{ data, error, + // successful }`, so a 200 answer can carry a failure. Reported as a success it is audited as + // `mcp.call_succeeded` and the failure is handed to the model as though it were content. + useComposioClient( + recording({ + execute: async () => + answered( + {}, + { + successful: false, + error: "Gmail rejected the query: invalid search syntax.", + }, + ), + }).client, + ); + + const result = await callTool( + { url: "composio://gmail", actorId: "user_asker" }, + "GMAIL_FETCH_EMAILS", + { __version: "20260903_00" }, + ); + + expect(result.isError).toBe(true); + expect(result.text).toContain("invalid search syntax"); + }); + + test("a successful answer hands the model the action's data and not the envelope", async () => { + useComposioClient( + recording({ + execute: async () => ({ + ...answered({ messages: [{ id: "m1" }] }), + logId: "log_must_not_appear", + }), + }).client, + ); + + const result = await callTool( + { url: "composio://gmail", actorId: "user_asker" }, + "GMAIL_FETCH_EMAILS", + { __version: "20260903_00" }, + ); + + expect(result.isError).toBe(false); + // `successful`, `error` and `logId` are the envelope this transport reads to decide the + // outcome. Reporting them as content spends a model's context on our own bookkeeping. Pinned + // as the whole string rather than as three absences, because a list of things that must not + // appear is only ever as long as the fields the envelope had on the day it was written — the + // vendor's `sessionInfo` is already in the type and named in none of them. + expect(result.text).toBe( + JSON.stringify({ messages: [{ id: "m1" }] }, null, 2), + ); + }); + + test("an unsuccessful answer with no sentence still says something actionable", async () => { + useComposioClient( + recording({ + execute: async () => answered({}, { successful: false, error: null }), + }).client, + ); + + const result = await callTool( + { url: "composio://gmail", actorId: "user_asker" }, + "GMAIL_FETCH_EMAILS", + { __version: "20260903_00" }, + ); + + expect(result.isError).toBe(true); + expect(result.text).toContain("GMAIL_FETCH_EMAILS"); + expect(result.text).toMatch(/Plugins page/); + }); + + test("a failure carrying only the vendor's placeholder says something actionable", async () => { + // "Error executing the tool X" is the string this module's own comment calls useless. Echoing it + // tells a person nothing they did not already know: they asked for that tool. + useComposioClient( + recording({ + execute: async () => { + throw new Error("Error executing the tool GMAIL_FETCH_EMAILS"); + }, + }).client, + ); + + const result = await callTool( + { url: "composio://gmail", actorId: "user_asker" }, + "GMAIL_FETCH_EMAILS", + { __version: "20260903_00" }, + ); + + expect(result.isError).toBe(true); + // ASKED OF THE WHOLE STRING, the way the listing-side sibling asks it. A not-equals only + // refuses the placeholder standing alone, so a refusal that carried it in the middle of a + // sentence — "GMAIL_FETCH_EMAILS failed: Error executing the tool GMAIL_FETCH_EMAILS" — is the + // same useless words in a model's context and in the audit row, and passed. + expect(result.text).not.toMatch(/error executing the tool/i); + expect(result.text).toMatch(/Plugins page/); + }); + + test("a failure that carries no message at all still says something actionable", async () => { + /* + * The empty-message arm of the fallback, which nothing reached. Both ways of arriving at it are + * real: `@composio/core` rejects with plain objects on some paths, so `error instanceof Error` + * is false and there is no message to read at all; and a thrown `Error` whose message is blank + * or whitespace is what a transport-level abort produces. + * + * Passed on unchanged, either one lands in a model's context and in `store.ts`'s audit row as an + * empty refusal — a failure with `isError: true` and nothing said, which reads to a model as + * permission to invent a reason and retry. + */ + for (const thrown of [{ status: 502 }, new Error(""), new Error(" \n ")]) { + useComposioClient( + recording({ + execute: async () => { + throw thrown; + }, + }).client, + ); + + const result = await callTool( + { url: "composio://gmail", actorId: "user_asker" }, + "GMAIL_FETCH_EMAILS", + { __version: "20260903_00" }, + ); + + expect(result.isError).toBe(true); + expect(result.text.trim()).not.toBe(""); + expect(result.text).toContain("GMAIL_FETCH_EMAILS"); + expect(result.text).toMatch(/Plugins page/); + } + }); + + test("an enormous vendor sentence is capped in a refusal too, and says so", async () => { + useComposioClient( + recording({ + execute: async () => + answered({}, { successful: false, error: "x".repeat(60_000) }), + }).client, + ); + + const result = await callTool( + { url: "composio://gmail", actorId: "user_asker" }, + "GMAIL_FETCH_EMAILS", + { __version: "20260903_00" }, + ); + + // A refusal goes into a model's context exactly as a result does, so an uncapped vendor sentence + // is the same unbounded spend the success path already refuses to make. + expect(result.isError).toBe(true); + expect(result.truncated).toBe(true); + // "and says so" is the marker, which nothing here used to check. + expect(result.text.slice(-TRUNCATION_MARKER.length)).toBe( + TRUNCATION_MARKER, + ); + expect(result.text.length).toBe(CAPPED_LENGTH); + }); + + test("an enormous thrown message is capped in a refusal too", async () => { + useComposioClient( + recording({ + execute: async () => { + throw new Error("y".repeat(60_000)); + }, + }).client, + ); + + const result = await callTool( + { url: "composio://gmail", actorId: "user_asker" }, + "GMAIL_FETCH_EMAILS", + { __version: "20260903_00" }, + ); + + expect(result.isError).toBe(true); + expect(result.truncated).toBe(true); + expect(result.text.slice(-TRUNCATION_MARKER.length)).toBe( + TRUNCATION_MARKER, + ); + expect(result.text.length).toBe(CAPPED_LENGTH); + }); + + test("our own serialization failure is not reported as the action having failed", async () => { + useComposioClient( + recording({ + execute: async () => { + const data: Record = { subject: "hello" }; + // A circular reference, which `JSON.stringify` refuses. The action already ran and the + // vendor already answered; what fails is this deployment reading that answer. + data.itself = data; + return answered(data); + }, + }).client, + ); + + const result = await callTool( + { url: "composio://gmail", actorId: "user_asker" }, + "GMAIL_FETCH_EMAILS", + { __version: "20260903_00" }, + ); + + expect(result.isError).toBe(true); + // Two different events, and the audit trail has to be able to tell them apart: the vendor did + // its part here. + expect(result.text).toMatch(/could not turn that answer into text/i); + expect(result.text).toContain("GMAIL_FETCH_EMAILS"); + }); + + test("the serialization refusal quotes a reason through the same door as the rest", async () => { + /* + * THE ONE PATH THAT REACHED A MODEL AND THE AUDIT ROW WITHOUT PASSING THE DOOR AT ALL. + * + * Every other refusal in this module reads its candidate sentence through the judgement that + * refuses the vendor's placeholder and the response dump. This one interpolated a raw + * `error.message` — whatever `JSON.stringify` threw with — straight into the sentence + * `store.ts` records, with nothing asked of it. A `toJSON` is the vendor's own hook on the + * vendor's own data, so what it throws with is theirs; the class of message is the same one + * refused four lines away, and the guard is not a guard if the string can walk round it. + * + * WHAT THE REFUSAL STILL HAS TO SAY is which of the two events this was: the action ran and + * Composio answered, and it is this deployment that could not read the answer. That claim is + * this file's own and survives having no reason to quote. + */ + useComposioClient( + recording({ + execute: async () => + answered({ + attachment: { + toJSON() { + throw new Error(RESPONSE_DUMP); + }, + }, + }), + }).client, + ); + + const result = await callTool( + { url: "composio://gmail", actorId: "user_asker" }, + "GMAIL_FETCH_EMAILS", + { __version: "20260903_00" }, + ); + + expect(result.isError).toBe(true); + expect(result.text).toMatch(/could not turn that answer into text/i); + expect(result.text).toContain("GMAIL_FETCH_EMAILS"); + expect(result.text).not.toContain("must-not-appear"); + expect(result.text).not.toContain("unrecognised"); + expect(result.text).not.toContain("{"); + }); + + test("an answer reporting an error while claiming success is a failure", async () => { + /* + * `ToolExecuteResponseSchema` spells `error` and `successful` as two independent required + * fields and correlates them nowhere; `transformToolExecuteResponse` copies both straight off + * the wire (`@composio/core` 0.18.1, `src/models/Tools.ts:215-222`). So the combination is a + * shape the vendor's own schema permits, and keying only on `successful === false` dropped the + * one sentence in it that says anything — audited as `mcp.call_succeeded`, with the failure + * handed to the model as though it were content. + * + * The strict reading is the safe one and it is also the vendor's: where the SDK has to derive + * the flag itself it writes `successful: !response.error` (`src/models/Tools.ts:1247`), so a + * present error IS a failure by their own arithmetic. Same rule as `effectOf` uses for + * contradictory labels — both at once is somebody else's bug, and we take the strict branch. + */ + useComposioClient( + recording({ + execute: async () => + answered( + { messages: [{ id: "m1" }] }, + { + successful: true, + error: "Gmail rejected the query: invalid search syntax.", + }, + ), + }).client, + ); + + const result = await callTool( + { url: "composio://gmail", actorId: "user_asker" }, + "GMAIL_FETCH_EMAILS", + { __version: "20260903_00" }, + ); + + expect(result.isError).toBe(true); + expect(result.text).toContain("invalid search syntax"); + // The data must not be handed over as content beside a reported failure. + expect(result.text).not.toContain("m1"); + }); + + test("an error made of nothing but padding beside a success is still a success", async () => { + /* + * The other side of the rule, and the reason it is worded as a SENTENCE rather than as a + * present field: `successful: !response.error` treats `""` as success, so an empty string is + * the vendor saying nothing went wrong in the least committal way available to it. + * + * THE PADDED ONES ARE THE HALF THAT WAS UNASSERTED. Only `""` was here, so the `.trim()` that + * decides whether this field says anything could be deleted with the suite green — and what it + * would cost is the worst outcome on this path: a newline in `error` read as a complaint turns + * a call that worked into a reported failure, the action's own data is withheld from the model + * as content, and `store.ts` audits a failure against a call the vendor was perfectly happy + * with. A blank field is not a sentence here for the same reason it is not one in + * `vendorSentence`. + */ + for (const error of ["", " ", "\n\t", "\r\n "]) { + useComposioClient( + recording({ + execute: async () => + answered({ messages: [] }, { successful: true, error }), + }).client, + ); + + const result = await callTool( + { url: "composio://gmail", actorId: "user_asker" }, + "GMAIL_FETCH_EMAILS", + { __version: "20260903_00" }, + ); + + const named = JSON.stringify(error); + expect(`${named}: ${result.isError}`).toBe(`${named}: false`); + expect(result.text).toBe(JSON.stringify({ messages: [] }, null, 2)); + } + }); + + test("a list where the envelope should be is refused, not called an empty success", async () => { + /* + * `typeof [] === "object"`, so an array cleared a guard written as a bare `typeof` test and was + * then read as an envelope: `error` and `successful` came off it as undefined, so nothing was + * reported, and `data` came off it as undefined, so `resultOf` answered "The action returned + * nothing." An answer this deployment could not read reached the model as a call that worked + * and found nothing, and `store.ts` wrote `mcp.call_succeeded` beside it — which is the exact + * outcome the third kind of failure exists to keep off the trail. + * + * Not a hypothetical shape: an envelope unwrapped one level too far, or a client that answers + * the batch form, is a list where this expects a record. + */ + for (const shape of [[], [{ data: { messages: [] }, successful: true }]]) { + useComposioClient( + recording({ + execute: async () => shape as unknown as ComposioResult, + }).client, + ); + + const result = await callTool( + { url: "composio://gmail", actorId: "user_asker" }, + "GMAIL_FETCH_EMAILS", + { __version: "20260903_00" }, + ); + + expect(result.isError).toBe(true); + expect(result.text).toContain("GMAIL_FETCH_EMAILS"); + expect(result.text).not.toContain("returned nothing"); + } + }); + + test("an object that is not the envelope is refused, not called an empty success", async () => { + /* + * THE HOLE THE ARRAY FIX LEFT, WHICH IS EVERY OTHER OBJECT. The guard above this one asked + * whether an object had arrived, so it caught `[]` and admitted the whole of the rest of the + * world — and the two shapes it was written about walked straight through it. + * + * An envelope unwrapped ONE level, which is what a client reaching one field too far resolves, + * is the action's own `data` standing where the envelope belongs: `error` and `successful` + * come off it as absent, so nothing is reported, and `data` comes off it as absent, so + * `resultOf` answers "The action returned nothing." A bare `{}` does the same by having + * nothing on it at all. Both reached the model as a call that worked and found nothing, and + * `store.ts` wrote `mcp.call_succeeded` beside each — while the refusal that never fired + * claimed the `{ data, error, successful }` envelope had been checked for. + * + * THE PARTIAL ONES ARE HERE FOR THE SAME REASON THE WHOLE ONES ARE. An answer carrying two of + * the three fields is not an envelope either, and each of them lands on the identical false + * success: no `data` serializes to nothing, and no `successful` reports nothing. + */ + const shapes: Record = { + "an envelope unwrapped one level": { messages: [{ id: "m1" }] }, + "a bare object": {}, + "an envelope with no data": { error: null, successful: true }, + "an envelope with no successful": { + data: { messages: [{ id: "m1" }] }, + error: null, + }, + "an envelope whose successful is present as undefined": { + data: {}, + error: null, + successful: undefined, + }, + }; + + for (const [shape, answer] of Object.entries(shapes)) { + useComposioClient( + recording({ + execute: async () => answer as ComposioResult, + }).client, + ); + + const result = await callTool( + { url: "composio://gmail", actorId: "user_asker" }, + "GMAIL_FETCH_EMAILS", + { __version: "20260903_00" }, + ); + + expect(`${shape}: ${result.isError}`).toBe(`${shape}: true`); + expect(result.text).toContain("GMAIL_FETCH_EMAILS"); + // The two halves of the false success this guard exists to stop: the sentence that reads as + // a call that worked, and the vendor's data handed over as content beside it. + expect(result.text).not.toContain("returned nothing"); + expect(result.text).not.toContain("m1"); + } + }); + + test("an error that is not a sentence is not read as silence", async () => { + /* + * `ToolExecuteResponseSchema` spells `error` a nullable string, so an object — or the issue + * list a gateway leaves there — is a field this deployment cannot read. Collapsed to `""` by a + * `typeof` test, it was indistinguishable from the vendor saying nothing went wrong: the data + * was handed to the model as content and the call was audited as a success, with the one field + * carrying the complaint shown to nobody. + * + * THE FLAG DECIDES WHICH SENTENCE, because the two states differ. Beside `successful: false` + * the vendor has already reported the failure and only its reason is unreadable, which is the + * actionable connection sentence. Beside a claimed success nothing here knows whether the + * action ran, and that failure is ours rather than the vendor's, so it says so in our words. + */ + const unreadable = { message: "Quota exceeded.", code: 429 }; + + useComposioClient( + recording({ + execute: async () => + ({ + data: { messages: [{ id: "m1" }] }, + error: unreadable, + successful: true, + }) as unknown as ComposioResult, + }).client, + ); + + const claimed = await callTool( + { url: "composio://gmail", actorId: "user_asker" }, + "GMAIL_FETCH_EMAILS", + { __version: "20260903_00" }, + ); + + expect(claimed.isError).toBe(true); + expect(claimed.text).toContain("could not read"); + // The data must not be handed over as content beside a complaint nobody could read. + expect(claimed.text).not.toContain("m1"); + + useComposioClient( + recording({ + execute: async () => + ({ + data: {}, + error: unreadable, + successful: false, + }) as unknown as ComposioResult, + }).client, + ); + + const reported = await callTool( + { url: "composio://gmail", actorId: "user_asker" }, + "GMAIL_FETCH_EMAILS", + { __version: "20260903_00" }, + ); + + expect(reported.isError).toBe(true); + expect(reported.text).toContain("Plugins page"); + }); + + test("a reported error that is a response dump is refused like the placeholder", async () => { + /* + * THE ESCAPE THE EXTRACTION WAS MEANT TO CLOSE, LEFT OPEN ON THE ONE CALLER THAT WAS NOT MOVED. + * + * `thrownSentence` was given both refusals — the vendor's placeholder AND the status code + * followed by a stringified body — precisely so that no path could grow one without the other. + * This path never went through it: it tested `VENDOR_PLACEHOLDER` against the envelope's `error` + * field itself, so the second refusal simply did not exist here, and a dump arriving in that + * field went to the model as the vendor's own report and into `store.ts`'s audit row beside it. + * + * BOTH FLAG VALUES, because the dump is equally unreadable under either and the branch that + * judges the sentence is reached under both. `successful: true` beside a non-empty `error` is + * already a reported failure by this module's own rule, so the only question left is what is + * said about it — and a request id and a validation payload are not it. + */ + for (const successful of [false, true]) { + useComposioClient( + recording({ + execute: async () => + answered( + { messages: [{ id: "m1" }] }, + { successful, error: RESPONSE_DUMP }, + ), + }).client, + ); + + const result = await callTool( + { url: "composio://gmail", actorId: "user_asker" }, + "GMAIL_FETCH_EMAILS", + { __version: "20260903_00" }, + ); + + const named = `successful: ${successful}`; + expect(`${named}: ${result.isError}`).toBe(`${named}: true`); + // Everything the dump would have carried into a model's context and the audit row. + expect(result.text).not.toContain("must-not-appear"); + expect(result.text).not.toContain("unrecognised"); + expect(result.text).not.toContain("{"); + // And what is said instead names the action and the one thing the reader can do about it, + // which is what every sibling on this path answers an unreadable report with. + expect(result.text).toContain("GMAIL_FETCH_EMAILS"); + expect(result.text).toMatch(/Plugins page/); + // The data must not be handed over as content beside a reported failure either. + expect(result.text).not.toContain("m1"); + } + }); + + test("a reported error that is a status code and words is still passed on", async () => { + /* + * THE LIMIT ON THE REFUSAL ABOVE, on this path as well as on `vendorSentence`'s. A dump is a + * status followed by the opening of a JSON document; a status followed by the body's own + * sentence is the client's other branch and is the most useful thing a reader gets. Widening + * the refusal to every message that opens with three digits would swap a named cause for + * advice about a connection that is working, so it has to redden something. + */ + useComposioClient( + recording({ + execute: async () => + answered( + {}, + { successful: false, error: "400 Invalid auth config id" }, + ), + }).client, + ); + + const result = await callTool( + { url: "composio://gmail", actorId: "user_asker" }, + "GMAIL_FETCH_EMAILS", + { __version: "20260903_00" }, + ); + + expect(result.isError).toBe(true); + expect(result.text).toBe("400 Invalid auth config id"); + }); + + test("a successful flag that is not a boolean is not read as a success", async () => { + /* + * `successful !== false` ASKED ONE QUESTION OF A FIELD WITH THREE ANSWERS. The schema spells + * it a required boolean, but the value is the vendor's and the type is this module's + * projection — and every shape below is none of them literally `false`, so each one passed as + * a success: the reported failure was handed to the model as content and `store.ts` audited + * `mcp.call_succeeded` beside it. This is the same defect the `error` field beside it was + * fixed for, on the field that decides the outcome outright. + * + * `"false"` IS THE ONE THAT PROVES FALSINESS IS NOT THE REPAIR. A non-empty string is truthy, + * so a plain `!answer.successful` reads the vendor's literal word "false" as a success just as + * the old check did, and `0` — which no schema permits and no reader can interpret — it would + * read as a considered no. What is wanted is neither: a field this deployment cannot read is + * the third kind of failure, and it says so in this file's own words rather than guessing a + * value for it. + */ + for (const successful of ["false", 0, null, "true", {}]) { + useComposioClient( + recording({ + execute: async () => + ({ + data: { messages: [{ id: "m1" }] }, + error: null, + successful, + }) as unknown as ComposioResult, + }).client, + ); + + const result = await callTool( + { url: "composio://gmail", actorId: "user_asker" }, + "GMAIL_FETCH_EMAILS", + { __version: "20260903_00" }, + ); + + const named = JSON.stringify(successful); + expect(`${named}: ${result.isError}`).toBe(`${named}: true`); + expect(result.text).toContain("GMAIL_FETCH_EMAILS"); + // Not the vendor's words: nobody reported this failure, so the sentence must not read as + // though Composio had. And the data must not travel beside it as content. + expect(result.text).toMatch(/could not read/i); + expect(result.text).not.toContain("m1"); + } + }); + + test("an unreadable flag loses to the sentence Composio did send", async () => { + /* + * THE SHAPE CHECK ABOVE MUST NOT COST A READER THE ONE USEFUL SENTENCE. A malformed flag + * beside a real complaint is a failure either way, and "this deployment could not read the + * flag" is the less actionable of the two things that could be said about it. So the vendor's + * own words still win, and the check is reached only where the alternative would be calling + * the answer a success. + */ + useComposioClient( + recording({ + execute: async () => + ({ + data: {}, + error: "Gmail rejected the query: invalid search syntax.", + successful: "false", + }) as unknown as ComposioResult, + }).client, + ); + + const result = await callTool( + { url: "composio://gmail", actorId: "user_asker" }, + "GMAIL_FETCH_EMAILS", + { __version: "20260903_00" }, + ); + + expect(result.isError).toBe(true); + expect(result.text).toContain("invalid search syntax"); + expect(result.text).not.toMatch(/could not read/i); + }); + + test("an answer that is not an envelope refuses rather than throwing", async () => { + /* + * THE NEVER-THROW CONTRACT, asserted against the shape that broke it. This module documents a + * failure as a RESULT and `store.ts` relies on it: a model is mid-run with a person waiting, + * and an exception ends the turn with nothing said and nothing audited. + * + * `reportedFailure(answer, …)` read `answer.successful` outside every try, so a client + * resolving `null` threw a `TypeError` straight out of `callTool`. Like the listing case, this + * is a shape `ToolExecuteResponseSchema` forbids and `ComposioActions` cannot police — the + * projection is ours, the adapter is unwritten, and a runtime resolution is not a type. + */ + for (const shape of [null, undefined, "ok", 7]) { + useComposioClient( + recording({ + execute: async () => shape as unknown as ComposioResult, + }).client, + ); + + const result = await callTool( + { url: "composio://gmail", actorId: "user_asker" }, + "GMAIL_FETCH_EMAILS", + { __version: "20260903_00" }, + ); + + expect(result.isError).toBe(true); + expect(result.text).toContain("GMAIL_FETCH_EMAILS"); + expect(result.text).not.toMatch(/is not an object|undefined is not/i); + } + }); + + test("a schema mismatch on the way to the call is not handed on as a Zod dump", async () => { + /* + * THE SDK'S PARSE RUNS BEFORE THE SDK'S OWN TRY DOES. `./composio-adapter` resolves the tool + * first — `getRawComposioToolBySlug`, which runs `ToolSchema.parse` — so a vendor answer their + * schema rejects arrives here as a raw `ZodError`, whose `message` is the issue array as JSON. + * The listing path has refused that string since the day it was written; this path passed the + * whole dump to the model and into `store.ts`'s audit row, wearing the vendor's words for a + * failure that is a version skew between this deployment and their package. + */ + const issues = [ + { + code: "invalid_type", + expected: "string", + received: "undefined", + path: ["toolkit", "slug"], + message: "Required", + }, + ]; + useComposioClient( + recording({ + execute: async () => { + throw Object.assign(new Error(JSON.stringify(issues, null, 2)), { + name: "ZodError", + issues, + }); + }, + }).client, + ); + + const result = await callTool( + { url: "composio://gmail", actorId: "user_asker" }, + "GMAIL_FETCH_EMAILS", + { __version: "20260903_00" }, + ); + + expect(result.isError).toBe(true); + expect(result.text).not.toContain("invalid_type"); + expect(result.text).not.toContain("received"); + expect(result.text).toContain("GMAIL_FETCH_EMAILS"); + // A vendor change rather than anything an administrator can do to this connection, so the + // sentence has to name the one step that helps. + expect(result.text).toMatch(/upgrad/i); + }); + + test("a placeholder nested in the cause is refused like the thrown one", async () => { + /* + * The same string, reached by the route the guard did not cover. `vendorSentence` is preferred + * over the thrown message, so a placeholder sitting where the useful sentence usually sits was + * handed to the model past a check written to stop it. + */ + useComposioClient( + recording({ + execute: async () => { + throw Object.assign( + new Error("Error executing the tool GMAIL_FETCH_EMAILS"), + { + cause: { + error: { + error: { + message: "Error executing the tool GMAIL_FETCH_EMAILS", + }, + }, + }, + }, + ); + }, + }).client, + ); + + const result = await callTool( + { url: "composio://gmail", actorId: "user_asker" }, + "GMAIL_FETCH_EMAILS", + { __version: "20260903_00" }, + ); + + expect(result.isError).toBe(true); + // ANCHORED TO NOTHING, for the reason above: the module's own `VENDOR_PLACEHOLDER` is anchored + // because it is deciding whether a string IS the placeholder, and this is asking whether a + // refusal CARRIES it. A start-anchored question of the answer lets it through anywhere but the + // first character. + expect(result.text).not.toMatch(/error executing the tool/i); + expect(result.text).toMatch(/Plugins page/); + }); + + test("a refusal this deployment authored beats whatever the vendor said", async () => { + /* + * THE ORDER `routes.ts` USES, WHICH THIS PATH HAD BACKWARDS. `brokerRefusal` there reads + * `brokerSentence` first and falls back to `vendorSentence`; this catch read `vendorSentence` + * first. Both meet the same throws — `./composio-adapter`'s `askVendor` raises a + * `BrokerRefusalError` out of the execute path as readily as out of a listing — so one vendor + * condition was answered with two different sentences depending on which door the reader came + * through, and on this one the authored remedy lost. + * + * AND IT LOST TO A READ ONE LEVEL SHALLOW. `vendorRefusal` authors a refusal only where + * `vendorSentence` of the ORIGINAL error was null, so the vendor keeps the last word wherever + * it had one. Asking the same question of the WRAPPER is a different question: its `cause` is + * the original, so the reach for `cause.error.error.message` lands one level in from where it + * landed before and finds whatever sits there — below, a bare "Invalid request" that the + * adapter had already judged not to be an explanation. + * + * What that costs is the whole point of translating the condition: a sentence naming the step + * that clears it, replaced by three words naming nothing. + */ + const authored = + 'Composio refuses a call whose toolkit version is "latest", and that is the version travelling with this one, so GMAIL_FETCH_EMAILS was not run. A dated version is recorded when an app\'s actions are listed, so refreshing gmail\'s tools on its Plugins page replaces "latest" with a version Composio will accept.'; + + useComposioClient( + recording({ + execute: async () => { + throw new BrokerRefusalError(authored, { + cause: { error: { error: { message: "Invalid request" } } }, + }); + }, + }).client, + ); + + const result = await callTool( + { url: "composio://gmail", actorId: "user_asker" }, + "GMAIL_FETCH_EMAILS", + { __version: "20260903_00" }, + ); + + expect(result.isError).toBe(true); + // Pinned whole rather than by a fragment: what this asserts is that the authored sentence + // arrives as its author wrote it, and a substring check would pass on a sentence that had + // been joined to the vendor's or cut short of the remedy. + expect(result.text).toBe(authored); + expect(result.text).not.toContain("Invalid request"); + }); + + 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 + // fixes it; a failure it knows nothing about stays a plain `Error`, and for those the vendor's + // own nested sentence is still worth far more than a generic top-level message. + useComposioClient( + recording({ + execute: async () => { + throw Object.assign( + new Error("Error executing the tool GMAIL_FETCH_EMAILS"), + nested( + "No connected account found for user ID u1 for toolkit gmail", + ), + ); + }, + }).client, + ); + + const result = await callTool( + { url: "composio://gmail", actorId: "user_asker" }, + "GMAIL_FETCH_EMAILS", + { __version: "20260903_00" }, + ); + + expect(result.isError).toBe(true); + expect(result.text).toContain("No connected account found"); + }); + + test("an answer that serializes to nothing at all is refused in this file's own words", async () => { + /* + * `JSON.stringify` ANSWERS `undefined` RATHER THAN THROWING for a function, a symbol, or + * anything else with no JSON form — `ComposioResult` is this module's projection and the value + * is the vendor's, so that is a shape this path meets rather than one it forbids. The + * `undefined` then reached the cap, which measures `.length`, and the engine's own + * `undefined is not an object` became the second half of a sentence this file wrote. + */ + useComposioClient( + recording({ + execute: async () => ({ + data: (() => "x") as unknown as Record, + error: null, + successful: true, + }), + }).client, + ); + + const result = await callTool( + { url: "composio://gmail", actorId: "user_asker" }, + "GMAIL_FETCH_EMAILS", + { __version: "20260903_00" }, + ); + + expect(result.isError).toBe(true); + expect(result.text).toMatch(/could not turn that answer into text/i); + expect(result.text).toContain("GMAIL_FETCH_EMAILS"); + expect(result.text).not.toMatch( + /is not an object|undefined is not|TypeError/i, + ); + }); +}); diff --git a/server/tests/config.test.ts b/server/tests/config.test.ts index 45061e0f5..d481ef324 100644 --- a/server/tests/config.test.ts +++ b/server/tests/config.test.ts @@ -896,3 +896,19 @@ describe("how far a Bot may hand work on", () => { ).toThrow("BOT_HANDOFF_MAX_PER_RUN"); }); }); + +/** + * Composio, which a deployment either bought or did not. + * + * Unset is the ordinary state and not a degraded one, so the absence has to read as `undefined` + * rather than as an empty string that later code would have to keep asking about. Trimmed like + * every other secret here, because a key pasted into a hosting dashboard arrives with whatever + * whitespace came with it and the vendor would refuse the padded copy. + */ +test("a Composio key is read when set and absent when not", () => { + expect(loadConfig(baseEnvironment).composioApiKey).toBeUndefined(); + expect( + loadConfig({ ...baseEnvironment, COMPOSIO_API_KEY: " ak_example " }) + .composioApiKey, + ).toBe("ak_example"); +}); diff --git a/server/tests/google-drive-rest.test.ts b/server/tests/google-drive-rest.test.ts index 0cf1cbdf1..9130febcc 100644 --- a/server/tests/google-drive-rest.test.ts +++ b/server/tests/google-drive-rest.test.ts @@ -1,6 +1,8 @@ -import { afterEach, describe, expect, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { accessFor } from "../src/plugins/access"; import { catalogueEntry } from "../src/plugins/catalogue"; import { callTool, listTools } from "../src/plugins/google-drive-rest"; +import { type McpTool, callTool as mcpCallTool } from "../src/plugins/mcp"; import { transportFor } from "../src/plugins/transport"; /** @@ -17,8 +19,35 @@ const connection = { }; const realFetch = globalThis.fetch; + +/** + * Anything this file did not stub is an escape, and an escape fails the test that let it out. + * + * Ordering used to matter here: a test awaited `listTools` before installing its stub, and got away + * with it only because this adapter's list happens to be a local constant. Reordering that one call + * fixes it once; a call made before its stub would go out to Google again the moment any of these + * functions grows a request. So `fetch` is armed to refuse instead, before every test. + * + * Refusing is not enough on its own — the adapter catches its own transport errors and reports them + * as a sentence, which would turn an escape into a plausible-looking failure message. The escapes + * are therefore recorded and the ledger asserted empty afterwards, so one is named as what it is + * rather than read as Drive being unreachable. + */ +let escapedToNetwork: string[] = []; + +beforeEach(() => { + escapedToNetwork = []; + // Annotated as answering, though it never does: a function that only throws infers as returning + // `never`, which does not overlap `fetch` enough for the cast the stub below makes freely. + globalThis.fetch = (async (input: string | URL): Promise => { + escapedToNetwork.push(String(input)); + throw new Error(`unstubbed fetch escaped to the network: ${String(input)}`); + }) as typeof fetch; +}); + afterEach(() => { globalThis.fetch = realFetch; + expect(escapedToNetwork).toEqual([]); }); /** Records what was requested and answers with a fixed body. */ @@ -41,29 +70,123 @@ function stubFetch( return calls; } +/** + * Arguments a tool will accept, read off the tool's own schema rather than written out here. + * + * A tool added to the adapter with a required argument this file has never heard of still gets + * called with one, so the coverage below cannot quietly stop covering it. + */ +function argsFor(tool: McpTool): Record { + const required: unknown[] = Array.isArray(tool.inputSchema.required) + ? tool.inputSchema.required + : []; + return Object.fromEntries(required.map((name) => [String(name), "given"])); +} + +/** + * A body that answers every advertised tool: a listing for the searches, a text file for the reads. + */ +const anyToolsBody = { + files: [], + id: "given", + name: "notes.txt", + mimeType: "text/plain", +}; + describe("the adapter is the transport the catalogue asks for", () => { - test("the Drive entry resolves to this adapter, not to MCP", async () => { + test("the Drive entry resolves to this adapter, not to MCP", () => { const entry = catalogueEntry("google-drive"); expect(entry?.transport).toBe("google-drive-rest"); // Identity, not shape: proves the registry wired this module rather than something MCP-shaped. - expect(transportFor(entry).callTool).toBe(callTool); + expect( + transportFor( + accessFor( + { provenance: "first-party", url: "https://www.googleapis.com" }, + entry, + ).transport, + ).callTool, + ).toBe(callTool); }); test("a server with no catalogue entry falls back to MCP", () => { // A custom server an administrator added by URL is somebody else's MCP endpoint by definition. - expect(transportFor(null).callTool).not.toBe(callTool); + // Composed through `accessFor`, which is where the absent-entry fallback now lives — and asserted + // as MCP rather than as "not Drive", which any wrongly resolved kind would also satisfy. + expect( + transportFor( + accessFor( + { provenance: "custom", url: "https://mcp.example.com/mcp" }, + null, + ).transport, + ).callTool, + ).toBe(mcpCallTool); }); test("every advertised tool is one the dispatcher handles", async () => { + // Stubbed before the first call of any kind, so nothing here depends on `listTools` staying + // local; the guard above turns a reintroduction of that order into a named failure. + stubFetch(anyToolsBody); const tools = await listTools(connection); - stubFetch({ files: [] }); + expect(tools.length).toBeGreaterThan(0); + for (const tool of tools) { - // Called with no arguments on purpose. A handled tool complains about a missing argument or - // answers; an unhandled one says it is not implemented, which is the failure being excluded. - const result = await callTool(connection, tool.name, {}); - expect(result.text).not.toContain("is not a tool this connector"); + /* + * Called with what the tool asks for, and asserted on what a handled tool DOES: it reaches + * Drive and answers. The dispatcher's fallthrough is the failure being excluded, and it is + * excluded by never making a request — which stays true however that refusal is worded, and + * which a reworded, mistyped or entirely different error cannot satisfy. + */ + const calls = stubFetch(anyToolsBody); + const result = await callTool(connection, tool.name, argsFor(tool)); + expect(calls.length).toBeGreaterThan(0); + expect(result.isError).toBe(false); } }); + + test("a tool the dispatcher does not implement is refused without a request", async () => { + // The other half of the pair: the fallthrough exists, and is what a tool NOT in the list gets. + const calls = stubFetch(anyToolsBody); + const result = await callTool(connection, "delete_everything", {}); + + expect(result.isError).toBe(true); + expect(calls).toHaveLength(0); + }); +}); + +/* + * Drive is `user-oauth`, so the store refuses a call with nobody's credential long before this + * module is reached and the adapter's own check is the second lock. It is asserted anyway: it is + * the difference between a sentence saying so and a request to Google carrying `Bearer undefined`, + * which Drive answers with a 401 whose meaning is a great deal less obvious. + */ +describe("a call with no credential never leaves the process", () => { + const withoutToken = { url: connection.url }; + + test("every advertised tool refuses, and none of them requests anything", async () => { + stubFetch(anyToolsBody); + const tools = await listTools(withoutToken); + expect(tools.length).toBeGreaterThan(0); + + for (const tool of tools) { + const calls = stubFetch(anyToolsBody); + const result = await callTool(withoutToken, tool.name, argsFor(tool)); + // Silence first, and asserted as silence rather than as wording: nothing was requested, so + // no `Bearer undefined` went to Google to come back as a 401 about the wrong thing. + expect(calls).toHaveLength(0); + expect(result.isError).toBe(true); + } + }); + + test("listing what the adapter offers asks nobody, so it needs nothing", async () => { + /* + * Two properties in one call, and the second is why this test is left unstubbed. The gate that + * once stood here made connecting Drive a four-stop journey, so a tokenless listing has to + * answer in full — and the reason it can is that it asks nobody, which the armed `fetch` above + * is what proves. A `listTools` that grew a request would fail here by name. + */ + expect(await listTools(withoutToken)).toEqual(await listTools(connection)); + expect(escapedToNetwork).toEqual([]); + }); }); describe("a search becomes the right Drive request", () => { diff --git a/server/tests/mcp-listing.test.ts b/server/tests/mcp-listing.test.ts new file mode 100644 index 000000000..7afabceba --- /dev/null +++ b/server/tests/mcp-listing.test.ts @@ -0,0 +1,183 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { MCPMock, type MCPToolDefinition } from "@copilotkit/aimock/mcp"; +import type { ToolAnnotations } from "@modelcontextprotocol/sdk/types.js"; +import { catalogueEntry, classifyTool } from "../src/plugins/catalogue"; +import { listTools } from "../src/plugins/mcp"; + +/** + * What an MCP listing carries out of the transport, and what the classifier then makes of it. + * + * WHY THIS SUITE EXISTS. The MCP specification lets a server publish `annotations.destructiveHint`, + * and `listTools` used to drop the whole `annotations` object on the floor. Nothing failed: every + * MCP-listed tool simply arrived with no effect, `refreshTools` recorded null, and `classifyTool` + * fell through to the reviewed write list. That looked correct — and it was, for every name a + * person had already reviewed. For a name the reviewed list did not happen to hold, a tool the + * vendor had explicitly declared destructive classified as a READ. Fail-open on a + * permission-adjacent decision, and invisible, because the only evidence was a field never read. + * + * These cases pin the transport and the classifier TOGETHER rather than separately. A unit test of + * `listTools` alone would prove a field is copied; a unit test of `classifyTool` alone would prove + * a string is honoured. Neither would have caught this, because the defect lived exactly in the + * join: the transport never produced the string the classifier was already willing to act on. So + * each case here goes over a real MCP connection and then through the same call `store.ts` makes, + * `classifyTool(entry, name, true, tool.effect ?? null)` — what `refreshTools` writes to the column + * and what the call path reads back out of it. + */ + +const mock = new MCPMock(); +let url = ""; + +/** + * A tool definition including the annotations the mock's own type does not declare. + * + * `MCPToolDefinition` names only `name`, `description` and `inputSchema`, but the mock stores the + * definition it is handed and serves it back verbatim, so annotations really do cross the wire. + * Widening the type here rather than casting keeps the fixture honest: `ToolAnnotations` is the + * SDK's own declaration, so a hint renamed upstream fails this file at compile time instead of + * silently ceasing to be served. + */ +type AnnotatedTool = MCPToolDefinition & { annotations?: ToolAnnotations }; + +/** Every tool needs one: the SDK rejects an entire listing that omits a single `inputSchema`. */ +const NO_ARGUMENTS = { type: "object", properties: {} } as const; + +/** + * Notion's real catalogue entry, not a fabricated one. + * + * The question this suite answers is what happens to the connector this deployment actually ships, + * so the reviewed write list under test has to be the shipped one. `notion-fetch` is on Notion's + * advertised listing and absent from `writeTools`; `notion-update-page` is on `writeTools`. Those + * two names are what make the narrowing and the no-widening cases meaningful. + */ +const notion = catalogueEntry("notion"); + +/** + * A destructive action absent from Notion's reviewed write list. + * + * Deliberately a name `writeTools` does not hold, because a name it DOES hold classifies as a write + * whatever the listing says — which would make this case pass without the transport carrying + * anything at all. + */ +const destructiveUnreviewed: AnnotatedTool = { + name: "notion-purge-workspace", + description: "Removes everything.", + inputSchema: NO_ARGUMENTS, + annotations: { destructiveHint: true }, +}; + +/** A read the vendor labels as one, which the reviewed list already classified as a read. */ +const readOnlyUnreviewed: AnnotatedTool = { + name: "notion-fetch", + description: "Reads a page.", + inputSchema: NO_ARGUMENTS, + annotations: { readOnlyHint: true }, +}; + +/** A reviewed write that the vendor contradicts by calling it read-only. */ +const readOnlyButReviewedAsWrite: AnnotatedTool = { + name: "notion-update-page", + description: "The vendor claims this only reads.", + inputSchema: NO_ARGUMENTS, + annotations: { readOnlyHint: true }, +}; + +/** A tool with no annotations at all, which is what most MCP servers publish. */ +const unannotated: AnnotatedTool = { + name: "notion-search", + description: "Says nothing about what it does.", + inputSchema: NO_ARGUMENTS, +}; + +beforeAll(async () => { + mock + .addTool(destructiveUnreviewed) + .addTool(readOnlyUnreviewed) + .addTool(readOnlyButReviewedAsWrite) + .addTool(unannotated); + url = await mock.start(); +}); + +afterAll(async () => { + await mock.stop?.(); +}); + +/** + * The classification a refresh would commit for one listed tool. + * + * Written as `tool.effect ?? null` because that is literally what `refreshTools` inserts into + * `mcp_tools.effect`, and `classifyTool` distinguishes null from the empty string. Reproducing the + * `??` here rather than passing `tool.effect` through is the difference between testing the shipped + * path and testing a plausible one. + */ +const classify = ( + tools: Awaited>, + name: string, +) => { + const tool = tools.find((candidate) => candidate.name === name); + if (!tool) throw new Error(`the mock did not list ${name}`); + return classifyTool(notion, name, true, tool.effect ?? null); +}; + +describe("what an MCP listing tells the classifier", () => { + test("a tool the vendor declares destructive is a write", async () => { + /* + * The case the whole change exists for. Nothing in the reviewed list names + * `notion-purge-workspace`, so before the annotations were surfaced this returned "read" — a + * Bot with a read grant could have called it. + */ + const tools = await listTools({ url }); + + expect(classify(tools, "notion-purge-workspace")).toBe("write"); + }); + + test("a destructive tool is also carried as destructive, not only as a write", async () => { + // `effect` gates the call; `destructive` is what the confirmation card reads. A tool that + // arrived as a write with `destructive` false would be gated correctly and presented wrongly. + const tools = await listTools({ url }); + const purge = tools.find((tool) => tool.name === "notion-purge-workspace"); + + expect(purge?.destructive).toBe(true); + }); + + test("a vendor's read-only claim cannot take a reviewed write off the write list", async () => { + /* + * The direction the criterion forbids. `notion-update-page` is on the reviewed `writeTools`, + * and a server that says otherwise — whether mistakenly or because somebody stood up a server + * that says whatever it likes — must not be able to widen what a Bot may do. + */ + const tools = await listTools({ url }); + + expect(classify(tools, "notion-update-page")).toBe("write"); + }); + + test("a vendor's read-only claim records nothing at all", async () => { + /* + * WITHHELD ON PURPOSE, and pinned so the omission reads as a decision rather than as the same + * oversight being fixed. `readOnlyHint` can only ever move an action towards "read", which is + * the widening the classifier's ordering exists to prevent. For a catalogued vendor it would + * change nothing — an advertised name absent from `writeTools` is already a read — so it buys + * no accuracy; for a server an administrator added by URL there is no reviewed list at all, and + * honouring it would let that server declare its own tools harmless and be believed. The SDK + * says as much where it declares these hints: clients should never make tool use decisions + * based on annotations received from untrusted servers. Acting only on the hint that narrows is + * how that warning is honoured while a declared destructive tool still gets gated. + */ + const tools = await listTools({ url }); + const fetch = tools.find((tool) => tool.name === "notion-fetch"); + + expect(fetch?.effect).toBeUndefined(); + // And so the reviewed list still decides, exactly as it did before this change. + expect(classify(tools, "notion-fetch")).toBe("read"); + }); + + test("a tool with no annotations is unchanged in every respect", async () => { + // The overwhelmingly common case, and the one that says existing Notion grants survive: no + // annotations means nothing recorded, which means the reviewed list decides as it always did. + const tools = await listTools({ url }); + const search = tools.find((tool) => tool.name === "notion-search"); + + expect(search?.effect).toBeUndefined(); + expect(search?.destructive).toBeUndefined(); + expect(classify(tools, "notion-search")).toBe("read"); + }); +}); diff --git a/server/tests/migration-journal.test.ts b/server/tests/migration-journal.test.ts index f1471a5ea..6740f9b23 100644 --- a/server/tests/migration-journal.test.ts +++ b/server/tests/migration-journal.test.ts @@ -73,4 +73,36 @@ describe("the migration journal", () => { expect(journal.entries.map((entry) => entry.tag).sort()).toEqual(files); }); + + test("has a snapshot for every migration", async () => { + /* + * A third way this goes wrong quietly, and the one that bites the NEXT person rather than this + * one. `generate` diffs the schema against the newest snapshot in `meta/`, so a migration that + * ships without one leaves the previous snapshot as the newest: the columns it added are absent + * from what `generate` compares against, and the next migration re-emits them. That migration + * then fails on every database the first one already ran on, because `ADD COLUMN` is not + * conditional. A hand-written migration needs a hand-written snapshot for the same reason a + * generated one gets one for free. + */ + const directory = new URL("../drizzle/", import.meta.url); + const snapshots = new Set( + (await readdir(new URL("meta/", directory))).filter((name) => + name.endsWith("_snapshot.json"), + ), + ); + + const journal = JSON.parse( + await readFile(new URL("meta/_journal.json", directory), "utf8"), + ) as { entries: { idx: number; tag: string }[] }; + + const missing = journal.entries + .map((entry) => ({ + entry, + snapshot: `${entry.tag.split("_")[0]}_snapshot.json`, + })) + .filter(({ snapshot }) => !snapshots.has(snapshot)) + .map(({ entry, snapshot }) => `${entry.tag} has no meta/${snapshot}`); + + expect(missing).toEqual([]); + }); }); diff --git a/server/tests/plugin-catalogue.test.ts b/server/tests/plugin-catalogue.test.ts index d6b533a4a..7aa8c49f7 100644 --- a/server/tests/plugin-catalogue.test.ts +++ b/server/tests/plugin-catalogue.test.ts @@ -44,15 +44,23 @@ describe("which servers this deployment will talk to", () => { /* * WHAT THIS NO LONGER COVERS. ServiceNow was the only per-instance entry, and removing it took * the anchored-pattern assertions with it — that a prefix, a suffix and a subdomain are each - * refused. `PATTERNS` is compiled from the catalogue by key, so a synthetic entry cannot reach a - * pattern and there is no way left to exercise the matching itself through the public API. + * refused. `PATTERNS` is compiled from the catalogue by KEY, so an entry this build never + * compiled a pattern for reaches no pattern, and there is no way left to exercise the matching + * itself through the public API. * * What survives is the fail-closed half, which is worth keeping on its own: an entry claiming to * be per-instance that this build has no pattern for is refused rather than admitted. Whoever * adds the next per-instance vendor should restore the anchoring cases with it. + * + * THE KEY IS DELIBERATELY ONE NO ENTRY HOLDS, and asserted to be. This case used to borrow + * `google-drive`, which passed only because no catalogue entry declares a `hostPattern` today: + * `PATTERNS` is keyed, not identity-checked, so a synthetic entry that reuses a real key reaches + * whatever pattern that key compiled. Giving Drive a pattern made the old version admit + * `https://acme.service-now.com`. With an unheld key the lookup misses for the reason the test + * names, whatever the catalogue later declares. */ const perInstance = { - key: "google-drive", + key: "no-entry-holds-this-key", title: "Per-instance vendor", vendor: "Example", summary: "", @@ -65,8 +73,9 @@ describe("which servers this deployment will talk to", () => { docsUrl: "", } as const; - // `PATTERNS` is compiled from the catalogue by key, so a synthetic entry reaches no pattern and - // is refused outright. That is itself the fail-closed property: no pattern means no. + expect(catalogueEntry(perInstance.key)).toBeNull(); + // No compiled pattern for this key, so the entry's own `hostPattern` is never consulted and the + // host is refused outright. That is itself the fail-closed property: no pattern means no. expect(hostAdmissible(perInstance, "https://acme.service-now.com")).toBe( false, ); @@ -543,3 +552,47 @@ describe("which credential a curated server is given", () => { ).toBeNull(); }); }); + +test("a curated entry keeps classifying from its write list when nothing was recorded", () => { + const notion = catalogueEntry("notion"); + expect(notion).not.toBeNull(); + if (!notion) return; + + // The behaviour that shipped before the column existed, unchanged for every existing row. + expect(classifyTool(notion, "notion-fetch", true)).toBe("read"); + expect(classifyTool(notion, "notion-update-page", true)).toBe("write"); +}); + +test("a recorded write overrides a curated entry that omits the action", () => { + const notion = catalogueEntry("notion"); + // Asserted rather than only guarded. The early return below reads as a pass, so a renamed or + // dropped key would retire this case silently instead of failing. + expect(notion).not.toBeNull(); + if (!notion) return; + + // The write list is known-incomplete. A vendor saying an action writes settles it, and the list + // being out of date stops mattering. + expect(classifyTool(notion, "notion-fetch", true, "write")).toBe("write"); +}); + +test("a recorded read cannot take an action off a curated entry's write list", () => { + const notion = catalogueEntry("notion"); + expect(notion).not.toBeNull(); + if (!notion) return; + + /* + * The mirror image of the case above, and the only direction that is refused. A recorded effect + * may NARROW what a Bot may do — the `write` case above — and may never widen it: `effect` is + * vendor-supplied text in an unconstrained column, `writeTools` was reviewed by a person, and a + * value in the column must not buy an action less scrutiny than review already gave it. The + * per-value cases live in composio-classify.test.ts; this pins the property beside the write list + * it protects, so dropping a name from that list fails here too. + */ + expect(notion.writeTools).toContain("notion-update-page"); + expect(classifyTool(notion, "notion-update-page", true, "read")).toBe( + "write", + ); + // And the empty string is a recorded value rather than a silence, so it does not fall through to + // the write list and read as a read for an action the list omits. + expect(classifyTool(notion, "notion-fetch", true, "")).toBe("write"); +}); diff --git a/server/tests/plugin-connect-route.test.ts b/server/tests/plugin-connect-route.test.ts index 3aa9617fd..aa995c3e5 100644 --- a/server/tests/plugin-connect-route.test.ts +++ b/server/tests/plugin-connect-route.test.ts @@ -40,7 +40,16 @@ function app(store: { ) => Promise; }) { const routes = createPluginRoutes( - store as never, + { + /* + * The read that decides whether this is a brokered row, which the handler makes before + * anything about the consent flow. None of these vendors is one: a brokered app is reached + * through Composio and enters none of the flow these tests are about. `undefined` is an id + * naming no row, which falls through to the flow below exactly as a non-brokered row does. + */ + serverAddress: async () => undefined, + ...store, + } as never, signedIn(), async () => true, { diff --git a/server/tests/plugin-oauth-callback.test.ts b/server/tests/plugin-oauth-callback.test.ts index a3d3f5c81..89f08936f 100644 --- a/server/tests/plugin-oauth-callback.test.ts +++ b/server/tests/plugin-oauth-callback.test.ts @@ -56,6 +56,9 @@ function app(input: { recordConnection?: (connection: Recorded) => Promise; }) { const store = { + // The brokered read the connect handler makes before anything about the consent flow. This + // deployment has no brokered rows, so it answers nothing and the flow below is untouched. + serverAddress: async () => undefined, oauthClientFor: async () => ({ clientId: "dyn-1", clientSecret: "" }), ensureOAuthClient: async () => ({ clientId: "dyn-1", clientSecret: "" }), recordConnection: diff --git a/server/tests/plugin-routes.test.ts b/server/tests/plugin-routes.test.ts index 14c072769..2981877fc 100644 --- a/server/tests/plugin-routes.test.ts +++ b/server/tests/plugin-routes.test.ts @@ -1,9 +1,19 @@ import { describe, expect, test } from "bun:test"; import { createApp } from "../src/app"; +import { DEV_ACTOR } from "../src/auth/dev-actor"; import { loadConfig } from "../src/config"; +import { ServerRowAmbiguousError } from "../src/plugins/access"; +import { + type BrokerApp, + type BrokerConnection, + type BrokerField, + BrokerRefusalError, +} from "../src/plugins/broker"; import { CatalogueEntryUnknownError, CustomServerRefusedError, + PluginInvariantError, + PluginRefusedError, } from "../src/plugins/store"; import { testEnvironment } from "./support/environment"; @@ -84,6 +94,29 @@ describe("adding a curated server", () => { expect((await request({ key: "nope" })).status).toBe(400); }); + test("a row the deployment cannot resolve comes back with its sentence", async () => { + /* + * ADDING REFRESHES, which is what puts this fault on this route. + * + * `addServer` asks the vendor what it offers before it answers — deliberately, so a bad + * credential is reported now rather than the first time a Bot uses one — so everything + * `refreshTools` raises arrives here as well: a vendor listing one action twice, a query of + * ours failing, a row whose two columns contradict each other. Unmapped, all of it left the + * route on the default path and the admin page said "That did not work", while the SAME fault + * on the refresh button said which row and what to do about it. + */ + const sentence = + "notion: the actions this app listed were not stored, so what it already had is unchanged."; + const request = appWith(async () => { + throw new PluginInvariantError(sentence); + }); + + const response = await request({ key: "notion" }); + + expect(response.status).toBe(409); + expect(await response.json()).toEqual({ error: sentence }); + }); + test("a failure that is not a refusal is not dressed up as one", async () => { // The must-not case. Mapping every throw to 400 would tell an administrator to correct their // input when the database is down, and would hide a real fault behind a message about @@ -104,6 +137,125 @@ describe("adding a curated server", () => { }); }); +/** + * What a refresh that cannot be resolved at all looks like to the administrator who pressed it. + * + * CRITERION. A contradiction between two of this deployment's own columns comes back with a body + * that names the row and says what to correct, on this route and only on this route. + * + * REASON. `ServerRowAmbiguousError` was mapped nowhere, so it left the route on the framework's + * default path: a 500 whose body is not JSON, which the admin client turns into its fallback + * sentence — "That did not work" — having found no `error` field to read. The one refusal that + * names exactly which row is wrong was the one an operator could not see, while the same sentence + * was reaching a model on the tool-call path. This route is admin-gated, which is what makes + * showing it here the right answer and showing it anywhere else the wrong one. + */ +function refreshApp( + refreshTools: () => Promise, + role: "admin" | "user" = "admin", +) { + const store = { + refreshTools, + // Every read the plugins surface makes on its way to the route under test. + listServers: async () => [], + listSkills: async () => [], + listGrants: async () => [], + }; + + const app = createApp( + loadConfig(testEnvironment()), + { + handler: () => new Response(null, { status: 204 }), + api: { getSession: async () => ({ user: ADMIN }) }, + } as never, + { rolesForUser: async () => [role] }, + // Positions 4-14 are the other stores; `store` is 15, pluginStore. + ...(Array.from({ length: 11 }) as never[]), + store as never, + ); + + return () => + app.request("http://openbot.test/api/plugins/servers/notion/refresh", { + method: "POST", + headers: { "content-type": "application/json" }, + body: "{}", + }); +} + +describe("refreshing a server that cannot be resolved", () => { + test("the administrator is told which row and what to do about it", async () => { + const sentence = + "notion is a server this deployment ships an entry for, and a row with that id says its " + + "provenance is composio. Rename it, or correct its provenance."; + const request = refreshApp(async () => { + throw new ServerRowAmbiguousError(sentence); + }); + + const response = await request(); + + // 409 rather than 500: nothing broke and nothing about the request was malformed. Two rows + // disagree, and the request cannot be answered until one of them changes. + expect(response.status).toBe(409); + // A body at all is the fix. Unmapped, this was a 500 carrying no JSON, and the page said + // "That did not work" because that is what it says when it finds no message. + expect(await response.json()).toEqual({ error: sentence }); + }); + + test("a failed query comes back as the reason, never as the statement", async () => { + /* + * The shape drizzle throws: `Failed query:` plus the whole statement, then `params:` and every + * value bound to it, with the driver's own error on `cause`. It is on the same shelf as the + * refusals above — not a vendor's doing, not the asker's to act on — so this route is where an + * operator is told about it, and it is the one member of that shelf whose `message` must not be + * what they are told. + */ + const request = refreshApp(async () => { + throw Object.assign( + new Error( + 'Failed query: select "credential_id" from "mcp_user_credentials" where "user_id" = $1 params: someone', + ), + { + query: 'select "credential_id" from "mcp_user_credentials"', + params: ["someone"], + cause: new Error("canceling statement due to statement timeout"), + }, + ); + }); + + const response = await request(); + expect(response.status).toBe(409); + const body = (await response.json()) as { error?: string }; + // The reason, which is what an administrator can act on. + expect(body.error).toContain( + "canceling statement due to statement timeout", + ); + // And none of the query. This route answers an administrator, but the browser it answers is + // still on somebody's laptop and the sentence still ends up in a screenshot and a ticket. + expect(body.error).not.toContain("Failed query"); + expect(body.error).not.toContain("params:"); + expect(body.error).not.toContain("mcp_user_credentials"); + }); + + test("a failure that is not one of ours is still not dressed up as one", async () => { + // The must-not case, the same one the add route above carries: a database that is down is not + // a row an administrator can go and correct, and answering 409 would send them to do it. + const request = refreshApp(async () => { + throw new Error("the database is unreachable"); + }); + + expect((await request()).status).toBe(500); + }); + + test("somebody who is not an administrator cannot press it at all", async () => { + const request = refreshApp(async () => { + throw new Error("the store must not be reached"); + }, "user"); + + // Which is what makes showing the sentence above safe: nobody else reaches this route. + expect((await request()).status).toBe(403); + }); +}); + /** * Granting one Bot to another, through the API an administrator actually has. * @@ -407,3 +559,1770 @@ describe("granting a Bot itself", () => { expect(calls).toEqual([]); }); }); + +/** + * The app directory an administrator picks a brokered app out of. + * + * TWO THINGS ARE BEING PINNED, and they are the two a reader would assume the vendor does for us. + * The search is ours, because `@composio/core` forwards only category, managed_by, sort_by, cursor + * and limit and drops a search term without saying so — a forwarded term comes back as an + * unfiltered first page, which looks exactly like a result. And the slug on a POST is checked + * against the directory that was just read, because that slug becomes the url every future call + * for the app runs against. + * + * The no-broker answer is a 503 naming the setting rather than an empty list: an empty directory + * and an absent one are different facts, and only one of them has a remedy. + */ +const DIRECTORY: BrokerApp[] = [ + { + slug: "slack", + name: "Slack", + description: "Post messages and read channels.", + logo: null, + categories: ["communication"], + actionCount: 63, + connection: { kind: "consent" }, + }, + { + slug: "gmail", + name: "Gmail", + description: "Read and send mail.", + logo: null, + categories: ["communication"], + actionCount: 24, + connection: { kind: "consent" }, + }, + { + slug: "linear", + name: "Linear", + description: "Track issues.", + logo: null, + categories: ["project-management"], + actionCount: 18, + connection: { kind: "consent" }, + }, + { + /* + * The fourth app is one Composio publishes and this deployment cannot drive: connecting it + * wants an OAuth application registered by whoever runs the deployment, and there is nowhere + * here to keep one. Fifty-six of the catalogue's apps are this, which is why the fixture + * carries one rather than pretending the catalogue is uniform. + */ + slug: "docusign", + name: "DocuSign", + description: "Send documents for signature.", + logo: null, + categories: ["documents"], + actionCount: 31, + connection: { + kind: "unsupported", + reason: + "DocuSign needs an OAuth application registered by whoever runs this deployment, and this deployment holds no place to put its own OAuth client for a brokered app.", + }, + }, +]; + +function directoryApp( + /** Null is a deployment with no COMPOSIO_API_KEY, which is the shipped default. */ + listApps: (() => Promise) | null = async () => DIRECTORY, + role: "admin" | "user" = "admin", + /** What this deployment has already added, which is where `enabled` comes from. */ + servers: Array<{ id: string; url: string }> = [], + /** + * How enabling fails, for the cases that are about the failing half. Null is the store that + * works: it records the call and answers a row. Enabling is where the second half of this + * surface's failures are decided, and none of them could be tested while the only store here + * succeeded. + */ + enable: (() => Promise) | null = null, +) { + const added: Array<{ + slug: string; + title: string; + by: string; + connection: BrokerConnection; + }> = []; + const store = { + // Every read the plugins surface makes on its way to the route under test. The directory asks + // for urls and is handed urls: the rows below carry an id as well, and the route never sees it. + serverUrls: async () => servers.map((server) => server.url), + listSkills: async () => [], + listGrants: async () => [], + addBrokeredApp: async (input: { + slug: string; + title: string; + by: string; + connection: BrokerConnection; + }) => { + if (enable) return enable(); + added.push(input); + return { id: `composio-${input.slug}`, url: `composio://${input.slug}` }; + }, + }; + + const app = createApp( + loadConfig(testEnvironment()), + { + handler: () => new Response(null, { status: 204 }), + api: { getSession: async () => ({ user: ADMIN }) }, + } as never, + { rolesForUser: async () => [role] }, + // Positions 4-14 are the other stores; `store` is 15, pluginStore. + ...(Array.from({ length: 11 }) as never[]), + store as never, + // Positions 16-25 are the stores after it; the broker is 26, `composio`. + ...(Array.from({ length: 10 }) as never[]), + listApps ? ({ broker: { listApps } } as never) : undefined, + ); + + return { added, app }; +} + +describe("the Composio directory", () => { + test("a deployment with no broker is told which setting to set", async () => { + const { app } = directoryApp(null); + + const response = await app.request( + "http://openbot.test/api/plugins/composio/apps", + ); + + // 503 rather than `{ apps: [] }`. An empty directory and an absent one are different facts, + // and a page shown the empty one draws "no apps available" over a deployment that simply has + // no key. + expect(response.status).toBe(503); + expect((await response.json()).error).toContain("COMPOSIO_API_KEY"); + }); + + test("a search term filters the directory here, not at the vendor", async () => { + /* + * `@composio/core` forwards only category, managed_by, sort_by, cursor and limit, and silently + * drops anything else — so a term handed to their client comes back as an unfiltered first + * page that reads as a result. The filter is ours, over slug, name and description. + */ + const { app } = directoryApp(); + + const response = await app.request( + "http://openbot.test/api/plugins/composio/apps?q=sla", + ); + + expect(response.status).toBe(200); + expect( + (await response.json()).apps.map((app: { slug: string }) => app.slug), + ).toEqual(["slack"]); + }); + + test("an app this deployment could not connect is never offered", async () => { + /* + * COMPOSIO PUBLISHES 1540 APPS AND FIFTY-SIX OF THEM CANNOT BE CONNECTED FROM HERE: they want + * an OAuth client registered by whoever runs the deployment, and this deployment holds nowhere + * to put one. Listed, they are a row an administrator presses Add on and meets the vendor's + * refusal at — a dead end offered as a choice. Hidden in the route rather than asked of the + * vendor, because which apps are connectable is a fact about what this deployment can drive, + * not about what Composio publishes. + */ + const { app } = directoryApp(); + + const response = await app.request( + "http://openbot.test/api/plugins/composio/apps", + ); + + expect(response.status).toBe(200); + const slugs = (await response.json()).apps.map( + (entry: { slug: string }) => entry.slug, + ); + expect(slugs).not.toContain("docusign"); + expect(slugs).toEqual(["slack", "gmail", "linear"]); + + // And the search branch reads the same filtered list, not the raw directory. Searching is what + // an administrator does to a 1540-app picker, so a term that names the unconnectable app is + // the request most likely to hand one back. + const searched = await app.request( + "http://openbot.test/api/plugins/composio/apps?q=docu", + ); + expect((await searched.json()).apps).toEqual([]); + }); + + test("an app is enabled by the url of the row, not by the row's id", async () => { + // Which app a row is comes off its url and only off its url, because that is where the + // transport reads it from. An id read as an app name is a different question wearing the same + // answer's clothes — and the read behind this route now hands over urls alone, so the id below + // is one the route could not consult even if it wanted to. + const { app } = directoryApp(undefined, "admin", [ + { id: "an-id-nobody-should-read", url: "composio://slack" }, + ]); + + const response = await app.request( + "http://openbot.test/api/plugins/composio/apps", + ); + + const apps = (await response.json()).apps as Array<{ + slug: string; + enabled: boolean; + }>; + expect(apps.find((entry) => entry.slug === "slack")?.enabled).toBe(true); + expect(apps.find((entry) => entry.slug === "gmail")?.enabled).toBe(false); + }); + + test("a slug the directory never answered with is refused", async () => { + /* + * THE VALIDATION IS THE WHOLE ROUTE. The slug becomes `composio://`, which is the url + * every future call for the app is resolved against, so a slug nobody listed is a row pointing + * at an app that does not exist — added, grantable, and dead at the first call. + */ + const { added, app } = directoryApp(); + + const response = await app.request( + "http://openbot.test/api/plugins/composio/apps", + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ slug: "not-an-app" }), + }, + ); + + expect(response.status).toBe(400); + expect(added).toEqual([]); + }); + + test("an app this deployment cannot connect is refused before the store", async () => { + /* + * THE ASYMMETRY THIS CLOSES. The GET hides every `unsupported` app, so an administrator + * cannot press Add on one through the picker; the POST validates against the unfiltered + * catalogue, so a request naming one by hand walks straight past that. What it would reach is + * `addBrokeredApp`, whose `schemeFor` records `null` for an unsupported app — and `null` on + * that column is read everywhere else as "not a brokered row at all". So the row this would + * write is one that lies about its own kind. + * + * The derivation's own `reason` is the answer, because it is the sentence that names what is + * missing for THIS app, and 503 because it is a refusal this deployment authored — the same + * status `brokerRefusal` gives one raised a layer down, and not a 400, which would read as a + * malformed request about an app Composio really does publish. + */ + const { added, app } = directoryApp(); + + const response = await app.request( + "http://openbot.test/api/plugins/composio/apps", + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ slug: "docusign" }), + }, + ); + + expect(response.status).toBe(503); + expect(((await response.json()) as { error: string }).error).toContain( + "OAuth application registered by whoever runs this deployment", + ); + // AND THE STORE WAS NEVER ASKED, which is the whole point of the guard: the misleading row is + // not written and then answered around, it is never reachable. + expect(added).toEqual([]); + }); + + test("an app the directory does list is added", async () => { + const { added, app } = directoryApp(); + + const response = await app.request( + "http://openbot.test/api/plugins/composio/apps", + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ slug: "slack" }), + }, + ); + + expect(response.status).toBe(201); + // The title comes off the directory entry, never off the request: the caller chose an app, not + // a name for it. + // And so does the connection: how an app connects is read off the catalogue row that was + // chosen, not derived a second time on the way to the store. + expect(added).toEqual([ + { + slug: "slack", + title: "Slack", + by: ADMIN.email, + connection: { kind: "consent" }, + }, + ]); + }); + + test("somebody who is not an administrator sees none of it", async () => { + // Enabling an app writes every one of its actions in front of a model, which is the same + // decision as adding an MCP server and stays an administrator's. + const { added, app } = directoryApp(undefined, "user"); + + expect( + (await app.request("http://openbot.test/api/plugins/composio/apps")) + .status, + ).toBe(403); + + const posted = await app.request( + "http://openbot.test/api/plugins/composio/apps", + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ slug: "slack" }), + }, + ); + expect(posted.status).toBe(403); + expect(added).toEqual([]); + }); + + test("a vendor failure is Composio's own sentence on both doors, not a 500", async () => { + /* + * THE WRONG KEY IS WHERE THIS FAILS FIRST, and nothing on either route caught anything: the + * administrator who had just pasted a key was shown a bare 500 and the vendor's whole response + * object went to the console, headers and trace id included. + * + * Both routes make the same call, so both answer in the same words. The POST is the one that + * mattered most: it is pressed by somebody who has just set the key and is waiting to hear + * whether it works. + */ + const failing = async (): Promise => { + throw WRONG_KEY; + }; + + const listed = await directoryApp(failing).app.request( + "http://openbot.test/api/plugins/composio/apps", + ); + const added = await directoryApp(failing).app.request( + "http://openbot.test/api/plugins/composio/apps", + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ slug: "slack" }), + }, + ); + const listedBody = await listed.text(); + const addedBody = await added.text(); + + expect(listed.status).toBe(502); + expect(added.status).toBe(502); + expect(JSON.parse(listedBody).error).toBe("Invalid API key provided."); + expect(JSON.parse(addedBody).error).toBe("Invalid API key provided."); + expect(listedBody).not.toContain("req_a_trace_id_nobody_should_read"); + expect(addedBody).not.toContain("req_a_trace_id_nobody_should_read"); + }); + + test("a failure the vendor did not explain names the setting to check", async () => { + // Null from `vendorSentence` leaves the route to say something itself, and what it says is the + // one thing an operator meeting this can act on: the key. Never the thrown object's own text, + // which on this path is as likely to be a stack frame as a sentence. + const { app } = directoryApp(async () => { + throw new Error("fetch failed"); + }); + + const response = await app.request( + "http://openbot.test/api/plugins/composio/apps", + ); + + expect(response.status).toBe(502); + const refusal = (await response.json()).error as string; + expect(refusal).toContain("COMPOSIO_API_KEY"); + expect(refusal).not.toContain("fetch failed"); + }); + + test("a refusal while enabling reaches the administrator who pressed the button", async () => { + /* + * THE DEAD BUTTON. An administrator pressed Add and Composio answered "Default auth config not + * found for toolkit linear_mcp. Composio does not have managed credentials for this toolkit." — + * everything needed to explain the failure, and a step somebody here can take. The route mapped + * that sentence for the directory read and for nothing else, so a refusal out of enabling left + * as an unhandled throw: a bodyless 500, and the browser's own "That app could not be added." + * over the top of a reason that existed. + * + * 503 with the refusal's own words, because a refusal this deployment authored is not a third + * party being down and the generic sentence would send an administrator to check a key that is + * fine. + * + * A CONNECTABLE APP IS PRESSED HERE, and it has to be. The fixture's `unsupported` row would + * be the natural choice — the refusal below is the one DocuSign would really raise — but the + * route now refuses an unsupported app itself, before the store is called at all, so pressing + * that row would answer 503 with the catalogue's own sentence and never reach the failing + * store this case exists to exercise. Linear is connectable, and a refusal can still come back + * out of enabling it: Composio holding no managed credentials is a fact about the vendor's + * side, not about what this deployment can drive. + */ + const { app } = directoryApp(undefined, "admin", [], async () => { + throw new BrokerRefusalError( + "Linear was not enabled: Composio holds no managed credentials for this toolkit, so there is no auth config for this deployment to create.", + ); + }); + + const said: string[] = []; + const realError = console.error; + console.error = (...args: unknown[]) => { + said.push(args.map(String).join(" ")); + }; + + try { + const response = await app.request( + "http://openbot.test/api/plugins/composio/apps", + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ slug: "linear" }), + }, + ); + + expect(response.status).toBe(503); + expect(((await response.json()) as { error: string }).error).toContain( + "no managed credentials for this toolkit", + ); + } finally { + console.error = realError; + } + + // AND NOTHING ON THE CONSOLE. The log below this branch is for the failure whose explanation + // cannot be read off the response; this refusal's explanation is the response. Widen that + // guard to fire on every refusal — or add a third sentence source and leave the guard out of + // step with it — and an operator gets a console line per dead button, about failures they can + // already read in the browser. + expect( + said.find((line) => line.includes("composio-app-not-enabled")), + ).toBeUndefined(); + }); + + test("a deployment fault while enabling is still a 409 in its own words", async () => { + /* + * WHAT THE BROKER MAPPING MUST NOT SWALLOW. The catch on this route now ends in + * `brokerRefusal`, and where that mapping sits decides the answer for three whole classes of + * failure: the deployment faults — an ambiguous server row, a broken invariant, a query this + * database refused — are recognised one branch ABOVE it and answered 409 with the sentence + * they carry. Move the broker mapping up and every one of them turns into a 502 saying + * "Composio said nothing about why" about a failure Composio had no part in, which would send + * an administrator to check a key that is fine and hide the row they actually have to fix. + * + * Nothing else on this route pins that ordering, so this is the case that stops a later reader + * tidying the branches into the wrong sequence. + */ + const { app } = directoryApp(undefined, "admin", [], async () => { + throw new ServerRowAmbiguousError( + "Two rows claim the url composio://slack, and this deployment cannot tell which one an enable belongs to.", + ); + }); + + const response = await app.request( + "http://openbot.test/api/plugins/composio/apps", + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ slug: "slack" }), + }, + ); + + expect(response.status).toBe(409); + const refusal = ((await response.json()) as { error: string }).error; + expect(refusal).toContain("Two rows claim the url"); + expect(refusal).not.toContain("Composio said nothing about why"); + }); + + test("a failure nobody explained is a 502 in the route's own words", async () => { + /* + * THE LAST CLASS ON THIS ROUTE, and the only one that gets logged. A refusal this deployment + * authored carries its own sentence, and a sentence Composio wrote carries Composio's; a bare + * `Error` carries neither, which is what `brokerSentence` and `vendorSentence` both answering + * null means. That is most often a programmer error on this side, so the route writes it to the + * console — the one failure whose explanation cannot be read off the response, because the + * response is the generic sentence and nothing more. + * + * SO THE CONSOLE IS READ HERE, not only the body. The response half alone pins nothing about + * the guard — `brokerRefusal` produces that same 502 whether the log block exists or not — so + * the line itself is asserted: it has to carry the sentence the response withholds, and the app + * it was about. Delete the guard and this case fails; widen its condition to log every refusal + * and the authored refusal above fails. + * + * THE FAKE KEY ON THE THROWN OBJECT pins the restraint the route's comment promises. The error + * is stringified, never spread, logged as an object or reached into, so a property that rode + * along on it reaches nobody. `JSON.stringify(error)` in place of `String(error)` is the quiet + * way to lose that, and it is what these last two assertions catch. + */ + const { app } = directoryApp(undefined, "admin", [], async () => { + throw Object.assign( + new Error("Cannot read properties of undefined (reading 'slug')"), + { apiKey: "ak_a_key_nobody_should_read" }, + ); + }); + const said: string[] = []; + const realError = console.error; + console.error = (...args: unknown[]) => { + said.push(args.map(String).join(" ")); + }; + + try { + const response = await app.request( + "http://openbot.test/api/plugins/composio/apps", + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ slug: "slack" }), + }, + ); + + expect(response.status).toBe(502); + const refusal = ((await response.json()) as { error: string }).error; + expect(refusal).toContain("Composio said nothing about why"); + // Never the thrown object's own text: on this path it is as likely to be a stack frame as a + // sentence, and it is the console line that carries it to an operator. + expect(refusal).not.toContain("Cannot read properties"); + } finally { + console.error = realError; + } + + const line = said.find((said) => said.includes("composio-app-not-enabled")); + expect(line).toBeDefined(); + // Which app an operator is about to be asked about, and the cause the browser was not given — + // the complement of the assertion above, and the whole point of the guard. + expect(line).toContain("slack"); + expect(line).toContain("Cannot read properties"); + expect(line).not.toContain("ak_a_key_nobody_should_read"); + }); +}); + +/** + * What one person's connected accounts are, when some of them are brokered. + * + * CRITERION. A brokered connection appears in `GET /connections` for the person who holds it, and + * for nobody else, in the same list as this deployment's own OAuth connections. + * + * REASON. The route answered out of `connectionsFor` alone, which reads the vault's join table, so + * an app connected through Composio was invisible to the browser however live it was. The settings + * page could then only lie about it or say nothing, and it said nothing. The two reads are separate + * because the tables are — one holds a refresh token, the other holds only the fact that Composio + * said yes — and this pins that the API does not make the reader care which. + * + * SCOPING IS THE OTHER HALF, and it is a per-person read with no `requireAdmin` in front of it: a + * union assembled from the wrong id would put somebody else's connected mailbox on this page. + */ +function connectionsApp( + person: { id: string; email: string }, + held: Array<{ serverId: string; scope: string; connectedAt: string }>, + brokered: Array<{ + userId: string; + row: { serverId: string; scope: string; connectedAt: string }; + }>, +) { + const store = { + // Every read the plugins surface makes on its way to the route under test. + listServers: async () => [], + listSkills: async () => [], + listGrants: async () => [], + connectionsFor: async (userId: string) => + userId === person.id ? held : [], + brokeredConnectionsFor: async (userId: string) => + brokered + .filter((connection) => connection.userId === userId) + .map((connection) => connection.row), + }; + + const app = createApp( + loadConfig(testEnvironment()), + { + handler: () => new Response(null, { status: 204 }), + api: { + getSession: async () => ({ + user: { ...person, name: "Somebody", image: null }, + }), + }, + } as never, + { rolesForUser: async () => ["user"] }, + // Positions 4-14 are the other stores; `store` is 15, pluginStore. + ...(Array.from({ length: 11 }) as never[]), + store as never, + ); + + return () => app.request("http://openbot.test/api/plugins/connections"); +} + +const ASKER = { id: "user_asker", email: "asker@openbot.test" }; +const SOMEBODY_ELSE = { id: "user_other", email: "other@openbot.test" }; + +describe("a person's own connections", () => { + test("a brokered connection is in the list beside the OAuth ones", async () => { + const request = connectionsApp( + ASKER, + [ + { + serverId: "notion", + scope: "read", + connectedAt: "2026-01-01T00:00:00.000Z", + }, + ], + [ + { + userId: ASKER.id, + row: { + serverId: "composio-slack", + scope: "", + connectedAt: "2026-02-02T00:00:00.000Z", + }, + }, + ], + ); + + const response = await request(); + + expect(response.status).toBe(200); + const body = (await response.json()) as { + connections: Array<{ serverId: string }>; + }; + // Sorted, so two requests answer in the same order: concatenating two lists that are each + // ordered within their own table does not produce an ordered list. + expect(body.connections.map((row) => row.serverId)).toEqual([ + "composio-slack", + "notion", + ]); + }); + + test("and is nobody else's", async () => { + // The must-not case. This route is behind `requireUser` and nothing else: a union read for the + // wrong person would show one person's connected account on another person's settings page. + const request = connectionsApp( + SOMEBODY_ELSE, + [], + [ + { + userId: ASKER.id, + row: { + serverId: "composio-slack", + scope: "", + connectedAt: "2026-02-02T00:00:00.000Z", + }, + }, + ], + ); + + const response = await request(); + + expect(response.status).toBe(200); + expect((await response.json()).connections).toEqual([]); + }); +}); + +/** + * The url a person is sent to when they connect a brokered app to their own account. + * + * A constant rather than a literal at each assertion, because the thing being asserted about it is + * mostly where it does NOT appear: it is handed to the browser that asked and to nothing else. + */ +const AUTHORIZATION_URL = "https://backend.composio.dev/s/a-bearer-capability"; + +/** + * Where this deployment tells Composio to send somebody back to, written out rather than composed. + * + * BUILT FROM THE DEPLOYMENT AND FROM NOTHING IN THE REQUEST, which is the property the tests below + * are about. The origin is `testEnvironment`'s own — no `OPENBOT_APP_URL` is set, so it falls back + * through to `BETTER_AUTH_URL` — and the path is the account's page, which is where the person + * pressed Connect and the page that asks Composio whether it worked. + * + * Two of them because a caller may name one of two PAGES and nothing else: an administrator who + * started this from the app's own admin screen comes back to that screen. A literal at each + * assertion rather than a call to `connectedAccountsUrlFor`, because an assertion that builds the + * expected value the way the code does cannot fail when the code's answer changes. + */ +const RETURN_URL = + "http://localhost:3001/settings/connected-accounts/composio-linear"; +const ADMIN_RETURN_URL = "http://localhost:3001/admin/plugins/composio-linear"; + +/** + * A failure in the shape `vendorSentence` reaches into, with the vendor's sentence at the bottom. + * + * The sentence the vendor actually sends for the failure a new operator meets first, nested exactly + * as `@composio/core` nests it: two levels inside `cause`, beside the whole HTTP response. The + * `headers` and `requestId` beside it are the point of this fixture — they are what must not come + * back out. + */ +const WRONG_KEY = Object.assign(new Error("Request failed"), { + cause: { + error: { + error: { message: "Invalid API key provided." }, + headers: { "x-request-id": "req_a_trace_id_nobody_should_read" }, + }, + status: 401, + }, +}); + +/** + * What the key app publishes, which is both the form a person is drawn and the list a submission + * is checked against. + * + * TWO FIELDS AND ONE OF THEM OPTIONAL, because the filter has to be shown to be about NAMES rather + * than about completeness: a submission naming only the required one is a person leaving the + * optional box alone, and it must connect. + */ +const PUBLISHED: BrokerField[] = [ + { + name: "api_key", + label: "API key", + help: "Your Firecrawl API key, a token starting with fc-", + required: true, + secret: true, + }, + { + name: "base_url", + label: "Base URL", + help: "Leave this alone unless you run Firecrawl yourself.", + required: false, + secret: false, + default: "https://api.firecrawl.dev", + }, +]; + +/** + * One brokered app, added, and the person connecting their own account to it. + * + * WHICH APP THIS IS COMES OFF THE ROW'S URL. `serverAddress` answers one row whose url is + * `composio://linear`, and the branch under test reads the app out of it with `toolkitOf` rather + * than off the id — the id is a row name (`composio-linear`) and reading one as the other works + * right up until somebody renames a row. + * + * `redirectUrl` NULL IS A DEPLOYMENT WITH NO COMPOSIO_API_KEY, the same way `directoryApp`'s null + * listing is: there is no broker at all, which is the shipped default and a state the surface has + * to answer honestly rather than by pretending nobody is connected. + */ +function brokeredApp( + /** What this deployment already holds for this person and this app. Null is nobody connected. */ + connection: { connectedAt: string } | null = null, + /** Null is a deployment with no COMPOSIO_API_KEY, which is the shipped default. */ + redirectUrl: string | null = AUTHORIZATION_URL, + /** + * The deployment as it is when something about it is what is under test. + * + * Every field is absent for the flows that work, which is most of this file. A failure needs a + * deployment that fails in one named way: a broker that throws where the vendor would have, a + * store whose brokered calls throw for the same reason one layer down, or an environment with no + * app URL to send anybody back to. + */ + deployment: { + authorizeThrows?: unknown; + storeThrows?: unknown; + /** A broker that will not say what an app asks for, which is the fields call failing. */ + fieldsThrow?: unknown; + environment?: Record; + } = {}, +) { + const authorized: Array<{ + userId: string; + toolkit: string; + returnUrl: string; + }> = []; + const queried: Array<{ toolkit: string; userId: string }> = []; + const confirmed: Array<{ toolkit: string; userId: string }> = []; + /** Every re-check that reached the store, so who it was made about is an assertion. */ + const rechecked: Array<{ toolkit: string; userId: string }> = []; + const disconnected: Array<{ + toolkit: string; + userId: string; + by: string; + reason: string; + }> = []; + /** Every time the route went and asked Composio what an app wants typed in. */ + const asked: Array<{ toolkit: string; authScheme: string }> = []; + /** + * Every submission that reached the store, which is what the filter is asserted through. + * + * The values are recorded because the assertion is about WHICH NAMES travel and not about what a + * person typed: a key the app never published must not be in here, and the published ones must + * arrive unchanged. + */ + const submitted: Array<{ + toolkit: string; + userId: string; + values: Record; + }> = []; + + const rows = [ + { + id: "composio-linear", + // The app's name, which is the one of the two a person has ever seen. The refusal below + // reads it off the row, and the id beside it is what that refusal used to quote instead. + title: "Linear", + url: "composio://linear", + /* + * The scheme this app's config was created as, which is what decides which of the two + * brokered flows the route opens. `OAUTH2` is a consent app: there is no form to draw, and + * every assertion above about a minted link depends on this row being read as one. + */ + authScheme: "OAUTH2", + }, + /* + * An app whose secret the person holds and types in, which is the other half of the brokered + * surface and the one with a form rather than a consent screen. + * + * A SECOND ROW RATHER THAN A SECOND SCHEME ON THE FIRST, because the two flows have to be + * shown not to reach each other: the consent tests below press Connect on Linear and must + * still get a link, and the form tests press it on this row and must never mint one. One row + * switching scheme between tests would prove one flow at a time and nothing about the fork. + */ + { + id: "composio-firecrawl", + title: "Firecrawl", + url: "composio://firecrawl", + authScheme: "API_KEY", + }, + /* + * An ordinary OAuth row, so that "this app is not brokered" is a real row and not a missing + * one. The two routes below answer the same way for both, and this is the half that would + * otherwise go untested: an id naming nothing at all is easy to refuse, while a server this + * deployment really has whose connection simply does not live at Composio is where a + * confusing answer would come from. + */ + { + id: "notion", + title: "Notion", + url: "https://notion.test/mcp", + // Null is not an older brokered row; it is a row that is not brokered at all. + authScheme: null, + }, + ]; + + const store = { + /* + * Every read the plugins surface makes on its way to the route under test. + * + * Three columns, looked up by id, because that is all these routes ask for: the url they read + * the app out of and the title a refusal names. The whole server list is what they used to ask + * for, and a stub that still answered one would be pretending they need more than they do. + */ + serverAddress: async (serverId: string) => + rows.find((row) => row.id === serverId), + listSkills: async () => [], + listGrants: async () => [], + brokeredConnection: async (input: { toolkit: string; userId: string }) => { + queried.push(input); + return connection; + }, + confirmBrokeredConnection: async (input: { + toolkit: string; + userId: string; + }) => { + confirmed.push(input); + // Thrown from where the store asks the broker, because that is where it throws in the + // product: `confirmBrokeredConnection` calls `isConnected` and catches nothing. + if (deployment.storeThrows) throw deployment.storeThrows; + return { connected: connection !== null }; + }, + recheckBrokeredConnection: async (input: { + toolkit: string; + userId: string; + }) => { + rechecked.push(input); + // Thrown from where the store raises it in the product: a probe that ran and was refused is a + // {@link PluginRefusedError}, and a broker that would not answer at all is the vendor's own + // object one layer down. Both leave this method the same way — by throwing. + if (deployment.storeThrows) throw deployment.storeThrows; + /* + * All three fields, because all three are what the row reads. `verifiedAt` is the date the + * sentence is drawn from and `probe` is what says a call was really made, which `verified` + * alone cannot. + */ + return { + verified: true, + verifiedAt: "2026-09-13T10:00:00.000Z", + probe: "LINEAR_GET_ME", + }; + }, + connectBrokeredWithFields: async (input: { + toolkit: string; + userId: string; + values: Record; + }) => { + submitted.push(input); + if (deployment.storeThrows) throw deployment.storeThrows; + /* + * All three fields, because all three are what the browser reads. `probe` named beside + * `verified: true` is the one state that says a call was really made with the key; the row + * cannot tell that apart from "there was nothing safe to try" without it. + */ + return { + connected: true as const, + verified: true, + probe: "FIRECRAWL_SCRAPE", + }; + }, + disconnectBrokered: async (input: { + toolkit: string; + userId: string; + by: string; + reason: string; + }) => { + disconnected.push(input); + // The revoke comes before the delete, so a throw here is the product's own ordering: the + // account is still live at the vendor and the row is still here. + if (deployment.storeThrows) throw deployment.storeThrows; + return { vendorRevocationRequested: true }; + }, + }; + + const app = createApp( + loadConfig(testEnvironment(deployment.environment)), + { + handler: () => new Response(null, { status: 204 }), + api: { getSession: async () => ({ user: ADMIN }) }, + } as never, + // Connecting an account is not an administrator's act: an administrator adds the app once, and + // then everybody connects their own. + { rolesForUser: async () => ["user"] }, + // Positions 4-14 are the other stores; `store` is 15, pluginStore. + ...(Array.from({ length: 11 }) as never[]), + store as never, + // Positions 16-25 are the stores after it; the broker is 26, `composio`. + ...(Array.from({ length: 10 }) as never[]), + redirectUrl + ? ({ + broker: { + authorize: async (request: { + userId: string; + toolkit: string; + returnUrl: string; + }) => { + // Recorded BEFORE the throw, so a test about a failing broker can still say the + // address it was asked with was this deployment's own. + authorized.push(request); + if (deployment.authorizeThrows) throw deployment.authorizeThrows; + return { redirectUrl }; + }, + connectionFields: async (request: { + toolkit: string; + authScheme: string; + }) => { + asked.push(request); + if (deployment.fieldsThrow) throw deployment.fieldsThrow; + return PUBLISHED; + }, + }, + } as never) + : undefined, + ); + + return { + authorized, + queried, + confirmed, + rechecked, + disconnected, + asked, + submitted, + /** + * The same route aimed at the app whose secret a person types. + * + * Its own helper rather than a fifth argument to `connect` below, because the two are different + * requests: that one is a consent app and carries a query and headers the tests about return + * addresses need, and this one carries a body and nothing else. + */ + connectFields: (body?: unknown) => + app.request( + "http://openbot.test/api/plugins/servers/composio-firecrawl/connect", + { + method: "POST", + headers: { "content-type": "application/json" }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }, + ), + connect: ( + body: unknown, + query = "", + /** + * Headers a caller controls, for the cases that assert none of them is read. + * + * A browser sends `Referer` and `Origin` on an ordinary same-origin POST without being asked, + * so a route reading either would be taking a return address from the request while looking + * like it took none. Defaulted empty, because every other case here is about the body. + */ + headers: Record = {}, + ) => + app.request( + `http://openbot.test/api/plugins/servers/composio-linear/connect${query}`, + { + method: "POST", + headers: { "content-type": "application/json", ...headers }, + body: JSON.stringify(body), + }, + ), + /* + * The two routes a person's own settings page drives, with every input a caller controls left + * open: which row, what is in the body, and what is in the query. The tests below hand all + * three a user id that is not the session's, because the assertion is that none of them + * changed who was acted on. + */ + confirm: (options: Caller = {}) => + app.request( + `http://openbot.test/api/plugins/servers/${options.serverId ?? "composio-linear"}/connection/confirm${options.query ?? ""}`, + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(options.body ?? {}), + }, + ), + /* + * The button beside them, and the one that is never pressed by a page. + * + * Given the same open inputs as the two below — the row, a body and a query — because it is the + * same identity question: a re-check spends a call on somebody's own account at the vendor, so a + * caller who could name a person would be spending a stranger's rate limit and rewriting the + * verification on their row. + */ + recheck: (options: Caller = {}) => + app.request( + `http://openbot.test/api/plugins/servers/${options.serverId ?? "composio-linear"}/connection/recheck${options.query ?? ""}`, + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(options.body ?? {}), + }, + ), + disconnect: (options: Caller = {}) => + app.request( + `http://openbot.test/api/plugins/servers/${options.serverId ?? "composio-linear"}/connection${options.query ?? ""}`, + { + method: "DELETE", + headers: { "content-type": "application/json" }, + body: JSON.stringify(options.body ?? {}), + }, + ), + }; +} + +/** Everything a caller of those two routes gets to choose. */ +type Caller = { + /** Which server row the route is aimed at. Defaults to the brokered one. */ + serverId?: string; + body?: unknown; + /** A query string, leading `?` included. */ + query?: string; +}; + +describe("connecting a brokered app", () => { + test("the link is minted for the session's own person, whatever the body says", async () => { + /* + * THE USER ID COMES FROM THE SESSION AND FROM NOWHERE ELSE. + * + * A brokered call opens whichever account the user id names, so a route that would take one + * out of a request body is one POST away from attaching somebody else's Linear to this + * person's row — or, the same defect turned around, minting a link that connects this person's + * account under somebody else's name. It is the defect the prior art this design copies + * shipped and fixed three separate times, which is why the body here carries a user id at all: + * the assertion is that it changed nothing. + */ + const { authorized, queried, connect } = brokeredApp(); + + const response = await connect({ userId: "user_somebody_else" }); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + authorizationUrl: AUTHORIZATION_URL, + }); + expect(authorized).toEqual([ + { userId: ADMIN.id, toolkit: "linear", returnUrl: RETURN_URL }, + ]); + // And the read that decided there was no connection yet asked about the same person. + expect(queried).toEqual([{ toolkit: "linear", userId: ADMIN.id }]); + }); + + test("a brokered row never reaches the checks that belong to the OAuth flow", async () => { + /* + * The ordering, stated as its own case. This deployment has no OPENBOT_PUBLIC_URL and + * `composio-linear` is in nobody's catalogue, so a brokered row that fell through to either of + * the two checks below the branch would be answered "no public URL" or "is not connected as an + * individual person" — the second of which is the opposite of true. Neither check applies: no + * authorization code comes back to us, no refresh token is stored, and no redirect URI of ours + * is registered anywhere. + */ + const { connect } = brokeredApp(); + + const body = await (await connect({})).json(); + + expect(body.authorizationUrl).toBe(AUTHORIZATION_URL); + }); + + test("a second connection is refused with the step to take", async () => { + const { authorized, connect } = brokeredApp({ + connectedAt: "2026-02-02T00:00:00.000Z", + }); + + const response = await connect({}); + + expect(response.status).toBe(409); + const refusal = (await response.json()).error as string; + // Naming the remedy rather than only refusing: the person has an account attached already, and + // the only way to a new link is through disconnecting the one they have. + expect(refusal.toLowerCase()).toContain("disconnect"); + /* + * THE APP'S TITLE, AND NOT THE ROW'S ID. "Linear" is the name of the thing this person + * connected; `composio-linear` is how this deployment keys a table, which they have never seen + * and cannot act on. The second assertion is not the first one twice: a sentence that named the + * app and then quoted the row id beside it would satisfy one and fail the other. + */ + expect(refusal).toContain("Linear"); + expect(refusal).not.toContain("composio-linear"); + // And nothing was minted, which is the half that matters: a link handed out here would attach + // a second account behind a row that already says connected. + expect(authorized).toEqual([]); + }); + + test("a deployment with no broker is told which setting to set", async () => { + // The same answer the directory gives, for the same reason: nobody was asked, and the remedy + // is one environment variable long. + const { connect } = brokeredApp(null, null); + + const response = await connect({}); + + expect(response.status).toBe(503); + expect((await response.json()).error).toContain("COMPOSIO_API_KEY"); + }); + + test("the address Composio sends somebody back to is built here, never taken from the request", async () => { + /* + * THE RETURN ADDRESS IS THIS DEPLOYMENT'S, AND A CALLER HAS NO SAY IN IT. + * + * Whoever names it names where a person lands holding a just-completed consent, so a url read + * off the body, the query or a header would be an open redirect with a consent screen in front + * of it — the same defect this repository's OAuth `returnTo` is narrowed to two names to + * avoid. The request below names an address in all three places at once, so a route that read + * any one of them fails here rather than passing on the two it ignored. + */ + const { authorized, connect } = brokeredApp(); + + const response = await connect( + { returnUrl: "https://evil.test/harvest", returnTo: "https://evil.test" }, + "?returnUrl=https%3A%2F%2Fevil.test%2Fharvest&callbackUrl=https%3A%2F%2Fevil.test", + { + returnUrl: "https://evil.test/harvest", + referer: "https://evil.test", + origin: "https://evil.test", + "x-forwarded-host": "evil.test", + }, + ); + + expect(response.status).toBe(200); + expect(authorized).toEqual([ + { userId: ADMIN.id, toolkit: "linear", returnUrl: RETURN_URL }, + ]); + }); + + test("an administrator who started on the app's own page is sent back to it", async () => { + /* + * The one thing a caller does get to choose, and it is a NAME rather than an address: `admin` + * or anything else, resolved against this deployment's own origin either way. An administrator + * connecting their account from the app's setup page left a page mid-task, and sending them to + * their personal settings afterwards is the round trip this exists to remove. + */ + const { authorized, connect } = brokeredApp(); + + await connect({}, "?returnTo=admin"); + + expect(authorized).toEqual([ + { userId: ADMIN.id, toolkit: "linear", returnUrl: ADMIN_RETURN_URL }, + ]); + }); + + test("a returnTo naming somewhere else is the default, not a destination", async () => { + // Narrowed to one of two names on the way in, so an unrecognised value never reaches the url + // that gets built. A full address in that parameter is the attack this shape refuses. + const { authorized, connect } = brokeredApp(); + + await connect({}, "?returnTo=https%3A%2F%2Fevil.test"); + + expect(authorized).toEqual([ + { userId: ADMIN.id, toolkit: "linear", returnUrl: RETURN_URL }, + ]); + }); + + test("a deployment with no app URL is refused rather than handed a link with no way back", async () => { + /* + * A CONSENT WITH NOWHERE TO RETURN TO STRANDS SOMEBODY, so no link is minted at all. + * + * The consent screen is on Composio's origin, so the address has to be absolute, and a + * deployment that cannot say where its own pages are has none to give. Minting the link anyway + * would leave a person on Composio's hosted page having just granted access to their mailbox, + * with no route back and nothing here knowing it happened. + * + * Single-user with no sign-in is the one deployment shape that genuinely has no app URL: + * everywhere else `OPENBOT_APP_URL`, `TRUSTED_ORIGINS` or the sign-in address supplies one. + */ + const { authorized, connect } = brokeredApp(null, AUTHORIZATION_URL, { + environment: { + OPENBOT_SINGLE_USER: "true", + BETTER_AUTH_URL: undefined, + BETTER_AUTH_SECRET: undefined, + GOOGLE_OAUTH_CLIENT_ID: undefined, + GOOGLE_OAUTH_CLIENT_SECRET: undefined, + INITIAL_ADMIN_EMAILS: undefined, + }, + }); + + const response = await connect({}); + + expect(response.status).toBe(503); + // The setting, because it is the whole remedy and nothing else on the screen names it. + expect((await response.json()).error).toContain("OPENBOT_APP_URL"); + expect(authorized).toEqual([]); + }); + + test("a broker that throws answers with Composio's own sentence, not a 500", async () => { + /* + * A WRONG KEY IS THE FIRST FAILURE A NEW OPERATOR MEETS, and it used to be the least legible: + * nothing on this path caught anything, so the person pressing Connect got a bare 500 and the + * vendor's whole thrown object went to the console. + * + * What comes back is the one sentence the vendor wrote and nothing else that travelled with + * it: not the request id, not the response headers, and not the link that was being minted + * when it failed — which is a bearer capability and belongs in one browser or nowhere. + */ + const { connect } = brokeredApp(null, AUTHORIZATION_URL, { + authorizeThrows: WRONG_KEY, + }); + + const response = await connect({}); + const body = await response.text(); + + // 502 rather than 500: nothing here broke, and a third party did not answer usefully. + expect(response.status).toBe(502); + expect(JSON.parse(body).error).toBe("Invalid API key provided."); + expect(body).not.toContain("req_a_trace_id_nobody_should_read"); + expect(body).not.toContain(AUTHORIZATION_URL); + }); + + test("a failure the vendor did not explain still says what to do", async () => { + // Null from `vendorSentence` is a failure this deployment cannot explain — a socket that hung + // up, an answer in a shape nobody recognises — and the fallback names the app and the step + // rather than echoing whatever the thrown object happened to stringify as. + const { connect } = brokeredApp(null, AUTHORIZATION_URL, { + authorizeThrows: new Error("socket hang up"), + }); + + const response = await connect({}); + + expect(response.status).toBe(502); + const refusal = (await response.json()).error as string; + expect(refusal).toContain("Linear"); + expect(refusal).not.toContain("socket hang up"); + }); +}); + +/** + * The other half of the same route: an app whose secret the person holds and types in. + * + * CRITERION. A key app answers the form on the first press and the connection on the second, the + * person it connects is the session's whatever the body says, every three of `connectBrokeredWithFields`'s + * fields reach the browser, and a name the app did not publish never reaches the store at all. + * + * REASON. Most Composio apps are not consent apps, so this branch is the ordinary path rather than + * the exotic one, and it is the only place in this deployment where a request body is forwarded to + * a vendor. What is submitted is spread into Composio's own field object, so an unfiltered body is + * two separate holes at once: a `status` key would sit beside the literal this deployment sets, and + * anything else a caller invents would travel unexamined. The filter is asserted by what is + * recorded in `submitted` rather than by the answer, because the answer is the same either way. + */ +describe("connecting an app whose secret a person types", () => { + test("the first press answers what the app publishes, and connects nobody", async () => { + /* + * NO BODY AT ALL, which is what the browser really sends on this press: it is a question about + * the app rather than about anybody's account. A route that required a body to answer the form + * would answer this request by trying to connect an empty one. + */ + const { asked, submitted, authorized, connectFields } = brokeredApp(); + + const response = await connectFields(); + + expect(response.status).toBe(200); + // The vendor's own list, passed through rather than restated: `help` is written for the person + // filling the box in, and nothing here is in a position to improve on it. + expect(await response.json()).toEqual({ fields: PUBLISHED }); + /* + * ASKED WITH THE SCHEME RECORDED ON THE ROW, never one derived again here. A form drawn for + * `BASIC` in front of a config created for `API_KEY` asks for boxes the person's app does not + * have. + */ + expect(asked).toEqual([{ toolkit: "firecrawl", authScheme: "API_KEY" }]); + // And nothing was connected and no consent link minted: this press writes nothing. + expect(submitted).toEqual([]); + expect(authorized).toEqual([]); + }); + + test("what the person typed connects them, and all three states come back", async () => { + /* + * THE PERSON IS THE SESSION'S HERE TOO, which is why the body carries somebody else's id: the + * values are a credential being attached to whichever account the user id names, so a route + * reading one off the body would hang this person's key off another person's row. + */ + const { submitted, authorized, connectFields } = brokeredApp(); + + const response = await connectFields({ + values: { api_key: "fc-live-a-secret" }, + userId: SOMEBODY_ELSE.id, + }); + + expect(response.status).toBe(200); + /* + * ALL THREE FIELDS, AND `probe` IS THE ONE THE ROW CANNOT DO WITHOUT. `verified` alone means + * three different things — nothing safe to try, tried and passed, tried and rejected — and two + * of them share the flag. A browser given only the boolean would tell somebody whose key the + * vendor rejected that nothing was ever checked. + */ + expect(await response.json()).toEqual({ + connected: true, + verified: true, + probe: "FIRECRAWL_SCRAPE", + }); + expect(submitted).toEqual([ + { + toolkit: "firecrawl", + userId: ADMIN.id, + values: { api_key: "fc-live-a-secret" }, + }, + ]); + // And no consent link was minted for an app that has no consent screen. + expect(authorized).toEqual([]); + }); + + test("a name the app never published is refused, and nothing is sent", async () => { + /* + * `status` IS THE SHARPEST CASE AND THAT IS WHY IT IS THE ONE SUBMITTED. The values are spread + * into the object the adapter builds for Composio, beside the literal `status: "ACTIVE"` that + * call sets, so an unfiltered body lets a caller write over it. Every other invented name is + * the same hole with a less interesting key in it. + * + * A person cannot type a name the form did not draw, so this request is either a caller doing + * something deliberate or an app whose published fields have moved — and neither is answered by + * connecting them anyway with part of what they sent. + */ + const { submitted, connectFields } = brokeredApp(); + + const response = await connectFields({ + values: { api_key: "fc-live-a-secret", status: "ACTIVE" }, + }); + + expect(response.status).toBe(400); + const refusal = (await response.json()).error as string; + expect(refusal).toContain("Firecrawl"); + /* + * THE SENTENCE CARRIES NOTHING THAT WAS SUBMITTED. The values are somebody's own credential and + * belong in no message, and the names are the caller's text rather than the vendor's on exactly + * the request where they are wrong. + */ + expect(refusal).not.toContain("fc-live-a-secret"); + expect(refusal).not.toContain("ACTIVE"); + // The half that matters: the key never left this deployment. + expect(submitted).toEqual([]); + }); + + test("a value that is not text is refused rather than forwarded as one", async () => { + // The names are checked against the published list and the values against being values at all: + // a number under a published name typechecks nowhere and reaches the vendor as whatever JSON + // makes of it. + const { submitted, connectFields } = brokeredApp(); + + const response = await connectFields({ values: { api_key: 12 } }); + + expect(response.status).toBe(400); + expect(submitted).toEqual([]); + }); + + test("an account already connected is refused before the form is drawn", async () => { + /* + * THE ONE-ACCOUNT GUARD STILL RUNS FIRST, which is the ordering both branches were put after on + * purpose. Drawing the form for somebody who already has an account attached invites them to + * type a key that would be refused after they had entered it, and asking Composio what the app + * wants is a call made on behalf of a request that is going to be refused anyway. + */ + const { asked, submitted, connectFields } = brokeredApp({ + connectedAt: "2026-02-02T00:00:00.000Z", + }); + + const response = await connectFields(); + + expect(response.status).toBe(409); + const refusal = (await response.json()).error as string; + expect(refusal.toLowerCase()).toContain("disconnect"); + // The app's name rather than the row's id, as on the consent half above. + expect(refusal).toContain("Firecrawl"); + expect(refusal).not.toContain("composio-firecrawl"); + expect(asked).toEqual([]); + expect(submitted).toEqual([]); + }); + + test("a consent app is untouched by either branch, whatever the body carries", async () => { + /* + * THE FORK IS ON THE SCHEME RECORDED ON THE ROW AND ON NOTHING IN THE REQUEST. A body carrying + * values is not a statement about how an app connects, and a route that read it as one would + * send somebody's typed key at a config created for a consent screen — which has nowhere to put + * it — instead of minting the link they pressed for. + */ + const { asked, submitted, authorized, connect } = brokeredApp(); + + const response = await connect({ values: { api_key: "fc-live-a-secret" } }); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + authorizationUrl: AUTHORIZATION_URL, + }); + expect(authorized).toEqual([ + { userId: ADMIN.id, toolkit: "linear", returnUrl: RETURN_URL }, + ]); + expect(asked).toEqual([]); + expect(submitted).toEqual([]); + }); + + test("a key app connects on a deployment with no app URL to come back to", async () => { + /* + * A SETTING WITH NO BEARING ON THIS FLOW DOES NOT GET TO REFUSE IT. + * + * `OPENBOT_APP_URL` is where Composio sends somebody back to once they have consented, and this + * half has no consent screen to come back from: the key is typed here, no link is minted, and + * nobody ever leaves the deployment. The guard for it used to stand in front of the fork, so a + * single-user deployment with no sign-in — the one shape that genuinely has no app URL — could + * connect none of the key apps that make up most of Composio's catalogue, and was told to set a + * variable that would not have changed anything about the press it refused. + * + * The same environment as the consent refusal above, which is what makes the pair meaningful: + * one deployment, one missing setting, and the two halves of this route answering differently + * because only one of them has a return leg. + */ + const { asked, submitted, authorized, connectFields } = brokeredApp( + null, + AUTHORIZATION_URL, + { + environment: { + OPENBOT_SINGLE_USER: "true", + BETTER_AUTH_URL: undefined, + BETTER_AUTH_SECRET: undefined, + GOOGLE_OAUTH_CLIENT_ID: undefined, + GOOGLE_OAUTH_CLIENT_SECRET: undefined, + INITIAL_ADMIN_EMAILS: undefined, + }, + }, + ); + + const response = await connectFields({ + values: { api_key: "fc-live-a-secret" }, + }); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + connected: true, + verified: true, + probe: "FIRECRAWL_SCRAPE", + }); + // The key reached the store rather than a 503, and the form was drawn from the vendor on the + // way through. + expect(asked).toEqual([{ toolkit: "firecrawl", authScheme: "API_KEY" }]); + expect(submitted).toEqual([ + { + toolkit: "firecrawl", + userId: DEV_ACTOR.id, + values: { api_key: "fc-live-a-secret" }, + }, + ]); + // And still no consent link, which is the whole reason the setting has no bearing here. + expect(authorized).toEqual([]); + }); + + test("a broker that will not say what an app asks for answers with its own sentence", async () => { + // The same mapping every other brokered call in this file makes: the vendor's one sentence, a + // 502 rather than a 500, and nothing else that travelled with it. + const { connectFields } = brokeredApp(null, AUTHORIZATION_URL, { + fieldsThrow: WRONG_KEY, + }); + + const response = await connectFields(); + const body = await response.text(); + + expect(response.status).toBe(502); + expect(JSON.parse(body).error).toBe("Invalid API key provided."); + expect(body).not.toContain("req_a_trace_id_nobody_should_read"); + }); + + test("a key the vendor rejected comes back in the words the store refused it with", async () => { + /* + * A MISTYPED KEY IS THE ORDINARY FAILURE HERE AND ITS SENTENCE IS THE WHOLE REMEDY. The store + * raises a refusal that already says what happened and what to do about it, and flattening that + * into "Composio said nothing about why" would leave the person who pasted a key with a newline + * in it reading a sentence about this deployment's API key. + */ + const { connectFields } = brokeredApp(null, AUTHORIZATION_URL, { + storeThrows: new PluginRefusedError( + "firecrawl would not answer with what was entered: 401 unauthorized. Nothing was saved, so entering it again is the whole of the retry.", + null, + ), + }); + + const response = await connectFields({ values: { api_key: "wrong" } }); + + expect(response.status).toBe(400); + expect((await response.json()).error).toContain("401 unauthorized"); + }); +}); + +/** + * Confirming a brokered connection, and ending one. + * + * CRITERION. Both routes act on the connection of the person whose session made the request, and on + * nobody else's, whatever a body or a query says; both refuse a row that is not brokered in so many + * words; and both tell a deployment with no broker which setting to set. + * + * REASON. The store's behaviour is pinned where it is decided — what a confirm writes, what a + * disconnect revokes — so what is left here is the routing, and the routing is where the damage + * would be. A user id these handlers could take from a caller turns one DELETE into a revoke of + * somebody else's grant at the vendor and one POST into a connection recorded under a person who + * never made one. It is the defect the prior art this design follows shipped three separate times, + * and the first test of each pair hands the route a body AND a query naming somebody else so that + * reading either is a failure rather than a silent pass. + * + * Neither route is admin-gated, deliberately: an administrator adds the app once and everybody then + * manages their own account, so `requireUser` is the whole of the gate and the identity is the whole + * of the scoping. + */ +describe("confirming and ending a brokered connection", () => { + test("confirm asks about the session's own person, whatever the caller says", async () => { + const { confirmed, confirm } = brokeredApp({ + connectedAt: "2026-02-02T00:00:00.000Z", + }); + + const response = await confirm({ + body: { userId: SOMEBODY_ELSE.id }, + query: `?userId=${SOMEBODY_ELSE.id}`, + }); + + expect(response.status).toBe(200); + // The store's answer, passed through rather than restated: `connected` is what the vendor said. + expect(await response.json()).toEqual({ connected: true }); + expect(confirmed).toEqual([{ toolkit: "linear", userId: ADMIN.id }]); + }); + + test("disconnect ends the session's own account, whatever the caller says", async () => { + const { disconnected, disconnect } = brokeredApp({ + connectedAt: "2026-02-02T00:00:00.000Z", + }); + + const response = await disconnect({ + body: { userId: SOMEBODY_ELSE.id }, + query: `?userId=${SOMEBODY_ELSE.id}`, + }); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ vendorRevocationRequested: true }); + /* + * The owner and the actor are the same person, and `reason` is the word that says why. The + * trail tells this apart from an administrator offboarding somebody by those three fields and + * by nothing else, so a route that filed `person_removed` here, or named a different owner, + * would leave a record of an act that did not happen. + */ + expect(disconnected).toEqual([ + { + toolkit: "linear", + userId: ADMIN.id, + by: ADMIN.id, + reason: "self", + }, + ]); + }); + + test("an app that is not brokered is refused for what is actually wrong", async () => { + // `notion` is a row this deployment really has. What is wrong is not that it is missing but + // that its connection does not live at Composio, and the sentence says so rather than talking + // about a broker setting or an app nobody has heard of. + const { confirmed, disconnected, confirm, disconnect } = brokeredApp(); + + const confirmResponse = await confirm({ serverId: "notion" }); + const disconnectResponse = await disconnect({ serverId: "notion" }); + + expect(confirmResponse.status).toBe(400); + expect(disconnectResponse.status).toBe(400); + expect((await confirmResponse.json()).error).toBe( + "That app is not reached through a broker.", + ); + expect((await disconnectResponse.json()).error).toBe( + "That app is not reached through a broker.", + ); + // And the store was left alone: a confirm here would write a row for an app whose connection + // is not Composio's to answer about, and a disconnect would ask the broker to revoke a grant + // it never issued. + expect(confirmed).toEqual([]); + expect(disconnected).toEqual([]); + }); + + test("a deployment with no broker is told which setting to set", async () => { + // The same answer the directory and connect give, for the same reason: nobody was asked, and + // the remedy is one environment variable long. Answering "not connected" instead would draw a + // settled state over a deployment that has no broker to have connected anybody at. + const { confirmed, disconnected, confirm, disconnect } = brokeredApp( + null, + null, + ); + + const confirmResponse = await confirm(); + const disconnectResponse = await disconnect(); + + expect(confirmResponse.status).toBe(503); + expect(disconnectResponse.status).toBe(503); + expect((await confirmResponse.json()).error).toContain("COMPOSIO_API_KEY"); + expect((await disconnectResponse.json()).error).toContain( + "COMPOSIO_API_KEY", + ); + expect(confirmed).toEqual([]); + expect(disconnected).toEqual([]); + }); + + test("a broker that throws reaches both routes as Composio's own sentence", async () => { + /* + * NEITHER OF THESE HAD ANY ERROR HANDLING, and there is no framework-level handler behind + * them, so a wrong key answered both with a bare 500 and put the vendor's whole response + * object on the console. + * + * Confirm is the sharper of the two. It runs on every page load, so the tempting answer to a + * failure is `{ connected: false }` — which would be this deployment inventing a fact only + * Composio holds, drawing "Not connected" over a live account and then deleting the row that + * said otherwise. A failure is not a no. + */ + const { confirm, disconnect } = brokeredApp( + { connectedAt: "2026-02-02T00:00:00.000Z" }, + AUTHORIZATION_URL, + { storeThrows: WRONG_KEY }, + ); + + const confirmResponse = await confirm(); + const disconnectResponse = await disconnect(); + const confirmBody = await confirmResponse.text(); + const disconnectBody = await disconnectResponse.text(); + + expect(confirmResponse.status).toBe(502); + expect(disconnectResponse.status).toBe(502); + expect(JSON.parse(confirmBody).error).toBe("Invalid API key provided."); + expect(JSON.parse(disconnectBody).error).toBe("Invalid API key provided."); + // And nothing else that travelled with the failure: the response headers it was carrying are + // a request id in an error body, which is somebody's trace and nobody's remedy. + expect(confirmBody).not.toContain("req_a_trace_id_nobody_should_read"); + expect(disconnectBody).not.toContain("req_a_trace_id_nobody_should_read"); + }); + + test("a failure the vendor did not explain still says what to do on both routes", async () => { + // The fallback says what a person can act on rather than guessing how far the call got: what + // the page shows is the vendor's last answer and not this one, and a disconnect is safe to + // press again because the revoke runs before anything here is deleted. + const { confirm, disconnect } = brokeredApp( + { connectedAt: "2026-02-02T00:00:00.000Z" }, + AUTHORIZATION_URL, + { storeThrows: new Error("socket hang up") }, + ); + + const confirmRefusal = (await (await confirm()).json()).error as string; + const disconnectRefusal = (await (await disconnect()).json()) + .error as string; + + expect(confirmRefusal).toContain("last answer"); + expect(disconnectRefusal).toContain("Disconnect again"); + expect(confirmRefusal).not.toContain("socket hang up"); + expect(disconnectRefusal).not.toContain("socket hang up"); + }); +}); + +/** + * Re-checking a brokered connection, which is a button and never a page load. + * + * CRITERION. The route acts on the connection of the person whose session made the request whatever + * a body or a query says; it refuses a row that is not brokered and a deployment with no broker in + * the same words the other two do; and a probe that RAN AND FAILED reaches the browser as a failure + * carrying the vendor's sentence rather than as a 200 saying the connection is not verified. + * + * REASON. The store decides what a re-check writes and what it refuses; what is left here is the + * routing, and two things about it would do damage. A user id taken from a caller would spend a + * stranger's rate limit at the vendor and rewrite the verification on their row from one POST. And + * an answer of `{ verified: false }` for a probe that ran and was refused would be + * indistinguishable, to the row drawing it, from an app that publishes nothing to check against — + * so the Re-check button would quietly disappear for the one person who most needs it, the one who + * has just fixed their key. + * + * IT IS NOT CALLED ON MOUNT, unlike confirm, and nothing here calls it twice. Composio never + * re-checks a key by itself, so this is the only thing that can; but the call is spent against the + * person's own quota at the vendor, and verifying on every render would burn it to redraw one word. + */ +describe("re-checking a brokered connection", () => { + test("the re-check is made about the session's own person, whatever the caller says", async () => { + const { rechecked, recheck } = brokeredApp({ + connectedAt: "2026-02-02T00:00:00.000Z", + }); + + const response = await recheck({ + body: { userId: SOMEBODY_ELSE.id }, + query: `?userId=${SOMEBODY_ELSE.id}`, + }); + + expect(response.status).toBe(200); + // The store's answer, passed through: what was checked, when, and with which action. + expect(await response.json()).toEqual({ + verified: true, + verifiedAt: "2026-09-13T10:00:00.000Z", + probe: "LINEAR_GET_ME", + }); + expect(rechecked).toEqual([{ toolkit: "linear", userId: ADMIN.id }]); + }); + + test("a probe that ran and failed is a failure, not an answer saying not verified", async () => { + /* + * THE MUST-NOT CASE OF THIS ROUTE. The store raises for a key the vendor rejected, and the + * sentence it raises with carries Composio's own words — so the route has to pass it through as + * a refusal. Catching it and answering `{ verified: false }` instead would lose the sentence and + * hand the row a flag it cannot read: the same `false` an app with nothing to probe produces. + */ + const { recheck } = brokeredApp( + { connectedAt: "2026-02-02T00:00:00.000Z" }, + AUTHORIZATION_URL, + { + storeThrows: new PluginRefusedError( + "Linear would not answer with the key it is holding: Invalid API key provided. Your connection is recorded here as unchecked until a key that works is entered.", + null, + ), + }, + ); + + const response = await recheck(); + const body = await response.text(); + + expect(response.status).toBe(400); + expect(JSON.parse(body).error).toContain("Invalid API key provided."); + // And it is a refusal rather than an answer: nothing in it for a row to read as a verification. + expect(JSON.parse(body).verified).toBeUndefined(); + }); + + test("an app that is not brokered is refused for what is actually wrong", async () => { + // `notion` is a row this deployment really has; what is wrong is that its connection does not + // live at Composio, so there is nothing here to re-check. Same sentence as the other two. + const { rechecked, recheck } = brokeredApp(); + + const response = await recheck({ serverId: "notion" }); + + expect(response.status).toBe(400); + expect((await response.json()).error).toBe( + "That app is not reached through a broker.", + ); + expect(rechecked).toEqual([]); + }); + + test("a deployment with no broker is told which setting to set", async () => { + const { rechecked, recheck } = brokeredApp(null, null); + + const response = await recheck(); + + expect(response.status).toBe(503); + expect((await response.json()).error).toContain("COMPOSIO_API_KEY"); + expect(rechecked).toEqual([]); + }); + + test("a broker that throws reaches the browser as Composio's own sentence", async () => { + // The other half of the failure mapping: a vendor object is not a refusal this deployment + // authored, so it comes back as the vendor's sentence at 502 — and nothing else that travelled + // with it, which is a request id nobody outside Composio can act on. + const { recheck } = brokeredApp( + { connectedAt: "2026-02-02T00:00:00.000Z" }, + AUTHORIZATION_URL, + { storeThrows: WRONG_KEY }, + ); + + const response = await recheck(); + const body = await response.text(); + + expect(response.status).toBe(502); + expect(JSON.parse(body).error).toBe("Invalid API key provided."); + expect(body).not.toContain("req_a_trace_id_nobody_should_read"); + }); +}); diff --git a/server/tests/plugin-store.integration.test.ts b/server/tests/plugin-store.integration.test.ts index b6f442e94..2ab2f862b 100644 --- a/server/tests/plugin-store.integration.test.ts +++ b/server/tests/plugin-store.integration.test.ts @@ -1,5 +1,6 @@ import { afterAll, + afterEach, beforeAll, beforeEach, describe, @@ -7,8 +8,9 @@ import { test, } from "bun:test"; import { randomUUID } from "node:crypto"; -import { MCPMock } from "@copilotkit/aimock/mcp"; -import { and, eq, inArray, like, sql } from "drizzle-orm"; +import { MCPMock, type MCPToolDefinition } from "@copilotkit/aimock/mcp"; +import type { ToolAnnotations } from "@modelcontextprotocol/sdk/types.js"; +import { and, asc, eq, gte, inArray, like, sql } from "drizzle-orm"; import { createAuditStore } from "../src/audit"; import type { ActionPolicy } from "../src/computer/policy"; import { @@ -17,10 +19,11 @@ import { decryptSecret, encryptSecret, } from "../src/credentials"; -import { createDatabase } from "../src/db/client"; +import { createDatabase, type Database } from "../src/db/client"; import { agents, auditEvents, + composioConnections, credentials as credentialRows, credentials, mcpServers, @@ -29,7 +32,18 @@ import { pluginGrants, users, } from "../src/db/schema"; +import { + accessFor, + CatalogueTransportUnroutableError, + ServerRowAmbiguousError, +} from "../src/plugins/access"; +import type { BrokerConnection, ComposioBroker } from "../src/plugins/broker"; +import type { CatalogueEntry } from "../src/plugins/catalogue"; import { catalogueEntry } from "../src/plugins/catalogue"; +import { + type ComposioResult, + useComposioClient, +} from "../src/plugins/composio"; import { redirectUriFor } from "../src/plugins/oauth"; import { type AccessToken, @@ -37,11 +51,15 @@ import { createPluginStore, exchangeRefreshTokenOverHttp, INVALID_CLIENT, + isDeploymentFault, type OAuthClient, + PluginInvariantError, PluginRefusedError, + type PluginStore, TokenRefusedError, unlistedAdvertisedTools, } from "../src/plugins/store"; +import { grantedTools, REFUSAL_MARKER } from "../src/plugins/tools"; import { TEST_POOL } from "./support/database"; /** @@ -71,56 +89,104 @@ const siblingToolName = `not_granted_${suite}`; let policy: ActionPolicy = { mode: "enforce", deny: [], allow: ["true"] }; /** - * Whether this deployment already had the server before the test ran. + * Whether THIS RUN is what put the server row there, and so is what should take it away. * * The id is a real catalogue key rather than a suite-scoped one, because what is under test includes * the vendor's own read/write classification. On a database somebody is using, that key is their * configured server, so it is removed only when the test is what created it. + * + * Which is why the flag counts creations rather than the absences it used to. `afterAll` runs even + * when a `beforeAll` above it has thrown, and every flag is then still sitting at its initialiser — + * so a teardown must not be authorised by a setup that never completed. Only a capture that ran and + * found the row missing can write the value the delete needs; `false` covers both "the deployment + * already had it" and "nobody ever looked", and neither of those is this suite's row to remove. */ -let serverWasAlreadyConfigured = false; +let suiteCreatedServerRow = false; /** - * Whether this deployment already advertised the tool this suite inserts. + * Whether THIS RUN is what advertised the tool, and so is what should stop advertising it. * * The vendor really does advertise `search_files`, so the row may be a refreshed fact about the * vendor rather than the suite's fixture. Deleting by name regardless would take a real one; leaving * it always would leave a fixture that reads on screen as a tool the vendor offers. + * + * Set the same way round as {@link suiteCreatedServerRow} and for the same reason: the delete waits + * on evidence that this run inserted the row, not on the mere absence of evidence that somebody else + * did. */ -let toolWasAlreadyAdvertised = false; +let suiteCreatedToolRow = false; const revokedCredentialIds: string[] = []; const issuedCredentialIds: string[] = []; + +/** + * The vault, stubbed, shared by every store in this file that does not need a real one. + * + * Named rather than inlined into the store below so that {@link freshStore} passes the SAME stub: + * a second copy would be a second place for "no credential is read here" to stop being true, and the + * refusals below are what make that claim worth anything. + */ +const credentialsStub = { + // No credential is ever read in these tests, because every call is refused before the vault. + readSecret: async () => null, + // Nor written in place. Loud rather than absent: a call reaching either of these would mean + // this file had started exercising something it does not claim to, and a silent no-op would + // hide that. + create: async () => { + throw new Error("this suite does not write credentials"); + }, + updateSecret: async () => { + throw new Error("this suite does not write credentials"); + }, + // `removeServer` does revoke: it retires the token the server was configured with so a re-add + // does not collide on `credentials_active_key_idx`. The stamp goes to the real row, because + // `removeServer` reads liveness from the table before deciding whether to revoke at all. + revoke: async (id: string) => { + const revokedAt = new Date(); + await database + .update(credentialRows) + .set({ revokedAt, updatedAt: revokedAt }) + .where(eq(credentialRows.id, id)); + revokedCredentialIds.push(id); + return revokedAt; + }, +}; + const store = createPluginStore({ database, auditStore: createAuditStore(database), - credentials: { - // No credential is ever read in these tests, because every call is refused before the vault. - readSecret: async () => null, - // Nor written in place. Loud rather than absent: a call reaching either of these would mean - // this file had started exercising something it does not claim to, and a silent no-op would - // hide that. - create: async () => { - throw new Error("this suite does not write credentials"); - }, - updateSecret: async () => { - throw new Error("this suite does not write credentials"); - }, - // `removeServer` does revoke: it retires the token the server was configured with so a re-add - // does not collide on `credentials_active_key_idx`. The stamp goes to the real row, because - // `removeServer` reads liveness from the table before deciding whether to revoke at all. - revoke: async (id: string) => { - const revokedAt = new Date(); - await database - .update(credentialRows) - .set({ revokedAt, updatedAt: revokedAt }) - .where(eq(credentialRows.id, id)); - revokedCredentialIds.push(id); - return revokedAt; - }, - }, + credentials: credentialsStub, encryptionKey: "x".repeat(44), policy: () => policy, }); +/** + * When this run began, by the DATABASE's clock, so every audit query can exclude what came before. + * + * The trail is the one table this file cannot tidy up after itself: `audit_events` is append-only, + * and 0012 closed the last way around that, so every row every previous run wrote is still there and + * still matches. The refusals are recorded against `google-drive/search_files` and named by rules + * about `google-drive` — production spellings, forced for the same reason the fixtures are — so a + * query narrowed only by target and rule matches nine hundred rows this run had nothing to do with, + * and an assertion that one exists is answered by a run that finished yesterday. That is a test + * which cannot fail: deleting the code that writes the row would leave it green. + * + * Postgres's clock rather than this process's, because the two are not the same clock and the + * comparison happens against a column the server stamps. + * + * Read through {@link sinceThisRun}, which refuses rather than defaulting: a bound of "the beginning + * of time" is the unscoped query back again, silently. + */ +let runStartedAt: Date | null = null; + +function sinceThisRun() { + if (!runStartedAt) { + throw new Error( + "the run's start was never recorded, so no audit query can be narrowed to it", + ); + } + return gte(auditEvents.createdAt, runStartedAt); +} + async function auditRowsFor(targetId: string) { return database .select({ @@ -134,10 +200,172 @@ async function auditRowsFor(targetId: string) { and( eq(auditEvents.targetType, "mcp_tool"), eq(auditEvents.targetId, targetId), + sinceThisRun(), ), ); } +/** + * Whether the guard below cleared this run to own the ids the Composio fixtures insert at. + * + * Read by the `afterAll` that removes those rows. A run the guard refused must not delete the rows + * it refused over, and `afterAll` still runs after a `beforeAll` has thrown. + */ +let ownsFixtureIds = false; + +/* + * Refuse to run at all against a database that already holds the ids this suite inserts at. + * + * The suites above own suite-scoped ids and only ever READ the deployment's own rows, so skipping a + * delete is enough for them — that is what `suiteCreatedServerRow` and its siblings are for. + * The fixtures in this file cannot do that: they INSERT at `gmail`, `notion`, `bot_helper` and + * `user_asker`, and those ids are not a choice. `gmail` is the toolkit slug that gets sent to + * Composio. `notion` is fixed twice over: the dynamic-registration suite pins `dynamicServerId` to + * it because that is the catalogue entry which registers its own client, and the test for an action + * listed before the effect columns existed inserts a `notion` server row directly, because a + * first-party row is what it is about. A fixture that inserts at an id cannot coexist with a real + * row at that id: skipping the delete would only turn the collision into a primary-key conflict, + * and capture-and-restore would be a lot of machinery whose failure mode is destroying the thing + * it protects, because the cascade has already run by the time it restores. + * + * What the cascade takes is why this is a refusal rather than a warning. `mcp_user_credentials` + * references `mcp_servers.id`, so removing a real `notion` row takes every person's per-user + * credential row with it and leaves their encrypted vault rows referenced by nothing — unreachable + * from any screen and invisible to `retireConnectionsFor`, which exists to stop exactly that state. + * Removing a real Bot takes six tables: its channel memberships, its agent profile, everyone's + * preferences for it, its routines and all of their run history, its component exclusions and its + * plugin grants. Removing a real PERSON takes ten: their sign-in accounts and live sessions, their + * roles, their channel memberships and intelligence mappings, their per-Bot preferences and written + * instructions, their skills, their routines, and every per-user connector credential they hold — + * and nulls the owner off their agent profiles and SSO provider besides. The fixtures then re-insert + * byte-identical look-alikes, so nothing on screen would say it happened. + * + * The list below is every table this file deletes from at an id it did not invent — an id spelled + * the way production spells it, so a row already sitting at it belongs to somebody else. Each of + * `mcp_servers`, `agents`, `composio_connections` and `users` is asked about here, and nothing else + * needs to be: `mcp_tools` and `plugin_grants` are the two remaining unconditional deletes and both + * are reached only by a key that references one of these four — `mcp_tools.server_id` names a + * server, `plugin_grants.agent_id` names a Bot — so a row at either could not exist without the + * guard having already refused over its parent. Every other delete in this file names an id + * carrying {@link suite}, and the reads that touch the deployment's own `google-drive` row skip + * their delete instead, on the {@link suiteCreatedServerRow} flags above. + * + * So the deletes in {@link freshDatabase} are authorised by {@link ownsFixtureIds} and this is what + * makes them safe. + */ +/** + * Every `composio_connections` row this file may claim, as one clause used by all three sites. + * + * CRITERION. The pair set is an app crossed with a person: `gmail` or `linear`, held by + * `user_asker`, by `user_leaver` or by the anonymous actor. The refuse-to-run guard, the per-test + * sweep and the teardown ask exactly that question and nothing wider, and a fixture written at a + * pair outside the cross has to widen one clause rather than three. + * + * A CROSS RATHER THAN A LIST, so it is a little wider than what the fixtures actually write — no + * test here connects `user_asker` to `linear`. That is deliberate and it is safe in this one + * direction: the guard runs first and has already established that no row sits anywhere in the + * cross, so every pair the sweep and the teardown delete is a pair this run put there. + * + * REASON. They disagreed. The guard and the sweep asked by person across every app; one test's + * cleanup asked by the anonymous actor across every app; the pair `("gmail", "")` was in none of + * them. So the file refused to run on rows it does not create, deleted rows it did not create, and + * left behind one that it did — three faces of one confusion about what makes a row this file's. + * The app is half the answer: every row here is at a real app, because a Composio app IS its + * toolkit slug and this file asserts things about the real ones — so naming the person alone + * claims that person's rows at every other app as well. + */ +function ownedConnections() { + return and( + inArray(composioConnections.toolkit, ["gmail", "linear"]), + inArray(composioConnections.userId, ["user_asker", "user_leaver", ""]), + ); +} + +beforeAll(async () => { + /* + * Stamped here, in the first hook the file registers, so no row this run writes is older than it + * and no row an earlier run wrote is newer. + */ + const [clock] = await database.execute<{ now: Date }>( + sql`select now() as now`, + ); + if (!clock) throw new Error("the database would not say what time it is"); + runStartedAt = clock.now; + + const [configuredServers, existingBots, existingConnections, existingPeople] = + await Promise.all([ + database + .select({ id: mcpServers.id }) + .from(mcpServers) + .where(inArray(mcpServers.id, ["gmail", "notion"])), + database + .select({ id: agents.id }) + .from(agents) + .where(eq(agents.id, "bot_helper")), + /* + * Brokered connections, keyed on the PAIR rather than on the person. + * + * CRITERION. This guard refuses on {@link ownedConnections} — two real apps crossed with + * three people — and on no other `composio_connections` row, because every pair this file + * inserts and every pair it deletes is inside that cross. + * + * REASON. Every connection row this file writes is at a real app, `gmail` or `linear` — the + * two people it invents and the anonymous actor alike — so the app is half of what makes a + * row this file's, and asking by person alone claims rows at every other app as well. Both + * spellings of that over-reach have already cost something. `user_id = ''` caught the + * anonymous row `composio-connections.test.ts` writes against its own run-suffixed app, so a + * run of that file killed before its cleanup refused every test here for good; `user_id IN + * (asker, leaver)` claims a `("slack", "user_asker")` row the same way, and `freshDatabase` + * would then DELETE it — a `composio_connections` row is the entire gate on a brokered + * call, and nothing else can find it again. + * + * The anonymous actor is one of the three people because `user_id` is notNull and notNull + * does not exclude the empty string, so `("gmail", "")` is a row a deployment can legally + * hold, which is the whole point of the test that inserts one. + */ + database + .select({ + toolkit: composioConnections.toolkit, + userId: composioConnections.userId, + }) + .from(composioConnections) + .where(ownedConnections()), + /* + * The person, who was missing from this guard entirely. + * + * `user_leaver` is inserted at and deleted at by the Composio fixtures, and the delete used to + * run whatever the guard had decided — so a real row at that id was removed, with the ten + * cascades above behind it, on a run the guard had already refused. + */ + database + .select({ id: users.id }) + .from(users) + .where(eq(users.id, "user_leaver")), + ]); + + const found = [ + ...configuredServers.map((row) => `the mcp_servers row '${row.id}'`), + ...existingBots.map((row) => `the Bot '${row.id}'`), + ...existingConnections.map( + (row) => + `the composio_connections row ('${row.toolkit}', '${row.userId}')`, + ), + ...existingPeople.map((row) => `the person '${row.id}'`), + ]; + + if (found.length > 0) { + throw new Error( + `This suite owns ${found.join(", ")} outright — it inserts at those exact ids and deletes ` + + "them before every test — and refuses to run against a database that already has them, " + + "because deleting a real server row takes every person's per-user credentials with it, " + + "deleting a real Bot takes the six tables behind it, and deleting a real person takes the " + + "ten behind them. Point DATABASE_URL at a scratch database.", + ); + } + + ownsFixtureIds = true; +}); + beforeAll(async () => { for (const id of [holderId, strangerId]) { await database @@ -151,15 +379,15 @@ beforeAll(async () => { .onConflictDoNothing(); } - serverWasAlreadyConfigured = + suiteCreatedServerRow = ( await database .select({ id: mcpServers.id }) .from(mcpServers) .where(eq(mcpServers.id, serverId)) - ).length > 0; + ).length === 0; - toolWasAlreadyAdvertised = + suiteCreatedToolRow = ( await database .select({ name: mcpTools.name }) @@ -167,7 +395,7 @@ beforeAll(async () => { .where( and(eq(mcpTools.serverId, serverId), eq(mcpTools.name, toolName)), ) - ).length > 0; + ).length === 0; // The server row is written directly rather than through addServer, so the test needs no vendor // to be reachable. What is under test is the decision, not the listing. @@ -230,12 +458,12 @@ afterAll(async () => { ); // A server row is deployment configuration, so it belongs to the deployment rather than here. // The fixture tool goes whether or not this suite owns the server, but only if it put it there. - if (!toolWasAlreadyAdvertised) { + if (suiteCreatedToolRow) { await database .delete(mcpTools) .where(and(eq(mcpTools.serverId, serverId), eq(mcpTools.name, toolName))); } - if (!serverWasAlreadyConfigured) { + if (suiteCreatedServerRow) { await database.delete(mcpTools).where(eq(mcpTools.serverId, serverId)); await database.delete(mcpServers).where(eq(mcpServers.id, serverId)); } @@ -492,7 +720,10 @@ describe("the policy is asked as well as the grant", () => { (row.payload as { decision?: { rule?: string } }).decision?.rule === rule, ); - expect(recorded.length).toBeGreaterThan(0); + // The one this call wrote. Exact, because the reads below are of `recorded[0]` and the list is + // in no order: with the rows of every previous run in it, that index was whichever the planner + // returned first, which is a row this code did not write. + expect(recorded).toHaveLength(1); /* * What tells this row apart from a call this deployment actually stopped. `allowed` is the * policy's answer and `carriedOut` is what the mode did with it, so a reader counting what a @@ -733,6 +964,80 @@ describe("removing an MCP server", () => { } }); + /** + * A credential that was already retired, and the read that decides whether to retire it again. + * + * CRITERION. `removeServer` must not ask the vault to revoke a credential whose row already + * carries a `revoked_at`, and must still remove the server row. + * + * REASON. It reads liveness from the table before deciding, and nothing asserted that. The test + * above inserts a LIVE row and so takes the true branch; the one below has no credential at all + * and so never runs the query. So `isNull(revoked_at)` could be dropped with the whole suite + * green — and in production `credentials.revoke` throws "not found or already revoked", which + * propagates before `delete(mcpServers)` and leaves a server row that cannot be removed by any + * number of attempts, on a route with no `catch`. Two ordinary states produce the row: a + * previous removal that failed after the revoke, and a key rotated by hand. + * + * ASSERTED AS "revoke was not called", not as the absence of a throw. The vault here is a stub + * that is deliberately forgiving — it stamps whatever id it is handed — so a test waiting for it + * to complain would pass with the clause gone. What the read decides is whether the call is made + * at all, and that is what {@link revokedCredentialIds} records. + */ + test("does not ask the vault to revoke a credential already revoked", async () => { + const removalServerId = `removal-target-retired-${suite}`; + revokedCredentialIds.length = 0; + const revokedAt = new Date(); + const [credentialRow] = await database + .insert(credentialRows) + .values({ + kind: "mcp", + provider: removalServerId, + keyId: `mcp-${removalServerId}`, + encryptedValue: "{}", + metadata: {}, + revokedAt, + updatedAt: revokedAt, + }) + .returning({ id: credentialRows.id }); + const credentialId = credentialRow?.id; + if (!credentialId) throw new Error("credential row was not created"); + issuedCredentialIds.push(credentialId); + await database.insert(mcpServers).values({ + id: removalServerId, + title: "removal target with a retired credential", + vendor: "test", + url: "https://example.invalid/mcp", + credentialId, + provenance: "custom", + }); + + await store.removeServer(removalServerId, "admin@openbot.local"); + + // Not asked, because the row already says it is retired. + expect(revokedCredentialIds).toEqual([]); + // And the server row is gone, which is the act an administrator asked for and the thing a + // throw from the vault would have prevented. + expect( + await database + .select({ id: mcpServers.id }) + .from(mcpServers) + .where(eq(mcpServers.id, removalServerId)), + ).toEqual([]); + // No second revocation in the trail either: a row saying access ended twice is a row an + // auditor has to reconcile against nothing having happened. + expect( + await database + .select({ id: auditEvents.id }) + .from(auditEvents) + .where( + and( + eq(auditEvents.eventType, "credential.revoked"), + eq(auditEvents.targetId, credentialId), + ), + ), + ).toEqual([]); + }); + test("does not call revoke when the server had no credential", async () => { const removalServerId = `removal-target-nocred-${suite}`; revokedCredentialIds.length = 0; @@ -752,6 +1057,14 @@ describe("removing an MCP server", () => { describe("the trail can be read by a second reader", () => { test("a refusal names the bot, the server and the tool in queryable JSON", async () => { + /* + * This run's refusal, not whichever of nine hundred the planner happened to hand back first. + * + * Unbounded, `limit(1)` was answered by the oldest row in the table — a refusal from a run + * whose Bot id no longer names anything — so the payload shape being asserted was a shape this + * branch's code had never written. Ordered as well as bounded, because `limit` without an order + * is a row the query plan picks. + */ const [row] = await database .select({ bot: sql`payload ->> 'bot'`, @@ -764,8 +1077,10 @@ describe("the trail can be read by a second reader", () => { eq(auditEvents.targetType, "mcp_tool"), eq(auditEvents.eventType, "mcp.call_rejected"), eq(auditEvents.targetId, ref), + sinceThisRun(), ), ) + .orderBy(asc(auditEvents.createdAt), asc(auditEvents.id)) .limit(1); // Asserted in SQL rather than through the application, because the stored payload shape is the @@ -1113,14 +1428,24 @@ describe("refresh token rotation", () => { sent.length = 0; } - let notionWasAlreadyConfigured = false; + /** + * Whether THIS RUN is what put the `notion` row there, and so is what should take it away. + * + * Counting creations rather than absences, the same way round as {@link suiteCreatedServerRow} and + * for the same reason: `afterAll` runs even when the `beforeAll` below it has thrown, and a flag + * still sitting at its initialiser then authorised the delete. Only a capture that ran and found + * the row missing can write the value the delete needs. + */ + let suiteCreatedNotionRow = false; /** * The OAuth client this deployment had before the suite ran, restored afterwards. * - * `mcp_servers.credential_id` is live configuration, and this suite repoints it. Restored - * unconditionally, because the delete below removes the row it would otherwise still address. + * `mcp_servers.credential_id` is live configuration, and this suite repoints it. `undefined` is + * "nobody looked", which is what the value is until the capture below runs and is what it is still + * sitting at if that `beforeAll` threw first — and a restore that treated it as `null` would not be + * restoring anything, it would be blanking a real deployment's client on the way out. */ - let clientBefore: string | null = null; + let clientBefore: string | null | undefined; beforeAll(async () => { await database @@ -1146,7 +1471,7 @@ describe("refresh token rotation", () => { .select({ id: mcpServers.id, credentialId: mcpServers.credentialId }) .from(mcpServers) .where(eq(mcpServers.id, rotationServerId)); - notionWasAlreadyConfigured = existing !== undefined; + suiteCreatedNotionRow = existing === undefined; clientBefore = existing?.credentialId ?? null; // Written directly, so the test needs no vendor to be reachable. What is under test is which @@ -1188,11 +1513,15 @@ describe("refresh token rotation", () => { eq(mcpUserCredentials.userId, rotationUserId), ), ); - // Before the deletes, because the column addresses one of the rows they remove. - await database - .update(mcpServers) - .set({ credentialId: clientBefore }) - .where(eq(mcpServers.id, rotationServerId)); + // Before the deletes, because the column addresses one of the rows they remove. Only when the + // capture actually ran: `undefined` is nobody having looked, and writing that back as null is + // not a restore. + if (clientBefore !== undefined) { + await database + .update(mcpServers) + .set({ credentialId: clientBefore }) + .where(eq(mcpServers.id, rotationServerId)); + } for (const id of vaultRows) { await database.delete(credentials).where(eq(credentials.id, id)); } @@ -1213,7 +1542,7 @@ describe("refresh token rotation", () => { ), ); // A server row is deployment configuration, so it goes only if this suite is what added it. - if (!notionWasAlreadyConfigured) { + if (suiteCreatedNotionRow) { await database .delete(mcpTools) .where(eq(mcpTools.serverId, rotationServerId)); @@ -1607,6 +1936,80 @@ describe("refresh token rotation", () => { }); }); +/** + * A real MCP server on localhost answering as the pinned Notion host, and everything a refresh + * against it overwrites put back afterwards. + * + * The seam is `fetch`: the host is pinned and nothing in the store will take a URL from a caller, so + * pointing the pinned host at the mock is what lets a real listing over the real protocol happen. + * What a refresh then overwrites is the deployment's own row — it replaces the advertised tool list + * wholesale and stamps `toolsRefreshedAt` and `lastError` — so the list and both stamps are read + * first and put back in a `finally`. + * + * A `finally` rather than a paragraph copied per test, because a restore is the part that a test + * still passes without: skip it and the cost lands on whatever runs next, reading a tool list this + * test invented. + */ +async function withMockedNotionListing( + notionServerId: string, + /* + * The mock's own tool shape, plus the annotations a real server publishes. + * + * `MCPToolDefinition` names only name, description and schema, and the mock hands whatever it was + * given straight back in its `tools/list` answer — so an annotation travels at runtime and is + * simply unspellable in the type. Widened here rather than cast at each fixture, because the + * hints are what `listTools` reads to decide an action's recorded effect, and a test about that + * decision should not be the one place a cast hides a shape drifting. + */ + tools: (MCPToolDefinition & { annotations?: ToolAnnotations })[], + body: () => Promise, +) { + const mock = new MCPMock(); + for (const tool of tools) mock.addTool(tool); + const mockUrl = await mock.start(); + + const advertisedBefore = await database + .select() + .from(mcpTools) + .where(eq(mcpTools.serverId, notionServerId)); + const [stampBefore] = await database + .select({ + toolsRefreshedAt: mcpServers.toolsRefreshedAt, + lastError: mcpServers.lastError, + }) + .from(mcpServers) + .where(eq(mcpServers.id, notionServerId)); + + const realFetch = globalThis.fetch; + globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => { + const target = String(input instanceof Request ? input.url : input); + return realFetch( + target.startsWith("https://mcp.notion.com") ? mockUrl : input, + init, + ); + }) as typeof fetch; + + try { + await body(); + } finally { + globalThis.fetch = realFetch; + await mock.stop?.(); + await database + .delete(mcpTools) + .where(eq(mcpTools.serverId, notionServerId)); + if (advertisedBefore.length > 0) { + await database.insert(mcpTools).values(advertisedBefore); + } + await database + .update(mcpServers) + .set({ + toolsRefreshedAt: stampBefore?.toolsRefreshedAt ?? null, + lastError: stampBefore?.lastError ?? null, + }) + .where(eq(mcpServers.id, notionServerId)); + } +} + /** * A client this deployment registered for itself, which the vendor has since forgotten. * @@ -1677,7 +2080,15 @@ describe("a dynamic client the vendor has evicted", () => { * * Genuine rather than stubbed, because what this suite asserts is that a re-registered client is * KEPT — which is a write and a read back through the encryption, not a call that was made. The - * one wrapper is the bookkeeping that lets the cleanup take exactly this suite's rows. + * wrappers are the bookkeeping that lets the cleanup take exactly this suite's rows. + * + * BOTH ways a row is minted, not just the first. `create` is the vault's answer when the key holds + * no live row; `rotate` is its answer when one does, and `recordConnection` and `storeOAuthClient` + * each pick between them on exactly that. So every reconnect after the first and every + * re-registration after the first went through `rotate` — which the spread handed straight to the + * real vault, unrecorded. This suite reconnects and re-registers repeatedly, and each run left + * thirteen `notion` credential rows nothing would ever remove, the last of them LIVE: an + * `mcp_user_token` for a person, unrevoked and referenced by nothing. */ const realVault = createCredentialStore(database); const vault = { @@ -1699,6 +2110,15 @@ describe("a dynamic client the vendor has evicted", () => { vaultRows.push(row.id); return row; }, + /** The same forwarding, for the same reason: `rotate` runs inside the caller's transaction too. */ + rotate: async ( + value: Parameters[0], + executor?: Parameters[1], + ) => { + const row = await realVault.rotate(value, executor); + vaultRows.push(row.id); + return row; + }, }; /** @@ -1874,6 +2294,8 @@ describe("a dynamic client the vendor has evicted", () => { and( eq(auditEvents.eventType, "mcp.oauth_client_registered"), eq(auditEvents.targetId, dynamicServerId), + // `notion` and `dyn-1` are fixed spellings, so without this the count is every run's. + sinceThisRun(), ), ); } @@ -1907,9 +2329,15 @@ describe("a dynamic client the vendor has evicted", () => { actorId: dynamicUserId, }); - let notionWasAlreadyConfigured = false; - /** This deployment's own client, restored afterwards: the column is live configuration. */ - let clientBefore: string | null = null; + /** Whether THIS RUN put the `notion` row there. Counted, never inferred from an absence. */ + let suiteCreatedNotionRow = false; + /** + * This deployment's own client, restored afterwards: the column is live configuration. + * + * `undefined` until the capture runs, so a `beforeAll` that dies before it leaves a teardown that + * knows it has nothing to put back rather than one that writes null over somebody's client. + */ + let clientBefore: string | null | undefined; // The vendor refuses the ordinary way unless a test says otherwise, so a test that varies the // refusal cannot leave the next one asserting against somebody else's setup. @@ -1941,7 +2369,7 @@ describe("a dynamic client the vendor has evicted", () => { .select({ id: mcpServers.id, credentialId: mcpServers.credentialId }) .from(mcpServers) .where(eq(mcpServers.id, dynamicServerId)); - notionWasAlreadyConfigured = existing !== undefined; + suiteCreatedNotionRow = existing === undefined; clientBefore = existing?.credentialId ?? null; await database @@ -1979,11 +2407,14 @@ describe("a dynamic client the vendor has evicted", () => { eq(mcpUserCredentials.userId, dynamicUserId), ), ); - // Before the deletes, because the column addresses one of the rows they remove. - await database - .update(mcpServers) - .set({ credentialId: clientBefore }) - .where(eq(mcpServers.id, dynamicServerId)); + // Before the deletes, because the column addresses one of the rows they remove. Skipped + // entirely when no capture ran, for the reason on {@link clientBefore}. + if (clientBefore !== undefined) { + await database + .update(mcpServers) + .set({ credentialId: clientBefore }) + .where(eq(mcpServers.id, dynamicServerId)); + } for (const id of vaultRows) { await database.delete(credentials).where(eq(credentials.id, id)); } @@ -2003,7 +2434,7 @@ describe("a dynamic client the vendor has evicted", () => { eq(mcpTools.name, dynamicToolName), ), ); - if (!notionWasAlreadyConfigured) { + if (suiteCreatedNotionRow) { await database .delete(mcpTools) .where(eq(mcpTools.serverId, dynamicServerId)); @@ -2428,9 +2859,8 @@ describe("a dynamic client the vendor has evicted", () => { * silent. * * The vendor here is a real MCP server on localhost, reached by pointing the pinned host at it for - * the length of this test. The host is pinned for good reasons and nothing in the store will take a - * URL from a caller, so the seam is fetch — which is also the honest one: what is under test is - * what a real listing over the real protocol produces. + * the length of this test — see {@link withMockedNotionListing}, which also puts back what the + * refresh overwrites. What is under test is what a real listing over the real protocol produces. */ test("a refresh names the advertised tools no write list covers", async () => { await putClient(EVICTED); @@ -2439,65 +2869,30 @@ describe("a dynamic client the vendor has evicted", () => { /** Suite-scoped, so it cannot be a name Notion really advertises, nor a name in `writeTools`. */ const unlistedName = `notion-invent-${suite}`; - const mock = new MCPMock(); - mock - .addTool({ - name: "notion-create-pages", - description: "A write the list already names.", - inputSchema: { type: "object", properties: {} }, - }) - .addTool({ - name: unlistedName, - description: "Advertised, and named by no write list.", - inputSchema: { type: "object", properties: {} }, - }); - const mockUrl = await mock.start(); - - // What the deployment currently advertises for this server, because a refresh replaces the list - // wholesale and this one is pointing the vendor at a mock. - const advertisedBefore = await database - .select() - .from(mcpTools) - .where(eq(mcpTools.serverId, dynamicServerId)); - const [stampBefore] = await database - .select({ - toolsRefreshedAt: mcpServers.toolsRefreshedAt, - lastError: mcpServers.lastError, - }) - .from(mcpServers) - .where(eq(mcpServers.id, dynamicServerId)); - const realFetch = globalThis.fetch; - globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => { - const target = String(input instanceof Request ? input.url : input); - return realFetch( - target.startsWith("https://mcp.notion.com") ? mockUrl : input, - init, - ); - }) as typeof fetch; - - try { - expect( - await dynamicStore.refreshTools(dynamicServerId, dynamicUserId), - ).toEqual({ tools: 2 }); - } finally { - globalThis.fetch = realFetch; - await mock.stop?.(); - await database - .delete(mcpTools) - .where(eq(mcpTools.serverId, dynamicServerId)); - if (advertisedBefore.length > 0) { - await database.insert(mcpTools).values(advertisedBefore); - } - await database - .update(mcpServers) - .set({ - toolsRefreshedAt: stampBefore?.toolsRefreshedAt ?? null, - lastError: stampBefore?.lastError ?? null, - }) - .where(eq(mcpServers.id, dynamicServerId)); - } + await withMockedNotionListing( + dynamicServerId, + [ + { + name: "notion-create-pages", + description: "A write the list already names.", + inputSchema: { type: "object", properties: {} }, + }, + { + name: unlistedName, + description: "Advertised, and named by no write list.", + inputSchema: { type: "object", properties: {} }, + }, + ], + async () => { + expect( + await dynamicStore.refreshTools(dynamicServerId, dynamicUserId), + ).toEqual({ tools: 2 }); + }, + ); + // Read after the restore, because the audit trail is what the refresh leaves that the restore + // does not take back. const named = ( await database .select({ payload: auditEvents.payload }) @@ -2507,6 +2902,14 @@ describe("a dynamic client the vendor has evicted", () => { eq(auditEvents.eventType, "configuration.changed"), eq(auditEvents.targetId, dynamicServerId), sql`payload ->> 'change' = 'unlisted_tools_advertised'`, + /* + * `named` is what THIS refresh recorded, not the union over every refresh there has + * ever been. `notion` and `notion-create-pages` are both fixed spellings, so both + * assertions below were being answered partly by rows older code wrote: the negative + * one would report today's classification as wrong on the strength of a row from + * before the write list covered that name. + */ + sinceThisRun(), ), ) ).flatMap((row) => (row.payload as { tools?: string[] }).tools ?? []); @@ -2516,6 +2919,199 @@ describe("a dynamic client the vendor has evicted", () => { expect(named).not.toContain("notion-create-pages"); }); + /** + * The classification an MCP server has always had, over a real listing that really happened. + * + * The three columns the refresh writes are the transports' to fill in, and an MCP server that + * annotates nothing fills in none of them — which is what every fixture in THIS test does, and + * so what it is about. It is no longer true of the transport in general: `listTools` reads + * `annotations.destructiveHint` and writes both `effect` and `destructive` from it, which the + * test below this one covers. `classifyTool` consults the reviewed `writeTools` + * list BEFORE the recorded `effect` column, on the criterion that a recorded value may narrow what + * a Bot is allowed and may never widen it — so a name the list covers stays a write whatever the + * column says, and a value appearing here can no longer turn one of Notion's reviewed writes into a + * read. The other direction is still open, which is what this test is for: a name the list does not + * cover falls through to the column, where PRESENCE rather than truthiness decides, so an effect + * recorded for Notion would silently reclassify every one of its reads as a write, on a connector + * nobody touched. Only `null` and `undefined` are silence. Asserted on the rows AND on what the + * Plugins page derives from them, because it is the second one that an administrator reads. + * + * It lives in this suite because this is the only place a `user-oauth` listing can actually be + * made to happen: the refresh runs on the grant of whoever pressed the button, so a Notion row with + * nobody connected records a refusal in `lastError` and writes no tools at all — which is a test + * that passes by having nothing to check. + */ + test("a refreshed MCP server records no effect, no marker and no version", async () => { + await putClient(EVICTED); + await connect(); + accepted = new Set([EVICTED.clientId]); + + await withMockedNotionListing( + dynamicServerId, + [ + { + name: "notion-fetch", + description: "A read no write list names.", + inputSchema: { type: "object", properties: {} }, + }, + { + name: "notion-create-pages", + description: "A write the list already names.", + inputSchema: { type: "object", properties: {} }, + }, + ], + async () => { + // Two tools listed, so the assertions below have something to be about: `every` over an + // empty list is true, and a refusal recorded in `lastError` would leave exactly that. + expect( + await dynamicStore.refreshTools(dynamicServerId, dynamicUserId), + ).toEqual({ tools: 2 }); + + const rows = await database + .select({ + name: mcpTools.name, + effect: mcpTools.effect, + destructive: mcpTools.destructive, + version: mcpTools.version, + }) + .from(mcpTools) + .where(eq(mcpTools.serverId, dynamicServerId)) + .orderBy(asc(mcpTools.name)); + + expect(rows).toEqual([ + { + name: "notion-create-pages", + effect: null, + destructive: false, + version: null, + }, + { + name: "notion-fetch", + effect: null, + destructive: false, + version: null, + }, + ]); + + const listed = (await dynamicStore.listServers()).find( + (server) => server.id === dynamicServerId, + ); + + // The reviewed write list, still deciding: the name it covers is a write and the name it + // does not is a read. A recorded effect on either row is what would take this over. + expect( + listed?.tools.map((tool) => ({ + name: tool.name, + effect: tool.effect, + })), + ).toEqual([ + { name: "notion-create-pages", effect: "write" }, + { name: "notion-fetch", effect: "read" }, + ]); + }, + ); + }); + + /** + * The one annotation this deployment believes, and the two it declines to. + * + * CRITERION. `destructiveHint === true` is recorded as `effect: "write"` and `destructive: true`. + * `readOnlyHint` is recorded as NOTHING AT ALL — not as `read`, not as a value overridden further + * down — whether or not the reviewed write list already covers the name. + * + * REASON. The SDK warns where it declares these hints that a client must not make tool-use + * decisions from annotations an untrusted server supplied, and the two hints are not symmetrical + * against that warning. `destructiveHint` can only move an action from read to write, so a server + * that lies with it restricts itself. `readOnlyHint` moves an action the other way, and it would + * buy nothing in the two curated cases — a name on `writeTools` never reaches the column, and a + * name absent from it already reads as a read — while opening the third: a server an + * administrator added by URL has no reviewed list, and `classifyTool` returns on the recorded + * column BEFORE its `if (!entry) return "write"`, so believing the hint would let an arbitrary + * server declare its whole surface harmless and turn "no reviewed list means everything is a + * write" into an opt-out. + * + * WHY NULL IS THE ASSERTION rather than a classification. Both `readOnlyHint` fixtures come out + * of `listServers` correctly whatever the column holds — one is on the write list, the other is + * not — so a classification assertion alone would pass with the hint written down and overruled + * downstream. NULL in the column is what says it was never read. + */ + test("a refresh records the effect an MCP server declares, and only the narrowing one", async () => { + await putClient(EVICTED); + await connect(); + accepted = new Set([EVICTED.clientId]); + + /** Suite-scoped, so it is not a name Notion really advertises nor one `writeTools` covers. */ + const destructiveName = `notion-destroy-${suite}`; + + await withMockedNotionListing( + dynamicServerId, + [ + { + name: "notion-fetch", + description: + "A read no write list names, declaring itself read-only.", + inputSchema: { type: "object", properties: {} }, + annotations: { readOnlyHint: true }, + }, + { + name: "notion-create-pages", + description: "A reviewed write, declaring itself read-only.", + inputSchema: { type: "object", properties: {} }, + annotations: { readOnlyHint: true }, + }, + { + name: destructiveName, + description: "Advertised, on no write list, declared destructive.", + inputSchema: { type: "object", properties: {} }, + annotations: { destructiveHint: true }, + }, + ], + async () => { + expect( + await dynamicStore.refreshTools(dynamicServerId, dynamicUserId), + ).toEqual({ tools: 3 }); + + const rows = await database + .select({ + name: mcpTools.name, + effect: mcpTools.effect, + destructive: mcpTools.destructive, + }) + .from(mcpTools) + .where(eq(mcpTools.serverId, dynamicServerId)) + .orderBy(asc(mcpTools.name)); + + expect(rows).toEqual([ + // The reviewed write, which said it was read-only. Nothing recorded, so nothing to + // overrule: the write list is still the only thing that answers for this name. + { name: "notion-create-pages", effect: null, destructive: false }, + // The declaration that narrows, taken at its word. + { name: destructiveName, effect: "write", destructive: true }, + // The unreviewed read, which also said it was read-only, and is believed about nothing. + { name: "notion-fetch", effect: null, destructive: false }, + ]); + + const listed = (await dynamicStore.listServers()).find( + (server) => server.id === dynamicServerId, + ); + + // What the Plugins page derives, which is what an administrator actually reads: the + // reviewed name is a write because review says so, the declared one is a write because the + // vendor narrowed it, and the third is the read it was already classified as. + expect( + listed?.tools.map((tool) => ({ + name: tool.name, + effect: tool.effect, + })), + ).toEqual([ + { name: "notion-create-pages", effect: "write" }, + { name: destructiveName, effect: "write" }, + { name: "notion-fetch", effect: "read" }, + ]); + }, + ); + }); + /** * What a failed refresh writes into `lastError`, and how much of it. * @@ -2753,6 +3349,36 @@ describe("a dynamic client the vendor has evicted", () => { }); }); +/** + * A custom server may not take a name the app directory already answers to. + * + * `/admin/plugins/composio` is a static route and `/admin/plugins/$key` is the one every server is + * opened through, and a static route wins. So a server whose id is literally `composio` would be + * listed, saved, refreshed and then never openable: the row for it would send the operator to the + * Composio screen instead. Brokered ids are `composio-`, so this is only reachable by typing + * the id into the custom-server form, which the id pattern otherwise allows. + */ +describe("a custom server may not be named after one of the app's own screens", () => { + test("the id composio is refused, and no server is written", async () => { + await expect( + store.addCustomServer({ + id: "composio", + title: "Collector", + url: "https://collector.example/mcp", + by: "admin@example.com", + }), + ).rejects.toBeInstanceOf(CustomServerRefusedError); + + // Written-and-unopenable is the whole harm, so the refusal has to stop the write rather than + // report on it afterwards. + const rows = await database + .select({ id: mcpServers.id }) + .from(mcpServers) + .where(eq(mcpServers.id, "composio")); + expect(rows).toHaveLength(0); + }); +}); + /** * Which credential a custom server is allowed to be pointed at. * @@ -2829,12 +3455,16 @@ describe("a custom server may only be pointed at its own kind of credential", () await database .delete(mcpServers) .where(like(mcpServers.id, `${customServerId}%`)); + // Every id the `beforeAll` above minted, which is the list this one has to match. The upsert's + // own token was missing from it, so each run left one live `mcp` credential behind for a server + // that no longer exists — a secret in the vault reachable from nothing. await database .delete(credentialRows) .where( inArray(credentialRows.id, [ deploymentCredentialId, personalCredentialId, + upsertCredentialId, oauthClientCredentialId, ]), ); @@ -3132,3 +3762,3358 @@ describe("a vendor reply that is not a token", () => { } }); }); + +/** + * What a brokered app needs before any of it can be asserted: a clean slate and a fixture. + * + * The suites above each own a suite-scoped id, because they run against a database somebody may be + * using. These fixtures cannot: a Composio app IS its toolkit slug — `gmail` is both the row's id and + * the name sent to Composio — so the rows have to be spelled the way production spells them, and + * `bot_helper` and `user_asker` name them in every assertion. + * + * What replaces the suffix is the guard at the top of this file, not the ordering below. The deletes + * here would be indefensible on their own — a real `notion` row cascades into every person's per-user + * credentials, a real Bot into the six tables behind it, a real person into the ten behind them. They + * are safe only because the guard has established that no row at any of these ids exists, which makes + * every row they remove one of this file's own, and the first thing below is the check that it did. + * Cleaning before each test rather than after is then just so a run that dies halfway leaves the next + * one nothing to trip over; the `afterAll` below is what stops the last test's fixtures from outliving + * the run. + */ +async function freshDatabase(): Promise { + /* + * The guard's answer, asked again rather than assumed. + * + * "Nothing gets this far unless the guard has established the ids are free" was the whole + * justification for the deletes below, and it was an assumption about the runner: a `beforeAll` + * that throws is supposed to stop every test under it. The rest of this file already declines to + * rely on that — `afterAll` is documented as running anyway, which is why {@link ownsFixtureIds} + * exists at all — and a delete cascading through a real person is not a thing to leave resting on + * the difference. So the same positive evidence the teardown waits for authorises these too, and + * a run that never got it fails loudly here instead of quietly emptying rows. + */ + if (!ownsFixtureIds) { + throw new Error( + "the ownership guard has not cleared this run to own 'gmail', 'notion', 'bot_helper', " + + "'user_asker' and 'user_leaver', so nothing may be deleted at those ids", + ); + } + /* + * The Bot's own grants, never a delete by ref. + * + * The primary key is (kind, ref, agent_id), and `gmail/GMAIL_FETCH_EMAILS` is a real action of a + * real app: a delete by ref alone would take an administrator's grant for a Bot people use. This + * file has already done that once — the `afterAll` near the top of it says what that cost. + */ + await database + .delete(pluginGrants) + .where(eq(pluginGrants.agentId, "bot_helper")); + await database.delete(agents).where(eq(agents.id, "bot_helper")); + // The actions before the servers. `mcp_tools` cascades on the server row anyway, so this is what + // clears actions a previous run left against a server row it is not what created. + await database + .delete(mcpTools) + .where(inArray(mcpTools.serverId, ["gmail", "notion"])); + await database + .delete(mcpServers) + .where(inArray(mcpServers.id, ["gmail", "notion"])); + /* + * Brokered connections, by the pair and never by half of it. + * + * NEITHER HALF ALONE. By toolkit it would take every person's Gmail connection, leaving one + * orphaned at the broker with no local row to find it by — the table has no foreign key to + * `users`, which is the property the first test below is about, so nothing else would ever + * remove it. By person it would take a `("slack", "user_asker")` row belonging to somebody else, + * for the same reason and at the same cost. {@link ownedConnections} is what the guard at the + * top of this file has already established nothing else holds. + * + * The anonymous pair is swept here as well as in the `finally` of the test that inserts it, + * because that `finally` covers a failed assertion and not a killed process — and the row it + * would leave is what the guard refuses on. A pair stranded earlier in this run is therefore + * gone before the next test looks; a pair that was already there when the run started is still + * the guard's to refuse, because at that point nothing has established it is ours. + */ + await database.delete(composioConnections).where(ownedConnections()); + // The person the connection outlives, who is a row in `users` like anybody else. Reached only + // through the check at the top of this function, because there is no suffix on this id to tell a + // fixture apart from somebody's account and ten cascades sit behind the difference. + await database.delete(users).where(eq(users.id, "user_leaver")); + return database; +} + +/** + * A store over the clean database, recording every event it writes. + * + * `recorded()` alongside the real insert rather than instead of it: the payload is what these tests + * assert about, and reading it back out of `audit_events` would assert what the column round-trips + * rather than what the store said. The row is still written, because a store whose audit insert + * never touched the database would not be exercising the one it has. + * + * NO `callVendor`. Whose account a call runs as and which transport a row resolves to are the + * properties under test, and both are decided on the way to the vendor — so the real path has to + * run, and the vendor is stubbed further out at {@link useComposioClient}. + * + * `options` is spread over the defaults rather than read field by field, so a test that needs one + * more seam — a broker, today — adds it at the call and nothing here has to learn its name. + */ +async function freshStore(options: { broker?: ComposioBroker } = {}) { + const database = await freshDatabase(); + const persisting = createAuditStore(database); + const events: Parameters[0][] = []; + const auditStore = { + insert: async (event: Parameters[0]) => { + events.push(event); + await persisting.insert(event); + }, + recorded: () => events, + }; + + const store = createPluginStore({ + database, + auditStore, + credentials: credentialsStub, + encryptionKey: "x".repeat(44), + policy: () => policy, + ...options, + }); + + return { store, database, auditStore }; +} + +/** + * A Composio Gmail app, one granted read action, one Bot, and optionally a connected person. + * + * `version: null` is the action Composio listed without one — a granted, callable row whose version + * column is null, which is a state the vendor's own optional field produces rather than a leftover + * from before the column existed. + * + * `url` is an option because the id and the url are two fields and nothing holds them equal: a row + * called `gmail` at `composio://slack` is the shape that used to pass the connection gate on one + * spelling and run against the other. The connected person is still connected to `gmail`. + * + * `authScheme` is the vendor's own scheme literal, as it was recorded when somebody enabled the app, + * and the call gate reads it to decide whether a connection row is required at all. Absent by + * default, which is what every other test here wants: a row that is not `NO_AUTH` is a row the gate + * still asks a connection for. + */ +async function seedComposioGmail( + database: Database, + store: PluginStore, + options: { + connect?: boolean; + version?: string | null; + url?: string; + authScheme?: string; + } = {}, +) { + await database.insert(mcpServers).values({ + id: "gmail", + title: "Gmail", + vendor: "Composio", + url: options.url ?? "composio://gmail", + provenance: "composio", + authScheme: options.authScheme ?? null, + }); + await database.insert(mcpTools).values({ + serverId: "gmail", + name: "GMAIL_FETCH_EMAILS", + description: "Fetch emails.", + effect: "read", + version: options.version === undefined ? "20260903_00" : options.version, + }); + await database.insert(agents).values({ + id: "bot_helper", + name: "Helper", + type: "built_in", + configuration: {}, + }); + if (options.connect !== false) { + await database + .insert(composioConnections) + .values({ toolkit: "gmail", userId: "user_asker" }); + } + await store.grant( + "mcp", + "gmail/GMAIL_FETCH_EMAILS", + "bot_helper", + "admin@example.com", + ); +} + +/** + * What Composio answers a call that worked, in the shape its own schema requires. + * + * `ToolExecuteResponseSchema` in `@composio/core` 0.18.1 spells `data`, `error` and `successful` + * REQUIRED. Every stub below used to answer `{}` or `{ messages: [] }`, which are shapes the vendor + * cannot produce, and nothing flagged it: `server/tsconfig.json` excludes `tests`, so no typecheck + * reads these files at all. They stayed green for a reason that is not the property under test — + * an ABSENT `successful` is not `successful === false`, so the transport's failure branch was + * simply never entered. A stub that can only answer things the vendor could actually say is what + * makes the success path's greenness mean something. + * + * Typed as {@link ComposioResult} rather than left to inference, so a vendor shape that drifts is a + * red squiggle here even though the suite is outside the typecheck's reach. + */ +const vendorAnswered = ( + data: Record = {}, +): ComposioResult => ({ + data, + error: null, + successful: true, +}); + +/** + * What Composio answers when it ran nothing and says why, which is a 200 and not a throw. + * + * `successful: false` beside a sentence is the vendor reporting its own failure inside the + * envelope, which is the case that used to come back from this transport as `isError: false`. + */ +const vendorRefused = (sentence: string): ComposioResult => ({ + data: {}, + error: sentence, + successful: false, +}); + +// The vendor is a process-wide registry, so a stub outliving its test would be answering somebody +// else's calls. +afterEach(() => useComposioClient(null)); + +/* + * The last test's fixtures, which nothing else would remove. + * + * {@link freshDatabase} cleans BEFORE each test, so without this the final test's rows are + * permanent: a `notion` server row and a `notion-fetch` action nobody configured, which makes + * whatever database this ran against advertise a connector nobody set up. Worse on the next run — + * the rotation and dynamic-registration suites above read the leak as a row they did not create, + * correctly decline to clean what looks like the deployment's own, and leave this delete as the only + * thing that removes it. + * + * Exactly what this file created, and only when the guard cleared the run to own these ids. + */ +afterAll(async () => { + if (!ownsFixtureIds) return; + await database + .delete(mcpTools) + .where(inArray(mcpTools.serverId, ["gmail", "notion"])); + await database + .delete(mcpServers) + .where(inArray(mcpServers.id, ["gmail", "notion"])); + await database.delete(composioConnections).where(ownedConnections()); + /* + * And the witness row, which is at neither `gmail` nor any person. + * + * CRITERION. Nothing at `sweep_witness_${suite}` outlives this run. + * + * REASON. It is removed in its own test's `finally`, which a killed process does not run — and + * nothing else would reach it: `ownedConnections` names `gmail` and `linear`, and the anonymous + * actor is precisely what `retireConnectionsFor` refuses to act on, so no operation in the + * product could clear it either. Named exactly rather than by prefix, because another run's + * witness is that run's to take back. + */ + await database + .delete(composioConnections) + .where(eq(composioConnections.toolkit, `sweep_witness_${suite}`)); + await database.delete(agents).where(eq(agents.id, "bot_helper")); + await database.delete(users).where(eq(users.id, "user_leaver")); +}); + +test("a Composio connection row survives the person being deleted", async () => { + const database = await freshDatabase(); + + await database + .insert(users) + .values({ id: "user_leaver", email: "leaver@example.com", name: "Leaver" }); + await database + .insert(composioConnections) + .values({ toolkit: "gmail", userId: "user_leaver" }); + + await database.delete(users).where(eq(users.id, "user_leaver")); + + /* + * Asked at the app this test connected, not at every app this person might hold. + * + * The guard at the top of this file is keyed on the pair, so a `("slack", "user_leaver")` row + * belonging to somebody else is deliberately allowed to exist — and asking by person alone would + * then read it into this assertion and fail over a row that has nothing to do with the property + * under test. + */ + const rows = await database + .select({ toolkit: composioConnections.toolkit }) + .from(composioConnections) + .where( + and( + eq(composioConnections.toolkit, "gmail"), + eq(composioConnections.userId, "user_leaver"), + ), + ); + + // The whole reason this table exists rather than reusing mcp_user_credentials: offboarding has to + // still find the connection and revoke it at Composio after the person is gone, and there is no + // vault row to find it by, because Composio holds the account. + expect(rows).toEqual([{ toolkit: "gmail" }]); +}); + +test("a Composio app is listed through the Composio transport, not dialled as MCP", async () => { + const { store, database } = await freshStore(); + const asked: string[] = []; + useComposioClient({ + listActions: async (toolkit) => { + asked.push(toolkit); + return []; + }, + execute: async () => vendorAnswered(), + }); + await database.insert(mcpServers).values({ + id: "gmail", + title: "Gmail", + vendor: "Composio", + url: "composio://gmail", + provenance: "composio", + }); + + await store.refreshTools("gmail", "admin_user"); + + // The transport comes from the resolved kind, not from the absent entry. Derived from the entry, + // this reached the MCP module instead and dialled `composio://gmail` as an HTTP server — which + // `refreshTools` swallows into `lastError`, so nothing but this reaches the vendor stub. + expect(asked).toEqual(["gmail"]); +}); + +test("a Composio call with nobody attributed is refused before it reaches the vendor", async () => { + const { store, database } = await freshStore(); + const reached: string[] = []; + useComposioClient({ + listActions: async () => [], + execute: async ({ slug }) => { + reached.push(slug); + return vendorAnswered(); + }, + }); + await seedComposioGmail(database, store); + + // The empty string is what the actor resolves to when nobody could be identified. Reaching the + // vendor with it would run in whatever account Composio has against "", or in nobody's, and either + // way the run is unattributable — the state every identity defect in OpenTag started from. + await expect( + store.callTool({ + ref: "gmail/GMAIL_FETCH_EMAILS", + args: {}, + botId: "bot_helper", + actorId: "", + }), + ).rejects.toThrow(/not attributed to anybody/i); + + expect(reached).toEqual([]); +}); + +/** + * The same refusal, with a legal row sitting at the anonymous actor. + * + * `composio_connections.user_id` is text notNull with NO foreign key — the property that lets a + * connection outlive its person, so offboarding can still find it and revoke it at the broker. And + * `notNull` does not exclude the empty string, so a row at `("gmail", "")` is legal: without the + * guard ahead of the lookup, that row IS the match, the connection gate passes, and the run goes + * out in whatever account Composio holds against "". The sibling `user-oauth` path cannot reach + * this state — `mcp_user_credentials.user_id` carries a foreign key to `users.id` — so its test + * asserts only the sentence, and borrowing that shape here would leave this property untested. + * + * A rejection, not a failed result, and that is the assertion doing the work: the transport repeats + * the refusal as its own last line, but it answers with `isError` rather than throwing. So a + * `rejects` here is what separates this gate from its twin downstream of the lookup. + */ +test("a Composio call with nobody attributed is refused even when a connection row exists for the empty actor", async () => { + const { store, database } = await freshStore(); + const reached: string[] = []; + useComposioClient({ + listActions: async () => [], + execute: async ({ slug }) => { + reached.push(slug); + return vendorAnswered(); + }, + }); + await seedComposioGmail(database, store); + await database + .insert(composioConnections) + .values({ toolkit: "gmail", userId: "" }); + /* + * A second anonymous row, at an app this file has nothing to do with. + * + * CRITERION. Whatever removes the row above must leave this one exactly where it is. + * + * REASON. The cleanup below used to be `user_id = ''`, which is every app at once. That reached + * the anonymous row `composio-connections.test.ts` writes against its own run-suffixed app — + * deleting another file's fixture out from under it when the two run together — and it is the + * other half of the same confusion the guard at the top of this file suffered from. Suffixed, so + * this row is provably this run's to insert and to take away again, and so no real deployment + * row can be what the assertion below is reading. + */ + const unrelatedApp = `sweep_witness_${suite}`; + await database + .insert(composioConnections) + .values({ toolkit: unrelatedApp, userId: "" }); + + try { + await expect( + store.callTool({ + ref: "gmail/GMAIL_FETCH_EMAILS", + args: {}, + botId: "bot_helper", + actorId: "", + }), + ).rejects.toThrow(/not attributed to anybody/i); + + expect(reached).toEqual([]); + + // Here rather than only in `freshDatabase`, so the row is gone the moment this test is done + // with it and the assertion below has something to read. Keyed on the PAIR either way: the app + // is what makes this row this file's, and the anonymous actor on its own names nobody's. + await database + .delete(composioConnections) + .where( + and( + eq(composioConnections.toolkit, "gmail"), + eq(composioConnections.userId, ""), + ), + ); + + // What the cleanup took, and what it did not. Asked as two facts about this list rather than + // as the whole of it, deliberately: a third app's anonymous row is somebody else's business, + // and a test that failed because one existed would be the same over-reach in assertion form. + const anonymous = ( + await database + .select({ toolkit: composioConnections.toolkit }) + .from(composioConnections) + .where(eq(composioConnections.userId, "")) + ).map((row) => row.toolkit); + expect(anonymous).not.toContain("gmail"); + expect(anonymous).toContain(unrelatedApp); + } finally { + // Both, so a failed assertion above still leaves the table as this test found it. Each is keyed + // on an app this run named, which is what makes the deletes this run's to make. + await database + .delete(composioConnections) + .where(eq(composioConnections.toolkit, unrelatedApp)); + await database + .delete(composioConnections) + .where( + and( + eq(composioConnections.toolkit, "gmail"), + eq(composioConnections.userId, ""), + ), + ); + } +}); + +test("a Composio call by somebody who has not connected the app is refused with a sentence they can act on", async () => { + const { store, database } = await freshStore(); + const reached: string[] = []; + useComposioClient({ + listActions: async () => [], + execute: async ({ slug }) => { + reached.push(slug); + return vendorAnswered(); + }, + }); + await seedComposioGmail(database, store, { connect: false }); + + await expect( + store.callTool({ + ref: "gmail/GMAIL_FETCH_EMAILS", + args: {}, + botId: "bot_helper", + actorId: "user_asker", + }), + ).rejects.toThrow(/connect it in settings/i); + + // Refused here rather than at Composio, so a person is told what to do instead of being shown + // somebody else's error, and so no call is spent finding out. + expect(reached).toEqual([]); +}); + +/** + * One person having connected the app is not the asking person having connected it. + * + * THE FAIL-OPEN THIS CLOSES. The gate reads `composio_connections` for `(toolkit, actorId)`, and + * every test around it seeds a database where the app is connected by the asker or by nobody at + * all — so dropping `eq(composioConnections.userId, actorId)` from that `where`, which turns the + * question into "has ANYBODY connected Gmail", left the whole suite green. That single term is what + * keeps one person's mailbox out of another's: with it gone, the first colleague to connect Gmail + * makes the app callable by everybody, the broker is handed the stranger's id, and Composio answers + * with whatever account it holds for them — or refuses in words that read as the connector being + * broken. + * + * The stranger is never inserted anywhere. `composio_connections.user_id` is text with no foreign + * key and the brokered path touches no vault row, so asking as somebody unknown writes nothing this + * suite would have to clean up — which is the only reason a second person can appear here without + * a fixture. + */ +test("a Composio call by somebody who has not connected the app is refused even though a colleague has", async () => { + const { store, database } = await freshStore(); + const reached: string[] = []; + useComposioClient({ + listActions: async () => [], + execute: async ({ slug }) => { + reached.push(slug); + return vendorAnswered(); + }, + }); + // `user_asker` is connected to Gmail. Nobody else is. + await seedComposioGmail(database, store); + + await expect( + store.callTool({ + ref: "gmail/GMAIL_FETCH_EMAILS", + args: {}, + botId: "bot_helper", + actorId: "user_stranger", + }), + ).rejects.toThrow(/connect it in settings/i); + + // Never dialled, which is the half that matters: a call let through here is spent at the broker + // in a stranger's name, and the person asking sees somebody else's mailbox or somebody else's + // error. + expect(reached).toEqual([]); +}); + +test("a Composio call whose url names no app is refused rather than falling back to the row id", async () => { + const { store, database } = await freshStore(); + const reached: string[] = []; + useComposioClient({ + listActions: async () => [], + execute: async ({ slug }) => { + reached.push(slug); + return vendorAnswered(); + }, + }); + // Brokered by provenance, with a url that names no Composio app: `accessFor` answers + // `{ credential: "brokered", toolkit: null }`, and falling back to the row id would check a + // Gmail connection and then dial a hostname. + await seedComposioGmail(database, store, { url: "https://example.com/mcp" }); + + await expect( + store.callTool({ + ref: "gmail/GMAIL_FETCH_EMAILS", + args: {}, + botId: "bot_helper", + actorId: "user_asker", + }), + ).rejects.toThrow(/no Composio app in its url/i); + + expect(reached).toEqual([]); +}); + +test("a Composio call whose row id and url name different apps is refused", async () => { + const { store, database } = await freshStore(); + const reached: string[] = []; + useComposioClient({ + listActions: async () => [], + execute: async ({ slug }) => { + reached.push(slug); + return vendorAnswered(); + }, + }); + /* + * The row is called `gmail` and the person has connected `gmail`; the url dials some OTHER app, + * which is the one the call would actually run in. + * + * SUITE-SCOPED, and that is not cosmetic. Spelled `slack`, this test asserted a refusal on the + * strength of `("slack", "user_asker")` not existing anywhere in the database — so it depended + * on this file owning a production person id at every app in the world, and a real deployment + * row at that pair would have turned the refusal into a completed call and read as this gate + * being broken. An app nobody can have connected is what the property actually needs. + */ + await seedComposioGmail(database, store, { + url: `composio://unconnected_${suite}`, + }); + + await expect( + store.callTool({ + ref: "gmail/GMAIL_FETCH_EMAILS", + args: {}, + botId: "bot_helper", + actorId: "user_asker", + }), + ).rejects.toThrow(/connect it in settings/i); + + // The gate keys on the app the url names, because that is the app the transport dials. Keyed on + // the row id, this call completed: it ran a Slack action against a Gmail connection, sent the + // version recorded for the Gmail action, and was audited as having reached the asker's own + // account — a person granted one app and dialled into another with nothing noticing. + expect(reached).toEqual([]); +}); + +/* + * WHETHER A BROKERED CALL NEEDS A CONNECTION ROW AT ALL IS THE APP'S OWN QUESTION, AND THESE TWO + * TESTS ARE ONE PAIR. + * + * The fixtures differ in exactly one field — the scheme recorded on the row when somebody enabled + * the app — and otherwise dial the same app, at the same person, with no connection row anywhere. + * So the opposite outcomes below cannot be caused by anything but the recorded scheme. + * + * SUITE-SCOPED APP, for the reason the test above is suite-scoped: `("unclaimed_", + * "user_asker")` is a pair no deployment can be holding, so "nobody has connected this" is a + * property of the run rather than a hope about the database. Spelled as a real app, a stray + * connection row somebody else wrote would make the first test pass for the wrong reason and the + * second one fail for it. + */ +test("a no-auth app runs without anybody having connected it", async () => { + const { store, database } = await freshStore(); + const reached: string[] = []; + useComposioClient({ + listActions: async () => [], + execute: async ({ slug }) => { + reached.push(slug); + return vendorAnswered(); + }, + }); + await seedComposioGmail(database, store, { + url: `composio://unclaimed_${suite}`, + authScheme: "NO_AUTH", + connect: false, + }); + + const result = await store.callTool({ + ref: "gmail/GMAIL_FETCH_EMAILS", + args: {}, + botId: "bot_helper", + actorId: "user_asker", + }); + + expect(result.isError).toBe(false); + // Dialled, not merely un-refused. A no-auth app has no account to open, so the deployment's own + // key is the entire credential the call goes out with, and reaching the vendor is what says the + // gate returned rather than threw. + expect(reached).toEqual(["GMAIL_FETCH_EMAILS"]); +}); + +test("an app whose accounts are somebody's still refuses without a row, and names the person's own step", async () => { + const { store, database } = await freshStore(); + const reached: string[] = []; + useComposioClient({ + listActions: async () => [], + execute: async ({ slug }) => { + reached.push(slug); + return vendorAnswered(); + }, + }); + await seedComposioGmail(database, store, { + url: `composio://unclaimed_${suite}`, + authScheme: "OAUTH2", + connect: false, + }); + + await expect( + store.callTool({ + ref: "gmail/GMAIL_FETCH_EMAILS", + args: {}, + botId: "bot_helper", + actorId: "user_asker", + }), + ).rejects.toThrow(/connect it in settings/i); + + // Never dialled. The refusal is local so the person is told their own next step instead of shown + // the broker's error about an account it cannot find, and no call is spent finding that out. + expect(reached).toEqual([]); +}); + +test("a Composio call sends the version recorded for that action", async () => { + const { store, database } = await freshStore(); + const calls: { slug: string; version: string }[] = []; + useComposioClient({ + listActions: async () => [], + execute: async ({ slug, version }) => { + calls.push({ slug, version }); + return vendorAnswered(); + }, + }); + await seedComposioGmail(database, store); + + await store.callTool({ + ref: "gmail/GMAIL_FETCH_EMAILS", + args: {}, + botId: "bot_helper", + actorId: "user_asker", + }); + + expect(calls).toEqual([ + { slug: "GMAIL_FETCH_EMAILS", version: "20260903_00" }, + ]); +}); + +/* + * The composite end-to-end outcome: a model that supplies the reserved key itself does not change + * which revision runs. What holds it is the unconditional strip above the merge in `store.ts`. + * + * This is not a mutation gate, and no single mutation isolates it: reverting the strip, keeping the + * strip but falling back to the raw arguments, and reversing the spread all leave it green, and its + * assertion is already covered by `a Composio call sends the version recorded for that action`. It + * is kept because the property is one somebody will want to confirm, not because it guards it. + */ +test("a version a model supplied in its own arguments cannot beat the recorded one", async () => { + const { store, database } = await freshStore(); + const calls: { slug: string; version: string }[] = []; + useComposioClient({ + listActions: async () => [], + execute: async ({ slug, version }) => { + calls.push({ slug, version }); + return vendorAnswered(); + }, + }); + await seedComposioGmail(database, store); + + await store.callTool({ + ref: "gmail/GMAIL_FETCH_EMAILS", + // A model filling in the reserved key itself. Stripped unconditionally, non-empty value and + // all, before the recorded version is merged — so it never reaches the vendor under either + // spread order. + args: { __version: "19700101_00" }, + botId: "bot_helper", + actorId: "user_asker", + }); + + // The listed revision, not the one the model asked for: supplying the reserved key changed + // nothing about which revision ran. + expect(calls).toEqual([ + { slug: "GMAIL_FETCH_EMAILS", version: "20260903_00" }, + ]); +}); + +test("a version a model supplied cannot stand in for an action with none recorded", async () => { + const { store, database } = await freshStore(); + const calls: { slug: string; version: string }[] = []; + useComposioClient({ + listActions: async () => [], + execute: async ({ slug, version }) => { + calls.push({ slug, version }); + return vendorAnswered(); + }, + }); + // The action with no recorded version, which is the branch the test above does not cover: there + // is nothing to merge in, and because the model's key was stripped there is no version in the + // arguments at all — which is what the transport refuses on. + await seedComposioGmail(database, store, { version: null }); + + const result = await store.callTool({ + ref: "gmail/GMAIL_FETCH_EMAILS", + args: { __version: "19700101_00" }, + botId: "bot_helper", + actorId: "user_asker", + }); + + // Never dialled. Honoured, the model's version runs a granted action at a revision that was never + // listed, never classified and never granted, and the audit row carries no version field to say + // which revision that was. + // + // Asserted before the refusal, so a regression fails here and names the version that reached the + // vendor, rather than failing on a boolean that names nothing. + expect(calls).toEqual([]); + + // The transport's refusal, which is the advertised answer for an action with no recorded version, + // rather than a call against a revision a model named. The sentence offers a refresh CONDITIONALLY + // — it recovers the action only where Composio publishes a version for it — because where the + // vendor publishes none, no number of refreshes will make the action callable, and promising a + // one-click fix that cannot work sends an operator round a loop. + expect(result.isError).toBe(true); + expect(result.text).toMatch( + /Refreshing this app's tools on its Plugins page recovers it only if/, + ); + expect(result.text).toMatch( + /Where Composio publishes none, no refresh will make it callable/, + ); +}); + +/* + * WHOSE ACCOUNT THE CALL OPENS, OBSERVED AT THE VENDOR. + * + * This is the claim the whole brokered transport exists to make, and until these two tests it was + * the one thing nothing looked at. The deployment holds ONE Composio key; which person's mailbox a + * call opens is decided entirely by the id sent beside it. Every stub in this file took `_userId` + * and threw it away, so a store that sent the Bot's id, or the empty string, or a constant, passed + * all of them — the property held by construction and nothing would have noticed it stopping. + * + * `execute`'s SECOND positional argument is that id. Recorded here rather than counted, so a + * regression fails naming the id that actually went out. + */ +test("a Composio call reaches the vendor as the person asking, not as the Bot", async () => { + const { store, database } = await freshStore(); + const reached: { slug: string; userId: string }[] = []; + useComposioClient({ + listActions: async () => [], + execute: async ({ slug, userId }) => { + reached.push({ slug, userId }); + return vendorAnswered(); + }, + }); + await seedComposioGmail(database, store); + + await store.callTool({ + ref: "gmail/GMAIL_FETCH_EMAILS", + args: {}, + botId: "bot_helper", + actorId: "user_asker", + }); + + // `user_asker`, not `bot_helper` and not "". The id comes off the connection the call path built + // from the session — `app.ts` takes it from a credential assertion and `routes.ts` from the + // session — and it is the only thing standing between this Bot and somebody else's mailbox. + expect(reached).toEqual([ + { slug: "GMAIL_FETCH_EMAILS", userId: "user_asker" }, + ]); +}); + +/** + * An identity a model wrote into its own arguments does not become the identity of the call. + * + * THE THREE KEYS ARE THE ONES THAT WOULD WORK IF ANYTHING READ THEM. `userId` is the parameter + * name on the transport's own projection, `user_id` is how Composio spells arguments, and + * `entityId` is what their SDK called this before it was renamed — so a model that guessed at any + * of the three would be guessing well. + * + * FORWARDED, NOT STRIPPED, AND THAT IS THE CORRECT BEHAVIOUR. The identity is `execute`'s second + * POSITIONAL argument, taken from `connection.actorId`; `args` is the fourth and reaches the vendor + * as the action's own parameters. Nothing on that path reads `args` looking for an identity, which + * is what makes this structural rather than checked. Stripping the keys instead would be a bug with + * a real victim: Composio's schemas are snake_case, `user_id` is an ordinary parameter name on real + * actions, and a transport that swallowed it would quietly drop an argument the person meant. So + * this asserts BOTH halves — the vendor is handed the asker as the identity, and it is handed the + * model's arguments untouched. + * + * The absent `__version` is the other half of the same statement: the reserved key is the ONLY + * thing removed from what a model sent. + */ +test("an identity a model puts in the arguments does not change whose account the call opens", async () => { + const { store, database } = await freshStore(); + const reached: { userId: string; args: Record }[] = []; + useComposioClient({ + listActions: async () => [], + execute: async ({ userId }, args) => { + reached.push({ userId, args }); + return vendorAnswered(); + }, + }); + await seedComposioGmail(database, store); + + await store.callTool({ + ref: "gmail/GMAIL_FETCH_EMAILS", + // A model naming somebody else, three ways, beside one argument it genuinely meant. + args: { + userId: "user_stranger", + user_id: "user_stranger", + entityId: "user_stranger", + query: "is:unread", + }, + botId: "bot_helper", + actorId: "user_asker", + }); + + expect(reached).toEqual([ + { + // The session's person. None of the three keys reached the identity, because the identity is + // not read from arguments at all. + userId: "user_asker", + // Passed through whole, minus nothing: the reserved version key is the only thing the call + // path removes, and the model sent none. + args: { + userId: "user_stranger", + user_id: "user_stranger", + entityId: "user_stranger", + query: "is:unread", + }, + }, + ]); +}); + +test("a Composio call is recorded as reaching the vendor as the person, not as the deployment", async () => { + const { store, database, auditStore } = await freshStore(); + useComposioClient({ + listActions: async () => [], + execute: async () => vendorAnswered({ messages: [] }), + }); + await seedComposioGmail(database, store); + + await store.callTool({ + ref: "gmail/GMAIL_FETCH_EMAILS", + args: {}, + botId: "bot_helper", + actorId: "user_asker", + }); + + const call = auditStore + .recorded() + .find((event) => event.eventType === "mcp.call_succeeded"); + + // The single question a per-person connector raises: whose account did this reach. Recorded as + // "deployment", the trail is wrong about exactly the thing this connector exists for — two rows for + // the same action and the same Bot can have touched two different mailboxes, and nothing else in + // the row says which. + expect(call?.payload).toMatchObject({ reachedAs: "user_asker" }); +}); + +/** + * Every call event this suite's store recorded against one tool, in order. + * + * Named because the assertions below are about WHICH event was written, and reading that off an + * unfiltered list would also pick up the grant the fixture makes. `mcp.call_` is the prefix the + * three outcomes share. + */ +function callEventsFor( + auditStore: { recorded: () => { eventType: string; targetId?: string }[] }, + targetId: string, +) { + return auditStore + .recorded() + .filter( + (event) => + event.targetId === targetId && event.eventType.startsWith("mcp.call_"), + ); +} + +/** + * A vendor that reported its own failure is filed as a failure, not as a success. + * + * `mcp.call_failed` is derived from `result.isError` and nothing asserted the derivation: flipping + * the two event names left the suite green, so the trail could have said `mcp.call_succeeded` about + * every refused call and the only surface that counts successes would have agreed. That is the same + * class of defect as the one the comment above the try block describes — a trail asserting the + * opposite of what happened — and it was still open on this branch. + * + * The sentence matters as much as the name. `payload.failure` is the vendor's own words, and it is + * the most useful thing an operator gets: it is what turned "the connector is broken" into "the + * connection lapsed" on the Drive path. + */ +test("a call the vendor refused is filed as failed, with the vendor's own sentence", async () => { + const { store, database, auditStore } = await freshStore(); + useComposioClient({ + listActions: async () => [], + execute: async () => + vendorRefused("Gmail rejected the request: bad label."), + }); + await seedComposioGmail(database, store); + + const result = await store.callTool({ + ref: "gmail/GMAIL_FETCH_EMAILS", + args: {}, + botId: "bot_helper", + actorId: "user_asker", + }); + + expect(result.isError).toBe(true); + + // Exactly one call row, and it is the failure. Asserted as the whole list rather than by finding + // a failure in it, because a `find` passes just as happily when a `mcp.call_succeeded` row sits + // beside it — and a success row for a refused call is the thing being ruled out. + expect(callEventsFor(auditStore, "gmail/GMAIL_FETCH_EMAILS")).toMatchObject([ + { + eventType: "mcp.call_failed", + payload: { + actor: "user_asker", + bot: "bot_helper", + failure: "Gmail rejected the request: bad label.", + }, + }, + ]); +}); + +/** + * Our own unreadable answer is filed under the SAME name as the vendor's refusal. + * + * GATED AS IT BEHAVES TODAY, AND THE CONFLATION IS THE FINDING. `callTool` in `composio.ts` is + * careful to keep these two apart — the vendor's `try` holds the vendor's call and nothing else, + * precisely so a `JSON.stringify` throw of ours is not reported as the action having failed — and + * then `store.ts` collapses the distinction again on the way to the trail, because the event name + * is derived from `isError` alone and both are `isError: true`. So the only thing telling an + * operator "Composio refused" from "Composio answered and we could not read it" is the sentence in + * `payload.failure`, which is prose and not a queryable field. A reader counting `mcp.call_failed` + * to decide whether a connector is healthy cannot separate a vendor fault from a bug of ours. + * + * A circular `data` is the honest way to reach it: `resultOf` stringifies whatever the vendor sent, + * and a structure that cannot be serialized is one of the three faults its comment names. + */ +test("an answer this deployment could not read is filed under the same name as a vendor refusal", async () => { + const { store, database, auditStore } = await freshStore(); + const circular: Record = {}; + circular.itself = circular; + useComposioClient({ + listActions: async () => [], + execute: async () => vendorAnswered(circular), + }); + await seedComposioGmail(database, store); + + const result = await store.callTool({ + ref: "gmail/GMAIL_FETCH_EMAILS", + args: {}, + botId: "bot_helper", + actorId: "user_asker", + }); + + expect(result.isError).toBe(true); + + const [event] = callEventsFor(auditStore, "gmail/GMAIL_FETCH_EMAILS"); + // The same event name the vendor's own refusal gets, one test above. + expect(event?.eventType).toBe("mcp.call_failed"); + // And the sentence is the only thing that says this one was ours. + expect((event?.payload as { failure?: string } | undefined)?.failure).toMatch( + /could not turn that answer into text/, + ); +}); + +test("an action's effect, destructive marker and version round-trip", async () => { + const database = await freshDatabase(); + + await database.insert(mcpServers).values({ + id: "gmail", + title: "Gmail", + vendor: "Composio", + url: "composio://gmail", + provenance: "composio", + }); + + await database.insert(mcpTools).values([ + { + serverId: "gmail", + name: "GMAIL_FETCH_EMAILS", + description: "Fetch emails.", + effect: "read", + destructive: false, + version: "20260903_00", + }, + { + serverId: "gmail", + name: "GMAIL_DELETE_MESSAGE", + description: "Delete a message.", + effect: "write", + destructive: true, + version: "20260903_00", + }, + ]); + + const rows = await database + .select({ + name: mcpTools.name, + effect: mcpTools.effect, + destructive: mcpTools.destructive, + version: mcpTools.version, + }) + .from(mcpTools) + .where(eq(mcpTools.serverId, "gmail")) + .orderBy(asc(mcpTools.name)); + + expect(rows).toEqual([ + { + name: "GMAIL_DELETE_MESSAGE", + effect: "write", + destructive: true, + version: "20260903_00", + }, + { + name: "GMAIL_FETCH_EMAILS", + effect: "read", + destructive: false, + version: "20260903_00", + }, + ]); +}); + +test("an action listed before these columns existed reads as unclassified and unversioned", async () => { + const database = await freshDatabase(); + + await database.insert(mcpServers).values({ + id: "notion", + title: "Notion", + vendor: "Notion", + url: "https://mcp.notion.com/mcp", + provenance: "first-party", + }); + await database.insert(mcpTools).values({ + serverId: "notion", + name: "notion-fetch", + description: "Fetch a page.", + }); + + const [row] = await database + .select({ + effect: mcpTools.effect, + destructive: mcpTools.destructive, + version: mcpTools.version, + }) + .from(mcpTools) + .where(eq(mcpTools.serverId, "notion")); + + // Null rather than a default: an existing row must keep meaning exactly what it meant, and the + // classifier decides what an absent effect implies. A column default of "write" would silently + // reclassify every already-listed Notion read as a write the moment the migration ran. + expect(row).toEqual({ effect: null, destructive: false, version: null }); +}); + +test("a brokered call is judged by the effect the vendor recorded, not by the absent catalogue entry", async () => { + const { store, database, auditStore } = await freshStore(); + useComposioClient({ + listActions: async () => [], + execute: async () => vendorAnswered(), + }); + // `effect: "read"` on the seeded action, and no catalogue entry for `gmail` at all — so the two + // sources disagree and the row records which one decided. + expect(catalogueEntry("gmail")).toBeNull(); + await seedComposioGmail(database, store); + + const result = await store.callTool({ + ref: "gmail/GMAIL_FETCH_EMAILS", + args: {}, + botId: "bot_helper", + actorId: "user_asker", + }); + + // The call completes: the transport is handed the version this action was listed at. Irrelevant to + // what is under test — `effect` is decided before the vendor is dialled and the row carries the + // decision on either outcome, which is the whole point of holding `decided` rather than writing it. + expect(result.isError).toBe(false); + + const call = auditStore + .recorded() + .find((event) => event.eventType === "mcp.call_succeeded"); + // "read", because Composio labelled the action and the classifier prefers that label. "write" is + // what an unlisted-in-`writeTools` tool on a server with no entry behind it comes out as, and that + // is what this row said while the recorded effect was being selected and never passed on: every + // Gmail read gated as a write, and `intent` in the policy context reading `write_tool`. + expect((call?.payload as { effect?: string } | undefined)?.effect).toBe( + "read", + ); +}); + +test("the Plugins page shows a brokered action with the effect the vendor recorded", async () => { + const { store, database } = await freshStore(); + await seedComposioGmail(database, store); + + const gmail = (await store.listServers()).find( + (server) => server.id === "gmail", + ); + + // Same disagreement as the call path, on the surface an administrator reads: no catalogue entry + // behind `gmail`, so the reviewed-list branch has nothing to say and would call every one of the + // app's actions a write. Shown as a write, this page tells an administrator that granting a Bot + // "fetch emails" grants it something that changes their mailbox. + expect( + gmail?.tools.map((tool) => ({ name: tool.name, effect: tool.effect })), + ).toEqual([{ name: "GMAIL_FETCH_EMAILS", effect: "read" }]); +}); + +/** + * The narrow read the brokered routes make, and the one thing it must not do. + * + * CRITERION. `serverAddress` answers the url the ROW holds, whatever the row is called. + * + * REASON. Everything brokered rests on the id and the url being allowed to differ: `addBrokeredApp` + * writes `composio://` and names the row for it, and nothing afterwards holds the two equal. + * The routes read the app out of the url for exactly that reason, so a read that answered them with + * `composio://${id}` — composed, and always agreeing with the id — would put the mismatch back + * underneath four call sites at once and read as correct on every row anybody had not renamed. The + * fixture is seeded with the disagreement on purpose, which is what `seedComposioGmail`'s own `url` + * option exists for. + * + * The url listing is asserted off this fixture rather than out of a case of its own: it answers the + * directory's set question from the same column, and the property worth pinning is the same one. + */ +test("a server's address is the url the row holds, not one composed from its id", async () => { + const { store, database } = await freshStore(); + // A row called `gmail` pointing at Slack, which is the shape the connection gate was once keyed + // on the wrong half of. + await seedComposioGmail(database, store, { url: "composio://slack" }); + + expect(await store.serverAddress("gmail")).toEqual({ + id: "gmail", + title: "Gmail", + // Not `composio://gmail`. That is what composing the url from the id would have answered, and + // it is the app this row does not run against. + url: "composio://slack", + // Null, because a scheme is written down when an authorization config is CREATED and this + // fixture creates none. The column holds nothing for a row nothing was created against rather + // than a default standing in for one, so null here is the read passing the column through. + authScheme: null, + }); + /* + * By membership rather than by equality, because this database is not only this test's: the + * suite's own `google-drive` row is there, and on a deployment somebody is using so is whatever + * they have added. What is being asserted is which of the two spellings of this row reaches the + * directory, and that survives the company. + */ + const urls = await store.serverUrls(); + expect(urls).toContain("composio://slack"); + expect(urls).not.toContain("composio://gmail"); +}); + +test("a server id naming no row is answered with nothing", async () => { + const { store, database } = await freshStore(); + await seedComposioGmail(database, store); + + // `undefined` rather than a throw or an empty row, because that is what the `.find` over the + // whole server list answered before — and the routes turn it into the 400 that says this app is + // not reached through a broker. + expect(await store.serverAddress("composio-gmail")).toBeUndefined(); +}); + +test("refreshing a Composio app records each action's effect, destructive marker and version", async () => { + const { store, database } = await freshStore(); + useComposioClient({ + listActions: async () => [ + { + slug: "GMAIL_FETCH_EMAILS", + description: "Fetch emails.", + inputParameters: { type: "object", properties: {} }, + tags: ["readOnlyHint"], + version: "20260903_00", + }, + { + slug: "GMAIL_DELETE_MESSAGE", + description: "Delete a message.", + inputParameters: { type: "object", properties: {} }, + tags: ["destructiveHint"], + version: "20260903_00", + }, + { + slug: "GMAIL_SEND_EMAIL", + description: "Send an email.", + inputParameters: { type: "object", properties: {} }, + tags: ["createHint"], + version: "20260903_00", + }, + ], + execute: async () => vendorAnswered(), + }); + + await database.insert(mcpServers).values({ + id: "gmail", + title: "Gmail", + vendor: "Composio", + url: "composio://gmail", + provenance: "composio", + }); + + await store.refreshTools("gmail", "admin_user"); + + const rows = await database + .select({ + name: mcpTools.name, + effect: mcpTools.effect, + destructive: mcpTools.destructive, + version: mcpTools.version, + }) + .from(mcpTools) + .where(eq(mcpTools.serverId, "gmail")) + .orderBy(asc(mcpTools.name)); + + // The vendor's own three answers, as the listing gave them: a label, a marker, and the version a + // call is impossible without. `createHint` is a write with no marker — an ordinary write is not + // dangerous, and marking it so teaches an approver to click through the colour. + expect(rows).toEqual([ + { + name: "GMAIL_DELETE_MESSAGE", + effect: "write", + destructive: true, + version: "20260903_00", + }, + { + name: "GMAIL_FETCH_EMAILS", + effect: "read", + destructive: false, + version: "20260903_00", + }, + { + name: "GMAIL_SEND_EMAIL", + effect: "write", + destructive: false, + version: "20260903_00", + }, + ]); +}); + +/** + * A grant on a Composio action the vendor has stopped listing, still reported as held. + * + * Confirmed for a brokered row rather than built: `listServers` derives `withdrawn` from the grants + * no advertised action covers, with no transport-specific branch, so the Drive suite above gates + * the mechanism. What a Composio row adds is that it inherits it — the app's page lists what the + * last refresh advertised, so with nothing reporting the gap a permission on an action Composio + * dropped is invisible and the Bot loses a capability nobody revoked. + * + * The refreshed listing names a DIFFERENT action rather than none at all, because an empty list is + * also what a refresh the vendor refused leaves behind: every grant would come out withdrawn and + * this would hold for a reason that has nothing to do with the action being gone. + */ +test("a granted Composio action that the vendor withdrew is still shown as granted", async () => { + const { store, database } = await freshStore(); + useComposioClient({ + listActions: async () => [ + { + slug: "GMAIL_SEND_EMAIL", + description: "Send an email.", + inputParameters: { type: "object", properties: {} }, + tags: ["createHint"], + version: "20260903_00", + }, + ], + execute: async () => vendorAnswered(), + }); + await seedComposioGmail(database, store); + + // The granted action is absent from what the vendor now lists, and another action is not. + await store.refreshTools("gmail", "admin_user"); + + const gmail = (await store.listServers()).find( + (server) => server.id === "gmail", + ); + + expect(gmail?.withdrawn).toEqual([ + { + ref: "gmail/GMAIL_FETCH_EMAILS", + name: "GMAIL_FETCH_EMAILS", + grantedTo: ["bot_helper"], + }, + ]); + // The advertised action came through as a tool, which is what says the refresh actually listed + // rather than failing into the empty list that would withdraw everything. + expect(gmail?.tools.map((tool) => tool.ref)).toEqual([ + "gmail/GMAIL_SEND_EMAIL", + ]); +}); + +/* + * A refresh the transport REFUSED, on the path every real deployment takes. + * + * NOTHING IN THE SHIPPED PRODUCT CALLS `useComposioClient`, so `installed` is null on every live + * install and `composio.listTools` throws for want of a client to ask with. The tests below install + * no stub, which is that state exactly rather than a fiction about it. + * + * THE PREMISE THIS DESCRIBE USED TO STATE — that the transport answers `[]` rather than throwing — + * WAS TRUE AND IS NOT. It was corrected at the seam, deliberately: a transport that could not ask + * anybody must throw, because an empty answer is indistinguishable from an app that genuinely + * publishes nothing. These three cases therefore land in `refreshTools`'s vendor `catch`, which + * records the sentence and returns before the replace — and they say nothing whatever about the + * empty-listing guard below it, which is what the describe after this one is for. Two suites + * asserting the same three outcomes through different branches is how that guard came to be + * deletable with every test still green. + * + * What is under test here is the catch branch's own promise: a listing that could not be made + * leaves every recorded action where it is, stamps no refresh, and withdraws nothing. + */ +describe("a refresh whose transport refused to ask anybody", () => { + test("leaves the actions the app already advertises, with what the vendor said about them", async () => { + const { store, database } = await freshStore(); + await seedComposioGmail(database, store); + + await store.refreshTools("gmail", "admin_user"); + + const rows = await database + .select({ + name: mcpTools.name, + effect: mcpTools.effect, + version: mcpTools.version, + }) + .from(mcpTools) + .where(eq(mcpTools.serverId, "gmail")); + + // The version above all: it is what `callTool` sends, so losing it breaks every later call on + // an app the refresh reported as fine. + expect(rows).toEqual([ + { name: "GMAIL_FETCH_EMAILS", effect: "read", version: "20260903_00" }, + ]); + }); + + test("does not report the app as healthy", async () => { + const { store, database } = await freshStore(); + await seedComposioGmail(database, store); + await database + .update(mcpServers) + .set({ lastError: "The vendor would not answer." }) + .where(eq(mcpServers.id, "gmail")); + + await store.refreshTools("gmail", "admin_user"); + + const [row] = await database + .select({ + lastError: mcpServers.lastError, + toolsRefreshedAt: mcpServers.toolsRefreshedAt, + }) + .from(mcpServers) + .where(eq(mcpServers.id, "gmail")); + + /* + * The transport's own sentence, named rather than merely counted as present. + * + * `not.toBeNull()` was what this asserted, and the refresh had in fact OVERWRITTEN the value + * the test set up two lines earlier — so "not cleared" passed on a different string than the + * one it was about, and would have gone on passing had the column been filled with anything at + * all, the empty-listing guard's sentence included. Which sentence is here is the whole + * difference between "nobody could be asked" and "the app was asked and offers nothing", and + * those send an operator to different places. + */ + expect(row?.lastError).toContain("Composio is not configured"); + expect(row?.lastError).not.toBe("The vendor would not answer."); + // And no refresh stamp, because nothing was listed: the column says when this deployment last + // learned what the app offers, and it did not learn it here. + expect(row?.toolsRefreshedAt).toBeNull(); + }); + + test("does not strand the grants the app is holding", async () => { + const { store, database, auditStore } = await freshStore(); + await seedComposioGmail(database, store); + + await store.refreshTools("gmail", "admin_user"); + + const gmail = (await store.listServers()).find( + (server) => server.id === "gmail", + ); + + // Still offered and still not withdrawn. + expect(gmail?.tools.map((tool) => tool.ref)).toEqual([ + "gmail/GMAIL_FETCH_EMAILS", + ]); + expect(gmail?.withdrawn).toEqual([]); + // Nor filed as having stopped being offered, which would be the trail asserting a withdrawal + // the vendor never made. + expect( + auditStore + .recorded() + .filter( + (event) => + (event.payload as { change?: string }).change === + "grants_not_advertised", + ), + ).toEqual([]); + }); + + test("a brokered row whose url names no app raises rather than blaming the vendor", async () => { + const { store, database } = await freshStore(); + // `accessFor` still answers `brokered` for any row whose provenance says composio, so this is + // `{ credential: "brokered", toolkit: null }` — a row a hand edit or an old backup produces and + // nothing in the product can. There is no app to ask, so an empty answer is not the vendor's. + await seedComposioGmail(database, store, { + url: "https://example.com/mcp", + }); + + await expect(store.refreshTools("gmail", "admin_user")).rejects.toThrow( + PluginInvariantError, + ); + + const [row] = await database + .select({ lastError: mcpServers.lastError }) + .from(mcpServers) + .where(eq(mcpServers.id, "gmail")); + + // The state is this deployment's own, so it must not be written down as something a vendor did. + expect(row?.lastError).toBeNull(); + }); +}); + +/** + * THE VENDOR ITSELF ANSWERING NOTHING, which is the state the empty-listing guard exists for. + * + * WHAT THIS COVERS THAT NOTHING ELSE DOES. The guard sits after the vendor `catch` and before the + * wholesale replace, and only a listing that was actually MADE and came back empty reaches it. The + * describe above cannot: its transport throws, so it returns from the catch several lines earlier. + * With those three cases routed around it, `if (listed.length === 0)` could be replaced by + * `if (false)` — deleting the guard outright — and the whole suite stayed green. Everything below + * reddens under that mutation, which is the only thing that makes the guard's presence a fact + * about this codebase rather than a comment in it. + * + * WHY IT MATTERS. The replace is a delete and an insert, so committing an empty answer deletes + * every `mcp_tools` row for the app and takes `effect`, `destructive` and `version` with it. + * `version` cannot be reconstructed — `callTool` refuses an action without one — so a refresh that + * reported success broke every later call, with the grants left pointing at rows that no longer + * exist. A stub that answers `[]` is a vendor's honest answer and is exactly what an app that has + * been emptied at the broker looks like; keeping what is held is the only reading that is + * recoverable if it is wrong. + */ +describe("a refresh the vendor answered with no actions at all", () => { + /** A client that answers, and answers nothing — which no throw can stand in for. */ + function useEmptyAnsweringClient() { + useComposioClient({ + listActions: async () => [], + execute: async () => vendorAnswered(), + }); + } + + test("keeps every action already recorded, with what the vendor said about them", async () => { + const { store, database } = await freshStore(); + useEmptyAnsweringClient(); + await seedComposioGmail(database, store); + + // The honest count is what is HELD, because nothing was replaced. Answering 0 here would tell + // the page the app offers nothing while the rows are still there. + expect(await store.refreshTools("gmail", "admin_user")).toEqual({ + tools: 1, + }); + + const rows = await database + .select({ + name: mcpTools.name, + effect: mcpTools.effect, + version: mcpTools.version, + }) + .from(mcpTools) + .where(eq(mcpTools.serverId, "gmail")); + + // The version above all: it is what `callTool` sends, so losing it breaks every later call on + // an app the refresh reported as fine. + expect(rows).toEqual([ + { name: "GMAIL_FETCH_EMAILS", effect: "read", version: "20260903_00" }, + ]); + }); + + test("says the actions were kept, and does not stamp a refresh", async () => { + const { store, database } = await freshStore(); + useEmptyAnsweringClient(); + await seedComposioGmail(database, store); + + await store.refreshTools("gmail", "admin_user"); + + const [row] = await database + .select({ + lastError: mcpServers.lastError, + toolsRefreshedAt: mcpServers.toolsRefreshedAt, + }) + .from(mcpServers) + .where(eq(mcpServers.id, "gmail")); + + /* + * The sentence for THIS state and not the other one. The app answered, so nothing here may + * send an operator to check their configuration — that is the refused transport's sentence, + * and the describe above asserts that one. What this reader needs to know is that the app + * listed nothing and that the actions it holds were not deleted over it. + */ + expect(row?.lastError).not.toBeNull(); + expect(row?.lastError).toContain("kept rather than deleted"); + // No stamp: the column says when this deployment last learned what the app offers, and an + // answer it declined to believe is not it. + expect(row?.toolsRefreshedAt).toBeNull(); + }); + + test("withdraws nothing and strands no grant", async () => { + const { store, database, auditStore } = await freshStore(); + useEmptyAnsweringClient(); + await seedComposioGmail(database, store); + + await store.refreshTools("gmail", "admin_user"); + + const gmail = (await store.listServers()).find( + (server) => server.id === "gmail", + ); + + // Still offered and still not withdrawn. + expect(gmail?.tools.map((tool) => tool.ref)).toEqual([ + "gmail/GMAIL_FETCH_EMAILS", + ]); + expect(gmail?.withdrawn).toEqual([]); + // Nor filed as having stopped being offered. That row is written from the listing, so an empty + // one committed would name every grant the app holds — the trail asserting a withdrawal on + // exactly the answer this deployment decided not to believe. + expect( + auditStore + .recorded() + .filter( + (event) => + (event.payload as { change?: string }).change === + "grants_not_advertised", + ), + ).toEqual([]); + }); +}); + +/** + * WHO IS TOLD WHAT, when the row itself is the thing that cannot be resolved. + * + * `ServerRowAmbiguousError` refuses a row whose provenance says `composio` and whose id is a + * curated catalogue slug: nothing in the row and the entry tells a tampered curated row apart from + * a brokered app that took the name, so there is no answer that is not wrong in one of the two + * worlds. It shipped caught NOWHERE. Every audience therefore got the wrong thing at once — the + * admin page a bodiless 500 it renders as "That did not work", and a model the operator's own + * sentence about correcting a provenance column, offered to an end user as the reason their tool + * failed. + * + * The store's half is asserted here: it refuses, and it records nothing about a vendor while doing + * so. What each audience then sees is asserted where that audience is — the model below, and the + * administrator in `plugin-routes.test.ts`, which is the file that exists for that mapping. + */ +describe("a row that resolves to two servers at once", () => { + /** A colliding row, spelled the way the collision actually occurs: a curated slug, brokered. */ + async function seedCollidingNotion(database: Database) { + await database.insert(mcpServers).values({ + id: "notion", + title: "Notion", + vendor: "Composio", + url: "composio://notion", + provenance: "composio", + }); + } + + test("a refresh refuses it, and writes nothing about a vendor", async () => { + const { store, database } = await freshStore(); + await seedCollidingNotion(database); + + await expect(store.refreshTools("notion", "admin_user")).rejects.toThrow( + ServerRowAmbiguousError, + ); + + const [row] = await database + .select({ + lastError: mcpServers.lastError, + toolsRefreshedAt: mcpServers.toolsRefreshedAt, + }) + .from(mcpServers) + .where(eq(mcpServers.id, "notion")); + + // Two of our columns disagreeing is not a vendor's answer, so it must not be written where the + // page draws what the vendor said. Raised instead, which is what the route reads. + expect(row?.lastError).toBeNull(); + expect(row?.toolsRefreshedAt).toBeNull(); + }); + + test("the model is told the call did not happen, and nothing about our columns", async () => { + const { store, database } = await freshStore(); + await seedCollidingNotion(database); + await database.insert(mcpTools).values({ + serverId: "notion", + name: "notion-fetch", + description: "Fetch a page.", + }); + await database.insert(agents).values({ + id: "bot_helper", + name: "Helper", + type: "built_in", + configuration: {}, + }); + await store.grant( + "mcp", + "notion/notion-fetch", + "bot_helper", + "admin@example.com", + ); + + const [tool] = await grantedTools({ + store, + botId: "bot_helper", + actorId: "user_asker", + }); + if (!tool) throw new Error("the Bot was offered no tool to call"); + + const answer = await tool.execute({}); + + /* + * Every part of the operator's sentence, named rather than summarised. + * + * The message says the row is one the deployment ships an entry for, that its provenance says + * composio, and that somebody should rename it or correct the column. Each of those is a fact + * about our database and an instruction only an administrator can act on; a model handed any + * of them can only relay or embroider it. Asserted piecewise so a reworded sentence that still + * leaks cannot pass by not matching one long string. + */ + expect(answer).not.toContain("provenance"); + expect(answer).not.toContain("rename"); + expect(answer).not.toContain("notion"); + // And not dressed as a refusal either: nothing was decided against, so the marker the + // transcript draws as a boundary holding would be a lie about which of the two happened. + expect(answer.startsWith(REFUSAL_MARKER)).toBe(false); + expect(answer).toBe("That tool could not be called."); + }); + + test("it is on the same shelf the store already raises rather than records", async () => { + /* + * The distinction, asked the way every audience asks it. + * + * Both audiences above branch on `isDeploymentFault` rather than on a class list of their own, + * so what makes them correct is this answer and not the two `catch` blocks. A class added to + * the shelf and forgotten here is the defect being fixed, one round later. + */ + expect(isDeploymentFault(new ServerRowAmbiguousError("x"))).toBe(true); + expect(isDeploymentFault(new CatalogueTransportUnroutableError("x"))).toBe( + true, + ); + expect(isDeploymentFault(new PluginInvariantError("x"))).toBe(true); + // And the refusal somebody CAN act on is not on it: its message is the one thing this codebase + // relays verbatim, to a model and to a browser alike. + expect(isDeploymentFault(new PluginRefusedError("x", null))).toBe(false); + expect(isDeploymentFault(new Error("the vendor did not answer"))).toBe( + false, + ); + }); +}); + +/** + * A catalogue entry naming the broker's transport, which no entry can be reached over. + * + * NOT A LIVE BUG AND NOT MEANT TO BECOME ONE. No entry declares it, and `CuratedTransportKind` now + * makes declaring it a compile error — which is the real fix, since entries are code. This is what + * holds when the type is bypassed: a cast, a fixture like the one below, or a loader that ever + * reads an entry from outside the build. + * + * WHAT THE UNREFUSED ANSWER WAS. `transport: "composio"` with `toolkit: null`, a credential taken + * from the entry's auth kind rather than `brokered`, and `reachedAs` from the same table. So the + * dial went to the broker while both gates that keep one person's brokered account out of + * another's — the connection lookup in `connectionTokenFor` and the app-slug check in + * `refreshTools` — were keyed on a null app and skipped, and the trail recorded whose account had + * been reached from a field that had nothing to do with it. Refusing is the only answer that does + * not assert something false. + */ +test("a catalogue entry declaring the broker's transport is refused, not dialled", () => { + /* + * Cast at the fixture, deliberately and in one place. The type is what keeps this out of the + * catalogue, so a test about what happens when the type is bypassed has to bypass it — and doing + * it here rather than in a helper keeps the bypass visible beside the thing it is testing. + */ + const brokered = { + key: "brokered-entry", + title: "Brokered Entry", + vendor: "Somebody", + summary: "An entry that names a transport an entry cannot be reached over.", + host: "https://mcp.example.com", + path: "/mcp", + auth: { + kind: "user-oauth" as const, + authorizationUrl: "https://example.com/auth", + tokenUrl: "https://example.com/token", + revokeUrl: "https://example.com/revoke", + scopes: [], + }, + writeTools: [], + transport: "composio", + docsUrl: "https://example.com/docs", + } as unknown as CatalogueEntry; + + expect(() => + accessFor( + { provenance: "first-party", url: "https://mcp.example.com/mcp" }, + brokered, + ), + ).toThrow(CatalogueTransportUnroutableError); + + // The same entry with the transport it is actually reached over resolves as any other curated + // per-person vendor does, so what is refused is the value and not the fixture. + expect( + accessFor( + { provenance: "first-party", url: "https://mcp.example.com/mcp" }, + { ...brokered, transport: undefined }, + ), + ).toEqual({ + transport: "mcp", + credential: "person-oauth", + reachedAs: "person", + toolkit: null, + }); +}); + +/** + * WHAT A VENDOR SENT THAT THIS DATABASE WILL NOT TAKE, and what came out when it did not. + * + * Both of these aborted the wholesale replace from INSIDE its transaction, which sits OUTSIDE the + * vendor `try` above it — so neither was recorded and neither was caught. What left `refreshTools` + * was drizzle's `DrizzleQueryError`, whose message is `Failed query:` followed by the entire + * statement and then `params:` and every value bound to it. A SQL dump on an error path is the + * same disclosure shape as a credential leak one layer out, and it reached the logs and any caller + * that prints an error, while `lastError` sat holding whatever it held before: stale, or null, on + * a refresh that had failed outright. + * + * Both are now settled before a statement is built, which is why the assertions below are about + * the rows rather than about a better error. + */ +describe("a listing this database would not have taken", () => { + test("a vendor naming one action twice records it once", async () => { + const { store, database } = await freshStore(); + /* + * The same slug twice, with different text, which is what a paginated listing that overlaps + * or a broker with two entries for one action produces. `(server_id, name)` is the primary + * key, so as one multi-row insert this refused the whole statement and rolled the delete back + * with it — leaving the app holding its old actions and the refresh throwing a dump. + */ + useComposioClient({ + listActions: async () => [ + { + slug: "GMAIL_SEND_EMAIL", + description: "Send an email.", + inputParameters: { type: "object", properties: {} }, + version: "20260903_00", + }, + { + slug: "GMAIL_SEND_EMAIL", + description: "Send an email, listed again.", + inputParameters: { type: "object", properties: {} }, + version: "20260903_00", + }, + ], + execute: async () => vendorAnswered(), + }); + await seedComposioGmail(database, store); + + // One action, because the vendor named one action. Not two rows, and not a refusal. + expect(await store.refreshTools("gmail", "admin_user")).toEqual({ + tools: 1, + }); + + const rows = await database + .select({ + name: mcpTools.name, + description: mcpTools.description, + }) + .from(mcpTools) + .where(eq(mcpTools.serverId, "gmail")); + + // The first occurrence, because the order is the vendor's own and there is no rule that says + // which of two identical names is the real one. + expect(rows).toEqual([ + { name: "GMAIL_SEND_EMAIL", description: "Send an email." }, + ]); + + const [row] = await database + .select({ lastError: mcpServers.lastError }) + .from(mcpServers) + .where(eq(mcpServers.id, "gmail")); + // A healthy refresh, because that is what it was. + expect(row?.lastError).toBeNull(); + }); + + test("a U+0000 in what the vendor wrote is dropped rather than aborting the replace", async () => { + const { store, database } = await freshStore(); + /* + * In the name, in the description and inside the schema, because all three reach the insert + * and the column types differ: `text` refuses the byte and `jsonb` refuses the escape, and + * each aborts the same transaction from a different statement position. + */ + useComposioClient({ + listActions: async () => [ + { + slug: "GMAIL_SEND\u0000_EMAIL", + description: "Send\u0000 an email.", + inputParameters: { + type: "object", + properties: { subject: { description: "The\u0000 subject" } }, + }, + version: "2026\u00000903_00", + }, + ], + execute: async () => vendorAnswered(), + }); + await seedComposioGmail(database, store); + + expect(await store.refreshTools("gmail", "admin_user")).toEqual({ + tools: 1, + }); + + const [stored] = await database + .select({ + name: mcpTools.name, + description: mcpTools.description, + inputSchema: mcpTools.inputSchema, + version: mcpTools.version, + }) + .from(mcpTools) + .where(eq(mcpTools.serverId, "gmail")); + + expect(stored?.name).toBe("GMAIL_SEND_EMAIL"); + expect(stored?.description).toBe("Send an email."); + expect(stored?.version).toBe("20260903_00"); + // Inside the schema too, and the rest of the schema rebuilt exactly as it arrived. + expect(stored?.inputSchema).toEqual({ + type: "object", + properties: { subject: { description: "The subject" } }, + }); + }); + + test("a replace this database still refuses raises without the statement", async () => { + /* + * A transaction forced to fail, because after the two cases above nothing a vendor can send + * reaches this branch — and this branch is the one that used to publish the dump. What is + * asserted is the SHAPE of what comes out: the driver's own complaint, and none of the + * statement or the values bound to it. + */ + const { store, database } = await freshStore(); + useComposioClient({ + listActions: async () => [ + { + slug: "GMAIL_SEND_EMAIL", + description: "Send an email.", + inputParameters: { type: "object", properties: {} }, + version: "20260903_00", + }, + ], + execute: async () => vendorAnswered(), + }); + await seedComposioGmail(database, store); + + /* + * Derived from the real one rather than stubbed, so every other query the refresh makes is + * the real query. The failure is spelled the way drizzle spells one: the statement and every + * bound value in `message`, the driver's own error hung off `cause`. That message is what + * used to escape. + */ + const refusing: Database = Object.create(database); + Object.defineProperty(refusing, "transaction", { + value: async () => { + throw Object.assign( + new Error( + 'Failed query: insert into "mcp_tools" ("server_id", "name") values ($1, $2) params: gmail, GMAIL_SEND_EMAIL', + ), + { + cause: new Error( + 'duplicate key value violates unique constraint "mcp_tools_pkey"', + ), + }, + ); + }, + }); + + const failing = createPluginStore({ + database: refusing, + auditStore: { insert: async () => {} }, + credentials: credentialsStub, + encryptionKey: "x".repeat(44), + policy: () => policy, + }); + + let thrown: unknown; + try { + await failing.refreshTools("gmail", "admin_user"); + } catch (error) { + thrown = error; + } + + const message = thrown instanceof Error ? thrown.message : String(thrown); + // The driver's complaint, which names what went wrong. + expect(message).toContain("duplicate key value violates unique constraint"); + // And nothing of the statement or of what was bound to it. + expect(message).not.toContain("Failed query"); + expect(message).not.toContain("insert into"); + expect(message).not.toContain("params:"); + // On the shelf that is raised rather than recorded and never relayed to a model, because a + // transaction this database would not take is not something the vendor did. + expect(isDeploymentFault(thrown)).toBe(true); + + // And what the app already had is still there, because nothing was committed. + expect( + await database + .select({ name: mcpTools.name }) + .from(mcpTools) + .where(eq(mcpTools.serverId, "gmail")), + ).toEqual([{ name: "GMAIL_FETCH_EMAILS" }]); + }); +}); + +/** + * A QUERY OF OURS THAT FAILED, on the two paths that copy a caught message onward. + * + * WHAT THE SHAPE IS. drizzle wraps every failure as a `DrizzleQueryError`: `message` is `Failed + * query:` plus the whole statement, then `params:` and every value bound to it, with the driver's + * own error on `cause`. On the tool-call path those values are credential ids, user ids and server + * ids; on the refresh path they are the vendor's tool list. + * + * WHERE IT COMES FROM. A per-person MCP listing is the shape that runs a query of ours inside the + * block that catches the vendor's failures: `connectionTokenFor` reads the asking person's stored + * grant there. `composio` never gets that far — `listNeedsCredential` is false for it, the only + * brokered transport — and a server added by URL reads its one token from the vault rather than + * from a query. So the failure is injected at that one read and arrives exactly where it would in + * production, rather than being handed to the `catch` from somewhere it could not come from. + */ +describe("a query of this deployment's own that failed", () => { + /** The drizzle shape, spelled once: statement and bound values in `message`, driver on `cause`. */ + function queryFailure() { + return Object.assign( + new Error( + 'Failed query: select "credential_id" from "mcp_user_credentials" where "user_id" = $1 params: user_asker', + ), + { + query: 'select "credential_id" from "mcp_user_credentials"', + params: ["user_asker"], + cause: new Error("canceling statement due to statement timeout"), + }, + ); + } + + test("a refresh raises it rather than recording it as what the vendor said", async () => { + const { database } = await freshStore(); + + /* + * Notion, because a per-person MCP listing is the only shape that runs a query of ours inside + * the vendor `try`. `composio` never gets there — `listNeedsCredential` is false for it, so + * `connectionTokenFor` is not called at all — and a server added by URL reads its one token + * from the vault rather than from a query. Resolved from the catalogue rather than spelled, so + * a renamed slug breaks this file instead of quietly emptying it. + */ + const notion = catalogueEntry("notion"); + if (!notion) { + throw new Error( + "catalogue slug `notion` is gone, so nothing here reaches a per-person listing", + ); + } + + // `user_leaver` rather than a new id: this file already owns a `users` row at it, so the + // person, the connection and the server row are all cleaned by machinery that exists. + await database.insert(users).values({ + id: "user_leaver", + email: "leaver@example.com", + name: "Leaver", + }); + const [grant] = await database + .insert(credentialRows) + .values({ + kind: "mcp_user_token", + provider: "notion", + keyId: "user_leaver", + encryptedValue: "{}", + metadata: {}, + }) + .returning({ id: credentialRows.id }); + if (!grant) throw new Error("grant row was not created"); + await database.insert(mcpServers).values({ + id: "notion", + title: notion.title, + vendor: notion.vendor, + url: `${notion.host}${notion.path}`, + provenance: "first-party", + }); + await database.insert(mcpUserCredentials).values({ + serverId: "notion", + userId: "user_leaver", + credentialId: grant.id, + scope: "", + }); + + /* + * The stored-grant read, failed — and nothing else. + * + * Derived from the real database so every other query the refresh makes is the real query. + * The second `select` is the one: `requireServer` reads the server row first, outside the + * block that catches vendor failures, and `connectionTokenFor`'s read of this person's grant + * is the next one and is inside it. Counting is what makes the failure land there rather than + * somewhere a blanket override would put it, and the assertions below distinguish the two — + * the row id in the message is added only by the conversion in that `catch`, so a failure + * escaping the earlier read would arrive as the raw dump and redden. + */ + let selects = 0; + const refusing: Database = Object.create(database); + Object.defineProperty(refusing, "select", { + value: (...args: never[]) => { + selects += 1; + if (selects === 2) throw queryFailure(); + return database.select(...args); + }, + }); + const failing = createPluginStore({ + database: refusing, + auditStore: { insert: async () => {} }, + credentials: credentialsStub, + encryptionKey: "x".repeat(44), + policy: () => policy, + }); + + let thrown: unknown; + try { + await failing.refreshTools("notion", "user_leaver"); + } catch (error) { + thrown = error; + } + + try { + const [row] = await database + .select({ + lastError: mcpServers.lastError, + toolsRefreshedAt: mcpServers.toolsRefreshedAt, + }) + .from(mcpServers) + .where(eq(mcpServers.id, "notion")); + + /* + * Nothing in the column, asserted FIRST because it is the half that was actually broken and + * because its failure prints what leaked. + * + * `lastError` is drawn on the Plugins page beside a refresh that looks merely to have + * failed, and the narrowing meant to keep our own faults out of it tested for a class that + * cannot arrive inside that `try` at all — so it could be deleted with every test green + * while the statement and every value bound to it went into a column an operator reads and + * an export carries. + */ + expect(row?.lastError).toBeNull(); + expect(row?.toolsRefreshedAt).toBeNull(); + + // Raised, because a query this database refused is not something the vendor did — the same + // criterion the replace further down this method is held to. + expect(isDeploymentFault(thrown)).toBe(true); + const message = thrown instanceof Error ? thrown.message : String(thrown); + expect(message).toContain("canceling statement due to statement timeout"); + expect(message).not.toContain("Failed query"); + expect(message).not.toContain("params:"); + expect(message).not.toContain("mcp_user_credentials"); + // Named by the conversion inside the refresh's own `catch`, which is how this asserts WHERE + // the failure was classified and not merely that something was thrown. + expect(message).toContain("notion:"); + } finally { + /* + * Locally, and in this order. `mcp_user_credentials.credential_id` is a real foreign key + * that deliberately does not cascade, so the join row has to go before the vault row — and + * the teardown that clears vault rows for this file runs before the one that clears server + * rows, which is what would otherwise leave a delete refusing. + */ + await database + .delete(mcpUserCredentials) + .where( + and( + eq(mcpUserCredentials.serverId, "notion"), + eq(mcpUserCredentials.userId, "user_leaver"), + ), + ); + await database + .delete(credentialRows) + .where(eq(credentialRows.id, grant.id)); + } + }); + + test("the model is told the call did not happen, and none of the query", async () => { + /* + * At the seam that decides, which is where the leak was. + * + * `grantedTools` takes a store, and the question is what it hands the model when that store + * throws — so the store is the thing stubbed and nothing else is. Every query on the call path + * runs inside `callTool`'s own recording block and comes out of it unchanged, so this shape + * arriving here is the production arrival, not an approximation of one. + */ + const [tool] = await grantedTools({ + store: { + listForAgent: async () => ({ + tools: [ + { + ref: "gmail/GMAIL_FETCH_EMAILS", + toolName: "gmail__GMAIL_FETCH_EMAILS", + description: "Fetch emails.", + inputSchema: { type: "object", properties: {} }, + }, + ], + skills: [], + }), + callTool: async () => { + throw queryFailure(); + }, + } as unknown as PluginStore, + botId: "bot_helper", + actorId: "user_asker", + }); + if (!tool) throw new Error("the Bot was offered no tool to call"); + + const answer = await tool.execute({}); + + /* + * What the model is handed, exactly. + * + * Not the statement and not the values bound to it — on this path those are credential ids, + * user ids and server ids. Not a sentence blaming the vendor either: the call never reached + * one, and `That tool could not be called: ` is what the model used to be given to + * explain the failure to the person asking. + */ + expect(answer).toBe("That tool could not be called."); + expect(answer).not.toContain("Failed query"); + expect(answer).not.toContain("params:"); + expect(answer).not.toContain("mcp_user_credentials"); + }); + + test("the trail gets the reason and none of the query", async () => { + const database = await freshDatabase(); + const events: { eventType: string; payload: unknown }[] = []; + /* + * Thrown at the vendor seam, and recorded by the block above it. + * + * WHERE THIS ARRIVES FROM IN PRODUCTION: `connectionTokenFor`, three lines earlier and inside + * the same `try` — its connection gate read, its vault read and its locked credential swap + * are all queries of ours. Reaching one of those and failing only it needs a counted override + * of every `select` the call path makes, which pins a test to the order of queries rather than + * to the property. `callVendor` is the one seam this store hands a caller, and a throw through + * it lands in exactly the `catch` those queries land in; what that `catch` can do about a + * throw is classify it, which is the property. + */ + const failing = createPluginStore({ + database, + auditStore: { + insert: async (event) => { + events.push(event as (typeof events)[number]); + }, + }, + credentials: credentialsStub, + encryptionKey: "x".repeat(44), + policy: () => policy, + callVendor: async () => { + throw queryFailure(); + }, + }); + await seedComposioGmail(database, failing); + + await expect( + failing.callTool({ + ref: "gmail/GMAIL_FETCH_EMAILS", + args: {}, + botId: "bot_helper", + actorId: "user_asker", + }), + ).rejects.toThrow(); + + const failed = events.filter( + (event) => event.eventType === "mcp.call_failed", + ); + expect(failed).toHaveLength(1); + const failure = + (failed[0]?.payload as { failure?: string } | undefined)?.failure ?? ""; + /* + * The reason, because "is this connector working" is asked of this row and the driver's + * complaint answers it. Not the statement and not the values bound to it: on this path those + * are credential ids, user ids and server ids, and `audit_events` is read by an operator and + * carried out of the deployment by an export. + */ + expect(failure).toContain("canceling statement due to statement timeout"); + expect(failure).not.toContain("Failed query"); + expect(failure).not.toContain("params:"); + expect(failure).not.toContain("mcp_user_credentials"); + }); +}); + +/** + * The genuine empty listing, which has to stay recordable. + * + * The guard above must not turn "this app advertises nothing" into a state the deployment cannot + * hold, or an app that really offers no actions would read as broken for good. An app with nothing + * recorded against it has nothing to lose, so the empty answer commits: refreshed, no error and no + * actions, which is the honest reading of an app that advertises none. + */ +test("an app with nothing recorded against it can be refreshed to no actions at all", async () => { + const { store, database } = await freshStore(); + useComposioClient({ + listActions: async () => [], + execute: async () => vendorAnswered(), + }); + await database.insert(mcpServers).values({ + id: "gmail", + title: "Gmail", + vendor: "Composio", + url: "composio://gmail", + provenance: "composio", + lastError: "Whatever was wrong last time.", + }); + + expect(await store.refreshTools("gmail", "admin_user")).toEqual({ tools: 0 }); + + const [row] = await database + .select({ + lastError: mcpServers.lastError, + toolsRefreshedAt: mcpServers.toolsRefreshedAt, + }) + .from(mcpServers) + .where(eq(mcpServers.id, "gmail")); + + expect(row?.lastError).toBeNull(); + expect(row?.toolsRefreshedAt).not.toBeNull(); +}); + +/** + * An audit write that fails, which is not the vendor misbehaving. + * + * The refresh used to run the listing, the replace, the server-row update and both audit writes + * inside one `catch` that recorded everything as `lastError` and answered `{ tools: 0 }`. So a + * database that would not take an audit row reported a vendor which had in fact answered correctly, + * and reported it against actions the refresh had already committed — sending whoever reads the + * page to a vendor's status page over a fault in their own database. + * + * Its own store rather than {@link freshStore}, because the audit insert is the seam that has to + * fail and that fixture's is deliberately a real one. + */ +test("an audit write that fails is not recorded as the vendor misbehaving", async () => { + const database = await freshDatabase(); + const failing = createPluginStore({ + database, + auditStore: { + insert: async (event) => { + if ( + (event.payload as { change?: string }).change === + "grants_not_advertised" + ) { + throw new Error("audit_events would not take the row"); + } + }, + }, + credentials: credentialsStub, + encryptionKey: "x".repeat(44), + policy: () => policy, + }); + + useComposioClient({ + listActions: async () => [ + { + slug: "GMAIL_SEND_EMAIL", + description: "Send an email.", + inputParameters: { type: "object", properties: {} }, + tags: ["createHint"], + version: "20260903_00", + }, + ], + execute: async () => vendorAnswered(), + }); + // The granted action is absent from what the vendor now lists, so the refresh reaches the audit + // write about grants nothing advertises — the one the stub above refuses. + await seedComposioGmail(database, failing); + + await expect(failing.refreshTools("gmail", "admin_user")).rejects.toThrow( + "audit_events would not take the row", + ); + + const [row] = await database + .select({ lastError: mcpServers.lastError }) + .from(mcpServers) + .where(eq(mcpServers.id, "gmail")); + + expect(row?.lastError).toBeNull(); +}); + +/** + * The moment a brokered app starts existing, which is an auth config before it is a row. + * + * The id and the url are different strings on purpose, and both are asserted. `composio-linear` is + * what a grant and a policy rule are written against, and it carries a prefix so it cannot land on + * a curated entry's slug or on one of the ids the fixtures above reserve; `composio://linear` is + * what `accessFor` reads the app off, and that is the field the transport and the connection gate + * both settle the app from. A test that asserted only one of them would pass on an implementation + * that made them equal, which is the arrangement those two rules exist to keep apart. + * + * THE BROKER IS ASKED FIRST AND EXACTLY ONCE. First because a row whose auth config does not exist + * is an app an administrator can see and nobody can connect to; once because `ensureAuthConfig` is + * idempotent at the vendor and a second call here would be this deployment leaning on that. The + * connection the caller resolved is asserted as it reaches the broker unchanged, because that kind + * decides what config is created at the vendor: an enable path that re-derived it here rather than + * passing the caller's through would be a second answer to the question this argument exists to + * settle once. + * + * `composio-linear` is cleaned up in a `finally` rather than by {@link freshDatabase}, which knows + * only the ids the guard at the top of this file cleared. The row is checked absent before the add + * for the same reason every delete in this file is guarded: the id is spelled the way production + * spells it, so a row already at it would be somebody's app rather than this test's. + */ +test("enabling an app writes a brokered row, and asks for its auth config first", async () => { + const asked: { + toolkit: string; + name: string; + connection: BrokerConnection; + }[] = []; + const rowsWhenAsked: string[] = []; + const unasked = (what: string) => async (): Promise => { + throw new Error(`enabling an app asked the broker to ${what}`); + }; + const broker: ComposioBroker = { + listApps: unasked("list the catalogue"), + ensureAuthConfig: async (config) => { + asked.push(config); + /* + * What the table held at the moment the broker was asked, which is how "first" is asserted + * rather than assumed. Counting the calls says nothing about the order, and the order is the + * whole property: an implementation that wrote the row and then asked would leave an app on + * the page that nobody can connect to whenever this call fails. + */ + const rows = await database + .select({ id: mcpServers.id }) + .from(mcpServers) + .where(eq(mcpServers.id, "composio-linear")); + rowsWhenAsked.push(...rows.map((row) => row.id)); + }, + deleteAuthConfig: unasked("delete an auth config"), + authorize: unasked("begin somebody's connection"), + isConnected: unasked("check somebody's connection"), + revoke: unasked("withdraw somebody's grant"), + }; + + const { store, database, auditStore } = await freshStore({ broker }); + useComposioClient({ + listActions: async () => [ + { + slug: "LINEAR_CREATE_ISSUE", + description: "Create an issue.", + inputParameters: { type: "object", properties: {} }, + tags: ["createHint"], + version: "20260903_00", + }, + ], + execute: async () => vendorAnswered(), + }); + + const [present] = await database + .select({ id: mcpServers.id }) + .from(mcpServers) + .where(eq(mcpServers.id, "composio-linear")); + if (present) { + throw new Error( + "a row at 'composio-linear' was already here, so it is not this test's to write over", + ); + } + + try { + const record = await store.addBrokeredApp({ + slug: "linear", + title: "Linear", + by: "admin@example.com", + connection: { kind: "consent" }, + }); + + expect(record.id).toBe("composio-linear"); + expect(record.url).toBe("composio://linear"); + expect(record.provenance).toBe("composio"); + // No credential of its own, and none to come: a brokered row is reached as the person asking, + // on their connection at the vendor, so there is nothing on this row for a vault to hold. + expect(record.hasCredential).toBe(false); + + expect(asked).toEqual([ + { toolkit: "linear", name: "Linear", connection: { kind: "consent" } }, + ]); + expect(rowsWhenAsked).toEqual([]); + + const changes = auditStore + .recorded() + .filter((event) => event.eventType === "configuration.changed"); + expect(changes).toHaveLength(1); + expect(changes[0]?.payload).toMatchObject({ + change: "mcp_server_added", + provenance: "composio", + }); + } finally { + await database + .delete(mcpTools) + .where(eq(mcpTools.serverId, "composio-linear")); + await database + .delete(mcpServers) + .where(eq(mcpServers.id, "composio-linear")); + } +}); + +/** + * The same call on a deployment that has no Composio key, which is the documented default. + * + * The refusal names the setting because the name is the whole remedy, and nothing else about a + * deployment with no broker will tell an administrator what to set. Asserted on the message rather + * than the class so that the sentence an operator actually reads is what this test is about. + */ +test("enabling an app with no broker says which setting is missing", async () => { + const { store } = await freshStore(); + + await expect( + store.addBrokeredApp({ + slug: "linear", + title: "Linear", + by: "admin@example.com", + }), + ).rejects.toThrow("COMPOSIO_API_KEY"); +}); + +/** + * A broker that answers only the methods a test names, in the order it was asked. + * + * WHAT IS NOT NAMED THROWS, which is the half that carries the assertions below. "The disconnect + * asked the broker to revoke" is worth very little on its own; "and asked it nothing else" is the + * property, because a connection path that also listed the catalogue or created an auth config + * would be doing work on somebody's behalf that nobody here has reasoned about. A default stub + * answering plausibly would let all of that pass unremarked. + * + * `order` records the calls rather than counting them, so a test can say which of two things + * happened first. The state a call found the database in is NOT recorded here: the handlers are + * the test's own functions, so a test that needs to know what a row looked like at the moment of + * the call reads it inside its own handler — the same way the enablement test above establishes + * that the auth config comes before the row. + * + * AND THE ENTRY NAMES WHOSE CALL IT WAS, where the call has an owner. A path that revokes for one + * person is fully described by `revoke`; a path that revokes for everybody connected to an app is + * not, because "two revokes happened" says nothing about who they were for and nothing about the + * order they went out in — and the order is what makes a removal repeatable. So the request's + * `userId` is appended where there is one, and calls that are about the deployment rather than a + * person (`deleteAuthConfig`) stay bare. + */ +function brokerSpy(answers: { + ensureAuthConfig?: (config: { + toolkit: string; + name: string; + }) => Promise; + deleteAuthConfig?: (toolkit: string) => Promise; + isConnected?: (request: { + userId: string; + toolkit: string; + }) => Promise; + revoke?: (request: { userId: string; toolkit: string }) => Promise; +}): { broker: ComposioBroker; order: string[] } { + const order: string[] = []; + const unasked = (what: string) => async (): Promise => { + throw new Error(`the connection path asked the broker to ${what}`); + }; + const asked = ( + name: string, + handler: ((request: Request) => Promise) | undefined, + what: string, + ) => { + return async (request: Request): Promise => { + const owner = + typeof request === "object" && + request !== null && + "userId" in request && + typeof request.userId === "string" + ? `:${request.userId}` + : ""; + order.push(`${name}${owner}`); + if (!handler) + throw new Error(`the connection path asked the broker to ${what}`); + return await handler(request); + }; + }; + + return { + order, + broker: { + listApps: unasked("list the catalogue"), + ensureAuthConfig: asked( + "ensureAuthConfig", + answers.ensureAuthConfig, + "create an auth config", + ), + deleteAuthConfig: asked( + "deleteAuthConfig", + answers.deleteAuthConfig, + "delete an auth config", + ), + authorize: unasked("begin somebody's connection"), + isConnected: asked( + "isConnected", + answers.isConnected, + "check somebody's connection", + ), + revoke: asked("revoke", answers.revoke, "withdraw somebody's grant"), + }, + }; +} + +/** + * The row is the vendor's answer written down, and nothing else may write it. + * + * CRITERION. After a confirm the deployment says somebody is connected if and only if Composio + * said so when asked. + * + * REASON. The return trip from consent is an ordinary redirect with nothing signed in it, so a + * browser arriving back on the page is evidence of nothing at all — not that the flow finished, + * and not that the account it finished with is the one this row would claim. A confirm that wrote + * a row because somebody came back would hand every subsequent brokered call a gate that passes + * for an account that may not exist, and the first thing anybody would see of the mistake is the + * vendor's own error about a connection it cannot find. + * + * BOTH ANSWERS IN ONE TEST, over one store, because the second is what makes the first mean + * something: a confirm that never wrote a row would pass the "not connected" assertion on its own. + */ +test("a brokered connection row is written only where the vendor says the account is live", async () => { + let live = false; + const { broker, order } = brokerSpy({ isConnected: async () => live }); + const { store, auditStore } = await freshStore({ broker }); + const pair = { toolkit: "gmail", userId: "user_asker" }; + + expect(await store.confirmBrokeredConnection(pair)).toEqual({ + connected: false, + }); + // Nothing at all, which is the whole of the first half: the gate a brokered call is decided on + // must not exist for somebody the vendor does not recognise. + expect(await store.brokeredConnection(pair)).toBeNull(); + expect(auditStore.recorded()).toHaveLength(0); + + live = true; + expect(await store.confirmBrokeredConnection(pair)).toEqual({ + connected: true, + }); + const connection = await store.brokeredConnection(pair); + expect(connection?.connectedAt).toBeTruthy(); + + expect(order).toEqual(["isConnected:user_asker", "isConnected:user_asker"]); + const connected = auditStore + .recorded() + .filter((event) => event.eventType === "mcp.account_connected"); + expect(connected).toHaveLength(1); + expect(connected[0]?.payload).toMatchObject({ + actor: "user_asker", + server: "gmail", + // Empty because Composio grants no scope this deployment is told about, and the field explains + // a later refusal for want of one. A guess written here would be an explanation nobody gave. + scope: "", + reconnected: false, + }); +}); + +/** + * An account ended in Composio's own dashboard is forgotten here the next time we ask. + * + * CRITERION. Where a row is already written down and the vendor says the account is not live, the + * confirm removes the row, and it files nothing in the trail for having removed it. + * + * REASON. The row is a cache of the vendor's answer, and nothing tells this deployment when that + * answer changes: a grant withdrawn at Composio ends the account with no callback arriving here. + * A confirm that only ever wrote rows would leave the settings list drawing "Connected" for an + * account nobody has, leave the gate every brokered call is decided on passing for it, and leave + * the app's own detail page — which asks the vendor on mount — contradicting the list beside it. + * + * THE TRAIL STAYS EMPTY, which is asserted rather than assumed. Nobody disconnected anything: the + * grant ended elsewhere and this is the record catching up, so an `mcp.account_disconnected` row + * written here would credit a page load with an act it did not perform. `disconnectBrokered` is + * what files that event, for the disconnect it actually carried out. + */ +test("confirming a brokered connection the vendor no longer has removes the row", async () => { + const { broker, order } = brokerSpy({ isConnected: async () => false }); + const { store, database, auditStore } = await freshStore({ broker }); + const pair = { toolkit: "gmail", userId: "user_asker" }; + await database.insert(composioConnections).values(pair); + + expect(await store.confirmBrokeredConnection(pair)).toEqual({ + connected: false, + }); + + expect(await store.brokeredConnection(pair)).toBeNull(); + expect(order).toEqual(["isConnected:user_asker"]); + expect(auditStore.recorded()).toHaveLength(0); +}); + +/** + * A confirm that healed a row nothing changed is a read, and the trail does not record reads. + * + * CRITERION. Confirming a connection that is already written down leaves exactly the one + * `mcp.account_connected` row the first confirm filed, however many times it is called. + * + * REASON. This method runs on page load and not on a button: the connector page calls it once per + * mount for every brokered app it draws. An event per yes from the vendor therefore writes ten + * "account connected" rows for somebody who opened the page ten times having connected once, and + * a trail padded with acts nobody performed cannot answer the only question it is kept for. It is + * the failure the `reconnected` field is already written to avoid, arriving one level up at the + * event itself. + * + * THE VENDOR IS STILL ASKED EVERY TIME, which is asserted here rather than assumed: the row is a + * cache of Composio's answer and the re-asking is how a row that drifted heals. What stops on the + * second call is the writing-down of the heal as somebody's act, not the heal. + */ +test("confirming a brokered connection already recorded writes no second trail row", async () => { + const { broker, order } = brokerSpy({ isConnected: async () => true }); + const { store, auditStore } = await freshStore({ broker }); + const pair = { toolkit: "gmail", userId: "user_asker" }; + + expect(await store.confirmBrokeredConnection(pair)).toEqual({ + connected: true, + }); + const first = await store.brokeredConnection(pair); + expect(first?.connectedAt).toBeTruthy(); + + expect(await store.confirmBrokeredConnection(pair)).toEqual({ + connected: true, + }); + expect(await store.confirmBrokeredConnection(pair)).toEqual({ + connected: true, + }); + + expect(order).toEqual([ + "isConnected:user_asker", + "isConnected:user_asker", + "isConnected:user_asker", + ]); + // Unmoved, because the person connected when they connected: the confirms above are page loads. + expect(await store.brokeredConnection(pair)).toEqual(first); + expect( + auditStore + .recorded() + .filter((event) => event.eventType === "mcp.account_connected"), + ).toHaveLength(1); +}); + +/** + * Disconnecting ends the account at the vendor BEFORE it forgets where the account was. + * + * CRITERION. The broker is asked to revoke while the row is still there, and the row goes only + * after it answered. + * + * REASON. The row is the only thing in this deployment that names which app this person connected: + * delete it first and a revoke that then fails leaves a live grant on somebody's mailbox that no + * operation here can reach, because the toolkit it would have to be revoked under is readable off + * a row that is by now gone. Ordering the other way is recoverable by definition — pressing + * disconnect again asks again. + * + * ASSERTED ON WHAT THE REVOKE SAW, not on a call count, because a count says nothing about order + * and the order is the entire property. + */ +test("disconnecting a brokered connection revokes at the vendor before the row goes", async () => { + const rowsWhenRevoked: string[] = []; + const { broker, order } = brokerSpy({ + revoke: async () => { + const rows = await database + .select({ userId: composioConnections.userId }) + .from(composioConnections) + .where(ownedConnections()); + rowsWhenRevoked.push(...rows.map((row) => row.userId)); + return true; + }, + }); + const { store, database, auditStore } = await freshStore({ broker }); + const pair = { toolkit: "gmail", userId: "user_asker" }; + await database.insert(composioConnections).values(pair); + + const outcome = await store.disconnectBrokered({ + ...pair, + by: "user_asker", + reason: "self", + }); + + expect(outcome).toEqual({ vendorRevocationRequested: true }); + expect(rowsWhenRevoked).toEqual(["user_asker"]); + expect(order).toEqual(["revoke:user_asker"]); + expect(await store.brokeredConnection(pair)).toBeNull(); + + const disconnected = auditStore + .recorded() + .filter((event) => event.eventType === "mcp.account_disconnected"); + expect(disconnected).toHaveLength(1); + expect(disconnected[0]?.payload).toMatchObject({ + actor: "user_asker", + server: "gmail", + owner: "user_asker", + reason: "self", + // What happened, not what was attempted. The broker said it withdrew a grant, so the trail + // says so; a field that always said true would make the row a worse record than none. + vendorRevocationRequested: true, + }); +}); + +/** + * A revoke that throws leaves the connection exactly where it was, so pressing again finishes it. + * + * CRITERION. A failed disconnect removes nothing and records nothing, and the same call made again + * against a broker that now answers completes the job. + * + * REASON. This is the payoff of the ordering above, stated as the behaviour somebody actually + * meets: Composio is down for a minute, the person presses disconnect, and the alternative to + * keeping the row is an account still live at the vendor with nothing left here that knows which + * app it belongs to. Keeping it means the only cost of the failure is that they press the button + * again. + */ +test("a brokered connection outlives a revoke that failed, and a second attempt ends it", async () => { + let broken = true; + const { broker } = brokerSpy({ + revoke: async () => { + if (broken) throw new Error("Composio would not answer (502)."); + return true; + }, + }); + const { store, database, auditStore } = await freshStore({ broker }); + const pair = { toolkit: "gmail", userId: "user_asker" }; + await database.insert(composioConnections).values(pair); + + await expect( + store.disconnectBrokered({ + ...pair, + by: "user_asker", + reason: "self", + }), + ).rejects.toThrow("Composio would not answer (502)."); + + expect(await store.brokeredConnection(pair)).not.toBeNull(); + // No row in the trail either. "Their account was disconnected" is a claim about the vendor, and + // nothing was disconnected anywhere. + expect(auditStore.recorded()).toHaveLength(0); + + broken = false; + expect( + await store.disconnectBrokered({ + ...pair, + by: "user_asker", + reason: "self", + }), + ).toEqual({ vendorRevocationRequested: true }); + expect(await store.brokeredConnection(pair)).toBeNull(); +}); + +/** + * A revoke that found nothing to withdraw says so, and the row goes all the same. + * + * CRITERION. Where the broker answers `false`, both the outcome and the trail carry + * `vendorRevocationRequested: false`, and the `composio_connections` row is deleted regardless. + * + * REASON. This is the grant somebody already ended in Composio's own dashboard. The account is + * gone at the vendor, so the local row is the stale half of a pair that has drifted and deleting + * it is what makes the two agree again. What must not happen is the trail claiming this + * deployment withdrew something: a row saying the grant was ended here when it was ended + * somewhere else is a worse record than none, because whoever reads back for who ended it is + * given the wrong answer in the same words as the right one. + * + * THE FALSE IS THE WHOLE TEST. `vendorRevocationRequested` is indistinguishable from a hardcoded + * `true` until a revoke answers no, and no other test in this file exercises one. + */ +test("a brokered disconnect that withdrew no grant records that it withdrew none", async () => { + const { broker, order } = brokerSpy({ revoke: async () => false }); + const { store, database, auditStore } = await freshStore({ broker }); + const pair = { toolkit: "gmail", userId: "user_asker" }; + await database.insert(composioConnections).values(pair); + + expect( + await store.disconnectBrokered({ + ...pair, + by: "user_asker", + reason: "self", + }), + ).toEqual({ vendorRevocationRequested: false }); + + expect(order).toEqual(["revoke:user_asker"]); + // Gone, because there was nothing at the vendor and the row was therefore the half that had + // drifted. Keeping it would leave the gate on every brokered call passing for an account that + // no longer exists anywhere. + expect(await store.brokeredConnection(pair)).toBeNull(); + + const disconnected = auditStore + .recorded() + .filter((event) => event.eventType === "mcp.account_disconnected"); + expect(disconnected).toHaveLength(1); + expect(disconnected[0]?.payload).toMatchObject({ + actor: "user_asker", + server: "gmail", + owner: "user_asker", + reason: "self", + vendorRevocationRequested: false, + }); +}); + +/** + * A disconnect that found nothing to disconnect says nothing in the trail. + * + * CRITERION. With no `composio_connections` row for the pair, the call still asks the broker to + * revoke, and files an `mcp.account_disconnected` event only where that ask withdrew a grant. + * Nothing here and nothing at the vendor is nothing disconnected, and the trail stays empty. + * + * REASON. This is the second press of Disconnect. The screen that made it easy — a row still + * reading "Connected" after a successful disconnect — has been fixed, but any caller can make the + * same call twice, and an event filed for it would tell whoever reads the trail back that + * somebody's account ended at a moment when nobody's did. A trail padded with acts nobody + * performed cannot answer the one question it is kept for, which is the reasoning + * `confirmBrokeredConnection` already files its connected event under. + * + * THE VENDOR IS ASKED ALL THE SAME, which is asserted and not assumed. The row is a cache of + * Composio's answer and it drifts by construction — the confirm deletes it on any `false` from the + * vendor — so an absence here is no evidence that the grant is gone, and this call is the only + * operation in this deployment that can end one. What the missing row stops is the writing-down of + * an act, not the ask. + * + * BOTH ANSWERS IN ONE TEST, because the second is what makes the first mean something: a guard + * that filed nothing whenever the row was missing would pass the empty-trail assertion on its own, + * and would lose the case that matters most — a live account ended for somebody whose local row + * had already gone. + */ +test("a brokered disconnect with nothing to disconnect files nothing in the trail", async () => { + let granted = false; + const { broker, order } = brokerSpy({ revoke: async () => granted }); + const { store, auditStore } = await freshStore({ broker }); + const pair = { toolkit: "gmail", userId: "user_asker" }; + + expect( + await store.disconnectBrokered({ + ...pair, + by: "user_asker", + reason: "self", + }), + ).toEqual({ vendorRevocationRequested: false }); + + expect(order).toEqual(["revoke:user_asker"]); + expect(await store.brokeredConnection(pair)).toBeNull(); + // Empty, which is the whole of the first half: no row went and no grant was withdrawn, so + // nobody was disconnected and the trail has nothing to say about it. + expect(auditStore.recorded()).toHaveLength(0); + + // The same absence locally, but this time the ask found a live account and ended it. That is an + // act — the weightier of the two this method performs — and it is recorded. + granted = true; + expect( + await store.disconnectBrokered({ + ...pair, + by: "user_asker", + reason: "self", + }), + ).toEqual({ vendorRevocationRequested: true }); + + const disconnected = auditStore + .recorded() + .filter((event) => event.eventType === "mcp.account_disconnected"); + expect(disconnected).toHaveLength(1); + expect(disconnected[0]?.payload).toMatchObject({ + actor: "user_asker", + server: "gmail", + owner: "user_asker", + reason: "self", + vendorRevocationRequested: true, + }); +}); + +/** + * A brokered connection is visible to the person who made it, under the server row's own id. + * + * CRITERION. `brokeredConnectionsFor` answers one row per app this person has connected, carrying + * the `serverId`, `scope` and `connectedAt` that `connectionsFor` answers with — so one screen + * draws both kinds of row — plus the `verified` and `verifiedAt` that only a brokered row has, + * because only a brokered row is a thing this deployment can re-check. The `serverId` in it is the + * id of the `mcp_servers` row whose url names that app. + * + * REASON. `connectionsFor` selects from the vault's join table alone, so a brokered connection was + * invisible to the browser and the settings screen could not honestly say whether somebody was + * connected. The id has to be joined rather than composed because the url is where the app is + * recorded: `composio-${toolkit}` spelled by hand answers with whatever production happens to + * spell today, and this fixture — a server row at `gmail`, not at `composio-gmail` — is the case + * that tells a joined id from a guessed one. + */ +test("a brokered connection is listed for its owner under the server row's own id", async () => { + const { store, database } = await freshStore(); + await seedComposioGmail(database, store); + + const connections = await store.brokeredConnectionsFor("user_asker"); + expect(connections).toHaveLength(1); + expect(connections[0]).toMatchObject({ + serverId: "gmail", + // Empty for the reason the confirm gives: Composio grants no scope it tells us about, and the + // field is returned anyway so one settings screen can draw both kinds of connection. + scope: "", + }); + expect(connections[0]?.connectedAt).toBeTruthy(); + + // Nobody else's, which is the only other thing this query promises. + expect(await store.brokeredConnectionsFor("user_leaver")).toEqual([]); +}); + +/** + * And it is the url that decides which server row a connection belongs to, not the id. + * + * CRITERION. A person connected to `gmail`, with the only Composio server row sitting at + * `composio://slack`, is listed against nothing. + * + * REASON. The id and the url are two fields and nothing in the schema holds them equal — which is + * the shape `seedComposioGmail` exists to reproduce. Reading the connection's app off the url is + * the same thing the directory route does when it decides which apps are enabled, and it is what + * stops this query telling somebody they have a Slack connection because a row called `gmail` + * happened to be pointed somewhere else. + */ +test("a brokered connection is not listed against a server row whose url names another app", async () => { + const { store, database } = await freshStore(); + await seedComposioGmail(database, store, { url: "composio://slack" }); + + expect(await store.brokeredConnectionsFor("user_asker")).toEqual([]); +}); + +/** + * Removing an app ends every account at the vendor, and only then forgets where they were. + * + * CRITERION. `removeServer` on a brokered row revokes at the broker for every connected person + * while their rows are still standing, deletes the rows after that, and drops the deployment's + * auth config last of all — and the trail says of each person that the grant was really withdrawn. + * + * REASON. Clearing `composio_connections` closes the gate this deployment owns and nothing else: + * the account the person attached is still live at Composio, and an administrator who pressed + * "remove" was not told they had left it there. So the removal ends the accounts too, and the + * order is forced. A brokered row's toolkit is readable off nothing but the row, so delete-first + * and a revoke that then fails leaves a live grant on somebody's mailbox with no value left here + * to revoke it under; revoke-first and the same failure leaves the app present, every account + * dead, and removing again finishes the job. Dead-and-reachable beats live-and-unreachable. + * + * THE AUTH CONFIG GOES LAST for the same reasoning one step out. An orphaned auth config grants + * nobody anything — it is a shape this deployment holds at Composio, not an account — while a live + * account whose config has already gone is access nobody here can end. + * + * ASSERTED ON WHAT EACH CALL SAW, not on a count. Counting says nothing about order, and the order + * is the entire property: an implementation that deleted the rows first and revoked off what the + * delete returned would make exactly the same calls in exactly the same sequence. + * + * THE PEOPLE CONNECT IN REVERSE, `user-b` before `user-a`, so that the sorted revoke is doing work + * rather than agreeing with the insertion order by luck. + */ +test("removing an app revokes everybody, then clears rows, then drops the config", async () => { + /** Who was still connected to `linear` at the moment of each broker call, in call order. */ + const connectedWhenAsked: string[][] = []; + const stillConnected = async () => + ( + await database + .select({ userId: composioConnections.userId }) + .from(composioConnections) + .where(eq(composioConnections.toolkit, "linear")) + .orderBy(asc(composioConnections.userId)) + ).map((row) => row.userId); + + const { broker, order } = brokerSpy({ + ensureAuthConfig: async () => {}, + isConnected: async () => true, + revoke: async () => { + connectedWhenAsked.push(await stillConnected()); + return true; + }, + deleteAuthConfig: async () => { + connectedWhenAsked.push(await stillConnected()); + }, + }); + const { store, database, auditStore } = await freshStore({ broker }); + useComposioClient({ + listActions: async () => [ + { + slug: "LINEAR_CREATE_ISSUE", + description: "Create an issue.", + inputParameters: { type: "object", properties: {} }, + tags: ["createHint"], + version: "20260903_00", + }, + ], + execute: async () => vendorAnswered(), + }); + + /* + * This fixture's own ids, checked here because the guard at the top of the file does not cover + * them: `composio-linear` and the pairs at `linear` are spelled the way production spells them, + * so a row already sitting at one belongs to somebody else and the cleanup below would take it. + */ + const [present] = await database + .select({ id: mcpServers.id }) + .from(mcpServers) + .where(eq(mcpServers.id, "composio-linear")); + const strangers = await database + .select({ userId: composioConnections.userId }) + .from(composioConnections) + .where( + and( + eq(composioConnections.toolkit, "linear"), + inArray(composioConnections.userId, ["user-a", "user-b"]), + ), + ); + if (present || strangers.length > 0) { + throw new Error( + "a 'composio-linear' server row or a 'linear' connection for user-a or user-b was already " + + "here, so it is not this test's to write over", + ); + } + + try { + await store.addBrokeredApp({ + slug: "linear", + title: "Linear", + by: "admin", + connection: { kind: "consent" }, + }); + for (const userId of ["user-b", "user-a"]) { + expect( + await store.confirmBrokeredConnection({ toolkit: "linear", userId }), + ).toEqual({ connected: true }); + } + + // The setup's own traffic, cleared so what follows is about the removal and nothing else. + order.length = 0; + connectedWhenAsked.length = 0; + + await store.removeServer("composio-linear", "admin"); + + expect(order).toEqual([ + "revoke:user-a", + "revoke:user-b", + "deleteAuthConfig", + ]); + /* + * Both revokes found both rows, and the auth config was dropped with none left. This is the + * ordering the call list above cannot see: revoking off the rows a delete had already returned + * would produce that same list while leaving nothing to revoke under if the broker refused. + */ + expect(connectedWhenAsked).toEqual([ + ["user-a", "user-b"], + ["user-a", "user-b"], + [], + ]); + + expect( + await store.brokeredConnection({ toolkit: "linear", userId: "user-a" }), + ).toBeNull(); + expect( + await store.brokeredConnection({ toolkit: "linear", userId: "user-b" }), + ).toBeNull(); + + const disconnected = auditStore + .recorded() + .filter((event) => event.eventType === "mcp.account_disconnected"); + expect(disconnected).toHaveLength(2); + expect(disconnected.map((event) => event.targetId)).toEqual([ + // The app, not the server row's id, because that is what a brokered connection is keyed on + // and the id is gone by the time anybody reads back. + "linear", + "linear", + ]); + expect(disconnected.map((event) => event.payload)).toEqual([ + { + actor: "admin", + server: "linear", + owner: "user-a", + // Not "they disconnected" and not "they were removed": an administrator took the whole + // app away and the person did nothing. + reason: "mcp_server_removed", + /* + * True, and true because the broker said so rather than because the call returned. This + * is the whole change: the row used to say `false` here whatever happened, which was + * honest only while the removal left every account live at Composio. + */ + vendorRevocationRequested: true, + }, + { + actor: "admin", + server: "linear", + owner: "user-b", + reason: "mcp_server_removed", + vendorRevocationRequested: true, + }, + ]); + } finally { + await database + .delete(mcpTools) + .where(eq(mcpTools.serverId, "composio-linear")); + await database + .delete(mcpServers) + .where(eq(mcpServers.id, "composio-linear")); + await database + .delete(composioConnections) + .where( + and( + eq(composioConnections.toolkit, "linear"), + inArray(composioConnections.userId, ["user-a", "user-b"]), + ), + ); + } +}); + +/** + * Offboarding somebody ends the accounts they connected, where they actually live. + * + * CRITERION. `retireConnectionsFor` on somebody holding two brokered connections revokes both at + * the broker while their rows are still standing, deletes the rows after that, counts both, and + * says in the trail of each that the grant was really withdrawn. + * + * REASON. A brokered connection holds no secret of ours, so deleting the row shuts the gate this + * deployment owns and leaves the mailbox attached at Composio. "We removed their access" was then + * untrue of the only thing that matters — the grant at the vendor — for the person it matters most + * about, one who has been removed and cannot be asked to disconnect anything themselves. + * + * REVOKE BEFORE DELETE, and the order is forced by what the row is. It is the only place naming + * which apps this person had, and it outlives the `users` row precisely so offboarding can still + * find them; that was the table's whole justification and until now nothing exercised it. Delete + * first and a broker that refuses leaves a live grant on a departed person's mailbox with nothing + * left here to revoke it under. Revoke first and the same refusal leaves the rows standing and + * offboarding repeatable. Dead-and-reachable beats live-and-unreachable. + * + * ASSERTED ON WHAT EACH CALL SAW, not on a count and not on the call list. Both revokes are for + * one person, so the recorded sequence is identical whichever order the code uses — an + * implementation revoking off what the delete returned would make the same two calls. Reading the + * table from inside the stub is the only thing here that can tell the two apart. + * + * THE APPS ARE CONNECTED IN REVERSE, `linear` before `gmail`, so the sorted revoke is doing work + * rather than agreeing with the insertion order by luck. + */ +test("removing a person revokes their brokered accounts at the broker", async () => { + /** Which apps this person was still connected to at the moment of each revoke, in call order. */ + const connectedWhenAsked: string[][] = []; + /** Which app each revoke was for, which the spy's own `revoke:user_leaver` entries cannot say. */ + const revoked: string[] = []; + // Through the file's own handle, which is the one `freshStore` hands back: the rows this test + // writes are swept by {@link freshDatabase} and the teardown, so nothing here needs a local name + // for the database. + const stillConnected = async () => + ( + await database + .select({ toolkit: composioConnections.toolkit }) + .from(composioConnections) + .where(eq(composioConnections.userId, "user_leaver")) + .orderBy(asc(composioConnections.toolkit)) + ).map((row) => row.toolkit); + + const { broker, order } = brokerSpy({ + isConnected: async () => true, + revoke: async ({ toolkit }) => { + connectedWhenAsked.push(await stillConnected()); + revoked.push(toolkit); + return true; + }, + }); + const { store, auditStore } = await freshStore({ broker }); + + for (const toolkit of ["linear", "gmail"]) { + expect( + await store.confirmBrokeredConnection({ toolkit, userId: "user_leaver" }), + ).toEqual({ connected: true }); + } + + // The setup's own traffic, cleared so what follows is about the offboarding and nothing else. + order.length = 0; + + expect(await store.retireConnectionsFor("user_leaver", "admin")).toEqual({ + // Both of them, because the number is what "we removed their access" claims. + retired: 2, + }); + + expect(order).toEqual(["revoke:user_leaver", "revoke:user_leaver"]); + expect(revoked).toEqual(["gmail", "linear"]); + /* + * Both revokes found both rows. This is the ordering neither list above can see: revoking off + * the rows a delete had already returned would produce both of them unchanged while leaving + * nothing to revoke under if the broker refused. + */ + expect(connectedWhenAsked).toEqual([ + ["gmail", "linear"], + ["gmail", "linear"], + ]); + + expect( + await store.brokeredConnection({ toolkit: "gmail", userId: "user_leaver" }), + ).toBeNull(); + expect( + await store.brokeredConnection({ + toolkit: "linear", + userId: "user_leaver", + }), + ).toBeNull(); + + const disconnected = auditStore + .recorded() + .filter((event) => event.eventType === "mcp.account_disconnected"); + expect(disconnected).toHaveLength(2); + // The app, which for a brokered connection is all the row records and all that is left once + // the person is gone. + expect(disconnected.map((event) => event.targetId)).toEqual([ + "gmail", + "linear", + ]); + expect(disconnected.map((event) => event.payload)).toEqual([ + { + actor: "admin", + server: "gmail", + owner: "user_leaver", + // An administrator removing somebody, never somebody changing their own mind. + reason: "person_removed", + /* + * True, and true because the broker said so rather than because the call returned. This + * is the whole change: the row used to say `false` here whatever happened, which was + * honest only while offboarding left every account live at Composio. + */ + vendorRevocationRequested: true, + }, + { + actor: "admin", + server: "linear", + owner: "user_leaver", + reason: "person_removed", + vendorRevocationRequested: true, + }, + ]); +}); + +/** + * And where there was no grant left to withdraw, offboarding says so. + * + * CRITERION. A broker answering `false` leaves `vendorRevocationRequested: false` in the trail, and + * the row is deleted and counted just the same. + * + * REASON. The account was already ended in Composio's own dashboard, so the local row is the stale + * half of a pair that has drifted. What must not happen is the trail claiming this deployment + * withdrew something: whoever reads back for who ended somebody's access is then given the wrong + * answer in the same words as the right one. + * + * THE FALSE IS THE WHOLE TEST. A pass-through is indistinguishable from a hardcoded `true` until a + * revoke answers no, and nothing else on this path exercises one. + */ +test("offboarding a brokered account nobody held any more withdraws nothing, and says so", async () => { + const { broker, order } = brokerSpy({ revoke: async () => false }); + const { store, database, auditStore } = await freshStore({ broker }); + const pair = { toolkit: "gmail", userId: "user_leaver" }; + await database.insert(composioConnections).values(pair); + + expect(await store.retireConnectionsFor("user_leaver", "admin")).toEqual({ + retired: 1, + }); + + expect(order).toEqual(["revoke:user_leaver"]); + // Gone, because there was nothing at the vendor and the row was therefore the half that had + // drifted. Keeping it would leave the gate passing for a person who no longer exists. + expect(await store.brokeredConnection(pair)).toBeNull(); + + const disconnected = auditStore + .recorded() + .filter((event) => event.eventType === "mcp.account_disconnected"); + expect(disconnected).toHaveLength(1); + expect(disconnected[0]?.payload).toMatchObject({ + actor: "admin", + server: "gmail", + owner: "user_leaver", + reason: "person_removed", + vendorRevocationRequested: false, + }); +}); + +/** + * The same false, on the other act that ends a brokered connection. + * + * CRITERION. `removeServer` on a brokered row whose broker answers `false` records + * `vendorRevocationRequested: false`, and still clears the row and drops the config. + * + * REASON. The removal test above pins the `true`, which a literal `true` in the store would pass + * just as well — and one did, for the whole of this suite, until this test. A field whose only + * purpose is to tell a grant this deployment ended from one that outlives it somewhere else is + * worth nothing if it can only ever say one of the two. + */ +test("removing an app records the grant it did not withdraw as not withdrawn", async () => { + const { broker, order } = brokerSpy({ + revoke: async () => false, + deleteAuthConfig: async () => {}, + }); + const { store, database, auditStore } = await freshStore({ broker }); + await seedComposioGmail(database, store); + + await store.removeServer("gmail", "admin"); + + expect(order).toEqual(["revoke:user_asker", "deleteAuthConfig"]); + expect( + await store.brokeredConnection({ toolkit: "gmail", userId: "user_asker" }), + ).toBeNull(); + + const disconnected = auditStore + .recorded() + .filter((event) => event.eventType === "mcp.account_disconnected"); + expect(disconnected).toHaveLength(1); + expect(disconnected[0]?.payload).toMatchObject({ + actor: "admin", + server: "gmail", + owner: "user_asker", + reason: "mcp_server_removed", + vendorRevocationRequested: false, + }); +}); + +/** + * The vendor's destructive label, carried out of the database to the screens that draw it. + * + * CRITERION. A `mcp_tools` row Composio labelled destructive reaches `listServers` as + * `destructive: true`, and every tool in the list carries the field as a boolean. + * + * REASON. The column has been recorded since the brokered transport landed and reached nothing: the + * store selected the row and then built a tool object without the field, so a delete read out + * identically to any other write on every screen. The per-Bot grants screen already draws its danger + * mark from `PluginTool.destructive`, which could only ever be false while the mapping was missing — + * a mark that cannot turn on is worse than no mark, because it reads as an assurance. The second + * assertion is the half a `true` alone would not hold: the field must be present on the ordinary read + * beside it, not only on the row that happens to be dangerous. + */ +test("a destructive action says so in the list the screens read", async () => { + const { store, database } = await freshStore(); + await seedComposioGmail(database, store); + await database.insert(mcpTools).values({ + serverId: "gmail", + name: "GMAIL_DELETE_MESSAGE", + description: "Delete a message.", + effect: "write", + destructive: true, + }); + + const gmail = (await store.listServers()).find( + (server) => server.id === "gmail", + ); + + const deletes = gmail?.tools.find( + (tool) => tool.name === "GMAIL_DELETE_MESSAGE", + ); + expect(deletes?.destructive).toBe(true); + // Not only on the dangerous row: the reader asks every tool the same question, and an undefined + // on the read beside it is a screen with nothing to draw rather than a screen drawing "safe". + expect(gmail?.tools.map((tool) => typeof tool.destructive)).toEqual( + gmail?.tools.map(() => "boolean"), + ); +}); diff --git a/server/tsconfig.json b/server/tsconfig.json index 8858dd281..9d8810dd6 100644 --- a/server/tsconfig.json +++ b/server/tsconfig.json @@ -3,9 +3,31 @@ // `scripts` too: the migration runner and the culler entrypoint ship in the image, so they are // product code that happens to live outside `src`. // + // `../scripts/composio-smoke.ts` FOR THE SAME REASON, one directory further out. It ships in the + // image — the Dockerfile copies that one file and no other — it is what `docs/plugins/composio.md` + // tells an operator to run when Composio misbehaves, and it imports `src/plugins/composio*` + // directly, so it is this workspace's code that happens to live at the repository root. Nothing + // else covered it: the root `scripts/` directory is in no workspace, and `bun run typecheck` is + // `bun run --filter '*' typecheck`, which matches workspaces only and never the root manifest. + // + // THE FILE AND NOT THE DIRECTORY, which is the line the Dockerfile already draws. The rest of + // `../scripts` is the laptop's, and `check-rendered-chart.ts` and `test-ci.ts` carry no import and + // no export, so TypeScript reads them as global scripts rather than as modules and taking the + // whole directory in fails the build on five errors. Three of them are that, all TS1375: a file + // that is not a module may not use top-level `await`, and both files do. + // + // THE OTHER TWO ARE NOT THE TWO SCRIPTS COLLIDING WITH EACH OTHER, which is what this comment used + // to say and what the code does not do — `status` appears in `test-ci.ts` and nowhere else in that + // directory. Its `const status = await proc.exited` collides with the AMBIENT `status`, which + // `lib.dom.d.ts` declares as a `string` and which is in scope because nothing here narrows `lib`: + // TS2451 for the redeclaration, and then TS2367 on the line below it, because comparing the lib's + // `string` with `0` is two types that do not overlap. A global shared with the standard library, + // not with a sibling. Named individually the way `app/tsconfig.json` names the two `shared` files + // it reaches for. + // // `tests` is NOT here yet, and that is a known gap rather than a decision: the suite has never been // type-checked and turning it on surfaces a few hundred years of accumulated `any`. It is what let // a test pass an options object where a connection string belongs and hear nothing back. Its own // change, because it is a sweep and not a fix. - "include": ["src", "scripts"] + "include": ["src", "scripts", "../scripts/composio-smoke.ts"] }