diff --git a/CHANGELOG.md b/CHANGELOG.md index 9de5ea9fe5..2cd51b8e0c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,28 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged. request for an administrator to switch on a work app, grant it to a Bot, or connect a Shared app's team account is kept, with who answered it and when. It starts empty and nothing is backfilled. +### A Bot can send, draft and reply in Gmail + +OpenBot left out every Composio action that takes a file, because a Bot has no way to supply one. +That also left out actions where the file is optional, such as an email's attachment, so Gmail's +send, draft, reply and update-draft actions never appeared and a Bot could read mail but not send +it. These actions are now offered without the optional file, and a call that includes one anyway is +refused with a message saying to try again without it. An action that cannot run without a file, +such as uploading one to Google Drive, is still left out. For an app that is already on, press +Refresh tools on its page in Admin to list the new actions, then grant them to a Bot as usual. + +### Connect works for an app Composio already holds your account for + +Pressing Connect on a Composio app, from a conversation's Connect card or from Connections, could end +in a refusal saying Composio already held a connected account for the app and that disconnecting it +would clear the way. The app showed as not connected, so there was nothing to disconnect. This +happened when Composio held the account and OpenBot had no record of it: on a fresh or upgraded +install, or on a second local install signing in as the same person with the same Composio key. Connect +now asks Composio again, records the account it holds, and shows the app as connected, the same way +opening the app's page already did. A Shared app's team account is still recorded only by an admin. +If Composio then reports no account after all, Connect says nothing was connected and asks you to +press it again. It no longer tells you to disconnect anything. + ### A Bot with an app or web research can use its browser in a conversation again A Bot that held any app or research tool, such as Parallel Search, could not open a page, click or diff --git a/agent-microsoft/requirements.txt b/agent-microsoft/requirements.txt index c5f73d486c..206df8a86f 100644 --- a/agent-microsoft/requirements.txt +++ b/agent-microsoft/requirements.txt @@ -1,4 +1,8 @@ -agent-framework-ag-ui +# agent-framework-ag-ui 1.7.0, released 2026-10-22, refuses to build an AG-UI endpoint unless its +# authorization mode is declared (authorization_scope_resolver, snapshot_scope_resolver or +# single_user=True), so every run in this harness fails at startup. 1.6.0 works. Lift this once +# the harness declares how it authorizes. +agent-framework-ag-ui<1.7 agent-framework-anthropic agent-framework-openai fastapi diff --git a/app/src/components/plugins/brokered-account-row.tsx b/app/src/components/plugins/brokered-account-row.tsx index 412ef4878a..38b76abf39 100644 --- a/app/src/components/plugins/brokered-account-row.tsx +++ b/app/src/components/plugins/brokered-account-row.tsx @@ -28,6 +28,7 @@ import { connectAccountMutationOptions, connectBrokeredWithFieldsMutationOptions, disconnectBrokeredMutationOptions, + invalidatePlugins, recheckBrokeredConnectionMutationOptions, } from "@/lib/plugins/mutations"; @@ -398,9 +399,19 @@ export function useBrokeredAccount(input: { * 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) => { + onSuccess: (start) => { forgetConfirmation(); forgetRecheck(); + /* + * ALREADY CONNECTED, AND NOTHING TO LEAVE FOR. The vendor held this account and this + * deployment had no row for it, so the route recorded it on this press. The held answers + * were just dropped, so the row falls back to the recorded one, and the refetch is what + * brings that row in. + */ + if (start !== null && "connected" in start) { + void invalidatePlugins(queryClient); + return; + } /* * NO URL IS AN ANSWER, NOT A URL TO FOLLOW. * @@ -417,13 +428,13 @@ export function useBrokeredAccount(input: { * would leave nobody any reason to correct it — and the sentence names the one act that ends * the state, which is a reload against a deployment whose app has caught up. */ - if (authorizationUrl === null) { + if (start === null) { report( "This app connects with a key, not a sign-in page, so nothing was connected. Reload the page and press Connect again.", ); return; } - window.location.href = authorizationUrl; + window.location.href = start.authorizationUrl; }, }); diff --git a/app/src/components/plugins/connect-in-new-tab.ts b/app/src/components/plugins/connect-in-new-tab.ts index 74e0a8166f..abe0666379 100644 --- a/app/src/components/plugins/connect-in-new-tab.ts +++ b/app/src/components/plugins/connect-in-new-tab.ts @@ -1,8 +1,7 @@ -import { useMutation } from "@tanstack/react-query"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useCallback, useEffect, useRef, useState } from "react"; import { connectAccountMutationOptions } from "@/lib/plugins/mutations"; import { pluginKeys } from "@/lib/plugins/queries"; -import { queryClient } from "@/query-client"; /** * Start this person's own connection to one app in a new tab, the way Settings starts it. @@ -21,6 +20,12 @@ import { queryClient } from "@/query-client"; * a connection with the same code rather than a second copy of the popup rules below. */ export function useConnectInNewTab() { + /* + * The provider's client, which is the one whoever drew the button reads from. In the app it is + * the module's own singleton; a screen mounted under another provider is refetched through its + * own, so an account recorded on the press is drawn where the press was made. + */ + const queryClient = useQueryClient(); const [waiting, setWaiting] = useState(false); /** This app takes a key somebody types, so there is no page to send them to. */ const [needsKey, setNeedsKey] = useState(false); @@ -65,7 +70,7 @@ export function useConnectInNewTab() { window.addEventListener("focus", finish, { once: true }); }, 1_000); return () => clearInterval(timer); - }, [waiting]); + }, [waiting, queryClient]); // Goes with whoever drew the button, and with a wait that ended some other way first. useEffect(() => { if (waiting || !onReturn.current) return; @@ -103,18 +108,31 @@ export function useConnectInNewTab() { const tab = claimTab(); handle.current = tab; connect.mutate(serverId, { - onSuccess: (authorizationUrl) => { + onSuccess: (start) => { + /* + * ALREADY CONNECTED: the account was held at the vendor with no row here, and the route + * recorded it on this press. There is no page to visit, so the claimed tab goes, and every + * query is refetched once, as the card does on its own connected edge. Whoever drew the + * button reads the new row there, and the Bot's next turn is asked against it. + */ + if (start !== null && "connected" in start) { + tab?.close(); + handle.current = null; + void queryClient.invalidateQueries(); + return; + } /* * No url means this app is connected by typing a key rather than by visiting a page — the * route answers `{ fields }` and the mutation unwraps nothing. Saying so, with the page * that has the form, beats a button that silently does nothing. */ - if (!authorizationUrl) { + if (start === null) { tab?.close(); handle.current = null; setNeedsKey(true); return; } + const { authorizationUrl } = start; setWaiting(true); if (tab) tab.location.replace(authorizationUrl); // The tab was blocked. The url is still good, so offer it as something to click. diff --git a/app/src/lib/plugins/mutations.ts b/app/src/lib/plugins/mutations.ts index c448077e42..2552a1f13a 100644 --- a/app/src/lib/plugins/mutations.ts +++ b/app/src/lib/plugins/mutations.ts @@ -398,22 +398,51 @@ export function registerOAuthClientMutationOptions(queryClient: QueryClient) { * assignment to `window.location.href` typechecks, and the compiler has been told the same lie in * a second place. `null` is the one absent-value this inference cannot quietly discard, which is * what makes the branch below it obligatory rather than advisory. + * + * AND A THIRD ANSWER, WHICH IS NOT A URL AND NOT A FORM: `{ connected: true }`. A brokered app + * whose account the vendor already holds, with no row for it here, is recorded by the route on + * the spot, so the press ends connected with no page to visit. Unwrapping only the url read that + * answer as `null`, and every caller drew `null` as "this app takes a key" or as a failure, over an + * account that had just been recorded as connected. It is its own member of {@link ConnectStart} + * so a caller has to say what it draws for it. */ export function connectAccountMutationOptions( returnTo: "settings" | "admin" = "settings", ) { return mutationOptions({ - mutationFn: async (serverId: string): Promise => { - const authorizationUrl = await client( + mutationFn: async (serverId: string): Promise => { + const fallback = "Couldn't connect that account."; + const response = await client( `/api/plugins/servers/${encodeURIComponent(serverId)}/connect?returnTo=${returnTo}`, - "authorizationUrl", - { method: "POST", fallback: "Couldn't connect that account." }, + { method: "POST", fallback }, ); - return authorizationUrl ?? null; + // Read as `client` reads an envelope: a body that is not an object is a failed request. + const body = (await response.json().catch(() => null)) as unknown; + if (!body || typeof body !== "object" || Array.isArray(body)) { + throw new Error(fallback); + } + const answer = body as { + authorizationUrl?: unknown; + connected?: unknown; + }; + if (answer.connected === true) return { connected: true }; + return typeof answer.authorizationUrl === "string" + ? { authorizationUrl: answer.authorizationUrl } + : null; }, }); } +/** + * What one press of Connect started: a consent page to visit, an account that is already + * connected, or `null` for neither (an app whose key somebody types). See + * {@link connectAccountMutationOptions}. + */ +export type ConnectStart = + | { authorizationUrl: string } + | { connected: true } + | null; + /** * What asking the vendor about an account comes back with. * diff --git a/app/src/routes/_authed/_app/connections.$key.tsx b/app/src/routes/_authed/_app/connections.$key.tsx index c8d03089b5..f28624f783 100644 --- a/app/src/routes/_authed/_app/connections.$key.tsx +++ b/app/src/routes/_authed/_app/connections.$key.tsx @@ -182,14 +182,21 @@ function AppConnectionPage() { * A full page navigation, not a fetch. The consent screen is the vendor's own and has to be shown * to you in your own browser; there is deliberately nothing here that could complete it for you. */ - onSuccess: (authorizationUrl) => { + onSuccess: (start) => { /* * A 200 with no url on it is not a url to follow. Every press that reaches here is on a * `user-oauth` catalogue entry, whose half of that route always mints one or refuses — so * this is the server having answered something this page does not understand, and saying so * is the only honest thing left. Assigning it navigated to a page called `undefined` on this * deployment's own origin. See `connectAccountMutationOptions`. + * + * `{ connected: true }` is a brokered app's answer and never this half's, so it is the same + * not-understood answer here. */ + const authorizationUrl = + start !== null && "authorizationUrl" in start + ? start.authorizationUrl + : null; if (authorizationUrl === null) { setNotice( "Couldn't start connecting, so nothing was connected. Try again, or ask an admin if it keeps happening.", diff --git a/app/src/routes/_authed/admin/plugins/$key.tsx b/app/src/routes/_authed/admin/plugins/$key.tsx index 4d780e42e0..3f398690b1 100644 --- a/app/src/routes/_authed/admin/plugins/$key.tsx +++ b/app/src/routes/_authed/admin/plugins/$key.tsx @@ -312,13 +312,20 @@ function RouteComponent() { * 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) => { + onSuccess: (start) => { /* * A 200 with no url on it is not a url to follow. This press is on a `user-oauth` catalogue * entry, whose half of that route always mints one or refuses, so this is the server having * answered something this page does not understand. Assigning it navigated to a page called * `undefined` on this deployment's own origin. See `connectAccountMutationOptions`. + * + * `{ connected: true }` is a brokered app's answer and never this half's, so it is the same + * not-understood answer here. */ + const authorizationUrl = + start !== null && "authorizationUrl" in start + ? start.authorizationUrl + : null; if (authorizationUrl === null) { setError( "OpenBot got no consent link back, so nothing was connected. Try again, or check this connector's setup if it keeps happening.", diff --git a/app/tests/connect-app-card.test.tsx b/app/tests/connect-app-card.test.tsx index a4dc054608..93e3fd5872 100644 --- a/app/tests/connect-app-card.test.tsx +++ b/app/tests/connect-app-card.test.tsx @@ -54,8 +54,11 @@ type Row = { serverId: string; holder?: string; revoked?: boolean }; function mounted( options: { connections?: Row[]; - /** What `POST /connect` answers: a page to visit, or a form to fill in. */ - answers?: "url" | "fields" | "failure"; + /** + * What `POST /connect` answers: a page to visit, a form to fill in, or an account the vendor + * already held, which the route records on the press and lists from then on. + */ + answers?: "url" | "fields" | "failure" | "connected"; /** False stands in for a browser that refused the tab, which answers null. */ popupsAllowed?: boolean; } = {}, @@ -84,6 +87,8 @@ function mounted( return tab as unknown as Window; }) as typeof window.open; + /** Whether the route has recorded the account, which is what the connections read lists. */ + let recorded = false; global.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { const url = String(input); const json = (body: unknown, status = 200) => @@ -92,13 +97,23 @@ function mounted( headers: { "content-type": "application/json" }, }); if (url.includes("/api/plugins/connections")) - return json({ connections, redirectUri: null }); + return json({ + connections: recorded + ? [...connections, { serverId: "srv_notion", holder: "person" }] + : connections, + redirectUri: null, + }); if (url.includes("/connect") && init?.method === "POST") { if (answers === "failure") return json({ error: "That account could not be connected." }, 502); // What a key-entry app answers: a form to fill in, and no page to visit. if (answers === "fields") return json({ fields: [{ name: "api_key", type: "string" }] }); + // What the route answers once it has recorded an account the vendor already held. + if (answers === "connected") { + recorded = true; + return json({ connected: true }); + } return json({ authorizationUrl: "https://notion.test/consent" }); } return json({}); @@ -215,6 +230,26 @@ test("says where to type a key for an app that has no page to visit", async () = expect(card.tabsClosed()).toBe(1); }); +test("says connected when the press records an account the vendor already held", async () => { + /* + * The vendor refused a second link because it already held this person's account, and the route + * recorded that account on the press. Read as "no url", the card told the person to type a key + * for an app that had just been connected through a sign-in. + */ + const card = mounted({ answers: "connected" }); + await waitFor(() => card.view.getByRole("button", { name: /^Connect$/ })); + + press(card); + + await waitFor(() => + expect(card.view.getByText(/Notion is connected/)).toBeTruthy(), + ); + expect(card.view.queryByText(/needs a key from you/)).toBeNull(); + // Nothing to visit: the claimed tab is let go without ever being pointed anywhere. + expect(card.tabsClosed()).toBe(1); + expect(card.sentTo).toEqual([]); +}); + test("lets go of the claimed tab when connecting fails", async () => { const card = mounted({ answers: "failure" }); await waitFor(() => card.view.getByRole("button", { name: /^Connect$/ })); diff --git a/server/src/plugins/broker.ts b/server/src/plugins/broker.ts index bdd76226e0..d3382bc580 100644 --- a/server/src/plugins/broker.ts +++ b/server/src/plugins/broker.ts @@ -930,6 +930,28 @@ export class BrokerAccountGoneError extends BrokerRefusalError { } } +/** + * An account the vendor ALREADY HOLDS for this person and this app, met while beginning another. + * + * {@link BrokerAccountGoneError} TURNED AROUND, AND ITS OWN CLASS FOR THE SAME REASON. That one is + * a row this deployment still has over an account the vendor no longer holds; this one is an + * account the vendor holds under a row this deployment does not have. A fresh install, an upgrade + * that lost the table, or a second install signing in as the same person against the same vendor + * key all arrive here, and the person sees an app drawn as not connected that the vendor refuses to + * connect a second time. Telling it apart by type is what lets the connect route ask the vendor + * once more and RECORD the account it already holds, which is the confirm route's own act, instead + * of handing the person a refusal that names a Disconnect button their page does not show. + * + * A {@link BrokerRefusalError} because it keeps that class's promise about the message, so a + * reader that never learned this class still passes on a sentence this deployment wrote. + */ +export class BrokerAccountHeldError extends BrokerRefusalError { + constructor(message: string, options?: ErrorOptions) { + super(message, options); + this.name = "BrokerAccountHeldError"; + } +} + /** * A return address that could not bring anybody back, refused before a consent is spent on it. * diff --git a/server/src/plugins/composio-adapter.ts b/server/src/plugins/composio-adapter.ts index 5cba2e2020..99a787e0cf 100644 --- a/server/src/plugins/composio-adapter.ts +++ b/server/src/plugins/composio-adapter.ts @@ -202,12 +202,13 @@ export function createComposioClient(apiKey: string): { * have to re-derive it. * * `dangerouslyAllowAutoUploadDownloadFiles` defaults OFF (`ConfigDefaults.node.ts:4`) and is - * the premise `./composio`'s file filter is written on; that filter drops every action whose - * schema stages a file under EITHER setting, so the premise is defended by a tested guard - * rather than by this literal, and turning the flag on here would be a change to make against - * that filter. `sensitiveFileUploadProtection`, `fileUploadPathDenySegments`, `fileUploadDirs` - * and `fileDownloadDir` only bear on uploads and downloads that the same filter means never - * happen. `provider` defaults to the SDK's `OpenAIProvider`, which this adapter never asks to + * the premise `./composio`'s file filter is written on; under EITHER setting that filter drops + * every action that needs a file and offers one that only accepts a file without that + * parameter, and the execute path refuses a call that carries it anyway. So the premise is + * defended by tested guards rather than by this literal, and turning the flag on here would be + * a change to make against that filter. `sensitiveFileUploadProtection`, + * `fileUploadPathDenySegments`, `fileUploadDirs` and `fileDownloadDir` only bear on uploads + * and downloads that the same filter means never happen. `provider` defaults to the SDK's `OpenAIProvider`, which this adapter never asks to * format anything — every read here goes through the raw client or the SDK's own models. * `host` and `defaultHeaders` are telemetry and header cosmetics, and telemetry is off above. * `toolkitVersions` defaults to "latest", which is the literal every listing in this file diff --git a/server/src/plugins/composio-client/actions.ts b/server/src/plugins/composio-client/actions.ts index 8b08454499..b16cc647d4 100644 --- a/server/src/plugins/composio-client/actions.ts +++ b/server/src/plugins/composio-client/actions.ts @@ -1,7 +1,8 @@ -import type { - ComposioAction, - ComposioActions, - ComposioResult, +import { + type ComposioAction, + type ComposioActions, + type ComposioResult, + withheldArguments, } from "../composio"; import { actionOf, @@ -177,6 +178,35 @@ export function actionsFor(context: ClientContext): ComposioActions { ); } + /* + * A FILE PARAMETER THE LISTING LEFT OUT IS REFUSED HERE, AND THE RESOLVE IS WHAT MAKES THAT + * POSSIBLE. `./composio` offers an action whose file parameters are all optional with those + * parameters removed, because the only value a model can put in one is a staging key it made + * up. A model can still send one, so the call has to be checked against the action's own + * schema, and the tool resolved above at the recorded version is that schema: the same + * definition the listing was read from, asked the same question by the same walk. See + * {@link withheldArguments}. + * + * REFUSED IN A SENTENCE RATHER THAN STRIPPED, which is how this file answers every call it + * will not send. Dropping the value quietly would run a different call from the one the model + * made and tell it nothing, so the email would go without the attachment the model believes it + * attached. The sentence names the parameter and the remedy, which is the same call without it. + * + * ONE LIMIT, WRITTEN DOWN RATHER THAN HIDDEN. What resolves here has been through + * `ToolSchema.parse`, which keeps `properties`, `required`, the composition keywords, `items` + * and the flag itself, and drops the rarer 2020-12 applicators (`prefixItems`, `contains` and + * the like). A file reachable only through one of those is withheld by the listing and not + * seen here. In the fifteen toolkits read when this was written, every file sat directly on a + * named property or under its `anyOf` or `items`, all of which survive the parse. + */ + const withheld = withheldArguments(resolved.inputParameters, args); + if (withheld.length > 0) { + const named = withheld.join(", "); + throw new Error( + `${call.slug} was not run, because it included ${named}, which is a file. OpenBot can't pass a file to this action, so any value there could only have been made up, and the action is offered without ${named} for that reason. Try again without ${named}.`, + ); + } + return askVendor( { outcome: `${call.slug} was not run`, app: call.toolkit }, () => diff --git a/server/src/plugins/composio-client/consent.ts b/server/src/plugins/composio-client/consent.ts index e14943acec..9a6a086836 100644 --- a/server/src/plugins/composio-client/consent.ts +++ b/server/src/plugins/composio-client/consent.ts @@ -337,7 +337,10 @@ export function consentMethods( * 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 + * track of. What it raises there is {@link BrokerAccountHeldError}, and the route answers + * that by recording the account the vendor holds, so the person who pressed Connect is + * connected through it and is not told to disconnect an account their page never showed. + * `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. * diff --git a/server/src/plugins/composio-vendor.ts b/server/src/plugins/composio-vendor.ts index 76cb5b0e12..666d76256c 100644 --- a/server/src/plugins/composio-vendor.ts +++ b/server/src/plugins/composio-vendor.ts @@ -1,5 +1,6 @@ import { BrokerAccountGoneError, + BrokerAccountHeldError, type BrokerApp, type BrokerConnection, BrokerRefusalError, @@ -1689,10 +1690,21 @@ function vendorRefusal( * 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. + * + * RAISED AS ITS OWN CLASS, BECAUSE THE ACCOUNT IT REPORTS IS ONE THIS DEPLOYMENT MAY NOT KNOW + * ABOUT. The connect route refuses a second connection itself wherever it holds a row, so this + * only reaches it where the row is missing: a fresh or upgraded install, or two installs + * signing in as one person against one Composio key. See {@link BrokerAccountHeldError}: the + * route asks again and records the account rather than passing this sentence on. + * + * AND THE SENTENCE NO LONGER SENDS ANYBODY TO DISCONNECT IT. It used to name this deployment's + * Connected accounts page as the place to end the account, which is false exactly when this + * fires: with no row here, that page draws the app as not connected and offers no Disconnect. */ 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.`, + return new BrokerAccountHeldError( + `Composio answered that this person already holds a connected account for ${app}, so ${outcome}. That is a settled state at Composio and not a moment that passes, so asking for another link meets the same answer. It is an account this person is already connected through, whether or not this deployment has recorded it yet.`, + { cause: error }, ); /* diff --git a/server/src/plugins/composio.ts b/server/src/plugins/composio.ts index 10e77dbfc5..5a024fab1a 100644 --- a/server/src/plugins/composio.ts +++ b/server/src/plugins/composio.ts @@ -88,8 +88,9 @@ export type ComposioAction = { * 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. + * this module promises is now worth more: it adds nothing to this schema, and the one thing it + * removes is a file parameter the action can do without, which no model can fill in (see + * {@link withoutOptionalFiles}). Everything else 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 @@ -365,7 +366,7 @@ function schemaNode(value: unknown): Record | null { const SCHEMA_DEPTH = 64; /** - * What the walk below answers: whether the action stages a file, or why that could not be settled. + * What the walk below answers: whether a schema stages a file, or why that could not be settled. * * THE THIRD ANSWER IS A CLAUSE RATHER THAN A `null`, so the refusal {@link listTools} writes can say * WHICH shape stopped the read without this function knowing the app or the action it is about. It @@ -375,12 +376,18 @@ const SCHEMA_DEPTH = 64; type FileVerdict = boolean | string; /** - * Whether an action asks for a file, anywhere in its schema. + * Whether a schema asks for a file, anywhere in it. * * `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. + * here to be read. + * + * IT IS NO LONGER ASKED OF A WHOLE ACTION. {@link withoutOptionalFiles} walks an action's named + * properties itself, because a file under a property the action can do without is a parameter to + * leave out rather than a reason to drop the action, and hands this walk everything else a schema + * says. A `true` from here is therefore a file no property can be left out to remove, which is + * still an action nobody can call. See {@link listTools} for what is done with either 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 @@ -489,6 +496,190 @@ function stagesAFile(schema: unknown, depth = 0): FileVerdict { return false; } +/** + * An action's schema with the file parameters it can do without taken out, and which those were. + * + * `withheld` names each removed parameter by its path of property names from the root, such as + * `["attachment"]`, or `["message", "attachment"]` for one inside an object parameter, because the + * call path has to recognise a call that carries one anyway. See {@link withheldArguments}. + */ +type OfferedSchema = { schema: unknown; withheld: string[][] }; + +/** + * What {@link withoutOptionalFiles} answers: the schema an action is offered under, `true` where no + * call to it can leave the file out, or the clause {@link stagesAFile} gives for a schema it could + * not read to the bottom. + */ +type FileOffer = OfferedSchema | true | string; + +/** + * The schema an action is offered under once every file parameter it can do without is removed. + * + * A FILE THE ACTION ONLY ACCEPTS IS NOT A FILE IT NEEDS, and this deployment used to treat the two + * the same. The walk asked whether a schema carried `file_uploadable` anywhere and dropped the action + * if it did, so Gmail's GMAIL_SEND_EMAIL, GMAIL_CREATE_EMAIL_DRAFT, GMAIL_REPLY_TO_THREAD and + * GMAIL_UPDATE_DRAFT were never offered: each carries an `attachment` that is a single file or a list + * of them, and none of them requires it. A Bot could read a mailbox and could not send, draft or + * reply. The argument for dropping (see {@link listTools}) is that a model can only invent the + * staging key a file parameter asks for, and that is exactly as true of an optional one. What + * differs is the remedy: a call that leaves the parameter out is a call that works, so the + * parameter goes and the action stays. + * + * WHAT IS REMOVED IS A NAMED PROPERTY THAT NOTHING REQUIRES, which is the one shape that can be taken + * out of a schema without changing what the rest of it means. A property that carries a file + * anywhere beneath it (on itself, under `anyOf`, as the `items` of a list, inside an object of its + * own) is a file parameter, and it is withheld only where the object naming it does not require it + * (see {@link requiresName}). The rest of the object is kept as the vendor wrote it: every other + * property, its `required`, its descriptions. + * + * THE UNIT IS THE DEEPEST PROPERTY A CALL CAN LEAVE OUT. A file the object around it requires makes + * that object a file parameter in turn, one level up, so the question is asked again of the object's + * own parent: an optional `message` whose `attachment` it requires is withheld whole, and a required + * `message` whose `attachment` is optional keeps everything but the `attachment`. Required all the + * way to the root is an action nobody can call, which answers `true` and is dropped as before. + * + * EVERYTHING ELSE IS STILL {@link stagesAFile}'S, AND STILL A DROP. A file under + * `additionalProperties`, `patternProperties` or a composition of the object itself is not behind a + * name that a call could leave out, and one in `$defs` is reached through a `$ref` this deployment + * does not resolve, so no property can be withheld to remove either. None of the fifteen toolkits + * read when this was written spells a file any of those ways. The same + * goes for the two shapes nothing can read: a flag that is not a flag and a schema nested past + * {@link SCHEMA_DEPTH} still answer their clause, here as there. + * + * NOTHING THE VENDOR HANDED OVER IS CHANGED. Where nothing is withheld the answer is the object that + * arrived, so an ordinary schema reaches a model as the same object; where something is, each object + * on the path to it is copied and everything off that path is shared. + */ +function withoutOptionalFiles(schema: unknown, depth = 0): FileOffer { + if (depth > SCHEMA_DEPTH) + return `nests more than ${SCHEMA_DEPTH} objects deep`; + const node = schemaNode(schema); + if (!node) return { schema, withheld: [] }; + const flagged = flagOf(node.file_uploadable, false); + if (flagged === null) + return "publishes a file_uploadable in it that is not a flag"; + if (flagged) return true; + + const { properties, ...besides } = node; + const named = schemaNode(properties); + const kept: [string, unknown][] = []; + const withheld: string[][] = []; + for (const [name, child] of Object.entries(named ?? {})) { + const answer = withoutOptionalFiles(child, depth + 1); + if (typeof answer === "string") return answer; + if (answer === true) { + if (requiresName(node, name)) return true; + withheld.push([name]); + continue; + } + kept.push([name, answer.schema]); + for (const path of answer.withheld) withheld.push([name, ...path]); + } + + const elsewhere = stagesAFile(besides, depth); + if (elsewhere !== false) return elsewhere; + + if (withheld.length === 0) return { schema, withheld }; + // `fromEntries` rather than assignment, so a property named `__proto__` stays a property. + return { + schema: { ...node, properties: Object.fromEntries(kept) }, + withheld, + }; +} + +/** + * Whether the object schema `node` requires its property `name`, which is what decides whether a + * file under that property can be left out. + * + * IT ANSWERS YES WHENEVER IT CANNOT BE SURE, because a wrong yes and a wrong no cost very different + * things. A wrong yes drops an action, which is what every action carrying a file used to get. A + * wrong no offers an action with a parameter removed that the vendor then insists on, and every call + * to it fails, which is the outcome the whole filter exists to prevent. + * + * So a `required` is read wherever one can apply to the object itself: its own, under the + * composition and conditional keywords, and in `dependentRequired`, `dependentSchemas` and draft-07 + * `dependencies`, where supplying some other parameter can require this one. A `required` that is + * not a list of names, a `$ref` (which needs a resolver this deployment does not have) and a schema + * nested past {@link SCHEMA_DEPTH} all answer yes rather than being guessed at. `not` is the one + * keyword passed over, because a name it requires is one the object must NOT carry. + */ +function requiresName( + node: Record, + name: string, + depth = 0, +): boolean { + if (depth > SCHEMA_DEPTH) return true; + if (node.$ref !== undefined) return true; + if (listsName(node.required, name)) return true; + + for (const key of ["allOf", "anyOf", "oneOf", "if", "then", "else"]) { + const branch = node[key]; + for (const child of Array.isArray(branch) ? branch : [branch]) { + const arm = schemaNode(child); + if (arm && requiresName(arm, name, depth + 1)) return true; + } + } + + for (const key of ["dependentRequired", "dependentSchemas", "dependencies"]) { + for (const value of Object.values(schemaNode(node[key]) ?? {})) { + // A boolean is a whole subschema saying yes or no, and it names nobody. + if (typeof value === "boolean") continue; + const arm = schemaNode(value); + if (arm ? requiresName(arm, name, depth + 1) : listsName(value, name)) + return true; + } + } + + return false; +} + +/** Whether a list of required names includes this one, which an unreadable list is taken to. */ +function listsName(list: unknown, name: string): boolean { + if (list === undefined || list === null) return false; + if (!Array.isArray(list)) return true; + return list.some( + (entry) => typeof entry !== "string" || entry.trim() === name.trim(), + ); +} + +/** + * The file parameters a call carries that the listing left out of the action, by name. + * + * WHY A CALL CAN CARRY ONE AT ALL. {@link listTools} removes such a parameter from the schema a + * model is shown, and a model that follows the schema never sends it. Nothing makes it follow the + * schema: Gmail's leaves other properties open, the built-in runtime's `parametersFor` (in + * `@openbot/toolkit`) passes an open property straight through to the call, and a model that has + * seen Gmail's API elsewhere knows the parameter is called `attachment`. What it would put there is + * the one value the parameter was removed for: a staging key it made up. + * + * ASKED OF THE SCHEMA THE CALL IS ABOUT, by the same walk the listing used, so the two cannot come + * apart over which parameters count. `./composio-client/actions.ts` asks this with the tool it has + * just resolved at the recorded version, and refuses the call in a sentence when anything comes + * back. An action the walk would drop, or cannot read, answers nothing here: it was never offered, + * and what a call to it carries is the vendor's to judge, as it always was. + * + * A NULL IS NOT A FILE. Composio documents `null` as "no attachment", so a call that spells the + * absence that way carries nothing that needs refusing. + */ +export function withheldArguments( + schema: unknown, + args: Record, +): string[] { + const offer = withoutOptionalFiles(schema); + if (offer === true || typeof offer === "string") return []; + return offer.withheld + .filter((path) => { + let value: unknown = args; + for (const name of path) { + const holder = schemaNode(value); + if (!holder || !Object.hasOwn(holder, name)) return false; + value = holder[name]; + } + return value !== undefined && value !== null; + }) + .map((path) => path.join(".")); +} + /** * Whether an action published under this schema would be refused if it were called with no arguments. * @@ -558,9 +749,10 @@ export function asksForArguments(schema: unknown, depth = 0): boolean { * * 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. + * does not have it. The one exception is an action that NEEDS A FILE, which is dropped, and an action + * that only accepts one is listed with that parameter removed. 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 @@ -682,8 +874,16 @@ export async function listTools(connection: { * 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 BELOW, which is deliberately still offered. - * There the vendor is the right party to reject a bad argument, and the action might well + * SO THE PARAMETER IS WHAT FAILS, AND IT IS THE ACTION ONLY WHERE A CALL CANNOT LEAVE IT OUT. An + * action whose file parameters are all optional is offered with them removed from its schema: + * Gmail's GMAIL_SEND_EMAIL with no `attachment` sends an email, and dropping it, which is what + * this filter used to do, left a Bot able to read a mailbox and unable to send, draft or reply. + * An action that requires a file, directly or inside a parameter it requires, is still dropped. + * {@link withoutOptionalFiles} decides which, and the call path refuses a call that carries a + * removed parameter anyway (see {@link withheldArguments}). + * + * WHY A DROPPED ONE IS NOT THE SAME AS THE SCHEMALESS ACTION BELOW, 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. @@ -720,14 +920,14 @@ export async function listTools(connection: { typeof action.slug === "string" && action.slug.trim() !== "" ? action.slug.trim() : `action ${position + 1} of the listing`, - verdict: stagesAFile(action.inputParameters), + verdict: withoutOptionalFiles(action.inputParameters), })); /* * A SCHEMA THIS DEPLOYMENT COULD NOT READ TO THE BOTTOM IS A REFUSAL, NOT AN ACTION OFFERED. * - * The two shapes {@link stagesAFile} cannot settle are a `file_uploadable` that is not a flag and - * a schema nested past {@link SCHEMA_DEPTH}, and read as "no file" they are both an action + * The two shapes {@link withoutOptionalFiles} cannot settle are a `file_uploadable` that is not a + * flag and a schema nested past {@link SCHEMA_DEPTH}, and read as "no file" they are both an action * published to a model whose every call fails at the vendor's staging lookup. That is the exact * thing the filter below exists to prevent, so the answer is the one the three field guards after * it already give: refuse while a sentence can still name the app and the action, and keep every @@ -742,13 +942,27 @@ export async function listTools(connection: { const unreadableSchema = read.find((row) => typeof row.verdict === "string"); if (unreadableSchema) { throw new Error( - `Composio's action list for ${toolkit} carries an input schema for ${unreadableSchema.named} that this deployment could not read to the bottom: it ${unreadableSchema.verdict}. Whether an action stages a file is what decides whether this deployment can offer it at all — one that does is dropped, because a model can only invent the staging key it asks for — so a schema that settles neither is not one to publish an action from. Nothing was refreshed and the actions already recorded for this app are kept rather than replaced by a listing whose schemas could not be read.`, + `Composio's action list for ${toolkit} carries an input schema for ${unreadableSchema.named} that this deployment could not read to the bottom: it ${unreadableSchema.verdict}. Whether an action stages a file is what decides whether this deployment can offer it at all: one that needs a file is dropped, and one that only accepts one is offered without it, because a model can only invent the staging key a file asks for. So a schema that settles neither is not one to publish an action from. Nothing was refreshed and the actions already recorded for this app are kept rather than replaced by a listing whose schemas could not be read.`, ); } - const offered = read - .filter((row) => row.verdict !== true) - .map((row) => row.action); + /* + * EACH OFFERED ACTION CARRIES THE SCHEMA IT IS OFFERED UNDER from here on, which is the vendor's + * own object wherever nothing was withheld from it. So the guards below and the map at the end + * read one action, and what `mcp_tools` records is what a model is shown. + */ + const offered: ComposioAction[] = []; + for (const { action, verdict } of read) { + if (verdict === true || typeof verdict === "string") continue; + offered.push( + verdict.withheld.length === 0 + ? action + : { + ...action, + inputParameters: verdict.schema as Record, + }, + ); + } /* * AN ACTION WITH NO SLUG BREAKS THE LISTING RATHER THAN BEING DROPPED FROM IT. @@ -772,9 +986,10 @@ export async function listTools(connection: { * 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, and which now runs - * ABOVE this. 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 — + * NOR IS IT THE FILE FILTER'S CASE, which drops the actions that need a file and is right to, and + * which now runs ABOVE this. 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 — * and an action it dropped is one the map never sees, so a missing slug on one of those is not * this guard's business. This is an answer that could not be read about an action that WILL be * published, which is the criterion the element check above already throws on, one field in. @@ -1001,7 +1216,7 @@ export async function listTools(connection: { */ 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.`, + `Every one of the ${actions.length} actions Composio listed for ${toolkit} needs 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.`, ); } @@ -1049,6 +1264,9 @@ export async function listTools(connection: { * is the right party to reject a bad argument — and `storableSchema` in `./store` walks any * shape into the `jsonb` column. So an unreadable schema already has an answer that keeps the * action callable, and refusing the app's whole listing over it would cost more than it saves. + * + * It is the schema the action is OFFERED under, which is the vendor's own object unless the + * file filter above withheld a parameter from it. */ inputSchema: action.inputParameters ?? {}, effect, diff --git a/server/src/plugins/route-groups/connections.ts b/server/src/plugins/route-groups/connections.ts index 51883c0422..ceff39ef01 100644 --- a/server/src/plugins/route-groups/connections.ts +++ b/server/src/plugins/route-groups/connections.ts @@ -2,6 +2,7 @@ import type { AppVariables } from "../../auth/guards"; import { reasonWithoutStatement } from "../../db/query-failure"; import { normalizeArcadeError } from "../arcade-adapter"; import { + BrokerAccountHeldError, type BrokerField, BrokerUnconfiguredError, brokerReturnUrl, @@ -129,6 +130,10 @@ export function mountConnectionRoutes({ * Answers with a URL rather than redirecting, so the browser decides when to leave the page. The * state is minted here, from the session and the row's mode, and never from anything the request * carries. + * + * A brokered app can answer two other ways: `{ fields }` for an app whose key somebody types, and + * `{ connected: true }` where the vendor already holds this account and this deployment had no + * row for it, which is recorded here exactly as the confirm route records it. */ routes.post("/servers/:id/connect", requireUser, async (context) => { const serverId = context.req.param("id"); @@ -757,6 +762,65 @@ export function mountConnectionRoutes({ }), )); } catch (error) { + /* + * THE VENDOR ALREADY HOLDS THIS ACCOUNT, AND THIS DEPLOYMENT HAS NO ROW FOR IT: RECORD IT, + * DON'T REFUSE IT. + * + * The one-account guard above already refused wherever a row stands here, so this can + * only be the other half of that rule: an account the vendor holds that this deployment + * lost track of. A fresh or upgraded install meets it, and so does every local install in + * single-user mode, because they all sign in as the same person against one vendor key. + * Refused, the person read a sentence telling them to disconnect an account on a page + * that drew the app as not connected and offered no Disconnect. Nothing they could press + * would ever end it. + * + * WHAT IS DONE INSTEAD IS EXACTLY WHAT CONFIRM DOES, THROUGH THE SAME STORE CALL. The + * vendor is asked whether this account is connected, and a yes writes the row and files + * `mcp.account_connected` for it, once, as a confirm on page load would have. The account + * is the one `brokeredAccountFor` resolved at the top of this branch and the actor is the + * session's, so a Shared app's account is still only ever adopted by an administrator, + * and nothing the request carries can name whose account is recorded. + * + * `{ connected: true }` IS THE STORE'S ANSWER, PASSED BACK WHOLE, which is the shape + * confirm answers with and the one the browser already reads as connected. No link was + * minted, so there is no url to hand back and nothing for the person to finish anywhere. + * + * A NO IS STILL A REFUSAL, AND IT SAYS WHAT HAPPENED. The vendor refused a second link + * and then answered that it holds no active account, which is a race with somebody + * ending it or a vendor that disagrees with itself. The row confirm deletes on a no was + * never here, so the person is not sent to disconnect anything; asking again is the step + * that settles it. 409, the status of the one-account guard above, because it is that + * guard's conflict seen from the vendor's side. + * + * A CONFIRM THAT THROWS IS ANSWERED AS CONFIRM ANSWERS IT, with the vendor's or this + * deployment's sentence and never with a guess about the account in either direction. + * + * ARCADE NEVER RAISES THIS CLASS, so this is a no-op there. Only Composio's adapter + * translates a held account into it. + */ + if (error instanceof BrokerAccountHeldError) { + let confirmed: { connected: boolean }; + try { + confirmed = await store.confirmBrokeredConnection({ + toolkit, + account, + by: context.var.actor.id, + }); + } catch (confirmFailure) { + const refusal = brokerRefusal( + confirmFailure, + `Couldn't check whether ${row.title} is already connected. Try again, or ask an admin if it keeps happening.`, + ); + return context.json({ error: refusal.error }, refusal.status); + } + if (confirmed.connected) return context.json(confirmed); + return context.json( + { + error: `OpenBot was told an account for ${row.title} is already connected, but found none when it checked. Nothing was connected. Press Connect again, or ask an admin if it keeps happening.`, + }, + 409, + ); + } logReturnUrlRefusal(error, serverId); const refusal = brokerRefusal( error, @@ -1041,6 +1105,11 @@ export function mountConnectionRoutes({ * deleted where it says no. Repeating it files no trail rows and moves no timestamps; the store * is where that is settled. * + * THE CONNECT ROUTE MAKES THE SAME CALL, for one case: the vendor refusing a new link because it + * already holds this account. That is a drift this route would heal on the next page load, met + * at the moment somebody pressed Connect, so it is healed there by the same store method rather + * than by a second writer of the row. + * * BEHIND `requireUser` AND NOT ADMIN-GATED AT THE ROUTE — `brokeredAccountFor` is where that * happens instead. A Personal app's confirm is not an administrative act, so the route itself * stays open to anybody signed in; a Shared app's is, and it is the resolver that tells the two diff --git a/server/tests/composio-adapter.test.ts b/server/tests/composio-adapter.test.ts index 4d3992f3c3..2b89607bf7 100644 --- a/server/tests/composio-adapter.test.ts +++ b/server/tests/composio-adapter.test.ts @@ -3832,7 +3832,7 @@ describe("each vendor condition reaches the reader as its own remedy", () => { { name: "ComposioMultipleConnectedAccountsError", raisedBy: "connectedAccounts.link", - remedy: /disconnecting the account they already hold/, + remedy: /an account this person is already connected through/, ask: whileLinking, }, { diff --git a/server/tests/composio-connect-adopts-held-account.test.ts b/server/tests/composio-connect-adopts-held-account.test.ts new file mode 100644 index 0000000000..ba408a6285 --- /dev/null +++ b/server/tests/composio-connect-adopts-held-account.test.ts @@ -0,0 +1,454 @@ +// server/tests/composio-connect-adopts-held-account.test.ts +import { afterAll, afterEach, describe, expect, test } from "bun:test"; +import { randomUUID } from "node:crypto"; +import { ComposioMultipleConnectedAccountsError } from "@composio/core"; +import { and, eq, inArray } from "drizzle-orm"; +import { Hono, type MiddlewareHandler } from "hono"; +import { createAuditStore } from "../src/audit"; +import type { AppVariables } from "../src/auth/guards"; +import { createDatabase } from "../src/db/client"; +import { auditEvents, brokeredConnections, mcpServers } from "../src/db/schema"; +import { + type ComposioVendor, + buildComposioClient, +} from "../src/plugins/composio-adapter"; +import { createPluginRoutes } from "../src/plugins/routes"; +import { createPluginStore } from "../src/plugins/store"; +import { TEST_POOL, testDatabaseUrl } from "./support/database"; + +/** + * PRESSING CONNECT ON AN APP COMPOSIO ALREADY HOLDS AN ACCOUNT FOR, WITH NO ROW FOR IT HERE. + * + * WHAT WAS SEEN. A person pressed Connect on GitHub and was told Composio "already holds a + * connected account for github", and that what clears it is "disconnecting the account they + * already hold, on this deployment's Connected accounts page". That page drew GitHub as not + * connected and offered no Disconnect. Composio held an ACTIVE account for the person and this + * deployment had no `brokered_connections` row for it: a fresh or upgraded install, or, in + * single-user mode, every local install signing in as the same person against one Composio key. + * The confirm route already heals exactly this drift when a page loads; Connect never asked it to. + * + * WHAT IS ASSERTED. The press records the account the vendor holds, through the same store call + * confirm makes, and answers `{ connected: true }`. Where the vendor refuses a second link and then + * answers that it holds nothing, the press is still a refusal, and it no longer sends anybody to + * disconnect an account their page does not show. A Shared app's account is still only ever + * recorded by an administrator. + * + * THE REAL ROUTE, THE REAL STORE, THE REAL COMPOSIO ADAPTER, AND A VENDOR THAT ANSWERS AS THE SDK + * DOES. Only the vendor's client is a double, and the one call this bug turns on is written to do + * what `@composio/core` 0.18.1's `ConnectedAccounts.link` does: list the person's ACTIVE accounts + * on that auth config first and throw the SDK's own `ComposioMultipleConnectedAccountsError`, with + * the SDK's own message, where it finds one. Every listing honours the query it is given, for the + * reason `composio-grants-for.test.ts` gives: a fake that answers every question with every row + * cannot tell a question about this person from one about somebody else. + */ + +const database = createDatabase(testDatabaseUrl(), TEST_POOL); +const suite = randomUUID().slice(0, 8); +const PERSON = `person-${suite}`; +const ADMIN = `admin-${suite}`; +/** Somebody else at this deployment, who holds an account the person pressing Connect does not. */ +const OTHER = `other-${suite}`; +const DEPLOYMENT = `openbot-deployment:acme-${suite}:R`; + +/** + * Every app a test here makes, so the afterEach can take its rows back. + * + * ONE SLUG PER TEST, because `audit_events` is append-only and no clean-up can reach it: a test + * counting `mcp.account_connected` rows under an app would otherwise be counting another test's. + */ +const made: string[] = []; + +/** The vendor's account statuses, spelled here because the adapter keeps its copy private. */ +type AccountStatus = + | "INITIALIZING" + | "INITIATED" + | "ACTIVE" + | "FAILED" + | "EXPIRED" + | "INACTIVE" + | "REVOKED"; + +/** An account as Composio holds it: whose, which app, which auth config, and in what state. */ +type HeldAccount = { + id: string; + userId: string; + toolkit: string; + config: string; + status: AccountStatus; +}; + +/** A vendor method nothing in a given test should reach, which says so rather than answering. */ +const refuse = (what: string) => async (): Promise => { + throw new Error(`${what} should not have been called in this test.`); +}; + +/** + * Composio as this suite needs it: one app's auth config (ours), the accounts it holds, and a + * record of what was asked. + * + * `afterRefusedLink` RUNS BETWEEN THE VENDOR REFUSING A LINK AND ANYTHING ASKING IT AGAIN, which is + * the one moment a race with somebody ending the account in Composio's own dashboard can land in. + */ +function composioHolding( + toolkit: string, + accounts: HeldAccount[], + options: { afterRefusedLink?: () => void } = {}, +) { + const config = { + id: `ac_${toolkit}`, + name: `${toolkit} (OpenBot)`, + status: "ENABLED", + }; + const asked = { listed: 0, minted: [] as string[] }; + + const vendor: ComposioVendor = { + tools: { + list: refuse("tools.list"), + getRawComposioToolBySlug: refuse("tools.getRawComposioToolBySlug"), + execute: refuse("tools.execute"), + }, + toolkits: { + list: refuse("toolkits.list"), + retrieve: refuse("toolkits.retrieve"), + }, + authConfigs: { + list: async (query) => ({ + items: query.toolkit === toolkit ? [config] : [], + nextCursor: null, + }), + create: refuse("authConfigs.create"), + delete: refuse("authConfigs.delete"), + }, + connectedAccounts: { + list: async (query) => { + asked.listed += 1; + return { + items: accounts + .filter( + (account) => + query.userIds.includes(account.userId) && + query.toolkitSlugs.includes(account.toolkit) && + query.statuses.some((status) => status === account.status) && + // Absent means every config, which is what the vendor does with no filter at all. + (query.authConfigIds === undefined || + query.authConfigIds.includes(account.config)), + ) + .map((account) => ({ + id: account.id, + status: account.status, + authConfig: { id: account.config }, + toolkit: { slug: account.toolkit }, + })), + nextCursor: null, + }; + }, + /* + * `@composio/core` 0.18.1, `ConnectedAccounts.link` (`dist/index.mjs:3211-3220`), line for + * line in what it decides: `this.list({ userIds: [userId], authConfigIds: [authConfigId], + * statuses: [ACTIVE] })`, and `if (existing.items.length > 0 && !opts.allowMultiple) throw new + * ComposioMultipleConnectedAccountsError(...)` with this exact message. This deployment never + * sends `allowMultiple`, so the refusal is unconditional on a held account. + */ + link: async (userId, authConfigId, linkOptions) => { + const existing = accounts.filter( + (account) => + account.userId === userId && + account.config === authConfigId && + account.status === "ACTIVE", + ); + if (existing.length > 0) { + options.afterRefusedLink?.(); + throw new ComposioMultipleConnectedAccountsError( + `Multiple connected accounts found for user ${userId} in auth config ${authConfigId}. Please use the allowMultiple option to allow multiple connected accounts.`, + ); + } + asked.minted.push(userId); + return { + redirectUrl: `https://connect.composio.test/link/${userId}?back=${encodeURIComponent(linkOptions.callbackUrl)}`, + }; + }, + create: refuse("connectedAccounts.create"), + delete: refuse("connectedAccounts.delete"), + }, + }; + + return { broker: buildComposioClient(vendor).broker, config, asked }; +} + +/** One ACTIVE account Composio holds for `userId` on this deployment's own config for `toolkit`. */ +function heldBy(userId: string, toolkit: string): HeldAccount { + return { + id: `ca_${userId}_${toolkit}`, + userId, + toolkit, + config: `ac_${toolkit}`, + status: "ACTIVE", + }; +} + +/** The app's row, as enabling it writes one: a consent app, Personal or Shared. */ +async function enable(toolkit: string, mode: "personal" | "shared") { + made.push(toolkit); + await database.insert(mcpServers).values({ + id: toolkit, + title: "GitHub", + vendor: "Composio", + url: `composio://${toolkit}`, + provenance: "composio", + authScheme: "OAUTH2", + accountMode: mode, + sharedVendorUserId: mode === "shared" ? DEPLOYMENT : null, + }); +} + +/** The real routes over the real store, signed in as `id` with `role`. */ +function signedInAs( + broker: ReturnType["broker"], + id: string, + role: "admin" | "user", +) { + const store = createPluginStore({ + brokerProvider: "composio", + database, + auditStore: createAuditStore(database), + credentials: {} as never, + encryptionKey: "x".repeat(44), + policy: () => ({ mode: "enforce", deny: [], allow: ["true"] }), + broker, + deploymentId: `acme-${suite}`, + }); + const session: MiddlewareHandler<{ Variables: AppVariables }> = async ( + context, + next, + ) => { + context.set("actor", { id, email: `${id}@example.test`, role } as never); + await next(); + }; + return new Hono().route( + "/api/plugins", + createPluginRoutes( + store, + session, + async () => true, + { + publicUrl: "https://openbot.example", + appUrl: "https://app.example", + encryptionKey: `${"A".repeat(43)}=`, + personHasAccess: async () => true, + }, + { broker, provider: "composio" }, + ), + ); +} + +const pressConnect = ( + app: ReturnType, + toolkit: string, + body?: unknown, +) => + app.request(`/api/plugins/servers/${toolkit}/connect`, { + method: "POST", + ...(body === undefined + ? {} + : { + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }), + }); + +const rowsFor = (toolkit: string) => + database + .select() + .from(brokeredConnections) + .where(eq(brokeredConnections.app, toolkit)); + +const connectedTrail = (toolkit: string) => + database + .select() + .from(auditEvents) + .where( + and( + eq(auditEvents.eventType, "mcp.account_connected"), + eq(auditEvents.targetId, toolkit), + ), + ); + +afterEach(async () => { + if (made.length === 0) return; + await database + .delete(brokeredConnections) + .where(inArray(brokeredConnections.app, made)); + await database.delete(mcpServers).where(inArray(mcpServers.id, made)); + made.length = 0; +}); + +afterAll(async () => { + await database.$client.end(); +}); + +describe("Connect, where Composio already holds this person's account and OpenBot has no row", () => { + test("records the account Composio holds and answers connected, as confirm would", async () => { + const toolkit = `github-${suite}-held`; + await enable(toolkit, "personal"); + const composio = composioHolding(toolkit, [heldBy(PERSON, toolkit)]); + + const response = await pressConnect( + signedInAs(composio.broker, PERSON, "user"), + toolkit, + ); + + // The shape confirm answers with, which the browser already reads as connected. + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ connected: true }); + // No consent link was minted: the vendor refused it, and there is nothing to finish anywhere. + expect(composio.asked.minted).toEqual([]); + + // The row confirm writes for a consent app the vendor says yes for: this person's own. The + // store records `connected_by` only for a deployment account, whose connector is not its holder. + // The two ids are equal because production writes them equal for a person (`personAccount`); + // this expects the row the store wrote, so the collapse is recorded in + // `fixture-identity-distinct.test.ts` rather than separated. + const rows = await rowsFor(toolkit); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + provider: "composio", + holder: "person", + userId: PERSON, + vendorUserId: PERSON, + connectedBy: null, + verified: true, + probeAction: null, + }); + + // And the trail row confirm files for a connection it records for the first time, once. + const trail = await connectedTrail(toolkit); + expect(trail).toHaveLength(1); + expect(trail[0]?.payload).toMatchObject({ + actor: PERSON, + server: toolkit, + holder: "person", + reconnected: false, + }); + + // The connections list now draws it as theirs, which is what the Connect card watches for. + const listed = await signedInAs(composio.broker, PERSON, "user").request( + "/api/plugins/connections", + ); + const { connections } = (await listed.json()) as { + connections: { serverId: string; holder: string }[]; + }; + expect(connections).toContainEqual( + expect.objectContaining({ serverId: toolkit, holder: "person" }), + ); + }); + + test("whose account is recorded comes from the session, never from the request", async () => { + // Somebody else holds an account; the person pressing does not, and names the other in the body. + const toolkit = `github-${suite}-body`; + await enable(toolkit, "personal"); + const composio = composioHolding(toolkit, [heldBy(OTHER, toolkit)]); + + const response = await pressConnect( + signedInAs(composio.broker, PERSON, "user"), + toolkit, + { holder: "person", vendorUserId: OTHER }, + ); + + // An ordinary consent link, minted for the person signed in, and nobody's account recorded. + expect(response.status).toBe(200); + const body = (await response.json()) as { authorizationUrl?: string }; + expect(body.authorizationUrl).toStartWith( + `https://connect.composio.test/link/${PERSON}?`, + ); + expect(composio.asked.minted).toEqual([PERSON]); + expect(await rowsFor(toolkit)).toEqual([]); + expect(await connectedTrail(toolkit)).toEqual([]); + }); + + test("a refusal whose account is gone when it is checked stays a refusal, and sends nobody to disconnect", async () => { + /* + * Composio refuses the link because it holds an ACTIVE account, and the account is ended in + * its own dashboard before anything here asks again. So the vendor's two answers disagree, and + * the honest end is no row and a sentence that matches the page: nothing to disconnect here. + */ + const toolkit = `github-${suite}-gone`; + await enable(toolkit, "personal"); + const accounts = [heldBy(PERSON, toolkit)]; + const composio = composioHolding(toolkit, accounts, { + afterRefusedLink: () => { + accounts.length = 0; + }, + }); + + const response = await pressConnect( + signedInAs(composio.broker, PERSON, "user"), + toolkit, + ); + + expect(response.status).toBe(409); + const { error } = (await response.json()) as { error: string }; + expect(error).toContain("Nothing was connected."); + expect(error).toContain("Press Connect again"); + // The page this person is on shows no account and no Disconnect, so the sentence names neither. + expect(error).not.toMatch(/disconnect/i); + // Spoken to whoever pressed Connect, so it names no broker. See routes-vendor-vocabulary. + expect(error).not.toMatch(/Composio/); + // The vendor was asked again, and its no is what was recorded: no row, no trail. + expect(composio.asked.listed).toBeGreaterThan(0); + expect(await rowsFor(toolkit)).toEqual([]); + expect(await connectedTrail(toolkit)).toEqual([]); + }); +}); + +describe("Connect on a Shared app whose team account Composio already holds", () => { + test("a non-admin is refused before the vendor is asked, and nothing is recorded", async () => { + const toolkit = `github-${suite}-shared-user`; + await enable(toolkit, "shared"); + const composio = composioHolding(toolkit, [heldBy(DEPLOYMENT, toolkit)]); + + const response = await pressConnect( + signedInAs(composio.broker, PERSON, "user"), + toolkit, + ); + + expect(response.status).toBe(403); + expect(await response.json()).toEqual({ + error: "Only an admin can connect shared apps.", + }); + expect(composio.asked.listed).toBe(0); + expect(composio.asked.minted).toEqual([]); + expect(await rowsFor(toolkit)).toEqual([]); + expect(await connectedTrail(toolkit)).toEqual([]); + }); + + test("an admin's press records the team account under the deployment, as confirm would", async () => { + const toolkit = `github-${suite}-shared-admin`; + await enable(toolkit, "shared"); + const composio = composioHolding(toolkit, [heldBy(DEPLOYMENT, toolkit)]); + + const response = await pressConnect( + signedInAs(composio.broker, ADMIN, "admin"), + toolkit, + ); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ connected: true }); + expect(composio.asked.minted).toEqual([]); + + const rows = await rowsFor(toolkit); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + holder: "deployment", + userId: null, + vendorUserId: DEPLOYMENT, + connectedBy: ADMIN, + }); + const trail = await connectedTrail(toolkit); + expect(trail).toHaveLength(1); + expect(trail[0]?.payload).toMatchObject({ + actor: ADMIN, + server: toolkit, + holder: "deployment", + }); + }); +}); diff --git a/server/tests/composio-file-parameters.test.ts b/server/tests/composio-file-parameters.test.ts new file mode 100644 index 0000000000..d218347081 --- /dev/null +++ b/server/tests/composio-file-parameters.test.ts @@ -0,0 +1,255 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { ToolSchema } from "@composio/core"; +import { + callTool, + listTools, + useComposioClient, + VERSION_ARG, +} from "../src/plugins/composio"; +import { + buildComposioClient, + type ComposioVendor, +} from "../src/plugins/composio-adapter"; +import fixture from "./fixtures/composio-file-actions.json"; + +/** + * Actions whose file parameter is optional, asserted on the rows Composio actually publishes. + * + * THE DEFECT. The listing dropped every action carrying a `file_uploadable` parameter anywhere in + * its schema, on the right argument (a model can only invent the staging key such a parameter asks + * for) applied to the wrong set. On Composio's Gmail toolkit at `toolkit_versions=latest` that + * dropped exactly GMAIL_SEND_EMAIL, GMAIL_CREATE_EMAIL_DRAFT, GMAIL_REPLY_TO_THREAD and + * GMAIL_UPDATE_DRAFT: the only file in each is `attachment`, a single file or a list of them, and + * none of the four requires it. A Bot with Gmail could read mail and could not send, draft or reply. + * + * THE ROWS ARE THE VENDOR'S, NOT A SKETCH OF THEM. `fixtures/composio-file-actions.json` holds the + * rows `client.tools.list` answered with, verbatim but for `output_parameters`, so the attachment + * arrives in the shape that defeated the old walk: an `anyOf` of the file object and an array of + * it, under a property nothing requires. The fake below hands those rows over the way the vendor + * would, through the real adapter: the listing as the raw page, and the single-tool resolve as the + * SDK's own `transformToolCases` returns it, renamed and run through `ToolSchema.parse` + * (`@composio/core` 0.18.1). + */ + +afterEach(() => useComposioClient(null)); + +/** One row as the listing hands it over: the wire's own snake_case, nothing parsed. */ +type WireRow = { + slug: string; + name: string; + version: string; + input_parameters: Record; + available_versions?: string[]; + deprecated?: { is_deprecated?: boolean }; + no_auth?: boolean; + [key: string]: unknown; +}; + +const rows: Record<"gmail" | "googledrive", WireRow[]> = fixture; + +/** A vendor method nothing in a given test should reach, which says so rather than answering. */ +const refuse = (what: string) => async (): Promise => { + throw new Error(`${what} should not have been called in this test.`); +}; + +/** + * The vendor for one app's rows, recording every execute it is asked for. + * + * Every row is copied on the way out, so nothing the code under test does to what it was handed + * can reach the next request, and the expectations below can be computed from the fixture itself. + */ +function vendorFor( + toolkit: keyof typeof rows, + sent: unknown[] = [], +): ComposioVendor { + const published = rows[toolkit]; + return { + tools: { + list: async (query) => ({ + items: query.toolkit_slug === toolkit ? structuredClone(published) : [], + next_cursor: null, + }), + getRawComposioToolBySlug: async (slug, options) => { + const row = published.find( + (candidate) => + candidate.slug === slug && candidate.version === options?.version, + ); + if (!row) throw new Error(`${slug} is not in this fixture.`); + // What `Tools.transformToolCases` builds before the SDK hands a resolved tool back. + return ToolSchema.parse({ + ...structuredClone(row), + inputParameters: structuredClone(row.input_parameters), + availableVersions: row.available_versions, + isDeprecated: row.deprecated?.is_deprecated ?? false, + isNoAuth: row.no_auth, + }); + }, + execute: async (slug, body) => { + sent.push([slug, structuredClone(body)]); + return { data: { id: "sent_1" }, error: null, successful: true }; + }, + }, + toolkits: { + list: refuse("toolkits.list"), + retrieve: refuse("toolkits.retrieve"), + }, + authConfigs: { + list: refuse("authConfigs.list"), + create: refuse("authConfigs.create"), + delete: refuse("authConfigs.delete"), + }, + connectedAccounts: { + list: refuse("connectedAccounts.list"), + link: refuse("connectedAccounts.link"), + create: refuse("connectedAccounts.create"), + delete: refuse("connectedAccounts.delete"), + }, + }; +} + +/** The row this fixture publishes under one slug, whole. */ +function published(toolkit: keyof typeof rows, slug: string): WireRow { + const row = rows[toolkit].find((candidate) => candidate.slug === slug); + if (!row) throw new Error(`${slug} is not in this fixture.`); + return row; +} + +/** A row's schema as the vendor wrote it, less one named property. */ +function without(schema: Record, name: string) { + const copy = structuredClone(schema) as { + properties: Record; + }; + delete copy.properties[name]; + return copy; +} + +const GMAIL_WRITES = [ + "GMAIL_SEND_EMAIL", + "GMAIL_CREATE_EMAIL_DRAFT", + "GMAIL_REPLY_TO_THREAD", + "GMAIL_UPDATE_DRAFT", +]; + +describe("an action whose file parameter is optional", () => { + test("Gmail's send, draft and reply actions are offered without the attachment", async () => { + useComposioClient(buildComposioClient(vendorFor("gmail")).actions); + + const listed = await listTools({ url: "composio://gmail" }); + + // All five, where the old filter left GMAIL_FETCH_EMAILS alone. + expect(listed.map((tool) => tool.name)).toEqual([ + ...GMAIL_WRITES, + "GMAIL_FETCH_EMAILS", + ]); + + for (const slug of GMAIL_WRITES) { + const tool = listed.find((candidate) => candidate.name === slug); + const wire = published("gmail", slug).input_parameters; + /* + * THE SCHEMA THE VENDOR WROTE, LESS THE ATTACHMENT AND NOTHING ELSE. Every other property, + * the `required` list where there is one (`thread_id`, `draft_id`), every description and + * example is still there, because a model choosing how to send an email reads all of it. + * The slug rides in the comparison so a failure names the action. + */ + expect({ slug, schema: tool?.inputSchema }).toEqual({ + slug, + schema: without(wire, "attachment"), + }); + // And no staging descriptor survives anywhere in what a model is shown. + expect(JSON.stringify(tool?.inputSchema)).not.toContain( + "file_uploadable", + ); + } + + // The two whose `required` is not empty still carry it, untouched. + const reply = listed.find((tool) => tool.name === "GMAIL_REPLY_TO_THREAD"); + expect(reply?.inputSchema.required).toEqual(["thread_id"]); + const update = listed.find((tool) => tool.name === "GMAIL_UPDATE_DRAFT"); + expect(update?.inputSchema.required).toEqual(["draft_id"]); + + // An action with no file in it reaches a model exactly as published. + const read = listed.find((tool) => tool.name === "GMAIL_FETCH_EMAILS"); + expect(read?.inputSchema).toEqual( + published("gmail", "GMAIL_FETCH_EMAILS").input_parameters, + ); + }); + + test("a Drive upload that needs its file is still dropped, beside a create that only accepts one", async () => { + /* + * THE OTHER HALF, ON A REAL PAIR FROM ONE APP. GOOGLEDRIVE_UPLOAD_FILE requires `file_to_upload` + * and GOOGLEDRIVE_CREATE_FILE does not, and both spell it as the same `FileUploadable` object. + * The first has no call a model can make, so it stays out; the second makes a file from its + * name and type alone, so it is offered without the upload. + */ + useComposioClient(buildComposioClient(vendorFor("googledrive")).actions); + + const listed = await listTools({ url: "composio://googledrive" }); + + expect(listed.map((tool) => tool.name)).toEqual([ + "GOOGLEDRIVE_CREATE_FILE", + ]); + expect(listed[0]?.inputSchema).toEqual( + without( + published("googledrive", "GOOGLEDRIVE_CREATE_FILE").input_parameters, + "file_to_upload", + ), + ); + }); + + test("a call that carries the attachment anyway is refused, and Composio is sent nothing", async () => { + const sent: unknown[] = []; + useComposioClient(buildComposioClient(vendorFor("gmail", sent)).actions); + const connection = { url: "composio://gmail", actorId: "user_1" }; + const version = published("gmail", "GMAIL_SEND_EMAIL").version; + const email = { + recipient_email: "someone@example.com", + subject: "The report", + body: "Attached.", + }; + /* + * BOTH SHAPES THE VENDOR ACCEPTS, because the `anyOf` takes one file or a list of them and a + * model that invents one will invent either. The `s3key` is the made-up value the parameter was + * removed for: nothing in this deployment uploads to Composio's bucket. + */ + const invented = { + name: "report.pdf", + mimetype: "application/pdf", + s3key: "made/up/by/the/model", + }; + + for (const attachment of [invented, [invented]]) { + const result = await callTool(connection, "GMAIL_SEND_EMAIL", { + [VERSION_ARG]: version, + ...email, + attachment, + }); + expect(result.isError).toBe(true); + // The sentence names the parameter and the remedy, so the model can make the call that works. + expect(result.text).toContain("attachment"); + expect(result.text).toMatch(/again without attachment/); + } + expect(sent).toEqual([]); + + /* + * AND THE SAME EMAIL WITHOUT IT GOES OUT, which is what the refusal says to do. A `null` is how + * Composio documents "no attachment", so it carries nothing that needs refusing and is passed on + * as the model wrote it. + */ + const sending = await callTool(connection, "GMAIL_SEND_EMAIL", { + [VERSION_ARG]: version, + ...email, + attachment: null, + }); + expect(sending.isError).toBe(false); + expect(sent).toEqual([ + [ + "GMAIL_SEND_EMAIL", + { + arguments: { ...email, attachment: null }, + userId: "user_1", + version, + }, + ], + ]); + }); +}); diff --git a/server/tests/composio-transport.test.ts b/server/tests/composio-transport.test.ts index 4c805c6c1c..9403a8318c 100644 --- a/server/tests/composio-transport.test.ts +++ b/server/tests/composio-transport.test.ts @@ -16,6 +16,7 @@ import { toolkitOf, useComposioClient, vendorSentence, + withheldArguments, } from "../src/plugins/composio"; import { MAX_RESULT_CHARS } from "../src/plugins/mcp"; @@ -193,11 +194,22 @@ const FILE_PROPERTY = { type: "string", file_uploadable: true }; * 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. + * + * REQUIRED BY DEFAULT, because a file under a property the action can do without is withheld + * rather than dropping the action. A walk that missed the keyword and one that found it would + * then both offer the action, and these cases are about which of the two this is; required, a + * file found is an action dropped and a file missed is an action offered. Pass `false` for the + * other reading, where a file found is a `field` removed. */ -function underProperty(keyword: string, sub: unknown): Record { +function underProperty( + keyword: string, + sub: unknown, + required = true, +): Record { return { type: "object", properties: { field: { type: "object", [keyword]: sub } }, + ...(required ? { required: ["field"] } : {}), }; } @@ -589,7 +601,10 @@ describe("listing an app's actions", () => { expect(asked).toEqual(["gmail"]); }); - test("an action that stages a file is not offered at all", async () => { + test("an action that needs a file is not offered at all", async () => { + // REQUIRED HERE ON PURPOSE. Composio's own GMAIL_SEND_EMAIL requires nothing and is offered + // without its attachment (`composio-file-parameters.test.ts`); the `required` below is what + // makes this the case no call can satisfy. useComposioClient( recording({ listActions: async () => [ @@ -883,6 +898,225 @@ describe("listing an app's actions", () => { } }); + test("a file under a parameter nothing requires is removed, down every keyword the walk reads", async () => { + /* + * THE SAME KEYWORDS AS THE TWO TESTS ABOVE, READ THE OTHER WAY. There `field` is required, so a + * file found beneath it is an action nobody can call. Here it is not, so a file found beneath it + * is a parameter the call can leave out: the action is offered and `field` is gone from it. A + * keyword the walk did not descend would leave `field` in place, file and all, which is the one + * outcome that gives a model a parameter it can only fill with an invented staging key. + */ + const keywords: [string, unknown][] = [ + ["additionalProperties", FILE_PROPERTY], + ["patternProperties", { any: FILE_PROPERTY }], + ["not", FILE_PROPERTY], + ["if", FILE_PROPERTY], + ["then", FILE_PROPERTY], + ["else", FILE_PROPERTY], + ["items", FILE_PROPERTY], + ["items", [{ type: "string" }, FILE_PROPERTY]], + ["anyOf", [{ type: "null" }, FILE_PROPERTY]], + ["oneOf", [FILE_PROPERTY]], + ["allOf", [FILE_PROPERTY]], + ["prefixItems", [{ type: "string" }, FILE_PROPERTY]], + ["contains", FILE_PROPERTY], + ["dependentSchemas", { send: FILE_PROPERTY }], + ["dependencies", { send: FILE_PROPERTY }], + ["propertyNames", FILE_PROPERTY], + ["unevaluatedProperties", FILE_PROPERTY], + ["unevaluatedItems", FILE_PROPERTY], + ]; + + for (const [keyword, sub] of keywords) { + useComposioClient(listing(underProperty(keyword, sub, false))); + const listed = await listTools({ url: "composio://gmail" }); + const staged = listed.find((tool) => tool.name === "GMAIL_STAGES_A_FILE"); + expect({ keyword, schema: staged?.inputSchema }).toEqual({ + keyword, + schema: { type: "object", properties: {} }, + }); + } + }); + + test("a file is removed at the deepest parameter a call can leave out, and nothing else goes with it", async () => { + /* + * A FILE THE OBJECT AROUND IT REQUIRES MAKES THAT OBJECT THE FILE PARAMETER, one level up. So an + * optional `message` that requires its `attachment` goes whole, a required `message` whose + * `attachment` is optional loses only the `attachment`, and required all the way down is an + * action no call can satisfy. Every sibling, every `required` and every description stays. + */ + const message = (requiresAttachment: boolean) => ({ + type: "object", + description: "The message to send.", + properties: { + subject: { type: "string", description: "Its subject." }, + attachment: FILE_PROPERTY, + }, + ...(requiresAttachment ? { required: ["subject", "attachment"] } : {}), + }); + const action = ( + requiresMessage: boolean, + requiresAttachment: boolean, + ): Record => ({ + type: "object", + properties: { + to: { type: "string", description: "Who it goes to." }, + message: message(requiresAttachment), + }, + required: requiresMessage ? ["to", "message"] : ["to"], + }); + + const cases: { where: string; schema: Record }[] = [ + { where: "optional inside a required one", schema: action(true, false) }, + { where: "required inside an optional one", schema: action(false, true) }, + ]; + const offered = [ + { + type: "object", + properties: { + to: { type: "string", description: "Who it goes to." }, + message: { + type: "object", + description: "The message to send.", + properties: { + subject: { type: "string", description: "Its subject." }, + }, + }, + }, + required: ["to", "message"], + }, + { + type: "object", + properties: { to: { type: "string", description: "Who it goes to." } }, + required: ["to"], + }, + ]; + + for (const [index, { where, schema }] of cases.entries()) { + const asHandedOver = structuredClone(schema); + useComposioClient(listing(schema)); + const listed = await listTools({ url: "composio://gmail" }); + const staged = listed.find((tool) => tool.name === "GMAIL_STAGES_A_FILE"); + expect({ where, schema: staged?.inputSchema }).toEqual({ + where, + schema: offered[index], + }); + // Removed from what a model is shown, never from the object the vendor handed over. + expect({ where, schema }).toEqual({ where, schema: asHandedOver }); + } + + useComposioClient(listing(action(true, true))); + const listed = await listTools({ url: "composio://gmail" }); + expect(listed.map((tool) => tool.name)).toEqual(["GMAIL_FETCH_EMAILS"]); + }); + + test("a file required anywhere a requirement can be written still drops the action", async () => { + /* + * A PARAMETER REMOVED THAT THE VENDOR STILL INSISTS ON IS THE WORST OUTCOME AVAILABLE: offered, + * granted, and failing on every call. So "nothing requires it" is read wherever a requirement + * can apply to the object, and a requirement that cannot be read is taken as one. + */ + const file = { attachment: FILE_PROPERTY, body: { type: "string" } }; + const required: { where: string; schema: Record }[] = [ + { + where: "required under allOf", + schema: { + type: "object", + properties: file, + allOf: [{ required: ["attachment"] }], + }, + }, + { + where: "required by one arm of anyOf", + schema: { + type: "object", + properties: file, + anyOf: [{ required: ["body"] }, { required: ["attachment"] }], + }, + }, + { + where: "required once another parameter is sent", + schema: { + type: "object", + properties: file, + dependentRequired: { body: ["attachment"] }, + }, + }, + { + where: "required by a draft-07 dependency", + schema: { + type: "object", + properties: file, + dependencies: { body: ["attachment"] }, + }, + }, + { + where: "a required list that is not a list", + schema: { type: "object", properties: file, required: "attachment" }, + }, + { + where: "a reference nothing here resolves", + schema: { + type: "object", + properties: file, + $ref: "#/$defs/needsAttachment", + }, + }, + ]; + + for (const { where, schema } of required) { + useComposioClient(listing(schema)); + const listed = await listTools({ url: "composio://gmail" }); + expect({ where, offered: listed.map((tool) => tool.name) }).toEqual({ + where, + offered: ["GMAIL_FETCH_EMAILS"], + }); + } + }); + + test("a call is checked for a removed file wherever the listing removed it", () => { + const schema = { + type: "object", + properties: { + to: { type: "string" }, + attachment: FILE_PROPERTY, + message: { + type: "object", + properties: { subject: { type: "string" }, inline: FILE_PROPERTY }, + }, + }, + required: ["to", "message"], + }; + const invented = { name: "a.pdf", mimetype: "application/pdf", s3key: "x" }; + + expect( + withheldArguments(schema, { + to: "a@example.com", + attachment: invented, + message: { subject: "Hi", inline: [invented] }, + }), + ).toEqual(["attachment", "message.inline"]); + // Absent, or the `null` Composio documents as "no file", is nothing to refuse. + expect( + withheldArguments(schema, { + to: "a@example.com", + attachment: null, + message: { subject: "Hi" }, + }), + ).toEqual([]); + // A parameter of the same name somewhere the listing removed nothing is not one it removed. + expect( + withheldArguments(schema, { to: "a@example.com", inline: invented }), + ).toEqual([]); + // And an action the listing never offered has nothing withheld to check for. + expect( + withheldArguments( + { ...schema, required: ["to", "message", "attachment"] }, + { attachment: invented }, + ), + ).toEqual([]); + }); + /** * A SCHEMA NOBODY CAN WALK TO THE BOTTOM IS A VENDOR VALUE ADMITTED WITHOUT A GUARD, and the walk * that reads it sits OUTSIDE the try that wraps the vendor's call. @@ -1812,40 +2046,44 @@ describe("listing an app's actions", () => { * 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. + * An app whose actions all need files is not an app with no actions, and the sentence has to + * say which of the two this is. Both files are REQUIRED here, as they are on Composio's own two + * Drive uploads: an optional one is removed from the action rather than dropping it, and would + * leave nothing for this refusal to be about. */ + const upload = { + type: "object", + properties: { + file_to_upload: FILE_PROPERTY, + folder_to_upload_to: { type: "string" }, + }, + required: ["file_to_upload"], + }; const onlyFiles = [ { - slug: "GMAIL_SEND_EMAIL", - description: "Send an email.", + slug: "GOOGLEDRIVE_UPLOAD_FILE", + description: "Upload a file.", tags: ["createHint"], version: "20260903_00", - inputParameters: { - type: "object", - properties: { attachment: FILE_PROPERTY }, - }, + inputParameters: upload, }, { - slug: "GMAIL_REPLY_TO_THREAD", - description: "Reply to a thread.", + slug: "GOOGLEDRIVE_RESUMABLE_UPLOAD", + description: "Upload a large file.", tags: ["createHint"], version: "20260903_00", - inputParameters: { - type: "object", - properties: { attachment: FILE_PROPERTY }, - }, + inputParameters: upload, }, ]; useComposioClient(recording({ listActions: async () => onlyFiles }).client); - const outcome = await listTools({ url: "composio://gmail" }).then( + const outcome = await listTools({ url: "composio://googledrive" }).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).toContain("googledrive"); 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 @@ -1883,7 +2121,7 @@ describe("listing an app's actions", () => { * end of `listTools`: a slug that is absent becomes `a tool named undefined that fails the * insert`, a `tags` that is not iterable throws `{} is not iterable` out of it, a `version` * that is not a string throws `version?.trim is not a function` out of it. The map runs over - * the OFFERED actions. An action that stages a file never reaches it — the file filter has + * the OFFERED actions. An action that needs a file never reaches it — the file filter has * already dropped it, deliberately and on every refresh — so nothing it carries can reach the * thing the guard exists to protect. * @@ -1893,8 +2131,10 @@ describe("listing an app's actions", () => { * was never going to publish. That is exactly the tool-and-version stranding the refusals were * written to prevent, caused by the refusals. * - * The malformed field is paired with a staged file in each case, so the only thing keeping the - * listing alive is that the action was dropped before it was read. + * The malformed field is paired with a required file in each case, so the only thing keeping + * the listing alive is that the action was dropped before it was read. Required, because an + * optional file is removed from an action that is then offered, and its broken field would + * reach the map after all. */ const dropped: { where: string; action: Record }[] = [ { where: "no slug", action: { slug: undefined } }, @@ -1928,6 +2168,7 @@ describe("listing an app's actions", () => { inputParameters: { type: "object", properties: { attachment: FILE_PROPERTY }, + required: ["attachment"], }, }, ] as unknown as ComposioAction[], diff --git a/server/tests/fixture-identity-distinct.test.ts b/server/tests/fixture-identity-distinct.test.ts index 7cbd4f5f33..8c210de446 100644 --- a/server/tests/fixture-identity-distinct.test.ts +++ b/server/tests/fixture-identity-distinct.test.ts @@ -115,6 +115,10 @@ const KNOWN_COLLAPSES: Record> = { "server/tests/composio-broker-seam.test.ts": { '"u" | "u"': 1, }, + // The row Connect records for a person's held account, expected as the store writes it. + "server/tests/composio-connect-adopts-held-account.test.ts": { + "PERSON | PERSON": 1, + }, "server/tests/composio-connection-kinds.test.ts": { "person | person": 14, }, diff --git a/server/tests/fixtures/composio-file-actions.json b/server/tests/fixtures/composio-file-actions.json new file mode 100644 index 0000000000..645a022af9 --- /dev/null +++ b/server/tests/fixtures/composio-file-actions.json @@ -0,0 +1,1321 @@ +{ + "note": "Captured from Composio's live tool listing (GET /api/v3/tools, toolkit_versions=latest, limit=1000) on 2026-10-22, read-only: the rows client.tools.list answers with, which is what the listing reads. Rows are verbatim except output_parameters, which nothing in this deployment reads and which is left out for size. Regenerate rather than hand-edit.", + "gmail": [ + { + "slug": "GMAIL_SEND_EMAIL", + "name": "Send Email", + "description": "Sends an email via Gmail API using the authenticated user's Google profile display name. Sends immediately and is irreversible — confirm recipients, subject, body, and attachments before calling. At least one of 'to' (or 'recipient_email'), 'cc', or 'bcc' must be provided. At least one of subject or body must be provided. Requires `is_html=True` if the body contains HTML. All common file types including PNG, JPG, PDF, MP4, etc. are supported as attachments. Gmail API limits total message size to ~25 MB after base64 encoding. To reply in an existing thread, use GMAIL_REPLY_TO_THREAD instead. No scheduled send support; enforce timing externally.", + "available_versions": ["20261018_00"], + "version": "20261018_00", + "toolkit": { + "slug": "gmail", + "name": "gmail", + "logo": "https://logos.composio.dev/api/gmail" + }, + "input_parameters": { + "type": "object", + "title": "SendEmailRequest", + "properties": { + "cc": { + "type": "array", + "items": { + "type": "string" + }, + "title": "Cc", + "default": [], + "examples": [["manager@example.com", "teamlead@example.com"]], + "description": "Carbon Copy (CC) recipients' email addresses. At least one of 'to'/'recipient_email', 'cc', or 'bcc' must be provided.", + "human_parameter_name": "CC recipients' email addresses", + "human_parameter_description": "Provide email addresses of people you want to inform about the email without them being the main recipient." + }, + "bcc": { + "type": "array", + "items": { + "type": "string" + }, + "title": "Bcc", + "default": [], + "examples": [["auditor@example.com"]], + "description": "Blind Carbon Copy (BCC) recipients' email addresses. At least one of 'to'/'recipient_email', 'cc', or 'bcc' must be provided.", + "human_parameter_name": "BCC recipients' email addresses", + "human_parameter_description": "Enter email addresses of people you want to receive the email without the main recipients knowing." + }, + "body": { + "type": "string", + "title": "Body", + "examples": [ + "Hello team, let's discuss the project updates tomorrow.", + "

Welcome!

Thank you for signing up.

", + "" + ], + "description": "Email content (plain text or HTML). Either subject or body must be provided for the email to be sent. If HTML, `is_html` must be `True`.", + "human_parameter_name": "Email content", + "human_parameter_description": "Write the main message of your email here. It can be plain text or formatted HTML." + }, + "is_html": { + "type": "boolean", + "title": "Is Html", + "default": false, + "description": "Set to `True` if the email body contains HTML tags.", + "human_parameter_name": "Does the email body contain HTML?", + "human_parameter_description": "Indicate whether the content of your email is formatted with HTML." + }, + "subject": { + "type": "string", + "title": "Subject", + "examples": ["Project Update Meeting", "Your Weekly Newsletter"], + "description": "Subject line of the email. Either subject or body must be provided for the email to be sent.", + "human_parameter_name": "Email subject line", + "human_parameter_description": "This is the title of your email that summarizes its content." + }, + "user_id": { + "type": "string", + "title": "User Id", + "default": "me", + "examples": ["user@example.com", "me"], + "description": "User's email address; the literal 'me' refers to the authenticated user.", + "human_parameter_name": "User ID", + "human_parameter_description": "This is the email address of the user whose mailbox you want to access. You can use 'me' to refer to your own mailbox." + }, + "attachment": { + "anyOf": [ + { + "type": "object", + "title": "FileUploadable", + "required": ["name", "mimetype", "s3key"], + "properties": { + "name": { + "type": "string", + "title": "Name", + "examples": ["document.pdf", "image.jpg", "report.docx"], + "description": "The filename that will be used when uploading the file to the destination service" + }, + "s3key": { + "type": "string", + "title": "S3Key", + "examples": ["47563/gmail/GET_ATTACHMENT/response/12345"], + "description": "The S3 key of a publicly accessible file, typically returned from a previous download action that stored the file in S3. This key references an existing file that can be uploaded to another service." + }, + "mimetype": { + "type": "string", + "title": "Mimetype", + "examples": [ + "application/pdf", + "image/jpeg", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document" + ], + "description": "The MIME type of the file" + } + }, + "file_uploadable": true + }, + { + "type": "array", + "items": { + "type": "object", + "title": "FileUploadable", + "required": ["name", "mimetype", "s3key"], + "properties": { + "name": { + "type": "string", + "title": "Name", + "examples": ["document.pdf", "image.jpg", "report.docx"], + "description": "The filename that will be used when uploading the file to the destination service" + }, + "s3key": { + "type": "string", + "title": "S3Key", + "examples": ["47563/gmail/GET_ATTACHMENT/response/12345"], + "description": "The S3 key of a publicly accessible file, typically returned from a previous download action that stored the file in S3. This key references an existing file that can be uploaded to another service." + }, + "mimetype": { + "type": "string", + "title": "Mimetype", + "examples": [ + "application/pdf", + "image/jpeg", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document" + ], + "description": "The MIME type of the file" + } + }, + "file_uploadable": true + } + } + ], + "title": "Attachment", + "description": "File(s) to attach. Accepts a single file or a list of files. IMPORTANT: mimetype MUST contain a '/' separator - single words like 'pdf' or 'new' are invalid. Gmail API limits: total message size must not exceed ~25 MB after base64 encoding. Omit or set to null for no attachment. Empty attachment objects (with all fields empty/whitespace) are treated as no attachment.", + "human_parameter_name": "File(s) to attach", + "human_parameter_description": "If you want to send file(s) with your email, provide them here. Can be a single file or a list of files. The mimetype must be in 'type/subtype' format (e.g., 'application/pdf' for PDFs, 'image/png' for PNG files). Total size of all attachments should be under 20 MB before encoding." + }, + "from_email": { + "type": "string", + "title": "From Email", + "examples": ["alias@example.com", "marketing@company.com"], + "description": "Sender email address for the 'From' header. Provide the connected account's primary email address or a verified alias configured in Gmail's 'Send mail as' settings. When omitted, the action attempts to resolve the primary email address from the authenticated Gmail profile.", + "human_parameter_name": "Sender email address (alias)", + "human_parameter_description": "Specify the sender's primary email address or a verified 'Send mail as' alias. Provide this explicitly when the sender cannot be resolved from the connected Gmail account." + }, + "recipient_email": { + "type": "string", + "title": "Recipient Email", + "examples": ["john@doe.com", "me"], + "description": "Primary recipient's email address. You can also use 'to' as an alias for this parameter. At least one of 'to'/'recipient_email', 'cc', or 'bcc' must be provided. Use extra_recipients if you want to send to multiple recipients. Use the special value 'me' to send to your own authenticated email address. Must be a full user@domain address; 'me' is not valid here and will fail.", + "human_parameter_name": "Primary recipient's email address", + "human_parameter_description": "Enter the main email address of the person you want to send the email to. Use 'me' to send to yourself." + }, + "extra_recipients": { + "type": "array", + "items": { + "type": "string" + }, + "title": "Extra Recipients", + "default": [], + "examples": [["jane.doe@example.com", "support@example.com"]], + "description": "Additional 'To' recipients' email addresses (not Cc or Bcc). Should only be used if recipient_email is also provided.", + "human_parameter_name": "Additional 'To' recipients' email addresses", + "human_parameter_description": "List extra email addresses for other recipients you want to send the email to." + }, + "include_signature": { + "type": "boolean", + "title": "Include Signature", + "default": true, + "description": "Append the sender's Gmail signature (the 'Send mail as' signature configured in Gmail settings) to the message body, ahead of any quoted or forwarded content. Skipped when the body already ends with that signature, when no signature is configured, or when the connection lacks permission to read Gmail settings. Set to false to send the body exactly as provided.", + "human_parameter_name": "Include Gmail signature?", + "human_parameter_description": "Append the sender's configured Gmail signature to the message." + } + } + }, + "scopes": [ + "https://mail.google.com/", + "https://www.googleapis.com/auth/gmail.modify", + "https://www.googleapis.com/auth/gmail.compose", + "https://www.googleapis.com/auth/gmail.send" + ], + "scope_requirements": { + "all_of": [ + { + "any_of": [ + "https://mail.google.com/", + "https://www.googleapis.com/auth/gmail.modify", + "https://www.googleapis.com/auth/gmail.compose", + "https://www.googleapis.com/auth/gmail.send" + ] + } + ] + }, + "tags": ["important", "openWorldHint", "createHint"], + "no_auth": false, + "human_description": "This action sends an email using the Gmail API from the authenticated user's email account. It requires at least one recipient and either a subject or body for the email to be sent.", + "is_deprecated": false, + "deprecated": { + "displayName": "Send Email", + "version": "20261018_00", + "available_versions": ["20261018_00"], + "is_deprecated": false, + "toolkit": { + "logo": "https://logos.composio.dev/api/gmail" + } + } + }, + { + "slug": "GMAIL_CREATE_EMAIL_DRAFT", + "name": "Create email draft", + "description": "Creates a Gmail email draft. While all fields are optional per the Gmail API, practical validation requires at least one of recipient_email, cc, or bcc and at least one of subject or body. Supports To/Cc/Bcc recipients, subject, plain/HTML body (ensure `is_html=True` for HTML), attachments, and threading. Returns a draft_id that must be used as-is with GMAIL_SEND_DRAFT — synthetic or stale IDs will fail. When creating a draft reply to an existing thread (thread_id provided), leave subject empty to stay in the same thread; setting a subject will create a NEW thread instead. HTTP 429 may occur on rapid creation/send sequences; apply exponential backoff.", + "available_versions": ["20261018_00"], + "version": "20261018_00", + "toolkit": { + "slug": "gmail", + "name": "gmail", + "logo": "https://logos.composio.dev/api/gmail" + }, + "input_parameters": { + "type": "object", + "title": "CreateEmailDraftRequest", + "properties": { + "cc": { + "type": "array", + "items": { + "type": "string" + }, + "title": "Cc", + "default": [], + "examples": [ + [ + "cc.recipient1@example.com", + "CC User " + ] + ], + "description": "Carbon Copy (CC) recipients' email addresses. Each must be a valid email address (e.g., 'user@example.com') or display name format (e.g., 'John Doe '). Plain names without email addresses are NOT valid. Optional for drafts (recipients can be added later before sending)." + }, + "bcc": { + "type": "array", + "items": { + "type": "string" + }, + "title": "Bcc", + "default": [], + "examples": [ + ["bcc.recipient@example.com", "BCC User "] + ], + "description": "Blind Carbon Copy (BCC) recipients' email addresses. Each must be a valid email address (e.g., 'user@example.com') or display name format (e.g., 'Bob Jones '). Plain names without email addresses are NOT valid. Optional for drafts (recipients can be added later before sending)." + }, + "body": { + "type": "string", + "title": "Body", + "examples": [ + "Hello Team,\n\nPlease find the attached report for your review.\n\nBest regards,\nYour Name", + "

Meeting Confirmation

This email confirms our meeting scheduled for next Tuesday.

" + ], + "description": "Email body content (plain text or HTML); `is_html` must be True if HTML. Optional - drafts can be created without a body and edited later before sending. Can also be provided as 'message_body'.", + "human_parameter_name": "Email Body", + "human_parameter_description": "Write the main content of your email here. This can be plain text or HTML, depending on your preference." + }, + "is_html": { + "type": "boolean", + "title": "Is Html", + "default": false, + "examples": [true, false], + "description": "Set to True if `body` is already formatted HTML. When False, plain text newlines are auto-converted to
tags. Both modes result in HTML email; this flag controls whether the body content is treated as raw HTML or plain text that gets HTML formatting applied.", + "human_parameter_name": "Is HTML?", + "human_parameter_description": "Set to True if your email body is already HTML. When False, plain text formatting (newlines, tabs) will be automatically converted to HTML for proper display." + }, + "subject": { + "type": "string", + "title": "Subject", + "examples": ["Project Update Q3", "Meeting Reminder"], + "description": "Email subject line. Optional - drafts can be created without a subject and edited later before sending. When creating a draft reply to an existing thread (thread_id provided), leave this empty to stay in the same thread. Setting a subject will create a NEW thread instead.", + "human_parameter_name": "Subject", + "human_parameter_description": "Enter the subject of your email. This helps the recipient understand what the email is about." + }, + "user_id": { + "type": "string", + "title": "User Id", + "default": "me", + "examples": ["me", "user@example.com"], + "description": "User's email address or 'me' for the authenticated user.", + "human_parameter_name": "User ID", + "human_parameter_description": "Provide the user ID or the email address of the user. Use 'me' to refer to the currently authenticated user." + }, + "thread_id": { + "type": "string", + "title": "Thread Id", + "examples": ["17f45ec49a9c3f1b"], + "description": "ID of an existing Gmail thread to reply to; omit for new thread. If the thread ID is invalid or inaccessible, the draft will be created as a new thread instead of failing.", + "human_parameter_name": "Thread ID", + "human_parameter_description": "If you want to reply to an ongoing conversation, provide the thread ID here. Leave it blank for a new message." + }, + "attachment": { + "anyOf": [ + { + "type": "object", + "title": "FileUploadable", + "required": ["name", "mimetype", "s3key"], + "properties": { + "name": { + "type": "string", + "title": "Name", + "examples": ["document.pdf", "image.jpg", "report.docx"], + "description": "The filename that will be used when uploading the file to the destination service" + }, + "s3key": { + "type": "string", + "title": "S3Key", + "examples": ["47563/gmail/GET_ATTACHMENT/response/12345"], + "description": "The S3 key of a publicly accessible file, typically returned from a previous download action that stored the file in S3. This key references an existing file that can be uploaded to another service." + }, + "mimetype": { + "type": "string", + "title": "Mimetype", + "examples": [ + "application/pdf", + "image/jpeg", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document" + ], + "description": "The MIME type of the file" + } + }, + "file_uploadable": true + }, + { + "type": "array", + "items": { + "type": "object", + "title": "FileUploadable", + "required": ["name", "mimetype", "s3key"], + "properties": { + "name": { + "type": "string", + "title": "Name", + "examples": ["document.pdf", "image.jpg", "report.docx"], + "description": "The filename that will be used when uploading the file to the destination service" + }, + "s3key": { + "type": "string", + "title": "S3Key", + "examples": ["47563/gmail/GET_ATTACHMENT/response/12345"], + "description": "The S3 key of a publicly accessible file, typically returned from a previous download action that stored the file in S3. This key references an existing file that can be uploaded to another service." + }, + "mimetype": { + "type": "string", + "title": "Mimetype", + "examples": [ + "application/pdf", + "image/jpeg", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document" + ], + "description": "The MIME type of the file" + } + }, + "file_uploadable": true + } + } + ], + "title": "Attachment", + "description": "File(s) to attach to the email. Accepts a single file or a list of files. Total message size including base64-encoded attachments must be under 25 MB; use shareable links (e.g., Google Drive) for larger files.", + "human_parameter_name": "Attachment(s)", + "human_parameter_description": "Upload file(s) you want to include with the email. Can be a single file or multiple files." + }, + "recipient_email": { + "type": "string", + "title": "Recipient Email", + "examples": [ + "john.doe@example.com", + "John Doe " + ], + "description": "Primary recipient's email address. Must be a valid email address (e.g., 'user@example.com') or display name format (e.g., 'John Doe '). A plain name without an email address (e.g., 'John Doe') is NOT valid - the '@' symbol and domain are required. Optional for drafts (recipients can be added later before sending). Use extra_recipients if you want to send to multiple recipients." + }, + "extra_recipients": { + "type": "array", + "items": { + "type": "string" + }, + "title": "Extra Recipients", + "default": [], + "examples": [ + ["jane.doe@example.com", "Jane Doe "] + ], + "description": "Additional 'To' recipients' email addresses (not Cc or Bcc). Each must be a valid email address (e.g., 'user@example.com'), display name format (e.g., 'Jane Doe '), or 'me' for the authenticated user. Plain names without email addresses are NOT valid. Should only be used if recipient_email is also provided." + }, + "include_signature": { + "type": "boolean", + "title": "Include Signature", + "default": true, + "description": "Append the sender's Gmail signature (the 'Send mail as' signature configured in Gmail settings) to the message body, ahead of any quoted or forwarded content. Skipped when the body already ends with that signature, when no signature is configured, or when the connection lacks permission to read Gmail settings. Set to false to send the body exactly as provided.", + "human_parameter_name": "Include Gmail signature?", + "human_parameter_description": "Append the sender's configured Gmail signature to the message." + } + } + }, + "scopes": [ + "https://mail.google.com/", + "https://www.googleapis.com/auth/gmail.compose", + "https://www.googleapis.com/auth/gmail.modify", + "https://www.googleapis.com/auth/gmail.readonly", + "https://www.googleapis.com/auth/contacts", + "https://www.googleapis.com/auth/contacts.readonly" + ], + "scope_requirements": { + "all_of": [ + { + "any_of": [ + "https://mail.google.com/", + "https://www.googleapis.com/auth/gmail.compose", + "https://www.googleapis.com/auth/gmail.modify" + ] + }, + { + "any_of": [ + "https://mail.google.com/", + "https://www.googleapis.com/auth/gmail.compose", + "https://www.googleapis.com/auth/gmail.modify", + "https://www.googleapis.com/auth/gmail.readonly" + ] + }, + { + "any_of": [ + "https://mail.google.com/", + "https://www.googleapis.com/auth/gmail.modify", + "https://www.googleapis.com/auth/gmail.readonly" + ] + }, + { + "any_of": [ + "https://www.googleapis.com/auth/contacts", + "https://www.googleapis.com/auth/contacts.readonly" + ] + } + ] + }, + "tags": ["important", "openWorldHint", "createHint"], + "no_auth": false, + "human_description": "This action creates a draft email in Gmail. It allows specifying recipients, subject, body content, and attachments, enabling users to prepare emails for sending at a later time.", + "is_deprecated": false, + "deprecated": { + "displayName": "Create email draft", + "version": "20261018_00", + "available_versions": ["20261018_00"], + "is_deprecated": false, + "toolkit": { + "logo": "https://logos.composio.dev/api/gmail" + } + } + }, + { + "slug": "GMAIL_REPLY_TO_THREAD", + "name": "Reply to email thread", + "description": "Sends a reply within a specific Gmail thread using the original thread's subject; do not provide a custom subject as it will start a new conversation instead of replying in-thread. Requires a valid `thread_id` and at least one of `recipient_email`, `cc`, or `bcc`. Supports optional file attachments.", + "available_versions": ["20261018_00"], + "version": "20261018_00", + "toolkit": { + "slug": "gmail", + "name": "gmail", + "logo": "https://logos.composio.dev/api/gmail" + }, + "input_parameters": { + "type": "object", + "title": "ReplyToThreadRequest", + "required": ["thread_id"], + "properties": { + "cc": { + "type": "array", + "items": { + "type": "string" + }, + "title": "Cc", + "default": [], + "examples": [ + ["cc.recipient1@example.com", "cc.recipient2@example.com"] + ], + "description": "Carbon Copy (CC) recipients' email addresses in format 'user@domain.com'. Each address must include both username and domain separated by '@'. At least one of cc, bcc, or recipient_email must be provided.", + "human_parameter_name": "CC Recipients", + "human_parameter_description": "Email addresses for people you want to keep in the loop but not address directly. At least one recipient is necessary." + }, + "bcc": { + "type": "array", + "items": { + "type": "string" + }, + "title": "Bcc", + "default": [], + "examples": [["bcc.recipient@example.com"]], + "description": "Blind Carbon Copy (BCC) recipients' email addresses in format 'user@domain.com'. Each address must include both username and domain separated by '@'. At least one of cc, bcc, or recipient_email must be provided.", + "human_parameter_name": "BCC Recipients", + "human_parameter_description": "Recipients who will receive your reply without others knowing their email addresses. At least one recipient is necessary." + }, + "is_html": { + "type": "boolean", + "title": "Is Html", + "default": false, + "examples": [true, false], + "description": "Indicates if `message_body` is HTML; if True, body must be valid HTML, if False, body should not contain HTML tags. Mismatch causes recipients to see raw HTML tags as plain text.", + "human_parameter_name": "Is HTML?", + "human_parameter_description": "Specify whether your message body is in HTML format. Set to true if it contains HTML tags." + }, + "user_id": { + "type": "string", + "title": "User Id", + "default": "me", + "examples": ["me", "user@example.com"], + "description": "Identifier for the user sending the reply; 'me' refers to the authenticated user.", + "human_parameter_name": "User ID", + "human_parameter_description": "The identifier for the user sending the reply. Use 'me' for your own account or provide your email address." + }, + "thread_id": { + "type": "string", + "title": "Thread Id", + "examples": ["169eefc8138e68ca", "msg-f:169eefc8138e68ca"], + "description": "Identifier of the Gmail thread for the reply. Must be a valid hexadecimal string, typically 15-16 characters long (e.g., '169eefc8138e68ca'). Prefixes like 'msg-f:' or 'thread-f:' are automatically stripped. Note: Format validation only checks the ID structure; the thread must also exist and be accessible in your Gmail account. Use GMAIL_LIST_THREADS or GMAIL_FETCH_EMAILS to retrieve valid thread IDs. Must be a threadId, not a messageId; passing a messageId can cause the reply to fail or start an unintended new thread." + }, + "attachment": { + "anyOf": [ + { + "type": "object", + "title": "FileUploadable", + "required": ["name", "mimetype", "s3key"], + "properties": { + "name": { + "type": "string", + "title": "Name", + "examples": ["document.pdf", "image.jpg", "report.docx"], + "description": "The filename that will be used when uploading the file to the destination service" + }, + "s3key": { + "type": "string", + "title": "S3Key", + "examples": ["47563/gmail/GET_ATTACHMENT/response/12345"], + "description": "The S3 key of a publicly accessible file, typically returned from a previous download action that stored the file in S3. This key references an existing file that can be uploaded to another service." + }, + "mimetype": { + "type": "string", + "title": "Mimetype", + "examples": [ + "application/pdf", + "image/jpeg", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document" + ], + "description": "The MIME type of the file" + } + }, + "file_uploadable": true + }, + { + "type": "array", + "items": { + "type": "object", + "title": "FileUploadable", + "required": ["name", "mimetype", "s3key"], + "properties": { + "name": { + "type": "string", + "title": "Name", + "examples": ["document.pdf", "image.jpg", "report.docx"], + "description": "The filename that will be used when uploading the file to the destination service" + }, + "s3key": { + "type": "string", + "title": "S3Key", + "examples": ["47563/gmail/GET_ATTACHMENT/response/12345"], + "description": "The S3 key of a publicly accessible file, typically returned from a previous download action that stored the file in S3. This key references an existing file that can be uploaded to another service." + }, + "mimetype": { + "type": "string", + "title": "Mimetype", + "examples": [ + "application/pdf", + "image/jpeg", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document" + ], + "description": "The MIME type of the file" + } + }, + "file_uploadable": true + } + } + ], + "title": "Attachment", + "description": "File(s) to attach to the reply. Accepts a single file or a list of files. Total message size including attachments must stay under 25 MB (400 badRequest if exceeded); use Drive links for large files.", + "human_parameter_name": "Attachment(s)", + "human_parameter_description": "File(s) you want to attach to your reply. Can be a single file or multiple files." + }, + "message_body": { + "type": "string", + "title": "Message Body", + "default": "", + "examples": [ + "Dear Sir, Nice talking to you. Yours respectfully, John" + ], + "description": "Content of the reply message, either plain text or HTML.", + "human_parameter_name": "Message Body", + "human_parameter_description": "The actual message you want to send as a reply. It can be plain text or formatted in HTML." + }, + "recipient_email": { + "type": "string", + "title": "Recipient Email", + "examples": ["john@doe.com"], + "description": "Primary recipient's email address in format 'user@domain.com'. Must include both username and domain separated by '@'. Required if cc and bcc is not provided, else can be optional. Use extra_recipients if you want to send to multiple recipients.", + "human_parameter_name": "Recipient Email", + "human_parameter_description": "The email address of the main recipient for your reply. This is required unless you're using cc or bcc." + }, + "extra_recipients": { + "type": "array", + "items": { + "type": "string" + }, + "title": "Extra Recipients", + "default": [], + "examples": [ + ["jane.doe@example.com", "another.person@example.com"] + ], + "description": "Additional 'To' recipients' email addresses in format 'user@domain.com' (not Cc or Bcc). Each address must include both username and domain separated by '@'. Should only be used if recipient_email is also provided.", + "human_parameter_name": "Additional Recipients", + "human_parameter_description": "Any additional email addresses you want to include in the 'To' field for this reply." + }, + "include_signature": { + "type": "boolean", + "title": "Include Signature", + "default": true, + "description": "Append the sender's Gmail signature (the 'Send mail as' signature configured in Gmail settings) to the message body, ahead of any quoted or forwarded content. Skipped when the body already ends with that signature, when no signature is configured, or when the connection lacks permission to read Gmail settings. Set to false to send the body exactly as provided.", + "human_parameter_name": "Include Gmail signature?", + "human_parameter_description": "Append the sender's configured Gmail signature to the message." + } + } + }, + "scopes": [ + "https://mail.google.com/", + "https://www.googleapis.com/auth/gmail.compose", + "https://www.googleapis.com/auth/gmail.modify", + "https://www.googleapis.com/auth/gmail.send", + "https://www.googleapis.com/auth/gmail.readonly", + "https://www.googleapis.com/auth/contacts", + "https://www.googleapis.com/auth/contacts.other.readonly", + "https://www.googleapis.com/auth/contacts.readonly" + ], + "scope_requirements": { + "all_of": [ + { + "any_of": [ + "https://mail.google.com/", + "https://www.googleapis.com/auth/gmail.compose", + "https://www.googleapis.com/auth/gmail.modify", + "https://www.googleapis.com/auth/gmail.send" + ] + }, + { + "any_of": [ + "https://mail.google.com/", + "https://www.googleapis.com/auth/gmail.compose", + "https://www.googleapis.com/auth/gmail.modify", + "https://www.googleapis.com/auth/gmail.readonly" + ] + }, + { + "any_of": [ + "https://mail.google.com/", + "https://www.googleapis.com/auth/gmail.modify", + "https://www.googleapis.com/auth/gmail.readonly" + ] + }, + { + "any_of": [ + "https://www.googleapis.com/auth/contacts", + "https://www.googleapis.com/auth/contacts.other.readonly", + "https://www.googleapis.com/auth/contacts.readonly" + ] + } + ] + }, + "tags": ["important", "openWorldHint", "createHint"], + "no_auth": false, + "human_description": "This action allows users to reply to an existing email thread in Gmail. Users must provide the thread ID and at least one recipient's email address to successfully send their reply.", + "is_deprecated": false, + "deprecated": { + "displayName": "Reply to email thread", + "version": "20261018_00", + "available_versions": ["20261018_00"], + "is_deprecated": false, + "toolkit": { + "logo": "https://logos.composio.dev/api/gmail" + } + } + }, + { + "slug": "GMAIL_UPDATE_DRAFT", + "name": "Update draft", + "description": "Updates (replaces) an existing Gmail draft's content in-place by draft ID. This action replaces the entire draft content with the new message - it does not patch individual fields. All fields are optional; if not provided, you should provide complete draft content to avoid data loss.", + "available_versions": ["20261018_00"], + "version": "20261018_00", + "toolkit": { + "slug": "gmail", + "name": "gmail", + "logo": "https://logos.composio.dev/api/gmail" + }, + "input_parameters": { + "type": "object", + "title": "UpdateDraftRequest", + "required": ["draft_id"], + "properties": { + "cc": { + "type": "array", + "items": { + "type": "string" + }, + "title": "Cc", + "default": [], + "examples": [ + [ + "cc.recipient1@example.com", + "CC User " + ] + ], + "description": "Carbon Copy (CC) recipients' email addresses. Each must be a valid email address or display name format." + }, + "bcc": { + "type": "array", + "items": { + "type": "string" + }, + "title": "Bcc", + "default": [], + "examples": [ + ["bcc.recipient@example.com", "BCC User "] + ], + "description": "Blind Carbon Copy (BCC) recipients' email addresses. Each must be a valid email address or display name format." + }, + "body": { + "type": "string", + "title": "Body", + "examples": [ + "Hello Team,\n\nPlease find the attached report.\n\nBest regards", + "

Meeting Confirmation

This confirms our meeting.

" + ], + "description": "Email body content (plain text or HTML); is_html must be True if HTML. If not provided, previous body is preserved. Can also be provided as 'message_body'." + }, + "is_html": { + "type": "boolean", + "title": "Is Html", + "default": false, + "examples": [true, false], + "description": "Set to True if body is already formatted HTML. When False, plain text newlines are auto-converted to
tags." + }, + "subject": { + "type": "string", + "title": "Subject", + "examples": ["Project Update Q3", "Meeting Reminder"], + "description": "Email subject line. If not provided, previous subject is preserved." + }, + "user_id": { + "type": "string", + "title": "User Id", + "default": "me", + "examples": ["me", "user@example.com"], + "description": "User's email address or 'me' for the authenticated user." + }, + "draft_id": { + "type": "string", + "title": "Draft Id", + "examples": ["r-8388446164079304564", "r1234567890123456789"], + "description": "The ID of the draft to update. Must be a valid draft ID from GMAIL_LIST_DRAFTS or GMAIL_CREATE_EMAIL_DRAFT." + }, + "thread_id": { + "type": "string", + "title": "Thread Id", + "examples": ["17f45ec49a9c3f1b"], + "description": "ID of an existing Gmail thread. If provided, the draft will be part of this thread." + }, + "attachment": { + "anyOf": [ + { + "type": "object", + "title": "FileUploadable", + "required": ["name", "mimetype", "s3key"], + "properties": { + "name": { + "type": "string", + "title": "Name", + "examples": ["document.pdf", "image.jpg", "report.docx"], + "description": "The filename that will be used when uploading the file to the destination service" + }, + "s3key": { + "type": "string", + "title": "S3Key", + "examples": ["47563/gmail/GET_ATTACHMENT/response/12345"], + "description": "The S3 key of a publicly accessible file, typically returned from a previous download action that stored the file in S3. This key references an existing file that can be uploaded to another service." + }, + "mimetype": { + "type": "string", + "title": "Mimetype", + "examples": [ + "application/pdf", + "image/jpeg", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document" + ], + "description": "The MIME type of the file" + } + }, + "file_uploadable": true + }, + { + "type": "array", + "items": { + "type": "object", + "title": "FileUploadable", + "required": ["name", "mimetype", "s3key"], + "properties": { + "name": { + "type": "string", + "title": "Name", + "examples": ["document.pdf", "image.jpg", "report.docx"], + "description": "The filename that will be used when uploading the file to the destination service" + }, + "s3key": { + "type": "string", + "title": "S3Key", + "examples": ["47563/gmail/GET_ATTACHMENT/response/12345"], + "description": "The S3 key of a publicly accessible file, typically returned from a previous download action that stored the file in S3. This key references an existing file that can be uploaded to another service." + }, + "mimetype": { + "type": "string", + "title": "Mimetype", + "examples": [ + "application/pdf", + "image/jpeg", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document" + ], + "description": "The MIME type of the file" + } + }, + "file_uploadable": true + } + } + ], + "title": "Attachment", + "description": "File(s) to attach to the draft. Accepts a single file or a list of files. Replaces any existing attachments." + }, + "recipient_email": { + "type": "string", + "title": "Recipient Email", + "examples": [ + "john.doe@example.com", + "John Doe " + ], + "description": "Primary recipient's email address. Must be a valid email address (e.g., 'user@example.com') or display name format (e.g., 'John Doe '). Optional - if not provided, previous recipients are preserved." + }, + "extra_recipients": { + "type": "array", + "items": { + "type": "string" + }, + "title": "Extra Recipients", + "default": [], + "examples": [ + ["jane.doe@example.com", "Jane Doe "] + ], + "description": "Additional 'To' recipients' email addresses. Each must be a valid email address or display name format. Should only be used if recipient_email is also provided." + }, + "include_signature": { + "type": "boolean", + "title": "Include Signature", + "default": true, + "description": "Append the sender's Gmail signature (the 'Send mail as' signature configured in Gmail settings) to the message body, ahead of any quoted or forwarded content. Skipped when the body already ends with that signature, when no signature is configured, or when the connection lacks permission to read Gmail settings. Set to false to send the body exactly as provided.", + "human_parameter_name": "Include Gmail signature?", + "human_parameter_description": "Append the sender's configured Gmail signature to the message." + } + } + }, + "scopes": [ + "https://mail.google.com/", + "https://www.googleapis.com/auth/gmail.modify", + "https://www.googleapis.com/auth/gmail.compose" + ], + "scope_requirements": { + "all_of": [ + { + "any_of": [ + "https://mail.google.com/", + "https://www.googleapis.com/auth/gmail.modify", + "https://www.googleapis.com/auth/gmail.compose" + ] + } + ] + }, + "tags": ["important", "openWorldHint", "updateHint", "messages"], + "no_auth": false, + "is_deprecated": false, + "deprecated": { + "displayName": "Update draft", + "version": "20261018_00", + "available_versions": ["20261018_00"], + "is_deprecated": false, + "toolkit": { + "logo": "https://logos.composio.dev/api/gmail" + } + } + }, + { + "slug": "GMAIL_FETCH_EMAILS", + "name": "Fetch emails", + "description": "Fetches a list of email messages from a Gmail account, supporting filtering, pagination, and optional full content retrieval. Results are NOT sorted by recency; sort by internalDate client-side. The messages field may be absent or empty (valid no-results state); always null-check before accessing messageId or threadId. Null-check subject and header fields before string operations. For large result sets, prefer ids_only=true or metadata-only listing, then hydrate via GMAIL_FETCH_MESSAGE_BY_MESSAGE_ID.", + "available_versions": ["20261018_00"], + "version": "20261018_00", + "toolkit": { + "slug": "gmail", + "name": "gmail", + "logo": "https://logos.composio.dev/api/gmail" + }, + "input_parameters": { + "type": "object", + "title": "FetchEmailsRequest", + "properties": { + "query": { + "type": "string", + "title": "Query", + "examples": [ + "from:john@example.com is:unread", + "subject:meeting has:attachment", + "after:2024/01/01 before:2024/02/01", + "is:snoozed", + "is:important OR is:starred", + "label:work -label:spam" + ], + "description": "Gmail advanced search query (e.g., 'from:user subject:meeting'). Supported operators: 'from:', 'to:', 'subject:', 'label:', 'has:', 'is:', 'in:', 'category:', 'after:YYYY/MM/DD', 'before:YYYY/MM/DD', AND/OR/NOT. IMPORTANT - 'is:' vs 'label:' usage: Use 'is:' for special mail states: is:snoozed, is:unread, is:read, is:starred, is:important. Use 'label:' ONLY for user-created labels (e.g., 'label:work', 'label:projects'). Note: 'muted' may work with both 'is:muted' and 'label:muted' based on community reports. Common mistake: 'label:snoozed' is WRONG - use 'is:snoozed' instead. Use quotes for exact phrases. Omit for no query filter. after:/before: evaluate whole calendar days in UTC; before: is exclusive — adjust for local timezone to avoid off-by-one-day gaps.", + "human_parameter_name": "Search query", + "human_parameter_description": "Input a search term to filter your emails. Use 'is:' for special states (is:snoozed, is:unread, is:read, is:starred, is:important). Use 'label:' only for user-created labels (exception: 'muted' may work with both)." + }, + "user_id": { + "type": "string", + "title": "User Id", + "default": "me", + "examples": ["me", "user@example.com"], + "description": "User's email address or 'me' for the authenticated user. Non-'me' addresses require domain-level delegation; without it, authentication or not-found errors result.", + "human_parameter_name": "User ID", + "human_parameter_description": "Provide the email address of the user whose mailbox you want to access. You can use 'me' to refer to your own mailbox." + }, + "verbose": { + "type": "boolean", + "title": "Verbose", + "default": true, + "examples": [true, false], + "description": "If false, uses optimized concurrent metadata fetching for faster performance (~75% improvement). If true, uses standard detailed message fetching. When false, only essential fields (subject, sender, recipient, time, labels) are guaranteed. Body content and attachment details require verbose=true even when include_payload=true.", + "human_parameter_name": "Verbose mode?", + "human_parameter_description": "Select this to get detailed information for each email or to speed up the process and get just the basics." + }, + "ids_only": { + "type": "boolean", + "title": "Ids Only", + "default": false, + "examples": [true, false], + "description": "If true, only returns message IDs from the list API without fetching individual message details. Fastest option for getting just message IDs and thread IDs.", + "human_parameter_name": "IDs only?", + "human_parameter_description": "Use this option if you just want the IDs of emails without loading their full details, making the request quicker." + }, + "label_ids": { + "type": "array", + "items": { + "type": "string" + }, + "title": "Label Ids", + "examples": ["INBOX", "UNREAD", "Label_123456"], + "description": "Filter by label IDs; only messages with all specified labels are returned (AND logic). Optional - omit or use empty list to fetch all messages without label filtering. System label IDs: 'INBOX', 'SPAM', 'TRASH', 'UNREAD', 'STARRED', 'IMPORTANT', 'CATEGORY_PRIMARY' (alias 'CATEGORY_PERSONAL'), 'CATEGORY_SOCIAL', 'CATEGORY_PROMOTIONS', 'CATEGORY_UPDATES', 'CATEGORY_FORUMS'. For custom/user-created labels, you MUST use the label ID (e.g., 'Label_123456'), NOT the display name. Use the 'listLabels' action to find label IDs for custom labels. Combining label_ids with label: in query applies AND logic across both, which can silently over-restrict results; use one strategy consistently.", + "human_parameter_name": "Label IDs", + "human_parameter_description": "List the label IDs to filter which messages to fetch. System labels (INBOX, SPAM, etc.) can be used directly by name. For custom labels, you must use the listLabels action first to get the label ID - do not use the label name you see in Gmail." + }, + "page_token": { + "type": "string", + "title": "Page Token", + "description": "Token for retrieving a specific page, obtained from a previous response's `nextPageToken`. Must be a valid opaque token string from a previous API response. Do not pass arbitrary values. Omit for the first page. Loop calls using nextPageToken until it is absent to avoid silently missing messages. resultSizeEstimate is approximate — do not use as a stopping condition.", + "human_parameter_name": "Page token", + "human_parameter_description": "If you've paginated results, use this token to get the next set of messages. Leave blank for the first page." + }, + "max_results": { + "type": "integer", + "title": "Max Results", + "default": 1, + "maximum": 500, + "minimum": 1, + "examples": ["10", "100", "500"], + "description": "Maximum number of messages to retrieve per page. Default of 1 retrieves only a single message; set higher for practical use. Hard cap is 500 per page.", + "human_parameter_name": "Maximum results", + "human_parameter_description": "Specify how many email messages you want to retrieve in one request, from 1 to 500." + }, + "include_payload": { + "type": "boolean", + "title": "Include Payload", + "default": true, + "examples": [true, false], + "description": "Set to true to include full message payload (headers, body, attachments); false for metadata only. payload may still be null even when true; use GMAIL_FETCH_MESSAGE_BY_MESSAGE_ID for guaranteed complete content. When payload is present, bodies are base64url-encoded in payload.parts; replace '-'→'+' and '_'→'/' and fix padding before decoding, and check both text/plain and text/html parts.", + "human_parameter_name": "Include full payload?", + "human_parameter_description": "Choose whether to get the full content of each email, including attachments and body, or just the essential metadata." + }, + "include_spam_trash": { + "type": "boolean", + "title": "Include Spam Trash", + "default": false, + "examples": [true, false], + "description": "Set to true to include messages from 'SPAM' and 'TRASH'.", + "human_parameter_name": "Include spam and trash?", + "human_parameter_description": "Decide if you want to see emails that are normally filtered into your SPAM or TRASH folders." + } + } + }, + "scopes": [ + "https://mail.google.com/", + "https://www.googleapis.com/auth/gmail.modify", + "https://www.googleapis.com/auth/gmail.readonly" + ], + "scope_requirements": { + "all_of": [ + { + "any_of": [ + "https://mail.google.com/", + "https://www.googleapis.com/auth/gmail.modify", + "https://www.googleapis.com/auth/gmail.readonly" + ] + } + ] + }, + "tags": ["important", "openWorldHint", "readOnlyHint"], + "no_auth": false, + "human_description": "This action retrieves a list of email messages from a specified Gmail account. It allows for filtering the messages based on various parameters such as label IDs and search queries, as well as controlling the level of detail returned for each message.", + "is_deprecated": false, + "deprecated": { + "displayName": "Fetch emails", + "version": "20261018_00", + "available_versions": ["20261018_00"], + "is_deprecated": false, + "toolkit": { + "logo": "https://logos.composio.dev/api/gmail" + } + } + } + ], + "googledrive": [ + { + "slug": "GOOGLEDRIVE_UPLOAD_FILE", + "name": "Upload File", + "description": "Uploads a file (max 5MB) to Google Drive, placing it in the specified folder or root if no valid folder ID is provided. Always creates a new file (never updates existing); use GOOGLEDRIVE_EDIT_FILE to update with a stable file_id. Uploaded files are private by default; configure sharing via GOOGLEDRIVE_ADD_FILE_SHARING_PREFERENCE.", + "available_versions": ["20261022_00"], + "version": "20261022_00", + "toolkit": { + "slug": "googledrive", + "name": "googledrive", + "logo": "https://logos.composio.dev/api/googledrive" + }, + "input_parameters": { + "type": "object", + "title": "UploadFileRequest", + "required": ["file_to_upload"], + "properties": { + "file_to_upload": { + "type": "object", + "title": "FileUploadable", + "required": ["name", "mimetype", "s3key"], + "properties": { + "name": { + "type": "string", + "title": "Name", + "examples": ["document.pdf", "image.jpg", "report.docx"], + "description": "The filename that will be used when uploading the file to the destination service" + }, + "s3key": { + "type": "string", + "title": "S3Key", + "examples": ["47563/gmail/GET_ATTACHMENT/response/12345"], + "description": "The S3 key of a publicly accessible file, typically returned from a previous download action that stored the file in S3. This key references an existing file that can be uploaded to another service." + }, + "mimetype": { + "type": "string", + "title": "Mimetype", + "examples": [ + "application/pdf", + "image/jpeg", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document" + ], + "description": "The MIME type of the file" + } + }, + "description": "File to upload to Google Drive (max 5MB). Use a clear filename and accurate MIME type, for example `application/pdf`; incorrect values can cause Drive to convert or misrender the file.", + "file_uploadable": true, + "additionalProperties": false, + "human_parameter_name": "File to upload", + "human_parameter_description": "The file to upload to Google Drive." + }, + "folder_to_upload_to": { + "type": "string", + "title": "Folder To Upload To", + "examples": ["1duXYCvYC5tIp5B_B1HWLq8LyDYXfMhPU"], + "description": "Optional ID of the target Google Drive folder; can be obtained using 'Find Folder' or similar actions. Invalid or missing IDs silently fall back to Drive root with no error — resolve the correct folder ID first using GOOGLEDRIVE_FIND_FILE.", + "human_parameter_name": "Target folder ID", + "human_parameter_description": "If you want the file placed in a specific Drive folder, paste that folder's ID here. Leave it blank to upload to your My Drive (root) instead." + } + } + }, + "scopes": [ + "https://www.googleapis.com/auth/drive", + "https://www.googleapis.com/auth/drive.appdata", + "https://www.googleapis.com/auth/drive.file" + ], + "scope_requirements": { + "all_of": [ + { + "any_of": [ + "https://www.googleapis.com/auth/drive", + "https://www.googleapis.com/auth/drive.appdata", + "https://www.googleapis.com/auth/drive.file" + ] + } + ] + }, + "tags": ["important", "openWorldHint", "createHint"], + "no_auth": false, + "human_description": "This action uploads a specified file to Google Drive, with an option to place it in a specific folder if an ID is provided. If no folder ID is given, the file will be uploaded to the root directory of the Drive.", + "is_deprecated": false, + "deprecated": { + "displayName": "Upload File", + "version": "20261022_00", + "available_versions": ["20261022_00"], + "is_deprecated": false, + "toolkit": { + "logo": "https://logos.composio.dev/api/googledrive" + } + } + }, + { + "slug": "GOOGLEDRIVE_CREATE_FILE", + "name": "Create File or Folder", + "description": "Creates a new file or folder in Google Drive. Supports both metadata-only creation (for folders and empty documents) and file upload with content. When file_to_upload is provided, uploads the actual file bytes; otherwise creates an empty file. Native Google file types (Docs, Sheets, Forms, etc.) and folders are created as empty shells when no content is provided; content must be added manually afterward. Newly created files are private by default — set sharing permissions afterward for collaboration. For shared-drive folders, use this tool with the target folder ID in `parents` rather than GOOGLEDRIVE_CREATE_FOLDER.", + "available_versions": ["20261022_00"], + "version": "20261022_00", + "toolkit": { + "slug": "googledrive", + "name": "googledrive", + "logo": "https://logos.composio.dev/api/googledrive" + }, + "input_parameters": { + "type": "object", + "title": "CreateFileRequest", + "properties": { + "name": { + "type": "string", + "title": "Name", + "description": "The name of the file. While optional, providing a meaningful name is strongly recommended. If not specified, Google Drive will create the file with name 'Untitled'.", + "human_parameter_name": "File name", + "human_parameter_description": "The title you want to give the new file or folder. Choose something meaningful so you can find it later. If omitted, the file will be named 'Untitled'." + }, + "fields": { + "type": "string", + "title": "Fields", + "description": "A comma-separated list of fields to include in the response.", + "human_parameter_name": "Response fields", + "human_parameter_description": "Which details you want back after creation. Leave empty for the defaults, or list specific fields if you only need a few." + }, + "parents": { + "type": "array", + "items": { + "type": "string" + }, + "title": "Parents", + "description": "Google Drive folder ID (not folder name) where the file will be created. Must be a list with exactly one folder ID (e.g., ['1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs07X8ygaR']). Folder IDs are long alphanumeric strings, not human-readable names. Use GOOGLEDRIVE_FIND_FOLDER or GOOGLEDRIVE_LIST_FILES to look up folder IDs by name. If omitted, the file is created in My Drive root.", + "human_parameter_name": "Parent folder ID", + "human_parameter_description": "The Google Drive folder ID (NOT the folder name) where you want to create the file. Must be a long alphanumeric string like '1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs07X8ygaR', provided as a list with exactly one ID (e.g., ['1BxiMVs0X...']). To find the folder ID, use GOOGLEDRIVE_FIND_FOLDER or GOOGLEDRIVE_LIST_FILES actions. If omitted, the file is created in My Drive root." + }, + "starred": { + "type": "boolean", + "title": "Starred", + "description": "Whether the user has starred the file.", + "human_parameter_name": "Star the file?", + "human_parameter_description": "Choose whether to mark this file as a favorite so it's easier to find later." + }, + "mimeType": { + "enum": [ + "application/vnd.google-apps.folder", + "application/vnd.google-apps.document", + "application/vnd.google-apps.spreadsheet", + "application/vnd.google-apps.presentation", + "application/vnd.google-apps.drawing", + "application/vnd.google-apps.form", + "application/vnd.google-apps.script", + "application/vnd.google-apps.shortcut", + "application/vnd.google-apps.site", + "application/vnd.google-apps.map", + "application/vnd.google-apps.jam", + "application/pdf", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "application/vnd.oasis.opendocument.text", + "application/rtf", + "text/plain", + "text/html", + "text/markdown", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "application/vnd.oasis.opendocument.spreadsheet", + "text/csv", + "text/tab-separated-values", + "application/vnd.openxmlformats-officedocument.presentationml.presentation", + "application/vnd.oasis.opendocument.presentation", + "image/jpeg", + "image/png", + "image/gif", + "image/bmp", + "image/webp", + "image/svg+xml", + "image/tiff", + "video/mp4", + "video/x-msvideo", + "video/quicktime", + "video/x-ms-wmv", + "video/webm", + "audio/mpeg", + "audio/wav", + "audio/ogg", + "application/zip", + "application/vnd.rar", + "application/x-tar", + "application/gzip", + "application/x-7z-compressed", + "application/json", + "application/xml", + "application/x-yaml", + "application/epub+zip", + "application/octet-stream" + ], + "type": "string", + "title": "MimeType", + "description": "Common MIME types for Google Drive file creation.", + "human_parameter_name": "MIME type", + "human_parameter_description": "Specify the type of item you're creating. Common values: GOOGLE_FOLDER for folders, GOOGLE_DOCUMENT for Google Docs, PDF, DOCX, TXT for documents, JPEG/PNG for images. Leave blank to let Google Drive infer from the filename." + }, + "description": { + "type": "string", + "title": "Description", + "description": "A short description of the file.", + "human_parameter_name": "File description", + "human_parameter_description": "A short note about the file to help you and others understand what it is or how it should be used." + }, + "file_to_upload": { + "type": "object", + "title": "FileUploadable", + "required": ["name", "mimetype", "s3key"], + "properties": { + "name": { + "type": "string", + "title": "Name", + "examples": ["document.pdf", "image.jpg", "report.docx"], + "description": "The filename that will be used when uploading the file to the destination service" + }, + "s3key": { + "type": "string", + "title": "S3Key", + "examples": ["47563/gmail/GET_ATTACHMENT/response/12345"], + "description": "The S3 key of a publicly accessible file, typically returned from a previous download action that stored the file in S3. This key references an existing file that can be uploaded to another service." + }, + "mimetype": { + "type": "string", + "title": "Mimetype", + "examples": [ + "application/pdf", + "image/jpeg", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document" + ], + "description": "The MIME type of the file" + } + }, + "description": "Optional file content to upload. If provided, the file will be created with the actual content from this upload. If omitted, creates an empty file (for folders) or empty Google Workspace document. Use this when you need to upload actual file bytes (images, PDFs, documents, etc.).", + "file_uploadable": true, + "additionalProperties": false, + "human_parameter_name": "File content to upload", + "human_parameter_description": "Optional. The actual file content to upload. If provided, creates a file with this content. If omitted, creates an empty file or folder." + } + } + }, + "scopes": [ + "https://www.googleapis.com/auth/drive", + "https://www.googleapis.com/auth/drive.file", + "https://www.googleapis.com/auth/drive.appdata" + ], + "scope_requirements": { + "all_of": [ + { + "any_of": [ + "https://www.googleapis.com/auth/drive", + "https://www.googleapis.com/auth/drive.file", + "https://www.googleapis.com/auth/drive.appdata" + ] + } + ] + }, + "tags": ["openWorldHint", "important", "file management", "createHint"], + "no_auth": false, + "human_description": "This action allows the user to create a new file or folder in a specified location. Users can provide metadata such as the name, MIME type, and parent folder IDs to specify the properties of the file being created.", + "is_deprecated": false, + "deprecated": { + "displayName": "Create File or Folder", + "version": "20261022_00", + "available_versions": ["20261022_00"], + "is_deprecated": false, + "toolkit": { + "logo": "https://logos.composio.dev/api/googledrive" + } + } + } + ] +} diff --git a/server/tests/plugin-store.integration.test.ts b/server/tests/plugin-store.integration.test.ts index 5f8a76eac3..342559f8bf 100644 --- a/server/tests/plugin-store.integration.test.ts +++ b/server/tests/plugin-store.integration.test.ts @@ -47,6 +47,7 @@ import { type ComposioResult, useComposioClient, } from "../src/plugins/composio"; +import { actionOf } from "../src/plugins/composio-vendor"; import { redirectUriFor } from "../src/plugins/oauth"; import type { AccountRef } from "../src/plugins/shared-accounts"; import { @@ -64,6 +65,7 @@ import { unlistedAdvertisedTools, } from "../src/plugins/store"; import { grantedTools, REFUSAL_MARKER } from "../src/plugins/tools"; +import fileActions from "./fixtures/composio-file-actions.json"; import { TEST_POOL, testDatabaseUrl } from "./support/database"; /** @@ -6019,6 +6021,66 @@ test("a catalogue entry declaring the broker's transport is refused, not dialled }); }); +/** + * A COMPOSIO ACTION WHOSE ONLY FILE IS OPTIONAL IS RECORDED, WITHOUT THE FILE. + * + * The listing used to drop every action with a `file_uploadable` parameter in it, so a refresh of + * Gmail recorded 59 of its 63 actions and never GMAIL_SEND_EMAIL, GMAIL_CREATE_EMAIL_DRAFT, + * GMAIL_REPLY_TO_THREAD or GMAIL_UPDATE_DRAFT: no row to grant, so no Bot could send, draft or + * reply. The rows below are Composio's own (`fixtures/composio-file-actions.json`), mapped through + * `actionOf` exactly as the adapter maps a page, and what is asserted is the row a grant points at + * and a model is shown. + */ +describe("a Composio action whose only file is optional", () => { + test("is recorded with its version and without the attachment a model cannot fill in", async () => { + const { store, database } = await freshStore(); + useComposioClient({ + listActions: async () => + fileActions.gmail.map((row, position) => + actionOf(structuredClone(row), position, "gmail"), + ), + execute: async () => vendorAnswered(), + }); + await seedComposioGmail(database, store); + + expect(await store.refreshTools("gmail", "admin_user")).toEqual({ + tools: 5, + }); + + const stored = await database + .select({ + name: mcpTools.name, + inputSchema: mcpTools.inputSchema, + version: mcpTools.version, + }) + .from(mcpTools) + .where(eq(mcpTools.serverId, "gmail")) + .orderBy(asc(mcpTools.name)); + + expect(stored.map((row) => row.name)).toEqual([ + "GMAIL_CREATE_EMAIL_DRAFT", + "GMAIL_FETCH_EMAILS", + "GMAIL_REPLY_TO_THREAD", + "GMAIL_SEND_EMAIL", + "GMAIL_UPDATE_DRAFT", + ]); + // Callable: the version Composio published, which a call has to send back. + expect(new Set(stored.map((row) => row.version))).toEqual( + new Set(["20261018_00"]), + ); + const send = stored.find((row) => row.name === "GMAIL_SEND_EMAIL") + ?.inputSchema as { properties?: Record } | undefined; + const properties = Object.keys(send?.properties ?? {}); + expect(properties).not.toContain("attachment"); + expect(properties).toContain("recipient_email"); + expect(properties).toContain("body"); + // No staging descriptor in any row a model is shown. + expect(JSON.stringify(stored.map((row) => row.inputSchema))).not.toContain( + "file_uploadable", + ); + }); +}); + /** * WHAT A VENDOR SENT THAT THIS DATABASE WILL NOT TAKE, and what came out when it did not. *