From aef4d4cb19587d5a806cca1f1ab92f09606b3956 Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Tue, 20 Oct 2026 19:01:44 -0300 Subject: [PATCH 01/33] Share enabling a Composio app and a Shared grant's approval with other callers Moves the body of POST /composio/apps into enableComposioApp and the Shared-approval rule of POST /grants into sharedGrantApproval, unchanged, so approving an access request can switch an app on and grant it through the same code. --- .../src/plugins/route-groups/composio-apps.ts | 365 ++++++++++-------- server/src/plugins/route-groups/grants.ts | 85 ++-- 2 files changed, 258 insertions(+), 192 deletions(-) diff --git a/server/src/plugins/route-groups/composio-apps.ts b/server/src/plugins/route-groups/composio-apps.ts index 5c23a242bd..b97c23ec2a 100644 --- a/server/src/plugins/route-groups/composio-apps.ts +++ b/server/src/plugins/route-groups/composio-apps.ts @@ -12,6 +12,7 @@ import { deploymentFaultSentence, isDeploymentFault, PluginRefusedError, + type ServerRecord, } from "../store"; import { brokerRefusal, @@ -146,13 +147,6 @@ export function mountComposioAppRoutes({ const forbidden = requireAdmin(context); if (forbidden) return forbidden; - if (!composio) { - return context.json( - { error: new BrokerUnconfiguredError().message }, - 503, - ); - } - const body = (await context.req.json().catch(() => null)) as { slug?: unknown; } | null; @@ -168,174 +162,213 @@ export function mountComposioAppRoutes({ * about that, so the narrowing answers with `undefined` rather than a second sentence. */ const slug = typeof body?.slug === "string" ? body.slug.trim() : undefined; - let directory: BrokerApp[] = []; - if (slug) { - try { - directory = await composio.broker.listApps(); - } catch (error) { - /* - * The same failure the directory route answers, in the same words, because it is the same - * call. Unhandled it was the worst 500 of the three: an administrator pressing Add on a - * deployment whose key is wrong was told nothing at all, on the one screen where the key - * had just been set. - */ - const refusal = brokerRefusal(error, DIRECTORY_UNAVAILABLE); - return context.json({ error: refusal.error }, refusal.status); - } - } - const app = directory.find((candidate) => candidate.slug === slug); - if (!app) { - return context.json( - { - error: slug - ? `${slug} is not an app Composio lists for this deployment.` - : "An app is required.", - }, - 400, - ); + const outcome = await enableComposioApp( + { store, composio }, + slug, + actorEmail(context), + ); + return outcome.ok + ? context.json({ server: outcome.server }, 201) + : context.json({ error: outcome.error }, outcome.status); + }); +} + +/** What enabling one Composio app answered: the row it wrote, or the refusal and its status. */ +export type EnableOutcome = + | { ok: true; server: ServerRecord } + | { ok: false; error: string; status: 400 | 409 | 502 | 503 }; + +/** + * Enable one app of Composio's catalogue, by slug: the body of `POST /composio/apps`, and what + * approving a person's request to switch an app on runs. + * + * ONE FUNCTION FOR BOTH DOORS, so an app switched on from the request inbox is checked against + * the live directory, refused when this deployment cannot drive it, and mapped to the same + * sentences as one switched on from the picker. The caller decides who may call it; both callers + * are administrator-only. + */ +export async function enableComposioApp( + { store, composio }: Pick, + slug: string | undefined, + by: string, +): Promise { + if (!composio) { + return { + ok: false, + error: new BrokerUnconfiguredError().message, + status: 503, + }; + } + let directory: BrokerApp[] = []; + if (slug) { + try { + directory = await composio.broker.listApps(); + } catch (error) { + /* + * The same failure the directory route answers, in the same words, because it is the same + * call. Unhandled it was the worst 500 of the three: an administrator pressing Add on a + * deployment whose key is wrong was told nothing at all, on the one screen where the key + * had just been set. + */ + return { ok: false, ...brokerRefusal(error, DIRECTORY_UNAVAILABLE) }; } + } + const app = directory.find((candidate) => candidate.slug === slug); + if (!app) { + return { + ok: false, + error: slug + ? `${slug} is not an app Composio lists for this deployment.` + : "An app is required.", + status: 400, + }; + } + + /* + * AN APP THIS DEPLOYMENT CANNOT CONNECT IS NOT AN APP TO ADD, and the GET half above is not + * enough on its own: it hides every `unsupported` app from the picker, so nobody presses Add on + * one, but this route validates against the unfiltered `directory` — so a request that names + * one by hand arrives here past that filter. + * + * WHAT IT WOULD REACH IS A STORE METHOD THAT RECORDS A MISLEADING ROW. `schemeFor` writes + * `null` on `auth_scheme` for an unsupported connection, and a null there is read everywhere + * else as "not a brokered row at all" — so the row enabling would write is one whose recorded + * kind contradicts what it is. + * + * A SECOND GUARD RATHER THAN A REPLACEMENT: the adapter keeps its own refusal, and the two + * catch different things. This one stops a CALLER OF THIS ROUTE reaching a store method that + * would write that row — which is a fact about `addBrokeredApp`, and holds for whatever broker + * is behind it, including one that would happily create the config. The adapter's stops ANY + * caller at all — this route, a script, a future path — from reaching the vendor for an app + * this deployment has nowhere to hold an OAuth client for. Neither subsumes the other, and the + * store itself still has no guard, which is precisely why the cheap one here is worth having. + * + * The derivation's own `reason` is the sentence, because it names what is missing for this app + * rather than for unsupported apps in general. 503 because this is a refusal this deployment + * authored — the same status `brokerRefusal` gives an authored refusal raised a layer down, and + * for the same reading: the brokered surface is unavailable for this app until somebody here + * changes something, rather than unavailable because a third party is down. Not the 400 above, + * which says the app does not exist; this one does exist, and this deployment cannot drive it. + */ + if (app.connection.kind === "unsupported") { + return { ok: false, error: app.connection.reason, status: 503 }; + } + try { + const server = await store.addBrokeredApp({ + slug: app.slug, + title: app.name, + logo: app.logo, + by, + // Off the directory entry the administrator chose, never derived a second time here: a + // second derivation is a second answer, which is the one thing `BrokerConnection` exists to + // prevent. It decides what config the store creates at the vendor, and what the row records. + connection: app.connection, + }); + return { ok: true, server }; + } catch (error) { + // The same mapping the add routes above make, for the same reason: a refusal an + // administrator can correct comes back as itself rather than as a 500. + if ( + error instanceof CustomServerRefusedError || + error instanceof PluginRefusedError + ) { + return { ok: false, error: error.message, status: 400 }; + } + // And, as on those routes, enabling refreshes before it answers, so every fault + // `refreshTools` raises arrives here as well. Its sentence blames nobody and names the row, + // which is the honest answer whichever side of the insert the fault arrived on. + if (isDeploymentFault(error)) { + return { + ok: false, + error: deploymentFaultSentence(error), + status: 409, + }; + } /* - * AN APP THIS DEPLOYMENT CANNOT CONNECT IS NOT AN APP TO ADD, and the GET half above is not - * enough on its own: it hides every `unsupported` app from the picker, so nobody presses Add on - * one, but this route validates against the unfiltered `directory` — so a request that names - * one by hand arrives here past that filter. + * THE ONE REFUSAL THIS ROUTE CAN ONLY MEET AFTER THE ROW IS COMMITTED, and the only one its + * three sibling add routes map and it did not. * - * WHAT IT WOULD REACH IS A STORE METHOD THAT RECORDS A MISLEADING ROW. `schemeFor` writes - * `null` on `auth_scheme` for an unsupported connection, and a null there is read everywhere - * else as "not a brokered row at all" — so the row enabling would write is one whose recorded - * kind contradicts what it is. + * CRITERION. A failure thrown after `addBrokeredApp` has written and audited the row is not + * reported as an app that was not enabled, and is not reported as Composio's doing. * - * A SECOND GUARD RATHER THAN A REPLACEMENT: the adapter keeps its own refusal, and the two - * catch different things. This one stops a CALLER OF THIS ROUTE reaching a store method that - * would write that row — which is a fact about `addBrokeredApp`, and holds for whatever broker - * is behind it, including one that would happily create the config. The adapter's stops ANY - * caller at all — this route, a script, a future path — from reaching the vendor for an app - * this deployment has nowhere to hold an OAuth client for. Neither subsumes the other, and the - * store itself still has no guard, which is precisely why the cheap one here is worth having. + * WHERE IT ARRIVES FROM. `addBrokeredApp` creates the auth config, inserts the row and files + * its `configuration.changed` entry, and only THEN refreshes and reads the row back out of + * `listServers` — raising this when it is not there, as `requireServer` does one call + * deeper. Every one of those steps has already committed by then. * - * The derivation's own `reason` is the sentence, because it names what is missing for this app - * rather than for unsupported apps in general. 503 because this is a refusal this deployment - * authored — the same status `brokerRefusal` gives an authored refusal raised a layer down, and - * for the same reading: the brokered surface is unavailable for this app until somebody here - * changes something, rather than unavailable because a third party is down. Not the 400 above, - * which says the app does not exist; this one does exist, and this deployment cannot drive it. + * WHAT IT USED TO BE ANSWERED WITH. Nothing caught it, so it fell through to the broker tail + * below and an administrator was told "Slack could not be enabled, and Composio said nothing + * about why. Try again, and check this deployment's Composio key if it persists." That is + * wrong three times over: the app WAS enabled, Composio had no part in the step that failed, + * and the one thing the reader now holds — a row on their Plugins page — went unmentioned + * while they were sent to check a key that is fine. + * + * 409 RATHER THAN THE 400 ITS SIBLINGS GIVE THIS CLASS, because it is not the same fact + * wearing the same class. On the add routes above, an unknown key is a caller naming a + * server this deployment will not connect to, which is a malformed request. Here the row was + * written and this deployment cannot see it: two of its own reads disagree, and the request + * cannot be answered until one of them changes — the reading the refresh route's own 409 + * spells out. The message is written here rather than passed through, because + * {@link CatalogueEntryUnknownError}'s own sentence is the one for the other case. */ - if (app.connection.kind === "unsupported") { - return context.json({ error: app.connection.reason }, 503); + if (error instanceof CatalogueEntryUnknownError) { + return { + ok: false, + error: `${app.name} was added, and then could not be read back out of this deployment's own servers — so the row and its trail entry stand, and what is on this page may be missing it. Reload the Plugins page: if ${app.name} is there it is enabled and there is nothing to redo, and if it is not, adding it again is safe.`, + status: 409, + }; + } + /* + * THE SAME MAPPING THE DIRECTORY READ ABOVE MAKES, FOR THE SAME REASON, and its absence here + * is what left an administrator with nothing. Composio's own sentence — "Default auth config + * not found for toolkit linear_mcp. Composio does not have managed credentials for this + * toolkit." — travelled as an unhandled throw, so the route answered a bodyless 500 and the + * browser fell back to its own "That app could not be added". Everything that explains the + * failure existed; nothing carried it the last step. + */ + /* + * WHAT THE MAPPING COST, PUT BACK. A throw used to reach Hono's default handler, which is a + * bad answer and a good log: the administrator got a bodyless 500, but the stack was printed + * where an operator could find it. Catching everything fixed the answer and silenced the log, + * and the failure that needs the log most is the one left here — not a refusal this + * deployment authored, which `brokerSentence` names and `isDeploymentFault` already took one + * branch above, and not a sentence Composio wrote, which `vendorSentence` reaches for. Both + * null is a fault nobody has explained, most often a programmer error on this side, and it + * would otherwise leave only the generic "Composio said nothing about why" in a browser + * nobody is reading a console from. + * + * The same shape and the same restraint as the connection-not-recorded log below: the slug, + * because it is the app an operator is about to be asked about; what the person was told, so + * the console line and the support request can be matched up; and the error read through + * {@link reasonWithoutStatement}, never spread, logged as an object or reached into. No key, + * no vendor response, no request body, and no statement — the two sentence tests below do not + * exclude a query failure, they are the branch one arrives in. + * + * AND THE NAME ON IT IS TRUE OF EVERY FAILURE THAT STILL REACHES IT. The two branches above + * take the whole of what `addBrokeredApp` can raise once the row is committed — the shelf, + * and the read-back that could not find it — so what is left arrives from the steps before + * the insert, where nothing was written and the app really was not enabled. The generic + * sentence below names Composio for the same reason: the one call in front of the insert is + * `ensureAuthConfig`, which is a call to the vendor. + */ + if (brokerSentence(error) === null && vendorSentence(error) === null) { + console.error( + JSON.stringify({ + type: "composio-app-not-enabled", + slug: app.slug, + note: "Enabling a brokered app failed for a reason neither this deployment nor Composio put a sentence to. The administrator was answered 502 with the generic sentence.", + error: reasonWithoutStatement(error), + }), + ); } - try { - const server = await store.addBrokeredApp({ - slug: app.slug, - title: app.name, - logo: app.logo, - by: actorEmail(context), - // Off the directory entry the administrator chose, never derived a second time here: a - // second derivation is a second answer, which is the one thing `BrokerConnection` exists to - // prevent. It decides what config the store creates at the vendor, and what the row records. - connection: app.connection, - }); - return context.json({ server }, 201); - } catch (error) { - // The same mapping the add routes above make, for the same reason: a refusal an - // administrator can correct comes back as itself rather than as a 500. - if ( - error instanceof CustomServerRefusedError || - error instanceof PluginRefusedError - ) { - return context.json({ error: error.message }, 400); - } - // And, as on those routes, enabling refreshes before it answers, so every fault - // `refreshTools` raises arrives here as well. Its sentence blames nobody and names the row, - // which is the honest answer whichever side of the insert the fault arrived on. - if (isDeploymentFault(error)) { - return context.json({ error: deploymentFaultSentence(error) }, 409); - } - /* - * THE ONE REFUSAL THIS ROUTE CAN ONLY MEET AFTER THE ROW IS COMMITTED, and the only one its - * three sibling add routes map and it did not. - * - * CRITERION. A failure thrown after `addBrokeredApp` has written and audited the row is not - * reported as an app that was not enabled, and is not reported as Composio's doing. - * - * WHERE IT ARRIVES FROM. `addBrokeredApp` creates the auth config, inserts the row and files - * its `configuration.changed` entry, and only THEN refreshes and reads the row back out of - * `listServers` — raising this when it is not there, as `requireServer` does one call - * deeper. Every one of those steps has already committed by then. - * - * WHAT IT USED TO BE ANSWERED WITH. Nothing caught it, so it fell through to the broker tail - * below and an administrator was told "Slack could not be enabled, and Composio said nothing - * about why. Try again, and check this deployment's Composio key if it persists." That is - * wrong three times over: the app WAS enabled, Composio had no part in the step that failed, - * and the one thing the reader now holds — a row on their Plugins page — went unmentioned - * while they were sent to check a key that is fine. - * - * 409 RATHER THAN THE 400 ITS SIBLINGS GIVE THIS CLASS, because it is not the same fact - * wearing the same class. On the add routes above, an unknown key is a caller naming a - * server this deployment will not connect to, which is a malformed request. Here the row was - * written and this deployment cannot see it: two of its own reads disagree, and the request - * cannot be answered until one of them changes — the reading the refresh route's own 409 - * spells out. The message is written here rather than passed through, because - * {@link CatalogueEntryUnknownError}'s own sentence is the one for the other case. - */ - if (error instanceof CatalogueEntryUnknownError) { - return context.json( - { - error: `${app.name} was added, and then could not be read back out of this deployment's own servers — so the row and its trail entry stand, and what is on this page may be missing it. Reload the Plugins page: if ${app.name} is there it is enabled and there is nothing to redo, and if it is not, adding it again is safe.`, - }, - 409, - ); - } - /* - * THE SAME MAPPING THE DIRECTORY READ ABOVE MAKES, FOR THE SAME REASON, and its absence here - * is what left an administrator with nothing. Composio's own sentence — "Default auth config - * not found for toolkit linear_mcp. Composio does not have managed credentials for this - * toolkit." — travelled as an unhandled throw, so the route answered a bodyless 500 and the - * browser fell back to its own "That app could not be added". Everything that explains the - * failure existed; nothing carried it the last step. - */ - /* - * WHAT THE MAPPING COST, PUT BACK. A throw used to reach Hono's default handler, which is a - * bad answer and a good log: the administrator got a bodyless 500, but the stack was printed - * where an operator could find it. Catching everything fixed the answer and silenced the log, - * and the failure that needs the log most is the one left here — not a refusal this - * deployment authored, which `brokerSentence` names and `isDeploymentFault` already took one - * branch above, and not a sentence Composio wrote, which `vendorSentence` reaches for. Both - * null is a fault nobody has explained, most often a programmer error on this side, and it - * would otherwise leave only the generic "Composio said nothing about why" in a browser - * nobody is reading a console from. - * - * The same shape and the same restraint as the connection-not-recorded log below: the slug, - * because it is the app an operator is about to be asked about; what the person was told, so - * the console line and the support request can be matched up; and the error read through - * {@link reasonWithoutStatement}, never spread, logged as an object or reached into. No key, - * no vendor response, no request body, and no statement — the two sentence tests below do not - * exclude a query failure, they are the branch one arrives in. - * - * AND THE NAME ON IT IS TRUE OF EVERY FAILURE THAT STILL REACHES IT. The two branches above - * take the whole of what `addBrokeredApp` can raise once the row is committed — the shelf, - * and the read-back that could not find it — so what is left arrives from the steps before - * the insert, where nothing was written and the app really was not enabled. The generic - * sentence below names Composio for the same reason: the one call in front of the insert is - * `ensureAuthConfig`, which is a call to the vendor. - */ - if (brokerSentence(error) === null && vendorSentence(error) === null) { - console.error( - JSON.stringify({ - type: "composio-app-not-enabled", - slug: app.slug, - note: "Enabling a brokered app failed for a reason neither this deployment nor Composio put a sentence to. The administrator was answered 502 with the generic sentence.", - error: reasonWithoutStatement(error), - }), - ); - } - - const refusal = brokerRefusal( + return { + ok: false, + ...brokerRefusal( error, `${app.name} could not be enabled, and Composio said nothing about why. Try again, and check this deployment's Composio key if it persists.`, - ); - return context.json({ error: refusal.error }, refusal.status); - } - }); + ), + }; + } } diff --git a/server/src/plugins/route-groups/grants.ts b/server/src/plugins/route-groups/grants.ts index dd1b0cb3a6..5000301b88 100644 --- a/server/src/plugins/route-groups/grants.ts +++ b/server/src/plugins/route-groups/grants.ts @@ -13,6 +13,46 @@ import { } from "../store"; import type { PluginRouteContext } from "./context"; +/** + * What a grant of one MCP tool must record beside it: the approval for who may use a Shared app's + * one account through this Bot. + * + * Without an approval the gate refuses every call the grant just allowed, so a grant with none sent + * approves the Bot's exposure as it stands now — unless an approval is already recorded, which is + * an administrator's earlier decision and not this grant's to widen. "Is Shared" is the app's + * answer (its answering row), never the granted row's own column. Null `approval` is nothing to + * record; `unreadable` is an approval sent that this deployment cannot read, which the caller + * refuses. + * + * A FUNCTION RATHER THAN LINES IN THE ROUTE because approving an access request grants through the + * same rule, and a Shared app granted from the request inbox must land with the same approval it + * would have had from the grant switch. + */ +export async function sharedGrantApproval( + { store, shared }: Pick, + ref: string, + agentId: string, + sent: unknown, +): Promise< + { appId: string; approval: SharedUseApproval | null } | "unreadable" +> { + if (!shared) return { appId: "", approval: null }; + const [serverId] = ref.split("/"); + const appId = await shared.use.appIdOf(serverId ?? ""); + if ((await store.serverAddress(appId))?.accountMode !== "shared") + return { appId, approval: null }; + if (sent !== undefined) { + const approval = asSharedUseApproval(sent); + return approval ? { appId, approval } : "unreadable"; + } + if (await shared.use.approvalFor(agentId, appId)) + return { appId, approval: null }; + return { + appId, + approval: exposureOf(await shared.use.botFacts(agentId)), + }; +} + /** Granting and revoking a server, skill or Bot, and a Bot's tool calls back. */ export function mountGrantRoutes({ routes, @@ -235,37 +275,30 @@ export function mountGrantRoutes({ if (refusal) return context.json({ error: refusal }, 403); /* - * A SHARED APP'S ACTION IS GRANTED WITH WHO MAY USE IT, in the one request. Without an approval - * the gate refuses every call the grant just allowed, so a grant with none sent approves the - * Bot's exposure as it stands now — unless an approval is already recorded, which is an - * administrator's earlier decision and not this request's to widen. "Is Shared" is the app's - * answer (its answering row), never the granted row's own column. + * A SHARED APP'S ACTION IS GRANTED WITH WHO MAY USE IT, in the one request. See + * {@link sharedGrantApproval} for what is recorded when none is sent. */ - let approval: SharedUseApproval | null = null; - let appId = ""; - if (kind === "mcp" && shared) { - const [serverId] = grantRef.split("/"); - appId = await shared.use.appIdOf(serverId ?? ""); - if ((await store.serverAddress(appId))?.accountMode === "shared") { - if (body?.approval !== undefined) { - approval = asSharedUseApproval(body.approval); - if (!approval) { - return context.json( - { error: "That approval is not one this deployment can read." }, - 400, - ); - } - } else if (!(await shared.use.approvalFor(grantAgentId, appId))) { - approval = exposureOf(await shared.use.botFacts(grantAgentId)); - } - } + const decided = + kind === "mcp" + ? await sharedGrantApproval( + { store, shared }, + grantRef, + grantAgentId, + body?.approval, + ) + : null; + if (decided === "unreadable") { + return context.json( + { error: "That approval is not one this deployment can read." }, + 400, + ); } await store.grant(kind, grantRef, grantAgentId, actorEmail(context)); - if (approval && shared) { + if (decided?.approval && shared) { await shared.use.setApproval({ botId: grantAgentId, - serverId: appId, - approval, + serverId: decided.appId, + approval: decided.approval, by: context.var.actor.id, }); } From f5368513543d71161441ef5e24978ec6c4181d2b Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Tue, 20 Oct 2026 19:01:44 -0300 Subject: [PATCH 02/33] Say what each app on a Bot is for the person looking, and let them ask an administrator for the rest GET /api/plugins/for/:agentId/apps and /apps/:app answer one of six states per app (ready, not_connected, reconnect, shared_not_connected, disabled, not_granted) from the tables the call gates read. Migration 0057 adds app_access_requests: a person files enable, grant or connect_shared as the state dictates, deduplicated by a partial unique index; an administrator approves by doing the change, or dismisses. Every step and refusal is audited as app_access.*. --- server/drizzle/0057_app_access_requests.sql | 19 + server/drizzle/meta/0057_snapshot.json | 10030 ++++++++++++++++ server/drizzle/meta/_journal.json | 7 + server/src/audit.ts | 12 + server/src/db/schema/plugins.ts | 70 + server/src/plugins/app-access.ts | 222 + server/src/plugins/plugin-store/app-access.ts | 782 ++ server/src/plugins/route-groups/app-access.ts | 538 + server/src/plugins/routes.ts | 2 + server/src/plugins/store.ts | 2 + .../app-access-routes.integration.test.ts | 915 ++ server/tests/app-access-state.test.ts | 177 + 12 files changed, 12776 insertions(+) create mode 100644 server/drizzle/0057_app_access_requests.sql create mode 100644 server/drizzle/meta/0057_snapshot.json create mode 100644 server/src/plugins/app-access.ts create mode 100644 server/src/plugins/plugin-store/app-access.ts create mode 100644 server/src/plugins/route-groups/app-access.ts create mode 100644 server/tests/app-access-routes.integration.test.ts create mode 100644 server/tests/app-access-state.test.ts diff --git a/server/drizzle/0057_app_access_requests.sql b/server/drizzle/0057_app_access_requests.sql new file mode 100644 index 0000000000..6de21e2346 --- /dev/null +++ b/server/drizzle/0057_app_access_requests.sql @@ -0,0 +1,19 @@ +CREATE TABLE "app_access_requests" ( + "id" text PRIMARY KEY NOT NULL, + "requested_by" text NOT NULL, + "agent_id" text, + "app_key" text NOT NULL, + "app_title" text NOT NULL, + "kind" text NOT NULL, + "status" text DEFAULT 'pending' NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "resolved_at" timestamp with time zone, + "resolved_by" text, + CONSTRAINT "app_access_requests_kind_check" CHECK ("app_access_requests"."kind" IN ('enable', 'grant', 'connect_shared')), + CONSTRAINT "app_access_requests_status_check" CHECK ("app_access_requests"."status" IN ('pending', 'approved', 'dismissed', 'cancelled')) +); +--> statement-breakpoint +ALTER TABLE "app_access_requests" ADD CONSTRAINT "app_access_requests_requested_by_users_id_fk" FOREIGN KEY ("requested_by") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "app_access_requests" ADD CONSTRAINT "app_access_requests_agent_id_agents_id_fk" FOREIGN KEY ("agent_id") REFERENCES "public"."agents"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE UNIQUE INDEX "app_access_requests_pending_idx" ON "app_access_requests" USING btree ("requested_by","app_key",coalesce("agent_id", ''),"kind") WHERE "app_access_requests"."status" = 'pending';--> statement-breakpoint +CREATE INDEX "app_access_requests_status_idx" ON "app_access_requests" USING btree ("status","app_key"); \ No newline at end of file diff --git a/server/drizzle/meta/0057_snapshot.json b/server/drizzle/meta/0057_snapshot.json new file mode 100644 index 0000000000..0865743d87 --- /dev/null +++ b/server/drizzle/meta/0057_snapshot.json @@ -0,0 +1,10030 @@ +{ + "id": "362d6852-f964-47ae-b9de-504b358334b5", + "prevId": "1e95338a-aa54-4146-9f37-32111c369e75", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.accounts": { + "name": "accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "accounts_provider_account_idx": { + "name": "accounts_provider_account_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "accounts_user_id_users_id_fk": { + "name": "accounts_user_id_users_id_fk", + "tableFrom": "accounts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agents": { + "name": "agents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "agent_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "configuration": { + "name": "configuration", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "package_id": { + "name": "package_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "override": { + "name": "override", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "agents_package_id_deployment_packages_id_fk": { + "name": "agents_package_id_deployment_packages_id_fk", + "tableFrom": "agents", + "tableTo": "deployment_packages", + "columnsFrom": [ + "package_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.attachments": { + "name": "attachments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size_bytes": { + "name": "size_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "bytes": { + "name": "bytes", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "attached_at": { + "name": "attached_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "upload_group": { + "name": "upload_group", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "attachments_channel_idx": { + "name": "attachments_channel_idx", + "columns": [ + { + "expression": "channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "attachments_uploaded_by_idx": { + "name": "attachments_uploaded_by_idx", + "columns": [ + { + "expression": "uploaded_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "attachments_upload_group_idx": { + "name": "attachments_upload_group_idx", + "columns": [ + { + "expression": "channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "uploaded_by", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "upload_group", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"attachments\".\"attached_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "attachments_staged_idx": { + "name": "attachments_staged_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"attachments\".\"attached_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "attachments_channel_id_channels_id_fk": { + "name": "attachments_channel_id_channels_id_fk", + "tableFrom": "attachments", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "attachments_uploaded_by_users_id_fk": { + "name": "attachments_uploaded_by_users_id_fk", + "tableFrom": "attachments", + "tableTo": "users", + "columnsFrom": [ + "uploaded_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_events": { + "name": "audit_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "initiator_kind": { + "name": "initiator_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'person'" + }, + "initiator_id": { + "name": "initiator_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_events_created_at_idx": { + "name": "audit_events_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_events_type_time_idx": { + "name": "audit_events_type_time_idx", + "columns": [ + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_events_actor_time_idx": { + "name": "audit_events_actor_time_idx", + "columns": [ + { + "expression": "actor_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_events_target_time_idx": { + "name": "audit_events_target_time_idx", + "columns": [ + { + "expression": "target_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_events_initiator_time_idx": { + "name": "audit_events_initiator_time_idx", + "columns": [ + { + "expression": "initiator_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.channel_agents": { + "name": "channel_agents", + "schema": "", + "columns": { + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "channel_agents_channel_id_channels_id_fk": { + "name": "channel_agents_channel_id_channels_id_fk", + "tableFrom": "channel_agents", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "channel_agents_agent_id_agents_id_fk": { + "name": "channel_agents_agent_id_agents_id_fk", + "tableFrom": "channel_agents", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "channel_agents_channel_id_agent_id_pk": { + "name": "channel_agents_channel_id_agent_id_pk", + "columns": [ + "channel_id", + "agent_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.channel_memberships": { + "name": "channel_memberships", + "schema": "", + "columns": { + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pinned_at": { + "name": "pinned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_read_at": { + "name": "last_read_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "channel_memberships_channel_id_channels_id_fk": { + "name": "channel_memberships_channel_id_channels_id_fk", + "tableFrom": "channel_memberships", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "channel_memberships_user_id_users_id_fk": { + "name": "channel_memberships_user_id_users_id_fk", + "tableFrom": "channel_memberships", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "channel_memberships_channel_id_user_id_pk": { + "name": "channel_memberships_channel_id_user_id_pk", + "columns": [ + "channel_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.channels": { + "name": "channels", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "suggested_prompts": { + "name": "suggested_prompts", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "allowed_groups": { + "name": "allowed_groups", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "package_id": { + "name": "package_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "override": { + "name": "override", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "summary_at": { + "name": "summary_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_message": { + "name": "last_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_message_source_id": { + "name": "last_message_source_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_message_at": { + "name": "last_message_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_message_agent_id": { + "name": "last_message_agent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "channels_recent_activity_idx": { + "name": "channels_recent_activity_idx", + "columns": [ + { + "expression": "COALESCE(\"last_message_at\", \"created_at\") DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "channels_awaiting_summary_idx": { + "name": "channels_awaiting_summary_idx", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"channels\".\"summary\" is null and \"channels\".\"deleted_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "channels_package_id_deployment_packages_id_fk": { + "name": "channels_package_id_deployment_packages_id_fk", + "tableFrom": "channels", + "tableTo": "deployment_packages", + "columnsFrom": [ + "package_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "channels_last_message_agent_id_agents_id_fk": { + "name": "channels_last_message_agent_id_agents_id_fk", + "tableFrom": "channels", + "tableTo": "agents", + "columnsFrom": [ + "last_message_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credentials": { + "name": "credentials", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "kind": { + "name": "kind", + "type": "credential_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_value": { + "name": "encrypted_value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_id": { + "name": "key_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credentials_active_key_idx": { + "name": "credentials_active_key_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"credentials\".\"revoked_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deployment_packages": { + "name": "deployment_packages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_path": { + "name": "source_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "checksum": { + "name": "checksum", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "loaded_at": { + "name": "loaded_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "deployment_packages_tenant_id_unique": { + "name": "deployment_packages_tenant_id_unique", + "nullsNotDistinct": false, + "columns": [ + "tenant_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.intelligence_channel_mappings": { + "name": "intelligence_channel_mappings", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "intelligence_channel_mappings_thread_idx": { + "name": "intelligence_channel_mappings_thread_idx", + "columns": [ + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "intelligence_channel_mappings_user_id_users_id_fk": { + "name": "intelligence_channel_mappings_user_id_users_id_fk", + "tableFrom": "intelligence_channel_mappings", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "intelligence_channel_mappings_channel_id_channels_id_fk": { + "name": "intelligence_channel_mappings_channel_id_channels_id_fk", + "tableFrom": "intelligence_channel_mappings", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "intelligence_channel_mappings_user_id_channel_id_pk": { + "name": "intelligence_channel_mappings_user_id_channel_id_pk", + "columns": [ + "user_id", + "channel_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.revoked_access": { + "name": "revoked_access", + "schema": "", + "columns": { + "email": { + "name": "email", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "revoked_by": { + "name": "revoked_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sessions_token_unique": { + "name": "sessions_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sso_providers": { + "name": "sso_providers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oidc_config": { + "name": "oidc_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "saml_config": { + "name": "saml_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "sso_providers_user_id_users_id_fk": { + "name": "sso_providers_user_id_users_id_fk", + "tableFrom": "sso_providers", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sso_providers_provider_id_unique": { + "name": "sso_providers_provider_id_unique", + "nullsNotDistinct": false, + "columns": [ + "provider_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_instructions": { + "name": "user_instructions", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "instructions": { + "name": "instructions", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_instructions_user_id_users_id_fk": { + "name": "user_instructions_user_id_users_id_fk", + "tableFrom": "user_instructions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_roles": { + "name": "user_roles", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_roles_user_id_users_id_fk": { + "name": "user_roles_user_id_users_id_fk", + "tableFrom": "user_roles", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_roles_user_id_role_pk": { + "name": "user_roles_user_id_role_pk", + "columns": [ + "user_id", + "role" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "preferences": { + "name": "preferences", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "groups": { + "name": "groups", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "onboarding_step": { + "name": "onboarding_step", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "onboarding_completed_at": { + "name": "onboarding_completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_signed_in_at": { + "name": "last_signed_in_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verifications": { + "name": "verifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.action_policy": { + "name": "action_policy", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deny": { + "name": "deny", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "allow": { + "name": "allow", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.computer_page_frame": { + "name": "computer_page_frame", + "schema": "", + "columns": { + "computer_id": { + "name": "computer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_call_id": { + "name": "tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "frame": { + "name": "frame", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "captured_at": { + "name": "captured_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "computer_page_frame_captured_idx": { + "name": "computer_page_frame_captured_idx", + "columns": [ + { + "expression": "captured_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "computer_page_frame_computer_id_tool_call_id_pk": { + "name": "computer_page_frame_computer_id_tool_call_id_pk", + "columns": [ + "computer_id", + "tool_call_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.computer_snapshot": { + "name": "computer_snapshot", + "schema": "", + "columns": { + "computer_id": { + "name": "computer_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "snapshot_id": { + "name": "snapshot_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "elements": { + "name": "elements", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "taken_at": { + "name": "taken_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "session": { + "name": "session", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_preferences": { + "name": "agent_preferences", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hidden_at": { + "name": "hidden_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "pinned_at": { + "name": "pinned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "agent_preferences_user_id_users_id_fk": { + "name": "agent_preferences_user_id_users_id_fk", + "tableFrom": "agent_preferences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_preferences_agent_id_agents_id_fk": { + "name": "agent_preferences_agent_id_agents_id_fk", + "tableFrom": "agent_preferences", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "agent_preferences_user_id_agent_id_pk": { + "name": "agent_preferences_user_id_agent_id_pk", + "columns": [ + "user_id", + "agent_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_profiles": { + "name": "agent_profiles", + "schema": "", + "columns": { + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role_description": { + "name": "role_description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "avatar_seed": { + "name": "avatar_seed", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "visibility": { + "name": "visibility", + "type": "agent_visibility", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "callback_token_hash": { + "name": "callback_token_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "callback_token_issued_at": { + "name": "callback_token_issued_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agent_profiles_visibility_deleted_idx": { + "name": "agent_profiles_visibility_deleted_idx", + "columns": [ + { + "expression": "visibility", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_profiles_agent_id_agents_id_fk": { + "name": "agent_profiles_agent_id_agents_id_fk", + "tableFrom": "agent_profiles", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_profiles_owner_user_id_users_id_fk": { + "name": "agent_profiles_owner_user_id_users_id_fk", + "tableFrom": "agent_profiles", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.routine_runs": { + "name": "routine_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "routine_id": { + "name": "routine_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "routine_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "waiting": { + "name": "waiting", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'schedule'" + } + }, + "indexes": { + "routine_runs_by_routine_idx": { + "name": "routine_runs_by_routine_idx", + "columns": [ + { + "expression": "routine_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "routine_runs_routine_id_routines_id_fk": { + "name": "routine_runs_routine_id_routines_id_fk", + "tableFrom": "routine_runs", + "tableTo": "routines", + "columnsFrom": [ + "routine_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.routine_sweeps": { + "name": "routine_sweeps", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "swept_at": { + "name": "swept_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "owner": { + "name": "owner", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.routines": { + "name": "routines", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "instruction": { + "name": "instruction", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cron": { + "name": "cron", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'UTC'" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "next_run_at": { + "name": "next_run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "enabled_at": { + "name": "enabled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "routines_due_idx": { + "name": "routines_due_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "routines_by_owner_idx": { + "name": "routines_by_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "routines_owner_user_id_users_id_fk": { + "name": "routines_owner_user_id_users_id_fk", + "tableFrom": "routines", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "routines_agent_id_agents_id_fk": { + "name": "routines_agent_id_agents_id_fk", + "tableFrom": "routines", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.component_exclusions": { + "name": "component_exclusions", + "schema": "", + "columns": { + "component_name": { + "name": "component_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "withheld_by": { + "name": "withheld_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "component_exclusions_component_name_components_name_fk": { + "name": "component_exclusions_component_name_components_name_fk", + "tableFrom": "component_exclusions", + "tableTo": "components", + "columnsFrom": [ + "component_name" + ], + "columnsTo": [ + "name" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "component_exclusions_agent_id_agents_id_fk": { + "name": "component_exclusions_agent_id_agents_id_fk", + "tableFrom": "component_exclusions", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "component_exclusions_component_name_agent_id_pk": { + "name": "component_exclusions_component_name_agent_id_pk", + "columns": [ + "component_name", + "agent_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.component_functions": { + "name": "component_functions", + "schema": "", + "columns": { + "component_name": { + "name": "component_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "function_name": { + "name": "function_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "granted_by": { + "name": "granted_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "component_functions_component_name_components_name_fk": { + "name": "component_functions_component_name_components_name_fk", + "tableFrom": "component_functions", + "tableTo": "components", + "columnsFrom": [ + "component_name" + ], + "columnsTo": [ + "name" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "component_functions_component_name_function_name_pk": { + "name": "component_functions_component_name_function_name_pk", + "columns": [ + "component_name", + "function_name" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.components": { + "name": "components", + "schema": "", + "columns": { + "name": { + "name": "name", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "draft_description": { + "name": "draft_description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "published_description": { + "name": "published_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published": { + "name": "published", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_access_requests": { + "name": "app_access_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "requested_by": { + "name": "requested_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "app_key": { + "name": "app_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "app_title": { + "name": "app_title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "resolved_by": { + "name": "resolved_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "app_access_requests_pending_idx": { + "name": "app_access_requests_pending_idx", + "columns": [ + { + "expression": "requested_by", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "app_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"agent_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"app_access_requests\".\"status\" = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "app_access_requests_status_idx": { + "name": "app_access_requests_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "app_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "app_access_requests_requested_by_users_id_fk": { + "name": "app_access_requests_requested_by_users_id_fk", + "tableFrom": "app_access_requests", + "tableTo": "users", + "columnsFrom": [ + "requested_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "app_access_requests_agent_id_agents_id_fk": { + "name": "app_access_requests_agent_id_agents_id_fk", + "tableFrom": "app_access_requests", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "app_access_requests_kind_check": { + "name": "app_access_requests_kind_check", + "value": "\"app_access_requests\".\"kind\" IN ('enable', 'grant', 'connect_shared')" + }, + "app_access_requests_status_check": { + "name": "app_access_requests_status_check", + "value": "\"app_access_requests\".\"status\" IN ('pending', 'approved', 'dismissed', 'cancelled')" + } + }, + "isRLSEnabled": false + }, + "public.brokered_connections": { + "name": "brokered_connections", + "schema": "", + "columns": { + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "app": { + "name": "app", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "holder": { + "name": "holder", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "vendor_user_id": { + "name": "vendor_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connected_by": { + "name": "connected_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "connected_at": { + "name": "connected_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "verified": { + "name": "verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "verified_at": { + "name": "verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "probe_action": { + "name": "probe_action", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "brokered_connections_person_idx": { + "name": "brokered_connections_person_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "app", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"brokered_connections\".\"holder\" = 'person'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "brokered_connections_deployment_idx": { + "name": "brokered_connections_deployment_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "app", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"brokered_connections\".\"holder\" = 'deployment'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "brokered_connections_user_idx": { + "name": "brokered_connections_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "brokered_connections_holder_check": { + "name": "brokered_connections_holder_check", + "value": "(\"brokered_connections\".\"holder\" = 'person' AND \"brokered_connections\".\"user_id\" IS NOT NULL) OR (\"brokered_connections\".\"holder\" = 'deployment' AND \"brokered_connections\".\"user_id\" IS NULL)" + }, + "brokered_connections_provider_check": { + "name": "brokered_connections_provider_check", + "value": "\"brokered_connections\".\"provider\" IN ('composio')" + } + }, + "isRLSEnabled": false + }, + "public.composio_connections": { + "name": "composio_connections", + "schema": "", + "columns": { + "toolkit": { + "name": "toolkit", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connected_at": { + "name": "connected_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "verified": { + "name": "verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "verified_at": { + "name": "verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "probe_action": { + "name": "probe_action", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "composio_connections_user_idx": { + "name": "composio_connections_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "composio_connections_toolkit_user_id_pk": { + "name": "composio_connections_toolkit_user_id_pk", + "columns": [ + "toolkit", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_servers": { + "name": "mcp_servers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "vendor": { + "name": "vendor", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provenance": { + "name": "provenance", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'first-party'" + }, + "credential_id": { + "name": "credential_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "auth_scheme": { + "name": "auth_scheme", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "account_mode": { + "name": "account_mode", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "shared_vendor_user_id": { + "name": "shared_vendor_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tools_refreshed_at": { + "name": "tools_refreshed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "added_by": { + "name": "added_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "mcp_servers_credential_id_credentials_id_fk": { + "name": "mcp_servers_credential_id_credentials_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "mcp_servers_account_mode_check": { + "name": "mcp_servers_account_mode_check", + "value": "\"mcp_servers\".\"account_mode\" IS NULL OR \"mcp_servers\".\"account_mode\" IN ('personal', 'shared')" + } + }, + "isRLSEnabled": false + }, + "public.mcp_tools": { + "name": "mcp_tools", + "schema": "", + "columns": { + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "input_schema": { + "name": "input_schema", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "effect": { + "name": "effect", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "destructive": { + "name": "destructive", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "mcp_tools_server_id_mcp_servers_id_fk": { + "name": "mcp_tools_server_id_mcp_servers_id_fk", + "tableFrom": "mcp_tools", + "tableTo": "mcp_servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "mcp_tools_server_id_name_pk": { + "name": "mcp_tools_server_id_name_pk", + "columns": [ + "server_id", + "name" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_user_credentials": { + "name": "mcp_user_credentials", + "schema": "", + "columns": { + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connected_at": { + "name": "connected_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_user_credentials_user_idx": { + "name": "mcp_user_credentials_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_user_credentials_server_id_mcp_servers_id_fk": { + "name": "mcp_user_credentials_server_id_mcp_servers_id_fk", + "tableFrom": "mcp_user_credentials", + "tableTo": "mcp_servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_user_credentials_user_id_users_id_fk": { + "name": "mcp_user_credentials_user_id_users_id_fk", + "tableFrom": "mcp_user_credentials", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_user_credentials_credential_id_credentials_id_fk": { + "name": "mcp_user_credentials_credential_id_credentials_id_fk", + "tableFrom": "mcp_user_credentials", + "tableTo": "credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "mcp_user_credentials_server_id_user_id_pk": { + "name": "mcp_user_credentials_server_id_user_id_pk", + "columns": [ + "server_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugin_grants": { + "name": "plugin_grants", + "schema": "", + "columns": { + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ref": { + "name": "ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "granted_by": { + "name": "granted_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "plugin_grants_agent_idx": { + "name": "plugin_grants_agent_idx", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "plugin_grants_agent_id_agents_id_fk": { + "name": "plugin_grants_agent_id_agents_id_fk", + "tableFrom": "plugin_grants", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "plugin_grants_kind_ref_agent_id_pk": { + "name": "plugin_grants_kind_ref_agent_id_pk", + "columns": [ + "kind", + "ref", + "agent_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sandboxed_components": { + "name": "sandboxed_components", + "schema": "", + "columns": { + "name": { + "name": "name", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "draft_description": { + "name": "draft_description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "draft_html": { + "name": "draft_html", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "draft_css": { + "name": "draft_css", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "draft_js_functions": { + "name": "draft_js_functions", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "draft_argument_schema": { + "name": "draft_argument_schema", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "published_description": { + "name": "published_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_html": { + "name": "published_html", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_css": { + "name": "published_css", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_js_functions": { + "name": "published_js_functions", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_argument_schema": { + "name": "published_argument_schema", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "sample_arguments": { + "name": "sample_arguments", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "published": { + "name": "published", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "authored_by": { + "name": "authored_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skill_tools": { + "name": "skill_tools", + "schema": "", + "columns": { + "skill_id": { + "name": "skill_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ref": { + "name": "ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "declared_by": { + "name": "declared_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skill_tools_ref_idx": { + "name": "skill_tools_ref_idx", + "columns": [ + { + "expression": "ref", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skill_tools_skill_id_skills_id_fk": { + "name": "skill_tools_skill_id_skills_id_fk", + "tableFrom": "skill_tools", + "tableTo": "skills", + "columnsFrom": [ + "skill_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "skill_tools_skill_id_ref_pk": { + "name": "skill_tools_skill_id_ref_pk", + "columns": [ + "skill_id", + "ref" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skills": { + "name": "skills", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "instructions": { + "name": "instructions", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'yours'" + }, + "installed_by": { + "name": "installed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skills_slug_key": { + "name": "skills_slug_key", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "skills_owner_idx": { + "name": "skills_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skills_owner_user_id_users_id_fk": { + "name": "skills_owner_user_id_users_id_fk", + "tableFrom": "skills", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.work_items": { + "name": "work_items", + "schema": "", + "columns": { + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_at": { + "name": "run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "claimed_by": { + "name": "claimed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lease_until": { + "name": "lease_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "work_items_claimable_idx": { + "name": "work_items_claimable_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "work_items_kind_key_pk": { + "name": "work_items_kind_key_pk", + "columns": [ + "kind", + "key" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.voice_sessions": { + "name": "voice_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "anchor_message_id": { + "name": "anchor_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "duration_seconds": { + "name": "duration_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "transcript": { + "name": "transcript", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "summary_status": { + "name": "summary_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'failed'" + } + }, + "indexes": { + "voice_sessions_channel_started_idx": { + "name": "voice_sessions_channel_started_idx", + "columns": [ + { + "expression": "channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "voice_sessions_channel_id_channels_id_fk": { + "name": "voice_sessions_channel_id_channels_id_fk", + "tableFrom": "voice_sessions", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "voice_sessions_user_id_users_id_fk": { + "name": "voice_sessions_user_id_users_id_fk", + "tableFrom": "voice_sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "voice_sessions_duration_check": { + "name": "voice_sessions_duration_check", + "value": "\"voice_sessions\".\"duration_seconds\" >= 0" + }, + "voice_sessions_summary_check": { + "name": "voice_sessions_summary_check", + "value": "(\"voice_sessions\".\"summary_status\" = 'ready' AND \"voice_sessions\".\"summary\" IS NOT NULL) OR (\"voice_sessions\".\"summary_status\" = 'failed' AND \"voice_sessions\".\"summary\" IS NULL)" + } + }, + "isRLSEnabled": false + }, + "public.learning_settings": { + "name": "learning_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.learning_thread_bindings": { + "name": "learning_thread_bindings", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "container_id": { + "name": "container_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "learning_thread_bindings_user_id_thread_id_pk": { + "name": "learning_thread_bindings_user_id_thread_id_pk", + "columns": [ + "user_id", + "thread_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.responsibilities": { + "name": "responsibilities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "instruction": { + "name": "instruction", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "success_criteria": { + "name": "success_criteria", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "responsibility_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "progress": { + "name": "progress", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "last_result": { + "name": "last_result", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subscriptions": { + "name": "subscriptions", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "responsibilities_owner_status_idx": { + "name": "responsibilities_owner_status_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "responsibilities_owner_user_id_users_id_fk": { + "name": "responsibilities_owner_user_id_users_id_fk", + "tableFrom": "responsibilities", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "responsibilities_agent_id_agents_id_fk": { + "name": "responsibilities_agent_id_agents_id_fk", + "tableFrom": "responsibilities", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.responsibility_events": { + "name": "responsibility_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "responsibility_events_source_id_idx": { + "name": "responsibility_events_source_id_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "responsibility_events_owner_user_id_users_id_fk": { + "name": "responsibility_events_owner_user_id_users_id_fk", + "tableFrom": "responsibility_events", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.responsibility_runs": { + "name": "responsibility_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "responsibility_id": { + "name": "responsibility_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_id": { + "name": "event_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "responsibility_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "reply_text": { + "name": "reply_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "waiting": { + "name": "waiting", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "continuation": { + "name": "continuation", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "responsibility_runs_event_idx": { + "name": "responsibility_runs_event_idx", + "columns": [ + { + "expression": "responsibility_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "responsibility_runs_status_idx": { + "name": "responsibility_runs_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "responsibility_runs_responsibility_id_responsibilities_id_fk": { + "name": "responsibility_runs_responsibility_id_responsibilities_id_fk", + "tableFrom": "responsibility_runs", + "tableTo": "responsibilities", + "columnsFrom": [ + "responsibility_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "responsibility_runs_event_id_responsibility_events_id_fk": { + "name": "responsibility_runs_event_id_responsibility_events_id_fk", + "tableFrom": "responsibility_runs", + "tableTo": "responsibility_events", + "columnsFrom": [ + "event_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.responsibility_source_bindings": { + "name": "responsibility_source_bindings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repository": { + "name": "repository", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "responsibility_source_bindings_owner_idx": { + "name": "responsibility_source_bindings_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "responsibility_source_bindings_owner_user_id_users_id_fk": { + "name": "responsibility_source_bindings_owner_user_id_users_id_fk", + "tableFrom": "responsibility_source_bindings", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "responsibility_source_bindings_credential_id_credentials_id_fk": { + "name": "responsibility_source_bindings_credential_id_credentials_id_fk", + "tableFrom": "responsibility_source_bindings", + "tableTo": "credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.responsibility_triggers": { + "name": "responsibility_triggers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "responsibility_id": { + "name": "responsibility_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "responsibility_triggers_owner_idx": { + "name": "responsibility_triggers_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "responsibility_triggers_responsibility_idx": { + "name": "responsibility_triggers_responsibility_idx", + "columns": [ + { + "expression": "responsibility_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "responsibility_triggers_kind_idx": { + "name": "responsibility_triggers_kind_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "responsibility_triggers_owner_user_id_users_id_fk": { + "name": "responsibility_triggers_owner_user_id_users_id_fk", + "tableFrom": "responsibility_triggers", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "responsibility_triggers_responsibility_id_responsibilities_id_fk": { + "name": "responsibility_triggers_responsibility_id_responsibilities_id_fk", + "tableFrom": "responsibility_triggers", + "tableTo": "responsibilities", + "columnsFrom": [ + "responsibility_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "responsibility_triggers_credential_id_credentials_id_fk": { + "name": "responsibility_triggers_credential_id_credentials_id_fk", + "tableFrom": "responsibility_triggers", + "tableTo": "credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.approval_preferences": { + "name": "approval_preferences", + "schema": "", + "columns": { + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "auto_review": { + "name": "auto_review", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "host_commands": { + "name": "host_commands", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'ask'" + } + }, + "indexes": {}, + "foreignKeys": { + "approval_preferences_owner_user_id_users_id_fk": { + "name": "approval_preferences_owner_user_id_users_id_fk", + "tableFrom": "approval_preferences", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "approval_preferences_host_commands_check": { + "name": "approval_preferences_host_commands_check", + "value": "\"approval_preferences\".\"host_commands\" IN ('ask', 'allow', 'never')" + } + }, + "isRLSEnabled": false + }, + "public.approval_requests": { + "name": "approval_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_call_id": { + "name": "tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action_digest": { + "name": "action_digest", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "decision": { + "name": "decision", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "decided_at": { + "name": "decided_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "consumed_at": { + "name": "consumed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "approval_requests_action_idx": { + "name": "approval_requests_action_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "approval_requests_inbox_idx": { + "name": "approval_requests_inbox_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "approval_requests_owner_user_id_users_id_fk": { + "name": "approval_requests_owner_user_id_users_id_fk", + "tableFrom": "approval_requests", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "approval_status_check": { + "name": "approval_status_check", + "value": "\"approval_requests\".\"status\" IN ('pending', 'approved', 'denied', 'consumed', 'completed')" + }, + "approval_decision_check": { + "name": "approval_decision_check", + "value": "\"approval_requests\".\"decision\" IN ('allow_once', 'allow_always', 'deny', 'handled')" + } + }, + "isRLSEnabled": false + }, + "public.approval_rules": { + "name": "approval_rules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bot_id": { + "name": "bot_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_ref": { + "name": "tool_ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "effect": { + "name": "effect", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "behaviour": { + "name": "behaviour", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'allow'" + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "approval_rules_owner_idx": { + "name": "approval_rules_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "approval_rules_owner_user_id_users_id_fk": { + "name": "approval_rules_owner_user_id_users_id_fk", + "tableFrom": "approval_rules", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "approval_rules_behaviour_check": { + "name": "approval_rules_behaviour_check", + "value": "\"approval_rules\".\"behaviour\" IN ('allow', 'pre_approved', 'ask', 'hand_off')" + } + }, + "isRLSEnabled": false + }, + "public.approval_team_rules": { + "name": "approval_team_rules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "bot_id": { + "name": "bot_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_ref": { + "name": "tool_ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "effect": { + "name": "effect", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "behaviour": { + "name": "behaviour", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "approval_team_rules_created_by_users_id_fk": { + "name": "approval_team_rules_created_by_users_id_fk", + "tableFrom": "approval_team_rules", + "tableTo": "users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "approval_team_rules_behaviour_check": { + "name": "approval_team_rules_behaviour_check", + "value": "\"approval_team_rules\".\"behaviour\" IN ('allow', 'pre_approved', 'ask', 'hand_off')" + } + }, + "isRLSEnabled": false + }, + "public.approval_team_settings": { + "name": "approval_team_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "enforce_auto_review": { + "name": "enforce_auto_review", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "custom_rules_enabled": { + "name": "custom_rules_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "host_commands_cap": { + "name": "host_commands_cap", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'allow'" + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "approval_team_settings_updated_by_users_id_fk": { + "name": "approval_team_settings_updated_by_users_id_fk", + "tableFrom": "approval_team_settings", + "tableTo": "users", + "columnsFrom": [ + "updated_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "approval_team_settings_host_commands_cap_check": { + "name": "approval_team_settings_host_commands_cap_check", + "value": "\"approval_team_settings\".\"host_commands_cap\" IN ('ask', 'allow', 'never')" + } + }, + "isRLSEnabled": false + }, + "public.memory_sources": { + "name": "memory_sources", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_ref": { + "name": "tool_ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "args": { + "name": "args", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "sync_status": { + "name": "sync_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'idle'" + }, + "sync_error": { + "name": "sync_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_sync_at": { + "name": "last_sync_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "next_sync_at": { + "name": "next_sync_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "memory_sources_owner_idx": { + "name": "memory_sources_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_sources_due_idx": { + "name": "memory_sources_due_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_sync_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "memory_sources_owner_user_id_users_id_fk": { + "name": "memory_sources_owner_user_id_users_id_fk", + "tableFrom": "memory_sources", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "memory_sources_agent_id_agents_id_fk": { + "name": "memory_sources_agent_id_agents_id_fk", + "tableFrom": "memory_sources", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.personal_memories": { + "name": "personal_memories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "import_digest": { + "name": "import_digest", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provenance": { + "name": "provenance", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "review_state": { + "name": "review_state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'unreviewed'" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "formed_by": { + "name": "formed_by", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'person'" + }, + "formed_by_agent_id": { + "name": "formed_by_agent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_app": { + "name": "source_app", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_ref": { + "name": "source_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_link": { + "name": "source_link", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "observed_at": { + "name": "observed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "intelligence_memory_id": { + "name": "intelligence_memory_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "intelligence_digest": { + "name": "intelligence_digest", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "personal_memories_owner_idx": { + "name": "personal_memories_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "personal_memories_intelligence_idx": { + "name": "personal_memories_intelligence_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "intelligence_memory_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "personal_memories_formed_idx": { + "name": "personal_memories_formed_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "formed_by_agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "personal_memories_import_idx": { + "name": "personal_memories_import_idx", + "columns": [ + { + "expression": "source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "personal_memories_formed_digest_idx": { + "name": "personal_memories_formed_digest_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "import_digest", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"personal_memories\".\"formed_by\" = 'bot'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "personal_memories_owner_user_id_users_id_fk": { + "name": "personal_memories_owner_user_id_users_id_fk", + "tableFrom": "personal_memories", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "personal_memories_source_id_memory_sources_id_fk": { + "name": "personal_memories_source_id_memory_sources_id_fk", + "tableFrom": "personal_memories", + "tableTo": "memory_sources", + "columnsFrom": [ + "source_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "personal_memories_formed_by_agent_id_agents_id_fk": { + "name": "personal_memories_formed_by_agent_id_agents_id_fk", + "tableFrom": "personal_memories", + "tableTo": "agents", + "columnsFrom": [ + "formed_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.default_opt_outs": { + "name": "default_opt_outs", + "schema": "", + "columns": { + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "feature": { + "name": "feature", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "default_opt_outs_owner_user_id_users_id_fk": { + "name": "default_opt_outs_owner_user_id_users_id_fk", + "tableFrom": "default_opt_outs", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "default_opt_outs_agent_id_agents_id_fk": { + "name": "default_opt_outs_agent_id_agents_id_fk", + "tableFrom": "default_opt_outs", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "default_opt_outs_owner_user_id_agent_id_feature_scope_pk": { + "name": "default_opt_outs_owner_user_id_agent_id_feature_scope_pk", + "columns": [ + "owner_user_id", + "agent_id", + "feature", + "scope" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "default_opt_outs_feature_check": { + "name": "default_opt_outs_feature_check", + "value": "\"default_opt_outs\".\"feature\" IN ('memory_source', 'proactive_research')" + } + }, + "isRLSEnabled": false + }, + "public.proactive_settings": { + "name": "proactive_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "focus": { + "name": "focus", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "interval_minutes": { + "name": "interval_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 240 + }, + "next_run_at": { + "name": "next_run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_status": { + "name": "last_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'idle'" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "proactive_settings_owner_agent_idx": { + "name": "proactive_settings_owner_agent_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "proactive_settings_due_idx": { + "name": "proactive_settings_due_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "proactive_settings_owner_user_id_users_id_fk": { + "name": "proactive_settings_owner_user_id_users_id_fk", + "tableFrom": "proactive_settings", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "proactive_settings_agent_id_agents_id_fk": { + "name": "proactive_settings_agent_id_agents_id_fk", + "tableFrom": "proactive_settings", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "proactive_settings_interval_check": { + "name": "proactive_settings_interval_check", + "value": "\"proactive_settings\".\"interval_minutes\" >= 60 AND \"proactive_settings\".\"interval_minutes\" <= 1440" + }, + "proactive_settings_status_check": { + "name": "proactive_settings_status_check", + "value": "\"proactive_settings\".\"last_status\" IN ('idle', 'running', 'succeeded', 'error')" + } + }, + "isRLSEnabled": false + }, + "public.proactive_suggestions": { + "name": "proactive_suggestions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "setting_id": { + "name": "setting_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "detail": { + "name": "detail", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_app": { + "name": "source_app", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_ref": { + "name": "source_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_link": { + "name": "source_link", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "delivered_at": { + "name": "delivered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "proactive_suggestions_owner_idx": { + "name": "proactive_suggestions_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "proactive_suggestions_run_idx": { + "name": "proactive_suggestions_run_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "proactive_suggestions_owner_user_id_users_id_fk": { + "name": "proactive_suggestions_owner_user_id_users_id_fk", + "tableFrom": "proactive_suggestions", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "proactive_suggestions_agent_id_agents_id_fk": { + "name": "proactive_suggestions_agent_id_agents_id_fk", + "tableFrom": "proactive_suggestions", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "proactive_suggestions_setting_id_proactive_settings_id_fk": { + "name": "proactive_suggestions_setting_id_proactive_settings_id_fk", + "tableFrom": "proactive_suggestions", + "tableTo": "proactive_settings", + "columnsFrom": [ + "setting_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "proactive_suggestions_status_check": { + "name": "proactive_suggestions_status_check", + "value": "\"proactive_suggestions\".\"status\" IN ('open', 'dismissed', 'started')" + } + }, + "isRLSEnabled": false + }, + "public.demonstrations": { + "name": "demonstrations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bot_id": { + "name": "bot_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'recording'" + }, + "actions": { + "name": "actions", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "draft": { + "name": "draft", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "skill_slug": { + "name": "skill_slug", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "demonstrations_owner_idx": { + "name": "demonstrations_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "demonstrations_active_bot_idx": { + "name": "demonstrations_active_bot_idx", + "columns": [ + { + "expression": "bot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"demonstrations\".\"status\" = 'recording'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "demonstrations_owner_user_id_users_id_fk": { + "name": "demonstrations_owner_user_id_users_id_fk", + "tableFrom": "demonstrations", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "demonstrations_bot_id_agents_id_fk": { + "name": "demonstrations_bot_id_agents_id_fk", + "tableFrom": "demonstrations", + "tableTo": "agents", + "columnsFrom": [ + "bot_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.delivery_bindings": { + "name": "delivery_bindings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "transport": { + "name": "transport", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "identity": { + "name": "identity", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "realm": { + "name": "realm", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mention_id": { + "name": "mention_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "opted_out_at": { + "name": "opted_out_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "delivery_binding_identity_idx": { + "name": "delivery_binding_identity_idx", + "columns": [ + { + "expression": "transport", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "realm", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "identity", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "delivery_bindings_owner_idx": { + "name": "delivery_bindings_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "delivery_bindings_owner_user_id_users_id_fk": { + "name": "delivery_bindings_owner_user_id_users_id_fk", + "tableFrom": "delivery_bindings", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "delivery_bindings_channel_id_channels_id_fk": { + "name": "delivery_bindings_channel_id_channels_id_fk", + "tableFrom": "delivery_bindings", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "delivery_bindings_agent_id_agents_id_fk": { + "name": "delivery_bindings_agent_id_agents_id_fk", + "tableFrom": "delivery_bindings", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.delivery_challenges": { + "name": "delivery_challenges", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "transport": { + "name": "transport", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "consumed_at": { + "name": "consumed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "delivery_challenges_owner_user_id_users_id_fk": { + "name": "delivery_challenges_owner_user_id_users_id_fk", + "tableFrom": "delivery_challenges", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "delivery_challenges_channel_id_channels_id_fk": { + "name": "delivery_challenges_channel_id_channels_id_fk", + "tableFrom": "delivery_challenges", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "delivery_challenges_agent_id_agents_id_fk": { + "name": "delivery_challenges_agent_id_agents_id_fk", + "tableFrom": "delivery_challenges", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.delivery_inbox": { + "name": "delivery_inbox", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "binding_id": { + "name": "binding_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "realm": { + "name": "realm", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "delivery_inbox_dedupe_idx": { + "name": "delivery_inbox_dedupe_idx", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "realm", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "delivery_inbox_owner_idx": { + "name": "delivery_inbox_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "delivery_inbox_owner_user_id_users_id_fk": { + "name": "delivery_inbox_owner_user_id_users_id_fk", + "tableFrom": "delivery_inbox", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "delivery_inbox_channel_id_channels_id_fk": { + "name": "delivery_inbox_channel_id_channels_id_fk", + "tableFrom": "delivery_inbox", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "delivery_inbox_agent_id_agents_id_fk": { + "name": "delivery_inbox_agent_id_agents_id_fk", + "tableFrom": "delivery_inbox", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.delivery_outbox": { + "name": "delivery_outbox", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dedupe_key": { + "name": "dedupe_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "binding_id": { + "name": "binding_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "device_id": { + "name": "device_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "transport": { + "name": "transport", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "delivery_outbox_owner_idx": { + "name": "delivery_outbox_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "delivery_outbox_provider_idx": { + "name": "delivery_outbox_provider_idx", + "columns": [ + { + "expression": "transport", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "delivery_outbox_owner_user_id_users_id_fk": { + "name": "delivery_outbox_owner_user_id_users_id_fk", + "tableFrom": "delivery_outbox", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "delivery_outbox_channel_id_channels_id_fk": { + "name": "delivery_outbox_channel_id_channels_id_fk", + "tableFrom": "delivery_outbox", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "delivery_outbox_agent_id_agents_id_fk": { + "name": "delivery_outbox_agent_id_agents_id_fk", + "tableFrom": "delivery_outbox", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "delivery_outbox_dedupe_key_unique": { + "name": "delivery_outbox_dedupe_key_unique", + "nullsNotDistinct": false, + "columns": [ + "dedupe_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.push_devices": { + "name": "push_devices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "platform": { + "name": "platform", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "push_devices_owner_idx": { + "name": "push_devices_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "push_devices_owner_user_id_users_id_fk": { + "name": "push_devices_owner_user_id_users_id_fk", + "tableFrom": "push_devices", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "push_devices_token_unique": { + "name": "push_devices_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.group_bot_threads": { + "name": "group_bot_threads", + "schema": "", + "columns": { + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "group_bot_threads_thread_idx": { + "name": "group_bot_threads_thread_idx", + "columns": [ + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "group_bot_threads_owner_user_id_users_id_fk": { + "name": "group_bot_threads_owner_user_id_users_id_fk", + "tableFrom": "group_bot_threads", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "group_bot_threads_channel_id_channels_id_fk": { + "name": "group_bot_threads_channel_id_channels_id_fk", + "tableFrom": "group_bot_threads", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "group_bot_threads_owner_user_id_channel_id_agent_id_pk": { + "name": "group_bot_threads_owner_user_id_channel_id_agent_id_pk", + "columns": [ + "owner_user_id", + "channel_id", + "agent_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.group_messages": { + "name": "group_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'completed'" + }, + "details": { + "name": "details", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "group_messages_channel_idx": { + "name": "group_messages_channel_idx", + "columns": [ + { + "expression": "channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "group_messages_channel_id_channels_id_fk": { + "name": "group_messages_channel_id_channels_id_fk", + "tableFrom": "group_messages", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "group_messages_owner_user_id_users_id_fk": { + "name": "group_messages_owner_user_id_users_id_fk", + "tableFrom": "group_messages", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "group_messages_status_check": { + "name": "group_messages_status_check", + "value": "\"group_messages\".\"status\" IN ('queued', 'running', 'waiting', 'completed', 'failed')" + } + }, + "isRLSEnabled": false + }, + "public.action_records": { + "name": "action_records", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "surface": { + "name": "surface", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bot_id": { + "name": "bot_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "command": { + "name": "command", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "action_records_created_idx": { + "name": "action_records_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "action_records_bot_idx": { + "name": "action_records_bot_idx", + "columns": [ + { + "expression": "bot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "action_records_surface_check": { + "name": "action_records_surface_check", + "value": "\"action_records\".\"surface\" IN ('cloud', 'local')" + } + }, + "isRLSEnabled": false + }, + "public.capability_settings": { + "name": "capability_settings", + "schema": "", + "columns": { + "scope_kind": { + "name": "scope_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_id": { + "name": "scope_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "capability": { + "name": "capability", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "allowed": { + "name": "allowed", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "capability_settings_scope_kind_scope_id_capability_pk": { + "name": "capability_settings_scope_kind_scope_id_capability_pk", + "columns": [ + "scope_kind", + "scope_id", + "capability" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "capability_settings_scope_kind_check": { + "name": "capability_settings_scope_kind_check", + "value": "\"capability_settings\".\"scope_kind\" IN ('organization', 'role', 'group')" + } + }, + "isRLSEnabled": false + }, + "public.enterprise_settings": { + "name": "enterprise_settings", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "value": { + "name": "value", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.model_usage": { + "name": "model_usage", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "allowed": { + "name": "allowed", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "model_usage_created_idx": { + "name": "model_usage_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "model_usage_model_idx": { + "name": "model_usage_model_idx", + "columns": [ + { + "expression": "model", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "model_usage_source_check": { + "name": "model_usage_source_check", + "value": "\"model_usage\".\"source\" IN ('configured', 'observed', 'unknown')" + } + }, + "isRLSEnabled": false + }, + "public.network_policies": { + "name": "network_policies", + "schema": "", + "columns": { + "scope_kind": { + "name": "scope_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_id": { + "name": "scope_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "rules": { + "name": "rules", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{\"entries\":[]}'::jsonb" + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "network_policies_scope_kind_scope_id_pk": { + "name": "network_policies_scope_kind_scope_id_pk", + "columns": [ + "scope_kind", + "scope_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "network_policies_scope_kind_check": { + "name": "network_policies_scope_kind_check", + "value": "\"network_policies\".\"scope_kind\" IN ('organization', 'group')" + }, + "network_policies_mode_check": { + "name": "network_policies_mode_check", + "value": "\"network_policies\".\"mode\" IN ('allow_all', 'defaults_plus_allowlist', 'allowlist_only')" + } + }, + "isRLSEnabled": false + }, + "public.scim_connection_bindings": { + "name": "scim_connection_bindings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connection_key": { + "name": "connection_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provisioning_domain_id": { + "name": "provisioning_domain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "decommissioned_at": { + "name": "decommissioned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "decommission_status": { + "name": "decommission_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "decommission_cursor_user_id": { + "name": "decommission_cursor_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "decommission_reconciled_user_count": { + "name": "decommission_reconciled_user_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "decommission_batch_count": { + "name": "decommission_batch_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "decommission_revision": { + "name": "decommission_revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "decommission_completed_at": { + "name": "decommission_completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "decommission_lease_id": { + "name": "decommission_lease_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "decommission_lease_expires_at": { + "name": "decommission_lease_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "scim_connection_bindings_connection_idx": { + "name": "scim_connection_bindings_connection_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_connection_bindings_connection_key_unique": { + "name": "scim_connection_bindings_connection_key_unique", + "nullsNotDistinct": false, + "columns": [ + "connection_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_group_members": { + "name": "scim_group_members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scim_user_id": { + "name": "scim_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "membership_key": { + "name": "membership_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "scim_group_members_connection_idx": { + "name": "scim_group_members_connection_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_group_members_group_idx": { + "name": "scim_group_members_group_idx", + "columns": [ + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_group_members_scim_user_idx": { + "name": "scim_group_members_scim_user_idx", + "columns": [ + { + "expression": "scim_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_group_members_group_id_scim_groups_id_fk": { + "name": "scim_group_members_group_id_scim_groups_id_fk", + "tableFrom": "scim_group_members", + "tableTo": "scim_groups", + "columnsFrom": [ + "group_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "scim_group_members_scim_user_id_scim_users_id_fk": { + "name": "scim_group_members_scim_user_id_scim_users_id_fk", + "tableFrom": "scim_group_members", + "tableTo": "scim_users", + "columnsFrom": [ + "scim_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_group_members_membership_key_unique": { + "name": "scim_group_members_membership_key_unique", + "nullsNotDistinct": false, + "columns": [ + "membership_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_groups": { + "name": "scim_groups", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provisioning_domain_id": { + "name": "provisioning_domain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name_key": { + "name": "display_name_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id_key": { + "name": "external_id_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "order_key": { + "name": "order_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "scim_groups_connection_idx": { + "name": "scim_groups_connection_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_groups_domain_idx": { + "name": "scim_groups_domain_idx", + "columns": [ + { + "expression": "provisioning_domain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_groups_display_name_key_unique": { + "name": "scim_groups_display_name_key_unique", + "nullsNotDistinct": false, + "columns": [ + "display_name_key" + ] + }, + "scim_groups_external_id_key_unique": { + "name": "scim_groups_external_id_key_unique", + "nullsNotDistinct": false, + "columns": [ + "external_id_key" + ] + }, + "scim_groups_order_key_unique": { + "name": "scim_groups_order_key_unique", + "nullsNotDistinct": false, + "columns": [ + "order_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_identity_tombstones": { + "name": "scim_identity_tombstones", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provisioning_domain_id": { + "name": "provisioning_domain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id_key": { + "name": "external_id_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "profile": { + "name": "profile", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "scim_identity_tombstones_connection_idx": { + "name": "scim_identity_tombstones_connection_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_identity_tombstones_domain_idx": { + "name": "scim_identity_tombstones_domain_idx", + "columns": [ + { + "expression": "provisioning_domain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_identity_tombstones_user_idx": { + "name": "scim_identity_tombstones_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_identity_tombstones_user_id_users_id_fk": { + "name": "scim_identity_tombstones_user_id_users_id_fk", + "tableFrom": "scim_identity_tombstones", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_identity_tombstones_external_id_key_unique": { + "name": "scim_identity_tombstones_external_id_key_unique", + "nullsNotDistinct": false, + "columns": [ + "external_id_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_projection_grants": { + "name": "scim_projection_grants", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provisioning_domain_id": { + "name": "provisioning_domain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scim_user_id": { + "name": "scim_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_kind": { + "name": "source_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_value": { + "name": "source_value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "grant_key": { + "name": "grant_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "scim_projection_grants_connection_idx": { + "name": "scim_projection_grants_connection_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_projection_grants_domain_idx": { + "name": "scim_projection_grants_domain_idx", + "columns": [ + { + "expression": "provisioning_domain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_projection_grants_scim_user_idx": { + "name": "scim_projection_grants_scim_user_idx", + "columns": [ + { + "expression": "scim_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_projection_grants_user_idx": { + "name": "scim_projection_grants_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_projection_grants_scim_user_id_scim_users_id_fk": { + "name": "scim_projection_grants_scim_user_id_scim_users_id_fk", + "tableFrom": "scim_projection_grants", + "tableTo": "scim_users", + "columnsFrom": [ + "scim_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "scim_projection_grants_user_id_users_id_fk": { + "name": "scim_projection_grants_user_id_users_id_fk", + "tableFrom": "scim_projection_grants", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_projection_grants_grant_key_unique": { + "name": "scim_projection_grants_grant_key_unique", + "nullsNotDistinct": false, + "columns": [ + "grant_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_subjects": { + "name": "scim_subjects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "profile_source_id": { + "name": "profile_source_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "scim_subjects_profile_source_idx": { + "name": "scim_subjects_profile_source_idx", + "columns": [ + { + "expression": "profile_source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_subjects_user_id_users_id_fk": { + "name": "scim_subjects_user_id_users_id_fk", + "tableFrom": "scim_subjects", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_subjects_user_id_unique": { + "name": "scim_subjects_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_users": { + "name": "scim_users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provisioning_domain_id": { + "name": "provisioning_domain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connection_user_key": { + "name": "connection_user_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_name": { + "name": "user_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_name_key": { + "name": "user_name_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "primary_email": { + "name": "primary_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "work_email_value_index": { + "name": "work_email_value_index", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_value_index": { + "name": "email_value_index", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "formatted_name": { + "name": "formatted_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "given_name": { + "name": "given_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "family_name": { + "name": "family_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "serialized_emails": { + "name": "serialized_emails", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "serialized_attributes": { + "name": "serialized_attributes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id_key": { + "name": "external_id_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "order_key": { + "name": "order_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "scim_users_connection_idx": { + "name": "scim_users_connection_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_users_domain_idx": { + "name": "scim_users_domain_idx", + "columns": [ + { + "expression": "provisioning_domain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_users_user_idx": { + "name": "scim_users_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_users_user_id_users_id_fk": { + "name": "scim_users_user_id_users_id_fk", + "tableFrom": "scim_users", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_users_connection_user_key_unique": { + "name": "scim_users_connection_user_key_unique", + "nullsNotDistinct": false, + "columns": [ + "connection_user_key" + ] + }, + "scim_users_user_name_key_unique": { + "name": "scim_users_user_name_key_unique", + "nullsNotDistinct": false, + "columns": [ + "user_name_key" + ] + }, + "scim_users_external_id_key_unique": { + "name": "scim_users_external_id_key_unique", + "nullsNotDistinct": false, + "columns": [ + "external_id_key" + ] + }, + "scim_users_order_key_unique": { + "name": "scim_users_order_key_unique", + "nullsNotDistinct": false, + "columns": [ + "order_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.bot_lifecycle": { + "name": "bot_lifecycle", + "schema": "", + "columns": { + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "paused_at": { + "name": "paused_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "notify": { + "name": "notify", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'all'" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "bot_lifecycle_owner_user_id_users_id_fk": { + "name": "bot_lifecycle_owner_user_id_users_id_fk", + "tableFrom": "bot_lifecycle", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "bot_lifecycle_agent_id_agents_id_fk": { + "name": "bot_lifecycle_agent_id_agents_id_fk", + "tableFrom": "bot_lifecycle", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "bot_lifecycle_owner_user_id_agent_id_pk": { + "name": "bot_lifecycle_owner_user_id_agent_id_pk", + "columns": [ + "owner_user_id", + "agent_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.update_routing_preferences": { + "name": "update_routing_preferences", + "schema": "", + "columns": { + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "transports": { + "name": "transports", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "update_routing_preferences_owner_user_id_users_id_fk": { + "name": "update_routing_preferences_owner_user_id_users_id_fk", + "tableFrom": "update_routing_preferences", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "update_routing_preferences_owner_user_id_kind_pk": { + "name": "update_routing_preferences_owner_user_id_kind_pk", + "columns": [ + "owner_user_id", + "kind" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.saved_logins": { + "name": "saved_logins", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_password": { + "name": "encrypted_password", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "saved_logins_owner_origin_username_idx": { + "name": "saved_logins_owner_origin_username_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "origin", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "username", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "saved_logins_owner_idx": { + "name": "saved_logins_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "saved_logins_owner_user_id_users_id_fk": { + "name": "saved_logins_owner_user_id_users_id_fk", + "tableFrom": "saved_logins", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sign_in_requests": { + "name": "sign_in_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bot_id": { + "name": "bot_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "method": { + "name": "method", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tool_call_id": { + "name": "tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "control_request_id": { + "name": "control_request_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "continuation": { + "name": "continuation", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "filling_until": { + "name": "filling_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "sign_in_requests_owner_idx": { + "name": "sign_in_requests_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sign_in_requests_owner_user_id_users_id_fk": { + "name": "sign_in_requests_owner_user_id_users_id_fk", + "tableFrom": "sign_in_requests", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "sign_in_requests_status_check": { + "name": "sign_in_requests_status_check", + "value": "\"sign_in_requests\".\"status\" IN ('pending', 'filling', 'code_required', 'taken_over', 'signed_in', 'failed', 'cancelled', 'expired')" + }, + "sign_in_requests_method_check": { + "name": "sign_in_requests_method_check", + "value": "\"sign_in_requests\".\"method\" IS NULL OR \"sign_in_requests\".\"method\" IN ('typed', 'saved', 'takeover')" + } + }, + "isRLSEnabled": false + }, + "public.team_bot_assignments": { + "name": "team_bot_assignments", + "schema": "", + "columns": { + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "group_name": { + "name": "group_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "assigned_by": { + "name": "assigned_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "assigned_at": { + "name": "assigned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "team_bot_assignments_agent_id_agents_id_fk": { + "name": "team_bot_assignments_agent_id_agents_id_fk", + "tableFrom": "team_bot_assignments", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "team_bot_assignments_assigned_by_users_id_fk": { + "name": "team_bot_assignments_assigned_by_users_id_fk", + "tableFrom": "team_bot_assignments", + "tableTo": "users", + "columnsFrom": [ + "assigned_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "team_bot_assignments_agent_id_group_name_pk": { + "name": "team_bot_assignments_agent_id_group_name_pk", + "columns": [ + "agent_id", + "group_name" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.team_bot_audience": { + "name": "team_bot_audience", + "schema": "", + "columns": { + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "team_bot_audience_agent_id_agents_id_fk": { + "name": "team_bot_audience_agent_id_agents_id_fk", + "tableFrom": "team_bot_audience", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "team_bot_audience_agent_id_kind_value_pk": { + "name": "team_bot_audience_agent_id_kind_value_pk", + "columns": [ + "agent_id", + "kind", + "value" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "team_bot_audience_kind_check": { + "name": "team_bot_audience_kind_check", + "value": "\"team_bot_audience\".\"kind\" IN ('user', 'group')" + } + }, + "isRLSEnabled": false + }, + "public.team_bot_consent_defaults": { + "name": "team_bot_consent_defaults", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "team_bot_consent_defaults_user_id_users_id_fk": { + "name": "team_bot_consent_defaults_user_id_users_id_fk", + "tableFrom": "team_bot_consent_defaults", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "team_bot_consent_defaults_user_id_server_id_pk": { + "name": "team_bot_consent_defaults_user_id_server_id_pk", + "columns": [ + "user_id", + "server_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.team_bot_consents": { + "name": "team_bot_consents", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "decision": { + "name": "decision", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "team_bot_consents_user_id_users_id_fk": { + "name": "team_bot_consents_user_id_users_id_fk", + "tableFrom": "team_bot_consents", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "team_bot_consents_agent_id_agents_id_fk": { + "name": "team_bot_consents_agent_id_agents_id_fk", + "tableFrom": "team_bot_consents", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "team_bot_consents_user_id_agent_id_server_id_pk": { + "name": "team_bot_consents_user_id_agent_id_server_id_pk", + "columns": [ + "user_id", + "agent_id", + "server_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "team_bot_consents_decision_check": { + "name": "team_bot_consents_decision_check", + "value": "\"team_bot_consents\".\"decision\" IN ('always', 'once')" + } + }, + "isRLSEnabled": false + }, + "public.team_bot_publications": { + "name": "team_bot_publications", + "schema": "", + "columns": { + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "published_by": { + "name": "published_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "audience": { + "name": "audience", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "team_bot_publications_agent_id_agents_id_fk": { + "name": "team_bot_publications_agent_id_agents_id_fk", + "tableFrom": "team_bot_publications", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "team_bot_publications_published_by_users_id_fk": { + "name": "team_bot_publications_published_by_users_id_fk", + "tableFrom": "team_bot_publications", + "tableTo": "users", + "columnsFrom": [ + "published_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "team_bot_publications_audience_check": { + "name": "team_bot_publications_audience_check", + "value": "\"team_bot_publications\".\"audience\" IN ('team', 'people')" + } + }, + "isRLSEnabled": false + }, + "public.shared_use_approval_members": { + "name": "shared_use_approval_members", + "schema": "", + "columns": { + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "shared_use_approval_members_approval_fk": { + "name": "shared_use_approval_members_approval_fk", + "tableFrom": "shared_use_approval_members", + "tableTo": "shared_use_approvals", + "columnsFrom": [ + "agent_id", + "server_id" + ], + "columnsTo": [ + "agent_id", + "server_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "shared_use_approval_members_agent_id_server_id_kind_value_pk": { + "name": "shared_use_approval_members_agent_id_server_id_kind_value_pk", + "columns": [ + "agent_id", + "server_id", + "kind", + "value" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "shared_use_approval_members_kind_check": { + "name": "shared_use_approval_members_kind_check", + "value": "\"shared_use_approval_members\".\"kind\" IN ('user', 'group')" + } + }, + "isRLSEnabled": false + }, + "public.shared_use_approvals": { + "name": "shared_use_approvals", + "schema": "", + "columns": { + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "audience": { + "name": "audience", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "outside_input": { + "name": "outside_input", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "approved_by": { + "name": "approved_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "approved_at": { + "name": "approved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "shared_use_approvals_agent_id_agents_id_fk": { + "name": "shared_use_approvals_agent_id_agents_id_fk", + "tableFrom": "shared_use_approvals", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_use_approvals_server_id_mcp_servers_id_fk": { + "name": "shared_use_approvals_server_id_mcp_servers_id_fk", + "tableFrom": "shared_use_approvals", + "tableTo": "mcp_servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "shared_use_approvals_agent_id_server_id_pk": { + "name": "shared_use_approvals_agent_id_server_id_pk", + "columns": [ + "agent_id", + "server_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "shared_use_approvals_audience_check": { + "name": "shared_use_approvals_audience_check", + "value": "\"shared_use_approvals\".\"audience\" IN ('owner', 'people', 'team')" + } + }, + "isRLSEnabled": false + }, + "public.shared_use_requests": { + "name": "shared_use_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "proposed_audience": { + "name": "proposed_audience", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "proposed_outside_input": { + "name": "proposed_outside_input", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "proposed_members": { + "name": "proposed_members", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "requested_by": { + "name": "requested_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "decided_by": { + "name": "decided_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "decided_at": { + "name": "decided_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "shared_use_requests_pending_idx": { + "name": "shared_use_requests_pending_idx", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"shared_use_requests\".\"status\" = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "shared_use_requests_status_idx": { + "name": "shared_use_requests_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "shared_use_requests_agent_id_agents_id_fk": { + "name": "shared_use_requests_agent_id_agents_id_fk", + "tableFrom": "shared_use_requests", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_use_requests_server_id_mcp_servers_id_fk": { + "name": "shared_use_requests_server_id_mcp_servers_id_fk", + "tableFrom": "shared_use_requests", + "tableTo": "mcp_servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "shared_use_requests_audience_check": { + "name": "shared_use_requests_audience_check", + "value": "\"shared_use_requests\".\"proposed_audience\" IN ('owner', 'people', 'team')" + }, + "shared_use_requests_reason_check": { + "name": "shared_use_requests_reason_check", + "value": "\"shared_use_requests\".\"reason\" IN ('publish', 'trigger', 'grant', 'refused_call')" + }, + "shared_use_requests_status_check": { + "name": "shared_use_requests_status_check", + "value": "\"shared_use_requests\".\"status\" IN ('pending', 'approved', 'declined', 'superseded')" + } + }, + "isRLSEnabled": false + }, + "public.identity_link_challenges": { + "name": "identity_link_challenges", + "schema": "", + "columns": { + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "identity_link_challenges_expiry_idx": { + "name": "identity_link_challenges_expiry_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "identity_link_challenges_user_provider_idx": { + "name": "identity_link_challenges_user_provider_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.identity_links": { + "name": "identity_links", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "realm": { + "name": "realm", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "handle": { + "name": "handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "verified_by": { + "name": "verified_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "identity_links_identity_idx": { + "name": "identity_links_identity_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "realm", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "identity_links_user_realm_idx": { + "name": "identity_links_user_realm_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "realm", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "identity_links_credential_idx": { + "name": "identity_links_credential_idx", + "columns": [ + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"identity_links\".\"credential_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "identity_links_credential_id_credentials_id_fk": { + "name": "identity_links_credential_id_credentials_id_fk", + "tableFrom": "identity_links", + "tableTo": "credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "identity_links_verified_by_check": { + "name": "identity_links_verified_by_check", + "value": "\"identity_links\".\"verified_by\" IN ('challenge', 'oauth')" + }, + "identity_links_status_check": { + "name": "identity_links_status_check", + "value": "\"identity_links\".\"status\" IN ('active', 'needs_reconnect')" + } + }, + "isRLSEnabled": false + } + }, + "enums": { + "public.agent_type": { + "name": "agent_type", + "schema": "public", + "values": [ + "built_in", + "remote_ag_ui", + "remote_mastra" + ] + }, + "public.credential_kind": { + "name": "credential_kind", + "schema": "public", + "values": [ + "model", + "connector", + "agent", + "mcp", + "mcp_oauth_client", + "mcp_user_token" + ] + }, + "public.role": { + "name": "role", + "schema": "public", + "values": [ + "admin", + "user" + ] + }, + "public.agent_visibility": { + "name": "agent_visibility", + "schema": "public", + "values": [ + "public", + "private" + ] + }, + "public.routine_run_status": { + "name": "routine_run_status", + "schema": "public", + "values": [ + "succeeded", + "failed", + "skipped", + "waiting" + ] + }, + "public.responsibility_run_status": { + "name": "responsibility_run_status", + "schema": "public", + "values": [ + "queued", + "running", + "waiting", + "succeeded", + "failed", + "skipped" + ] + }, + "public.responsibility_status": { + "name": "responsibility_status", + "schema": "public", + "values": [ + "active", + "paused", + "completed" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/server/drizzle/meta/_journal.json b/server/drizzle/meta/_journal.json index 70e4dfa608..44b89e0004 100644 --- a/server/drizzle/meta/_journal.json +++ b/server/drizzle/meta/_journal.json @@ -400,6 +400,13 @@ "when": 1791866936937, "tag": "0056_sign_in_code_step", "breakpoints": true + }, + { + "idx": 57, + "version": "7", + "when": 1792533008204, + "tag": "0057_app_access_requests", + "breakpoints": true } ] } \ No newline at end of file diff --git a/server/src/audit.ts b/server/src/audit.ts index 356e0f0a8e..ebe3e27054 100644 --- a/server/src/audit.ts +++ b/server/src/audit.ts @@ -244,6 +244,18 @@ export const auditEventTypes = [ "shared_use.approved", // An administrator answered a request to use a Shared app through a Bot. "shared_use.declined", + /* + * A person asking an administrator to switch a work app on, grant it to a Bot, or connect a + * Shared app's team account (`app_access_requests`), and what became of it: cancelled by the + * person, approved or dismissed by an administrator, or refused — a request that could not be + * filed, or an approval that could not be carried out. Target the app key; payload `request`, + * `app`, `bot`, `kind`, and for a refusal `reason`. + */ + "app_access.requested", + "app_access.cancelled", + "app_access.approved", + "app_access.dismissed", + "app_access.refused", // Every action a Bot takes on its computer, allowed or refused. Both, always: a trail that records // only what was permitted cannot answer whether the Bot tried. "computer.action_allowed", diff --git a/server/src/db/schema/plugins.ts b/server/src/db/schema/plugins.ts index 7d2d7d2e00..0f35358638 100644 --- a/server/src/db/schema/plugins.ts +++ b/server/src/db/schema/plugins.ts @@ -688,3 +688,73 @@ export const sandboxedComponents = pgTable("sandboxed_components", { createdAt: createdAt(), updatedAt: updatedAt(), }); + +/** + * A person asking an administrator to make one work app usable through one Bot. + * + * WHAT IS ASKED FOR IS THE SERVER'S, NEVER THE BROWSER'S. `kind` is derived from the app's state for + * the person and the Bot at the moment of asking — switched off is `enable`, held by no grant is + * `grant`, a Shared app with no team account is `connect_shared` — so a request cannot be worded as + * something the deployment does not actually need, and approving it does exactly the one thing + * that state was missing. + * + * `app_key` IS THE SERVER ID THE APP HAS OR WOULD HAVE, and is not a foreign key. A request to + * switch an app on names an app with no `mcp_servers` row yet — `composio-` for a Composio app, + * the catalogue key for one this deployment ships — and is the row's id the moment it is enabled, + * so one key follows the app across the change being asked for. `app_title` is the app's name as + * the server resolved it when the request was filed, so an administrator's list can name an app + * that has no row to read the name from. + * + * `agent_id` IS NULLABLE FOR A REQUEST ABOUT THE APP ALONE. Every path that files one today names + * the Bot, so approving a request to switch an app on can grant it to that Bot in the same step; + * the column admits none because "switch this on" is a complete question without one. + * + * ONE PENDING REQUEST PER (PERSON, APP, BOT, KIND), held by the partial unique index rather than by + * a read-then-write, so two replicas filing the same request at once cannot both insert. The Bot is + * read through `coalesce` because Postgres treats two NULLs as distinct, and an index on the bare + * column would let the same app-only request pile up. + * + * NO ROW IS DELETED WHEN IT IS ANSWERED. Approved, dismissed and cancelled requests stay, with who + * resolved them and when; the trail says the same thing in its own rows. + */ +export const appAccessRequests = pgTable( + "app_access_requests", + { + id: text("id").primaryKey(), + requestedBy: text("requested_by") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + agentId: text("agent_id").references(() => agents.id, { + onDelete: "cascade", + }), + appKey: text("app_key").notNull(), + appTitle: text("app_title").notNull(), + kind: text("kind").$type<"enable" | "grant" | "connect_shared">().notNull(), + status: text("status") + .$type<"pending" | "approved" | "dismissed" | "cancelled">() + .notNull() + .default("pending"), + createdAt: createdAt(), + resolvedAt: timestamp("resolved_at", { withTimezone: true }), + resolvedBy: text("resolved_by"), + }, + (table) => [ + uniqueIndex("app_access_requests_pending_idx") + .on( + table.requestedBy, + table.appKey, + sql`coalesce(${table.agentId}, '')`, + table.kind, + ) + .where(sql`${table.status} = 'pending'`), + index("app_access_requests_status_idx").on(table.status, table.appKey), + check( + "app_access_requests_kind_check", + sql`${table.kind} IN ('enable', 'grant', 'connect_shared')`, + ), + check( + "app_access_requests_status_check", + sql`${table.status} IN ('pending', 'approved', 'dismissed', 'cancelled')`, + ), + ], +); diff --git a/server/src/plugins/app-access.ts b/server/src/plugins/app-access.ts new file mode 100644 index 0000000000..cf8d6f7948 --- /dev/null +++ b/server/src/plugins/app-access.ts @@ -0,0 +1,222 @@ +import { schemeKind } from "./broker"; +import type { CatalogueEntry } from "./catalogue"; + +/** + * Whether one person can use one work app through one Bot, and if not, what the next step is and + * whose it is. + * + * WHY THIS EXISTS AS ONE ANSWER. Every fact it reads already had a home: whether the app is + * enabled is a row in `mcp_servers`, whether Composio is configured is a key in the environment, + * whether the Bot holds the app is `plugin_grants`, whether the person connected is + * `brokered_connections` or `mcp_user_credentials`, and whether a Shared app has its team account + * is a deployment row in `brokered_connections`. Each screen that wanted to say "you can use this" + * re-derived it from whichever of those it had loaded, and the Bot page had none of the + * connection facts at all — so it listed what a Bot holds with no word about whether the person + * looking could use any of it. Resolved here, once, from the same tables every gate reads, and sent + * to the browser as a word it draws rather than a rule it recomputes. + * + * NOTHING HERE ASKS A VENDOR. "Connected" is what this deployment last recorded — the `verified` + * and `probe` columns a connect or a re-check wrote, the `revoked_at` a withdrawal set — exactly + * as `GET /api/plugins/connections` reports it. A connection that died at the vendor since then + * reads as `ready` until the next call or re-check finds out; asking the vendor on every page load + * would be a network call per app per person per render, which is the cost those columns exist to + * avoid. + */ + +/** + * The six things a person can be told about one app on one Bot. + * + * `ready` — usable now, as far as this deployment knows. + * `not_connected` — enabled and held, and this app is used under each person's own account, which + * this person has not connected. Theirs to fix. + * `reconnect` — they connected once, and the connection is withdrawn or its key was refused. Theirs + * to fix. + * `shared_not_connected` — a Shared app whose one team account is not connected, or whose key was + * refused. Only an administrator can connect it. + * `disabled` — switched off at the deployment: no row, Composio not configured, or an app that + * still needs something only an administrator can supply. + * `not_granted` — enabled, and this Bot holds none of its tools. Only an administrator can grant. + */ +export const APP_ACCESS_STATES = [ + "ready", + "not_connected", + "reconnect", + "shared_not_connected", + "disabled", + "not_granted", +] as const; + +export type AppAccessState = (typeof APP_ACCESS_STATES)[number]; + +/** What an administrator would be asked to do. See `app_access_requests`. */ +export type AppAccessRequestKind = "enable" | "grant" | "connect_shared"; + +/** + * Which request each state files, and null where the next step is the person's own. + * + * A TABLE RATHER THAN A BRANCH, so the compiler asks the question again for any state added later. + * The browser never names a kind: it asks for access, and the server files the kind this table + * names for the state it computed. + */ +export const REQUEST_KIND_FOR: Record< + AppAccessState, + AppAccessRequestKind | null +> = { + ready: null, + not_connected: null, + reconnect: null, + shared_not_connected: "connect_shared", + disabled: "enable", + not_granted: "grant", +}; + +/** Whose account the app's calls land in. */ +export type AppAccount = + /** Each person's own, which each person connects. */ + | "personal" + /** One team account the deployment holds, which only an administrator connects. */ + | "shared" + /** No person's account: a deployment token, a server added by URL, or no credential at all. */ + | "deployment"; + +/** + * How the account is connected, for the screen that starts it: the deployment's own OAuth flow, a + * Composio consent screen, a Composio key form, or nothing to connect. + */ +export type AppConnect = "oauth" | "consent" | "key" | null; + +/** Where the app comes from, which decides what enabling it takes. */ +export type AppProvider = "composio" | "catalogue" | "custom"; + +/** One app as the Bot page and the chat card draw it. */ +export type AppAccess = { + /** The server id the app has, or the one enabling it would mint. What a request names. */ + key: string; + /** + * The app's own name — "Gmail", never "Composio Gmail" and never a humanised `composio-gmail`. + * A Composio row's title is the directory's name for the app; a catalogue row's is the entry's. + */ + displayName: string; + logo: string | null; + provider: AppProvider; + account: AppAccount; + connect: AppConnect; + /** The row's recorded scheme, for a brokered app; null otherwise. */ + authScheme: string | null; + /** The app's mode, off its answering row; null for an app that is not brokered. */ + accountMode: "personal" | "shared" | null; + state: AppAccessState; + /** Whether this Bot holds any of the app's tools. */ + granted: boolean; + /** The request a press of Request access files for this state, and null where there is none. */ + requestKind: AppAccessRequestKind | null; + /** + * This person's pending request for exactly that kind, if they already asked. A pending request + * of another kind is left out: it asks for something this state no longer needs. + */ + request: { id: string; kind: AppAccessRequestKind; createdAt: string } | null; +}; + +/** + * Everything the state is decided from, gathered by the store. Each field is a fact some table + * already holds; nothing here is a judgement. + */ +export type AppAccessFacts = { + provider: AppProvider; + /** Whether a row stands for the app at all. */ + present: boolean; + /** Whether this deployment has a Composio key, for a brokered app. */ + composioConfigured: boolean; + /** The scheme the app's answering row records, for a brokered app. */ + authScheme: string | null; + accountMode: "personal" | "shared" | null; + /** The catalogue entry's auth, for an app this deployment ships. */ + catalogueAuth: CatalogueEntry["auth"] | null; + /** Whether the row holds a credential: the OAuth client, or the deployment's token. */ + hasCredential: boolean; + granted: boolean; + /** This person's own connection, or null where they have none. */ + personal: { usable: boolean } | null; + /** The deployment's account at a Shared app, or null where none is connected. */ + shared: { usable: boolean } | null; +}; + +/** Whose account, from the same facts `accessFor` resolves a call with. */ +export function accountOf(facts: AppAccessFacts): AppAccount { + if (facts.provider === "composio") { + if (schemeKind(facts.authScheme) === "none") return "deployment"; + return facts.accountMode === "shared" ? "shared" : "personal"; + } + if (facts.provider === "catalogue") + return facts.catalogueAuth?.kind === "user-oauth" + ? "personal" + : "deployment"; + return "deployment"; +} + +export function connectOf(facts: AppAccessFacts): AppConnect { + if (facts.provider === "composio") { + const kind = schemeKind(facts.authScheme); + return kind === "consent" || kind === "key" ? kind : null; + } + return facts.catalogueAuth?.kind === "user-oauth" ? "oauth" : null; +} + +/** + * Whether the deployment has what it needs for anybody to use the app, before any grant or + * connection is asked about; null when it does, and the reason when it does not. + * + * THE REASONS ARE THE ONES APPROVING AN `enable` REQUEST HAS TO ANSWER, so the approve route reads + * this too and refuses with the step that is missing rather than recording an approval nothing + * changed. + */ +export type Unavailable = + | "absent" + | "composio_unconfigured" + | "scheme_unreadable" + | "oauth_client_missing" + | "token_missing"; + +export function unavailableReason(facts: AppAccessFacts): Unavailable | null { + if (!facts.present) return "absent"; + if (facts.provider === "composio") { + if (!facts.composioConfigured) return "composio_unconfigured"; + if (schemeKind(facts.authScheme) === "unreadable") + return "scheme_unreadable"; + return null; + } + const auth = facts.catalogueAuth; + if (auth?.kind === "user-oauth") { + /* + * A client that registers itself on first use is ready the moment the row exists; a manual + * one waits for an administrator to paste it in, and until then nobody can connect — the + * connect route answers that with the same sentence for everybody. + */ + if (auth.clientRegistration !== "dynamic" && !facts.hasCredential) + return "oauth_client_missing"; + return null; + } + if (auth?.kind === "deployment-bearer" && !facts.hasCredential) + return "token_missing"; + return null; +} + +/** + * The rule, in the order a person meets the obstacles: the deployment first, then the Bot, then + * their own account. + * + * A KEY THE VENDOR REFUSED READS AS `reconnect`, AND AN UNCHECKED ONE AS `ready`. The store + * reduces each connection to `usable` the way `brokeredConnectionsFor` sets out its four states: a + * probe that ran and was refused is a dead key; no probe at all is a key accepted without being + * tried, which is exactly what the settings page says about it too. + */ +export function appAccessState(facts: AppAccessFacts): AppAccessState { + if (unavailableReason(facts) !== null) return "disabled"; + if (!facts.granted) return "not_granted"; + const account = accountOf(facts); + if (account === "deployment") return "ready"; + if (account === "shared") + return facts.shared?.usable ? "ready" : "shared_not_connected"; + if (!facts.personal) return "not_connected"; + return facts.personal.usable ? "ready" : "reconnect"; +} diff --git a/server/src/plugins/plugin-store/app-access.ts b/server/src/plugins/plugin-store/app-access.ts new file mode 100644 index 0000000000..18beb5334b --- /dev/null +++ b/server/src/plugins/plugin-store/app-access.ts @@ -0,0 +1,782 @@ +import { randomUUID } from "node:crypto"; +import { and, asc, desc, eq, inArray, isNull, or, type SQL } from "drizzle-orm"; +import { type AuditEventInput, recordAuditEvent } from "../../audit"; +import { + agents, + appAccessRequests, + brokeredConnections, + credentials as credentialRows, + mcpServers, + mcpTools, + mcpUserCredentials, + pluginGrants, + users, +} from "../../db/schema"; +import { + type AppAccess, + type AppAccessFacts, + type AppAccessRequestKind, + type AppProvider, + accountOf, + appAccessState, + connectOf, + REQUEST_KIND_FOR, + type Unavailable, + unavailableReason, +} from "../app-access"; +import type { BrokerApp } from "../broker"; +import { CATALOGUE, type CatalogueEntry, catalogueEntry } from "../catalogue"; +import { toolkitOf } from "../composio"; +import type { StoreContext } from "./context"; +import { iso } from "./model"; + +/** + * One app as this module resolves it before anybody's connection is asked about: which rows are + * it, which of them answers for it, and what the deployment holds for it. + * + * `rowIds` IS EVERY ROW THAT IS THIS APP, so a grant made through a duplicate row at a Composio + * app's url counts as holding the app — the same "the apps, not the rows" rule + * `sharedAppsHeldBy` follows. `answeringId` is the one row whose columns decide, and the one a + * grant made by approving a request is written against. + */ +type AppCandidate = { + key: string; + title: string; + logo: string | null; + provider: AppProvider; + present: boolean; + answeringId: string | null; + rowIds: string[]; + toolkit: string | null; + entry: CatalogueEntry | null; + authScheme: string | null; + accountMode: "personal" | "shared" | null; + hasCredential: boolean; +}; + +type ServerRow = { + id: string; + title: string; + logo: string | null; + url: string; + provenance: string; + authScheme: string | null; + accountMode: "personal" | "shared" | null; + credentialId: string | null; +}; + +/** The directory, asked only to name an app this deployment has no row for. */ +export type AppDirectory = () => Promise; + +/** What one app is for the deployment and, where a person was named, for them. */ +export type AppInspection = { + access: AppAccess; + /** Why nobody can use it yet, as {@link unavailableReason} answers; null when somebody can. */ + unavailable: Unavailable | null; + /** The row a grant is written against, and null for an app with no row. */ + answeringId: string | null; + /** The Composio app, for a brokered one. */ + toolkit: string | null; + /** The catalogue entry, for an app this deployment ships. */ + entry: CatalogueEntry | null; + /** Whether the Shared team account is connected and its key was not refused. */ + sharedUsable: boolean; +}; + +export type AppAccessRequestStatus = + | "pending" + | "approved" + | "dismissed" + | "cancelled"; + +/** A request as the person who filed it and the administrator answering it both read it. */ +export type AppAccessRequestRecord = { + id: string; + appKey: string; + appTitle: string; + kind: AppAccessRequestKind; + status: AppAccessRequestStatus; + agentId: string | null; + /** The Bot's name, or null for a request about the app alone. */ + botName: string | null; + /** Who asked. Shown to an administrator because they asked; nothing about their connections is. */ + requestedBy: string; + requesterEmail: string | null; + requesterName: string | null; + createdAt: string; + resolvedAt: string | null; + resolvedBy: string | null; +}; + +const SLUG_OF_ID = /^composio-/; + +/** + * Per-person, per-Bot status of the work apps a Bot holds, and the requests a person files when + * only an administrator can change it. See `../app-access.ts` for the rule itself. + */ +export function appAccessMethods(context: StoreContext) { + const { database, auditStore, brokeredAppRowsAt } = context; + + /* + * EVERY ROW, ONCE. `mcp_servers` is the deployment's list of apps — tens of rows, not thousands — + * and every question below is about which of them are one app. Reading the table once and + * grouping it here costs one query; asking per app would cost one per app and per duplicate. + * Ordered by id so the grouping meets rows in the order the answering rule names them. + */ + async function allRows(): Promise { + return database + .select({ + id: mcpServers.id, + title: mcpServers.title, + logo: mcpServers.logo, + url: mcpServers.url, + provenance: mcpServers.provenance, + authScheme: mcpServers.authScheme, + accountMode: mcpServers.accountMode, + credentialId: mcpServers.credentialId, + }) + .from(mcpServers) + .orderBy(asc(mcpServers.id)); + } + + /** + * The rows grouped into apps, keyed by every row id, so a grant's server half finds its app. + * + * A BROKERED APP IS ITS ANSWERING ROW, whichever row a grant names: the id, title, scheme and + * mode all come off the row {@link brokeredAppRowsAt} names for the app's canonical url — the one + * rule every brokered reader in the store answers by. A brokered row with no row at the + * canonical url is an app of its own whose scheme nobody recorded, which the state reads as + * switched off; the gate refuses such a row too. + */ + async function appsByRow(rows: ServerRow[]) { + const toolkitOfRow = new Map(); + for (const row of rows) { + const toolkit = row.provenance === "composio" ? toolkitOf(row.url) : null; + if (toolkit && !catalogueEntry(row.id)) toolkitOfRow.set(row.id, toolkit); + } + const answering = await brokeredAppRowsAt([ + ...new Set([...toolkitOfRow.values()].map((t) => `composio://${t}`)), + ]); + const byId = new Map(rows.map((row) => [row.id, row])); + const apps = new Map(); + const byRow = new Map(); + + for (const row of rows) { + const toolkit = toolkitOfRow.get(row.id) ?? null; + const head = toolkit ? answering.get(`composio://${toolkit}`) : undefined; + const key = head?.id ?? row.id; + let app = apps.get(key); + if (!app) { + const source = byId.get(key) ?? row; + const entry = toolkit ? null : catalogueEntry(row.id); + app = { + key, + title: entry?.title ?? source.title, + logo: source.logo, + provider: toolkit ? "composio" : entry ? "catalogue" : "custom", + present: true, + answeringId: key, + rowIds: [], + toolkit, + entry, + authScheme: toolkit ? (head?.authScheme ?? null) : source.authScheme, + accountMode: toolkit ? (head?.accountMode ?? null) : null, + hasCredential: source.credentialId !== null, + }; + apps.set(key, app); + } + app.rowIds.push(row.id); + byRow.set(row.id, app); + } + return byRow; + } + + /** + * An app with no row: a catalogue entry nobody added, or a Composio app nobody enabled. + * + * `key` is the id enabling it would mint — the entry's key, or `composio-` as + * `addBrokeredApp` names a row — so a request filed now names the row the approval creates. + */ + function absent(input: { + key: string; + title: string; + logo?: string | null; + provider: AppProvider; + toolkit?: string | null; + entry?: CatalogueEntry | null; + }): AppCandidate { + return { + key: input.key, + title: input.title, + logo: input.logo ?? null, + provider: input.provider, + present: false, + answeringId: null, + rowIds: [], + toolkit: input.toolkit ?? null, + entry: input.entry ?? null, + authScheme: null, + accountMode: null, + hasCredential: false, + }; + } + + /* + * THE DIRECTORY IS ASKED ONLY FOR AN APP THIS DEPLOYMENT HAS NO ROW FOR, and its failure is an + * absence rather than an error: it is the one read here that leaves this process, it is cached by + * the adapter, and a vendor that will not answer leaves an app unnamed rather than a page that + * cannot load. + */ + async function readDirectory(directory: AppDirectory | undefined) { + if (!directory) return null; + try { + return await directory(); + } catch { + return null; + } + } + + /** A key with no row, named as well as the deployment can name it. */ + async function absentFor( + key: string, + directory: AppDirectory | undefined, + listed: BrokerApp[] | null | undefined, + ): Promise { + const entry = catalogueEntry(key); + if (entry) + return absent({ + key: entry.key, + title: entry.title, + provider: "catalogue", + entry, + }); + const slug = key.replace(SLUG_OF_ID, ""); + if (toolkitOf(`composio://${slug}`) !== slug) return null; + const apps = listed === undefined ? await readDirectory(directory) : listed; + const app = apps?.find((candidate) => candidate.slug === slug); + if (app) + return absent({ + key: `composio-${app.slug}`, + title: app.name, + logo: app.logo, + provider: "composio", + toolkit: app.slug, + }); + /* + * A `composio-` id the directory cannot vouch for is still an app somebody once enabled — a + * grant can outlive its row — so it is named by its slug rather than dropped. A bare word that + * matches nothing is not an app at all. + */ + if (SLUG_OF_ID.test(key)) + return absent({ + key, + title: slug, + provider: "composio", + toolkit: slug, + }); + return null; + } + + /** + * One app by whatever a caller called it: a server id, a catalogue key, a Composio slug with or + * without its `composio-` prefix, or the app's name. Null for a word that names no app here or + * in the directory. + */ + async function candidateFor( + app: string, + directory: AppDirectory | undefined, + ): Promise { + const asked = app.trim(); + if (!asked) return null; + const lower = asked.toLowerCase(); + const rows = await allRows(); + const byRow = await appsByRow(rows); + + const byId = byRow.get(asked); + if (byId) return byId; + + const slug = lower.replace(SLUG_OF_ID, ""); + for (const candidate of new Set(byRow.values())) + if (candidate.toolkit === slug) return candidate; + + const entry = + catalogueEntry(lower) ?? + CATALOGUE.find((item) => item.title.toLowerCase() === lower) ?? + null; + if (entry) + return byRow.get(entry.key) ?? absentFor(entry.key, undefined, null); + + for (const candidate of new Set(byRow.values())) + if (candidate.title.toLowerCase() === lower) return candidate; + + const listed = await readDirectory(directory); + const named = listed?.find( + (candidate) => + candidate.slug === slug || candidate.name.toLowerCase() === lower, + ); + if (named) return absentFor(`composio-${named.slug}`, undefined, listed); + return absentFor(SLUG_OF_ID.test(lower) ? lower : asked, undefined, listed); + } + + /** The server halves of a Bot's MCP grants. */ + async function grantedServerIds(agentId: string): Promise> { + const grants = await database + .select({ ref: pluginGrants.ref }) + .from(pluginGrants) + .where( + and(eq(pluginGrants.kind, "mcp"), eq(pluginGrants.agentId, agentId)), + ); + return new Set( + grants.map((grant) => grant.ref.split("/")[0] ?? "").filter(Boolean), + ); + } + + /** + * The connection facts for a set of apps, for one person or for nobody. + * + * THE SAME COLUMNS `GET /connections` REPORTS, read without the broker. A brokered connection is + * usable unless a check ran and the vendor refused its key (`probe_action` set, `verified` + * false) — the one of `brokeredConnectionsFor`'s four states that is a dead key. A held one is + * usable unless its credential was withdrawn. + */ + async function connectionFacts(apps: AppCandidate[], userId: string | null) { + const toolkits = [ + ...new Set(apps.map((app) => app.toolkit).filter((t) => t !== null)), + ] as string[]; + const heldIds = apps + .filter((app) => app.provider === "catalogue" && app.present) + .flatMap((app) => app.rowIds); + const usable = (row: { verified: boolean; probeAction: string | null }) => + !(row.probeAction !== null && !row.verified); + + const [personal, deployment, held] = await Promise.all([ + userId && toolkits.length > 0 + ? database + .select({ + app: brokeredConnections.app, + verified: brokeredConnections.verified, + probeAction: brokeredConnections.probeAction, + }) + .from(brokeredConnections) + .where( + and( + eq(brokeredConnections.provider, "composio"), + eq(brokeredConnections.holder, "person"), + eq(brokeredConnections.userId, userId), + inArray(brokeredConnections.app, toolkits), + ), + ) + : [], + toolkits.length > 0 + ? database + .select({ + app: brokeredConnections.app, + verified: brokeredConnections.verified, + probeAction: brokeredConnections.probeAction, + }) + .from(brokeredConnections) + .where( + and( + eq(brokeredConnections.provider, "composio"), + eq(brokeredConnections.holder, "deployment"), + inArray(brokeredConnections.app, toolkits), + ), + ) + : [], + userId && heldIds.length > 0 + ? database + .select({ + serverId: mcpUserCredentials.serverId, + revokedAt: credentialRows.revokedAt, + }) + .from(mcpUserCredentials) + .innerJoin( + credentialRows, + eq(credentialRows.id, mcpUserCredentials.credentialId), + ) + .where( + and( + eq(mcpUserCredentials.userId, userId), + inArray(mcpUserCredentials.serverId, heldIds), + ), + ) + : [], + ]); + return { + personalBrokered: new Map(personal.map((row) => [row.app, usable(row)])), + deploymentBrokered: new Map( + deployment.map((row) => [row.app, usable(row)]), + ), + personalHeld: new Map( + held.map((row) => [row.serverId, row.revokedAt === null]), + ), + }; + } + + /** This person's pending requests about these apps, on this Bot or on no Bot. */ + async function pendingRequestsFor( + userId: string, + agentId: string, + keys: string[], + ) { + if (keys.length === 0) return []; + return database + .select({ + id: appAccessRequests.id, + appKey: appAccessRequests.appKey, + kind: appAccessRequests.kind, + createdAt: appAccessRequests.createdAt, + }) + .from(appAccessRequests) + .where( + and( + eq(appAccessRequests.requestedBy, userId), + eq(appAccessRequests.status, "pending"), + inArray(appAccessRequests.appKey, keys), + or( + eq(appAccessRequests.agentId, agentId), + isNull(appAccessRequests.agentId), + ), + ), + ) + .orderBy(asc(appAccessRequests.createdAt)); + } + + /** Every candidate described for one person and one Bot. */ + async function describe( + apps: AppCandidate[], + input: { + userId: string | null; + agentId: string; + composioConfigured: boolean; + }, + ): Promise { + const [granted, connections, pending] = await Promise.all([ + grantedServerIds(input.agentId), + connectionFacts(apps, input.userId), + input.userId + ? pendingRequestsFor( + input.userId, + input.agentId, + apps.map((app) => app.key), + ) + : [], + ]); + return apps.map((app) => { + const sharedUsable = app.toolkit + ? connections.deploymentBrokered.get(app.toolkit) === true + : false; + const personal = + app.provider === "composio" && app.toolkit + ? connections.personalBrokered.get(app.toolkit) + : app.provider === "catalogue" + ? app.rowIds + .map((id) => connections.personalHeld.get(id)) + .find((value) => value !== undefined) + : undefined; + const facts: AppAccessFacts = { + provider: app.provider, + present: app.present, + composioConfigured: input.composioConfigured, + authScheme: app.authScheme, + accountMode: app.accountMode, + catalogueAuth: app.entry?.auth ?? null, + hasCredential: app.hasCredential, + granted: [app.key, ...app.rowIds].some((id) => granted.has(id)), + personal: personal === undefined ? null : { usable: personal }, + shared: app.toolkit + ? connections.deploymentBrokered.has(app.toolkit) + ? { usable: sharedUsable } + : null + : null, + }; + const state = appAccessState(facts); + const requestKind = REQUEST_KIND_FOR[state]; + const request = + requestKind === null + ? undefined + : pending.find( + (row) => row.appKey === app.key && row.kind === requestKind, + ); + return { + access: { + key: app.key, + displayName: app.title, + logo: app.logo, + provider: app.provider, + account: accountOf(facts), + connect: connectOf(facts), + authScheme: app.provider === "composio" ? app.authScheme : null, + accountMode: app.accountMode, + state, + granted: facts.granted, + requestKind, + request: request + ? { + id: request.id, + kind: request.kind, + createdAt: iso(request.createdAt) ?? "", + } + : null, + }, + unavailable: unavailableReason(facts), + answeringId: app.answeringId, + toolkit: app.toolkit, + entry: app.entry, + sharedUsable, + }; + }); + } + + const requestColumns = { + request: appAccessRequests, + botName: agents.name, + requesterEmail: users.email, + requesterName: users.name, + }; + + function toRecord(row: { + request: typeof appAccessRequests.$inferSelect; + botName: string | null; + requesterEmail: string | null; + requesterName: string | null; + }): AppAccessRequestRecord { + return { + id: row.request.id, + appKey: row.request.appKey, + appTitle: row.request.appTitle, + kind: row.request.kind, + status: row.request.status, + agentId: row.request.agentId, + botName: row.botName, + requestedBy: row.request.requestedBy, + requesterEmail: row.requesterEmail, + requesterName: row.requesterName, + createdAt: iso(row.request.createdAt) ?? "", + resolvedAt: iso(row.request.resolvedAt), + resolvedBy: row.request.resolvedBy, + }; + } + + async function requestRows(where: SQL | undefined) { + const rows = await database + .select(requestColumns) + .from(appAccessRequests) + .leftJoin(agents, eq(agents.id, appAccessRequests.agentId)) + .leftJoin(users, eq(users.id, appAccessRequests.requestedBy)) + .where(where) + .orderBy(desc(appAccessRequests.createdAt), asc(appAccessRequests.id)); + return rows.map(toRecord); + } + + return { + /** + * Every work app one Bot holds, each with what it is for this person. + * + * HELD MEANS A GRANT, WHETHER OR NOT THE APP IS STILL THERE. A grant whose row is gone — an app + * removed before removal took its grants with it, or restored from an older backup — is still + * something this Bot was given, and is listed as switched off rather than dropped, because + * "this Bot used to reach Gmail and cannot now" is the one thing a person on the Bot's page + * needs to read about it. + * + * Sorted by name, so two loads draw one order. + */ + async appAccessForBot(input: { + userId: string; + agentId: string; + composioConfigured: boolean; + directory?: AppDirectory; + }): Promise { + const granted = await grantedServerIds(input.agentId); + if (granted.size === 0) return []; + const byRow = await appsByRow(await allRows()); + const apps = new Map(); + const missing: string[] = []; + for (const serverId of granted) { + const app = byRow.get(serverId); + if (app) apps.set(app.key, app); + else missing.push(serverId); + } + const listed = missing.some((id) => SLUG_OF_ID.test(id)) + ? await readDirectory(input.directory) + : null; + for (const serverId of missing) { + const app = + (await absentFor(serverId, undefined, listed)) ?? + absent({ key: serverId, title: serverId, provider: "custom" }); + if (!apps.has(app.key)) apps.set(app.key, app); + } + const described = await describe([...apps.values()], input); + return described + .map((item) => item.access) + .sort((left, right) => + left.displayName.localeCompare(right.displayName), + ); + }, + + /** + * One app, by whatever a caller called it, for this Bot and — where `userId` is given — this + * person. Null for a word that names no app. + * + * Used by the chat card, which asks about an app the Bot may hold nothing for, and by the + * approve route, which asks for the deployment's half alone and passes no person. + */ + async inspectApp(input: { + app: string; + agentId: string; + userId: string | null; + composioConfigured: boolean; + directory?: AppDirectory; + }): Promise { + const candidate = await candidateFor(input.app, input.directory); + if (!candidate) return null; + const [described] = await describe([candidate], input); + return described ?? null; + }, + + /** Every tool the app's answering row advertises, as grant refs. */ + async appToolRefs(serverId: string): Promise { + const rows = await database + .select({ name: mcpTools.name }) + .from(mcpTools) + .where(eq(mcpTools.serverId, serverId)) + .orderBy(asc(mcpTools.name)); + return rows.map((row) => `${serverId}/${row.name}`); + }, + + /** + * File a request, or hand back the one already pending. + * + * THE INDEX DECIDES, NOT A READ. `on conflict do nothing` against the partial unique index is + * what keeps two replicas filing the same request from inserting twice; the read after it only + * finds which row won. A request answered between the two is filed again, once. + */ + async fileAppAccessRequest(input: { + requestedBy: string; + agentId: string | null; + appKey: string; + appTitle: string; + kind: AppAccessRequestKind; + }): Promise<{ request: AppAccessRequestRecord; created: boolean }> { + for (let attempt = 0; attempt < 2; attempt += 1) { + const id = randomUUID(); + const inserted = await database + .insert(appAccessRequests) + .values({ id, ...input }) + .onConflictDoNothing() + .returning({ id: appAccessRequests.id }); + const [request] = await requestRows( + inserted.length > 0 + ? eq(appAccessRequests.id, id) + : and( + eq(appAccessRequests.requestedBy, input.requestedBy), + eq(appAccessRequests.appKey, input.appKey), + eq(appAccessRequests.kind, input.kind), + eq(appAccessRequests.status, "pending"), + input.agentId === null + ? isNull(appAccessRequests.agentId) + : eq(appAccessRequests.agentId, input.agentId), + ), + ); + if (request) { + if (inserted.length > 0) + await recordAuditEvent(auditStore, { + eventType: "app_access.requested", + targetType: "mcp_server", + targetId: input.appKey, + actorUserId: input.requestedBy, + payload: { + request: id, + app: input.appKey, + bot: input.agentId, + kind: input.kind, + }, + }); + return { request, created: inserted.length > 0 }; + } + } + throw new Error( + `A request for ${input.appKey} was neither filed nor found pending.`, + ); + }, + + async appAccessRequest(id: string): Promise { + const [request] = await requestRows(eq(appAccessRequests.id, id)); + return request ?? null; + }, + + /** Pending requests: one person's, one app's, or every one. Newest first. */ + async pendingAppAccessRequests(filter: { + requestedBy?: string; + appKey?: string; + }): Promise { + return requestRows( + and( + eq(appAccessRequests.status, "pending"), + filter.requestedBy + ? eq(appAccessRequests.requestedBy, filter.requestedBy) + : undefined, + filter.appKey + ? eq(appAccessRequests.appKey, filter.appKey) + : undefined, + ), + ); + }, + + /** + * Close a pending request, and only a pending one. + * + * THE CONDITION IS THE WHOLE OF THE CONCURRENCY STORY. Two administrators answering at once, + * or a person cancelling while an administrator approves, both run this; the first update + * finds the row pending and the second finds nothing, and null is how its caller learns it + * lost. `requestedBy`, when given, confines a cancel to the person's own requests in the same + * statement. + */ + async closeAppAccessRequest(input: { + id: string; + status: Exclude; + by: string; + requestedBy?: string; + }): Promise { + const [closed] = await database + .update(appAccessRequests) + .set({ + status: input.status, + resolvedAt: new Date(), + resolvedBy: input.by, + }) + .where( + and( + eq(appAccessRequests.id, input.id), + eq(appAccessRequests.status, "pending"), + input.requestedBy + ? eq(appAccessRequests.requestedBy, input.requestedBy) + : undefined, + ), + ) + .returning({ id: appAccessRequests.id }); + if (!closed) return null; + const [record] = await requestRows(eq(appAccessRequests.id, closed.id)); + return record ?? null; + }, + + /** + * A row in the trail about a request, written here so the routes write through the same + * redacting writer every other plugin event uses. + */ + async recordAppAccessEvent( + event: Omit & { + eventType: + | "app_access.cancelled" + | "app_access.approved" + | "app_access.dismissed" + | "app_access.refused"; + }, + ): Promise { + await recordAuditEvent(auditStore, { + ...event, + targetType: "mcp_server", + }); + }, + }; +} diff --git a/server/src/plugins/route-groups/app-access.ts b/server/src/plugins/route-groups/app-access.ts new file mode 100644 index 0000000000..f1f98c19ac --- /dev/null +++ b/server/src/plugins/route-groups/app-access.ts @@ -0,0 +1,538 @@ +import type { Context } from "hono"; +import { type AppVariables, requireAdmin } from "../../auth/guards"; +import type { Unavailable } from "../app-access"; +import type { CatalogueEntry } from "../catalogue"; +import type { + AppAccessRequestRecord, + AppDirectory, +} from "../plugin-store/app-access"; +import { + CatalogueEntryUnknownError, + CustomServerRefusedError, + deploymentFaultSentence, + isDeploymentFault, +} from "../store"; +import { enableComposioApp } from "./composio-apps"; +import type { PluginRouteContext } from "./context"; +import { sharedGrantApproval } from "./grants"; + +type RouteContext = Context<{ Variables: AppVariables }>; + +/** + * What enabling a catalogue entry would need that only an administrator can type, or null when + * adding its row is the whole of it. + * + * A vendor that gives every customer their own host needs that host; a deployment token needs the + * token; an OAuth client that does not register itself needs the client. Approving a request + * cannot invent any of those, so it says which one is missing and leaves the request pending. + */ +function adminInputFor(entry: CatalogueEntry): string | null { + if (entry.host === null) return `the address of your ${entry.title} instance`; + if (entry.auth.kind === "deployment-bearer") + return "the token it is reached with"; + if ( + entry.auth.kind === "user-oauth" && + entry.auth.clientRegistration !== "dynamic" + ) + return "an OAuth client registered with the vendor"; + return null; +} + +/** The sentence an approval is refused with when the app is there and still cannot be used. */ +function unavailableSentence(reason: Unavailable, title: string): string { + switch (reason) { + case "absent": + return `${title} is not an app this deployment can switch on from here.`; + case "composio_unconfigured": + return `${title} is a Composio app, and this deployment has no Composio key. Set COMPOSIO_API_KEY, then approve this request again.`; + case "scheme_unreadable": + return `${title} is enabled, but how it connects is not recorded in a way this deployment can read. Remove it and enable it again on its page under Plugins, then approve this request again.`; + case "oauth_client_missing": + return `${title} is added, but has no OAuth client yet, so nobody can connect it. Register one on its page under Plugins, then approve this request again.`; + case "token_missing": + return `${title} is added, but has no token yet. Add one on its page under Plugins, then approve this request again.`; + } +} + +/** + * Where a person sees which work apps a Bot can use for them, asks an administrator for the ones + * only an administrator can fix, and where the administrator answers. + * + * THE STATE IS ALWAYS THE SERVER'S. A person's browser names a Bot and an app; what the app is for + * them, and therefore what a request asks for, is computed here from the same tables every gate + * reads. A request cannot be filed for a state the app is not in, and approving one does the thing + * that state was missing — switching the app on, granting it to the Bot — in the same press. + * + * WHY NOT `shared_use_requests`. That table answers a different question: a Shared app whose team + * account IS connected, and whether an administrator approves the audience a Bot exposes it to. + * "Please connect the team account" is a request about the account itself, before any audience can + * matter, and has none of that table's columns to fill. The style, the conditional decide and the + * audit vocabulary are that flow's; the rows are not. + */ +export function mountAppAccessRoutes({ + routes, + store, + requireUser, + canUseBot, + composio, + shared, + actorEmail, +}: PluginRouteContext) { + const composioConfigured = Boolean(composio); + /* + * Asked only to name an app this deployment has no row for — see `readDirectory` in the store. + * The adapter caches the listing, so a card asking about an app nobody enabled costs a vendor + * call at most once per cache window rather than once per render. + */ + const directory: AppDirectory | undefined = composio + ? () => composio.broker.listApps() + : undefined; + + const refused = async ( + context: RouteContext, + input: { + app: string; + reason: string; + error: string; + status: 400 | 403 | 404 | 409 | 502 | 503; + request?: string; + bot?: string | null; + kind?: string; + }, + ) => { + await store.recordAppAccessEvent({ + eventType: "app_access.refused", + targetId: input.app, + actorUserId: context.var.actor.id, + payload: { + app: input.app, + reason: input.reason, + ...(input.request ? { request: input.request } : {}), + ...(input.bot !== undefined ? { bot: input.bot } : {}), + ...(input.kind ? { kind: input.kind } : {}), + }, + }); + return context.json({ error: input.error }, input.status); + }; + + /** + * Every work app this Bot holds, with what each one is for the person asking. + * + * Only for a Bot the person may use, and answered 404 otherwise, for the reason the grant list + * beside it gives: what a private Bot holds is a fact about that Bot. + */ + routes.get("/for/:agentId/apps", requireUser, async (context) => { + const agentId = context.req.param("agentId"); + if (!(await canUseBot(context.var.actor, agentId))) + return context.json({ error: "There is no such Bot." }, 404); + return context.json({ + apps: await store.appAccessForBot({ + userId: context.var.actor.id, + agentId, + composioConfigured, + directory, + }), + }); + }); + + /** + * One app, by key or by name, whether or not this Bot holds it — so a card in a conversation can + * say an app is switched off or not granted, and offer the request that fixes it. + */ + routes.get("/for/:agentId/apps/:app", requireUser, async (context) => { + const agentId = context.req.param("agentId"); + if (!(await canUseBot(context.var.actor, agentId))) + return context.json({ error: "There is no such Bot." }, 404); + const app = context.req.param("app"); + const inspected = await store.inspectApp({ + app, + agentId, + userId: context.var.actor.id, + composioConfigured, + directory, + }); + if (!inspected) + return context.json( + { error: `This deployment has no app called ${app}.` }, + 404, + ); + return context.json({ app: inspected.access }); + }); + + /** + * Ask an administrator for access. The body names the Bot and the app; the kind is the server's. + * + * A SECOND PRESS IS THE FIRST REQUEST. One pending request per person, app, Bot and kind, so + * asking again answers with the one already waiting and `created: false`, and writes no second + * trail row. + */ + routes.post("/access-requests", requireUser, async (context) => { + const body = (await context.req.json().catch(() => null)) as { + agentId?: unknown; + app?: unknown; + } | null; + const agentId = + typeof body?.agentId === "string" ? body.agentId.trim() : ""; + const app = typeof body?.app === "string" ? body.app.trim() : ""; + if (!agentId || !app) + return context.json({ error: "Name the Bot and the app." }, 400); + if (!(await canUseBot(context.var.actor, agentId))) + return refused(context, { + app, + bot: agentId, + reason: "no_such_bot", + error: "There is no such Bot.", + status: 404, + }); + const inspected = await store.inspectApp({ + app, + agentId, + userId: context.var.actor.id, + composioConfigured, + directory, + }); + if (!inspected) + return refused(context, { + app, + bot: agentId, + reason: "no_such_app", + error: `This deployment has no app called ${app}.`, + status: 404, + }); + const { access } = inspected; + if (!access.requestKind) + return refused(context, { + app: access.key, + bot: agentId, + reason: `nothing_to_request:${access.state}`, + error: + access.state === "ready" + ? `${access.displayName} is ready to use. There is nothing to ask an administrator for.` + : `${access.displayName} is waiting on your own account, not on an administrator. Connect it yourself.`, + status: 409, + }); + const filed = await store.fileAppAccessRequest({ + requestedBy: context.var.actor.id, + agentId, + appKey: access.key, + appTitle: access.displayName, + kind: access.requestKind, + }); + return context.json(filed, filed.created ? 201 : 200); + }); + + /** The person's own pending requests, newest first. */ + routes.get("/access-requests/mine", requireUser, async (context) => + context.json({ + requests: await store.pendingAppAccessRequests({ + requestedBy: context.var.actor.id, + }), + }), + ); + + /** + * Withdraw one's own pending request. Somebody else's reads exactly like one that does not exist, + * so this route cannot be used to learn who asked for what. + */ + routes.delete("/access-requests/:id", requireUser, async (context) => { + const id = context.req.param("id"); + const closed = await store.closeAppAccessRequest({ + id, + status: "cancelled", + by: context.var.actor.id, + requestedBy: context.var.actor.id, + }); + if (!closed) { + const existing = await store.appAccessRequest(id); + const mine = existing?.requestedBy === context.var.actor.id; + return refused(context, { + app: mine ? (existing?.appKey ?? "") : "", + request: id, + reason: mine ? "already_resolved" : "no_such_request", + error: mine + ? "That request has already been answered." + : "There is no such request.", + status: mine ? 409 : 404, + }); + } + await store.recordAppAccessEvent({ + eventType: "app_access.cancelled", + targetId: closed.appKey, + actorUserId: context.var.actor.id, + payload: { + request: closed.id, + app: closed.appKey, + bot: closed.agentId, + kind: closed.kind, + }, + }); + return context.json({ request: closed }); + }); + + /** + * Every pending request, for an administrator: one app's with `?app=`, or all of them, and a + * count per app either way so the Plugins list can badge each app without a second read. + */ + routes.get("/access-requests", requireUser, async (context) => { + const forbidden = requireAdmin(context); + if (forbidden) return forbidden; + const all = await store.pendingAppAccessRequests({}); + const byApp: Record = {}; + for (const request of all) + byApp[request.appKey] = (byApp[request.appKey] ?? 0) + 1; + const app = context.req.query("app")?.trim(); + return context.json({ + requests: app ? all.filter((request) => request.appKey === app) : all, + counts: { total: all.length, byApp }, + }); + }); + + /** + * Approve a request by doing what it asks: switch the app on, grant its tools to the Bot, or — + * for a team account, which only a consent screen can connect — confirm it has been connected. + * + * THE CHANGE FIRST, THEN THE CONDITIONAL CLOSE. Enabling and granting are both idempotent, so + * two administrators approving at once both arrive at the same deployment and one of them is + * told the request was already answered. Closing first would leave an approved request over a + * change that then failed. + * + * `refs` NARROWS THE GRANT. Absent, the Bot is granted every tool the app advertises — which is + * what "let this Bot use Gmail" means — and every call is still policy-checked and audited. A + * ref outside the app is refused rather than dropped. + */ + routes.post("/access-requests/:id/approve", requireUser, async (context) => { + const forbidden = requireAdmin(context); + if (forbidden) return forbidden; + const id = context.req.param("id"); + const request = await store.appAccessRequest(id); + if (request?.status !== "pending") + return refused(context, { + app: request?.appKey ?? "", + request: id, + reason: request ? "already_resolved" : "no_such_request", + error: request + ? "That request has already been answered." + : "There is no such request.", + status: request ? 409 : 404, + }); + const body = (await context.req.json().catch(() => null)) as { + refs?: unknown; + } | null; + if ( + body?.refs !== undefined && + (!Array.isArray(body.refs) || + body.refs.some((ref) => typeof ref !== "string")) + ) + return context.json({ error: "Tools must be a list of refs." }, 400); + const chosen = body?.refs as string[] | undefined; + + const deny = ( + reason: string, + error: string, + status: 400 | 404 | 409 | 502 | 503, + ) => + refused(context, { + app: request.appKey, + request: request.id, + bot: request.agentId, + kind: request.kind, + reason, + error, + status, + }); + + const inspect = () => + store.inspectApp({ + app: request.appKey, + agentId: request.agentId ?? "", + userId: null, + composioConfigured, + directory, + }); + + let inspected = await inspect(); + if (!inspected) + return deny( + "no_such_app", + `${request.appTitle} is no longer an app this deployment can find.`, + 409, + ); + + let enabled = false; + if (request.kind !== "connect_shared" && inspected.unavailable) { + const title = inspected.access.displayName; + if (inspected.unavailable !== "absent") + return deny( + inspected.unavailable, + unavailableSentence(inspected.unavailable, title), + inspected.unavailable === "composio_unconfigured" ? 503 : 409, + ); + if (inspected.toolkit) { + if (!composio) + return deny( + "composio_unconfigured", + unavailableSentence("composio_unconfigured", title), + 503, + ); + const outcome = await enableComposioApp( + { store, composio }, + inspected.toolkit, + actorEmail(context), + ); + if (!outcome.ok) + return deny("enable_failed", outcome.error, outcome.status); + } else if (inspected.entry) { + const needed = adminInputFor(inspected.entry); + if (needed) + return deny( + "needs_admin_input", + `${title} needs ${needed}, which only an administrator can supply. Add it on its page under Plugins, then approve this request again.`, + 409, + ); + try { + await store.addServer({ + key: inspected.entry.key, + by: actorEmail(context), + }); + } catch (error) { + if ( + error instanceof CatalogueEntryUnknownError || + error instanceof CustomServerRefusedError + ) + return deny("enable_failed", error.message, 400); + if (isDeploymentFault(error)) + return deny("enable_failed", deploymentFaultSentence(error), 409); + throw error; + } + } else { + return deny("absent", unavailableSentence("absent", title), 409); + } + enabled = true; + inspected = await inspect(); + if (!inspected || inspected.unavailable) + return deny( + "enable_failed", + `${title} was switched on and still cannot be used. Open its page under Plugins to see why.`, + 409, + ); + } + + let granted: string[] = []; + if ( + request.kind !== "connect_shared" && + request.agentId && + !inspected.access.granted + ) { + const agentId = request.agentId; + const serverId = inspected.answeringId; + if (!(await store.agentIsRegistered(agentId)) || !serverId) + return deny("no_such_bot", "That Bot is no longer here.", 409); + const advertised = await store.appToolRefs(serverId); + const refs = chosen ?? advertised; + const outside = refs.filter((ref) => !advertised.includes(ref)); + if (outside.length > 0) + return deny( + "refs_outside_app", + `${outside.join(", ")} ${outside.length === 1 ? "is" : "are"} not among ${inspected.access.displayName}'s tools.`, + 400, + ); + if (refs.length === 0) + return deny( + "no_tools", + `${inspected.access.displayName} advertises no tools to grant yet. Refresh its tools on its page under Plugins, then approve this request again.`, + 409, + ); + for (const ref of refs) { + await store.grant("mcp", ref, agentId, actorEmail(context)); + } + const decided = await sharedGrantApproval( + { store, shared }, + refs[0] ?? "", + agentId, + undefined, + ); + if (decided !== "unreadable" && decided.approval && shared) + await shared.use.setApproval({ + botId: agentId, + serverId: decided.appId, + approval: decided.approval, + by: context.var.actor.id, + }); + granted = refs; + } + + if ( + request.kind === "connect_shared" && + inspected.access.account === "shared" && + !inspected.sharedUsable + ) + return deny( + "shared_not_connected", + `The shared ${inspected.access.displayName} account is not connected yet. Connect it on ${inspected.access.displayName}'s page under Plugins, then approve this request.`, + 409, + ); + + const closed = await store.closeAppAccessRequest({ + id: request.id, + status: "approved", + by: context.var.actor.id, + }); + if (!closed) + return deny( + "already_resolved", + "That request has already been answered.", + 409, + ); + await store.recordAppAccessEvent({ + eventType: "app_access.approved", + targetId: closed.appKey, + actorUserId: context.var.actor.id, + payload: { + request: closed.id, + app: closed.appKey, + bot: closed.agentId, + kind: closed.kind, + enabled, + granted: granted.length, + }, + }); + return context.json({ request: closed, enabled, granted }); + }); + + /** Dismiss a request: nothing changes but the request, which stops waiting. */ + routes.post("/access-requests/:id/dismiss", requireUser, async (context) => { + const forbidden = requireAdmin(context); + if (forbidden) return forbidden; + const id = context.req.param("id"); + const closed: AppAccessRequestRecord | null = + await store.closeAppAccessRequest({ + id, + status: "dismissed", + by: context.var.actor.id, + }); + if (!closed) { + const existing = await store.appAccessRequest(id); + return refused(context, { + app: existing?.appKey ?? "", + request: id, + reason: existing ? "already_resolved" : "no_such_request", + error: existing + ? "That request has already been answered." + : "There is no such request.", + status: existing ? 409 : 404, + }); + } + await store.recordAppAccessEvent({ + eventType: "app_access.dismissed", + targetId: closed.appKey, + actorUserId: context.var.actor.id, + payload: { + request: closed.id, + app: closed.appKey, + bot: closed.agentId, + kind: closed.kind, + }, + }); + return context.json({ request: closed }); + }); +} diff --git a/server/src/plugins/routes.ts b/server/src/plugins/routes.ts index b3cebe4ba9..e046513f55 100644 --- a/server/src/plugins/routes.ts +++ b/server/src/plugins/routes.ts @@ -2,6 +2,7 @@ import { Hono, type MiddlewareHandler } from "hono"; import type { BotAccessCheck } from "../agents/profile-policy"; import type { AppVariables } from "../auth/guards"; import type { AccountModeSwitch } from "./account-mode"; +import { mountAppAccessRoutes } from "./route-groups/app-access"; import type { ComposioBroker } from "./broker"; import { mountComposioAppRoutes } from "./route-groups/composio-apps"; import { mountConnectionRoutes } from "./route-groups/connections"; @@ -131,6 +132,7 @@ export function createPluginRoutes( mountOAuthCallbackRoute(context); mountSkillRoutes(context); mountGrantRoutes(context); + mountAppAccessRoutes(context); return routes; } diff --git a/server/src/plugins/store.ts b/server/src/plugins/store.ts index 556bedac79..373965d3f4 100644 --- a/server/src/plugins/store.ts +++ b/server/src/plugins/store.ts @@ -1,3 +1,4 @@ +import { appAccessMethods } from "./plugin-store/app-access"; import { callMethods } from "./plugin-store/calls"; import { connectionMethods } from "./plugin-store/connections"; import { createStoreContext } from "./plugin-store/context"; @@ -48,6 +49,7 @@ export function createPluginStore(options: PluginStoreOptions) { ...grantMethods(context), ...connectionMethods(context), ...callMethods(context), + ...appAccessMethods(context), }; } diff --git a/server/tests/app-access-routes.integration.test.ts b/server/tests/app-access-routes.integration.test.ts new file mode 100644 index 0000000000..1ff23e2aa3 --- /dev/null +++ b/server/tests/app-access-routes.integration.test.ts @@ -0,0 +1,915 @@ +// server/tests/app-access-routes.integration.test.ts +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + test, +} from "bun:test"; +import { randomUUID } from "node:crypto"; +import { and, eq, inArray, like } from "drizzle-orm"; +import { Hono, type MiddlewareHandler } from "hono"; +import type { AuditEventInput, AuditStore } from "../src/audit"; +import type { AppVariables } from "../src/auth/guards"; +import { createDatabase } from "../src/db/client"; +import { + agentProfiles, + agents, + appAccessRequests, + brokeredConnections, + credentials, + mcpServers, + mcpTools, + mcpUserCredentials, + pluginGrants, + sharedUseApprovals, + users, +} from "../src/db/schema"; +import type { AppAccess } from "../src/plugins/app-access"; +import type { BrokerApp, ConnectedAppBroker } from "../src/plugins/broker"; +import type { AppAccessRequestRecord } from "../src/plugins/plugin-store/app-access"; +import { createPluginRoutes } from "../src/plugins/routes"; +import { createSharedUseStore } from "../src/plugins/shared-use-store"; +import { + createPluginStore, + type PluginStore, + type ServerRecord, +} from "../src/plugins/store"; +import { TEST_POOL, testDatabaseUrl } from "./support/database"; + +/** + * What a work app is for one person on one Bot, the requests they file when only an administrator + * can change it, and what approving one does — against the real tables, through the real routes. + * + * The vendor is the one thing faked. `addBrokeredApp` refreshes an app's tools from Composio + * itself, which no test can reach, so the store's enable is replaced with one that writes the row + * and a tool the way the real one ends; everything around it — the directory check, the grant, the + * Shared approval, the conditional close, the trail — is the shipped code. + */ + +const database = createDatabase(testDatabaseUrl(), TEST_POOL); +const suite = randomUUID().slice(0, 8); +const id = (name: string) => `${name}-${suite}`; + +const person = id("person"); +const other = id("other"); +const admin = id("admin"); +const bot = id("bot"); +const hidden = id("hidden"); + +/** A Composio slug for this suite, and the row id `addBrokeredApp` would give it. */ +const slug = (name: string) => `${name}_${suite}`; +const row = (name: string) => `composio-${slug(name)}`; + +const GMAIL = row("gmail"); +const SLACK = row("slack"); +const LINEAR = row("linear"); +const NEWS = row("news"); +const CALENDAR = row("calendar"); +const GONE = row("gone"); +const DROPBOX = row("dropbox"); + +const events: AuditEventInput[] = []; +const audit: AuditStore = { + insert: async (event) => { + events.push(event); + }, +}; +const eventsOf = (type: string) => + events.filter((event) => event.eventType === type); + +const directory: BrokerApp[] = [ + { + slug: slug("dropbox"), + name: "Dropbox", + description: "Files", + logo: null, + categories: [], + actionCount: 1, + connection: { kind: "consent" }, + }, + { + slug: slug("gone"), + name: "Gone App", + description: "", + logo: null, + categories: [], + actionCount: 1, + connection: { kind: "consent" }, + }, +]; + +const broker: ConnectedAppBroker = { + listApps: async () => directory, + ensureAuthConfig: async () => "created", + deleteAuthConfig: async () => {}, + authorize: async () => ({ redirectUrl: "https://vendor.example/consent" }), + isConnected: async () => true, + revoke: async () => true, + connectionFields: async () => [], + connectWithFields: async () => ({ accountId: "ca_1" }), + revokeAccount: async () => {}, + accountName: async () => "team", +}; + +const enabled: string[] = []; + +function storeFor(): PluginStore { + const real = createPluginStore({ + database, + auditStore: audit, + credentials: {} as never, + encryptionKey: "x".repeat(44), + policy: () => ({ mode: "enforce", deny: [], allow: ["true"] }), + broker, + deploymentId: `acme-${suite}`, + }); + return { + ...real, + async addBrokeredApp(input) { + enabled.push(input.slug); + await database.insert(mcpServers).values({ + id: `composio-${input.slug}`, + title: input.title, + vendor: "Composio", + url: `composio://${input.slug}`, + provenance: "composio", + authScheme: "OAUTH2", + accountMode: "personal", + }); + await database.insert(mcpTools).values({ + serverId: `composio-${input.slug}`, + name: "LIST_FILES", + description: "List files", + inputSchema: {}, + }); + return { id: `composio-${input.slug}` } as ServerRecord; + }, + }; +} + +function appAs( + actorId: string, + role: "admin" | "user" = "user", + options: { composio?: boolean } = {}, +) { + const store = storeFor(); + const signedIn: MiddlewareHandler<{ Variables: AppVariables }> = async ( + context, + next, + ) => { + context.set("actor", { + id: actorId, + email: `${actorId}@example.test`, + role, + } as never); + await next(); + }; + const sharedUse = createSharedUseStore(database); + return new Hono().route( + "/api/plugins", + createPluginRoutes( + store, + signedIn, + // Everybody may use the suite's Bot; nobody but an administrator may use the hidden one. + async (actor, agentId) => agentId !== hidden || actor.role === "admin", + undefined, + options.composio === false ? undefined : { broker }, + { modes: {} as never, use: sharedUse }, + ), + ); +} + +async function json(response: Response): Promise { + return (await response.json()) as T; +} + +const post = (body: unknown) => ({ + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), +}); + +async function seedApp(input: { + key: string; + title: string; + toolkit?: string; + authScheme?: string | null; + accountMode?: "personal" | "shared" | null; + tools?: string[]; + grant?: boolean; +}) { + await database.insert(mcpServers).values({ + id: input.key, + title: input.title, + vendor: "Composio", + url: `composio://${input.toolkit ?? input.key.replace(/^composio-/, "")}`, + provenance: "composio", + authScheme: input.authScheme === undefined ? "OAUTH2" : input.authScheme, + accountMode: + input.accountMode === undefined ? "personal" : input.accountMode, + sharedVendorUserId: + input.accountMode === "shared" ? `openbot-deployment:${suite}` : null, + }); + for (const tool of input.tools ?? ["READ"]) + await database.insert(mcpTools).values({ + serverId: input.key, + name: tool, + description: tool, + inputSchema: {}, + }); + if (input.grant !== false) + await database.insert(pluginGrants).values({ + kind: "mcp", + ref: `${input.key}/${(input.tools ?? ["READ"])[0]}`, + agentId: bot, + }); +} + +const ourServers = like(mcpServers.id, `%${suite}%`); + +async function clearApps() { + await database + .delete(appAccessRequests) + .where(inArray(appAccessRequests.requestedBy, [person, other, admin])); + await database + .delete(pluginGrants) + .where(inArray(pluginGrants.agentId, [bot, hidden])); + await database + .delete(brokeredConnections) + .where(like(brokeredConnections.app, `%${suite}%`)); + await database + .delete(sharedUseApprovals) + .where(eq(sharedUseApprovals.agentId, bot)); + await database.delete(mcpServers).where(ourServers); + await database + .delete(mcpUserCredentials) + .where(eq(mcpUserCredentials.serverId, "google-drive")); + await database.delete(mcpServers).where(eq(mcpServers.id, "google-drive")); + await database + .delete(credentials) + .where(eq(credentials.provider, `google-drive-${suite}`)); +} + +beforeAll(async () => { + for (const user of [person, other, admin]) + await database.insert(users).values({ + id: user, + email: `${user}@example.test`, + name: user, + emailVerified: true, + }); + for (const agent of [bot, hidden]) { + await database.insert(agents).values({ + id: agent, + name: `Bot ${agent}`, + type: "built_in", + configuration: {}, + }); + await database.insert(agentProfiles).values({ + agentId: agent, + ownerUserId: person, + visibility: "private", + title: "Helper", + roleDescription: "Helper", + avatarSeed: "x", + }); + } +}); + +beforeEach(async () => { + events.length = 0; + enabled.length = 0; + await clearApps(); +}); + +afterAll(async () => { + await clearApps(); + await database.delete(agents).where(inArray(agents.id, [bot, hidden])); + await database.delete(users).where(inArray(users.id, [person, other, admin])); +}); + +describe("what each app a Bot holds is, for the person asking", () => { + test("every state a held app can be in, named by the app's own name", async () => { + await seedApp({ key: GMAIL, title: "Gmail" }); + await seedApp({ key: SLACK, title: "Slack", accountMode: "shared" }); + await seedApp({ key: LINEAR, title: "Linear", authScheme: "API_KEY" }); + await seedApp({ + key: NEWS, + title: "Hacker News", + authScheme: "NO_AUTH", + accountMode: null, + }); + await seedApp({ key: CALENDAR, title: "Google Calendar", grant: false }); + // A grant whose app is gone: still something this Bot was given. + await database + .insert(pluginGrants) + .values({ kind: "mcp", ref: `${GONE}/SEND`, agentId: bot }); + // Linear connected, and its key refused by the vendor at the last check. + await database.insert(brokeredConnections).values({ + provider: "composio", + app: slug("linear"), + holder: "person", + userId: person, + vendorUserId: person, + verified: false, + probeAction: "LINEAR_LIST", + }); + + const response = await appAs(person).request( + `/api/plugins/for/${bot}/apps`, + ); + expect(response.status).toBe(200); + const { apps } = await json<{ apps: AppAccess[] }>(response); + const byKey = new Map(apps.map((app) => [app.key, app])); + + expect(byKey.get(GMAIL)).toMatchObject({ + displayName: "Gmail", + state: "not_connected", + account: "personal", + connect: "consent", + requestKind: null, + granted: true, + }); + expect(byKey.get(SLACK)).toMatchObject({ + state: "shared_not_connected", + account: "shared", + requestKind: "connect_shared", + }); + expect(byKey.get(LINEAR)).toMatchObject({ + state: "reconnect", + connect: "key", + }); + expect(byKey.get(NEWS)).toMatchObject({ + state: "ready", + account: "deployment", + }); + expect(byKey.get(GONE)).toMatchObject({ + displayName: "Gone App", + state: "disabled", + requestKind: "enable", + }); + // Not held, so not on the Bot's list. + expect(byKey.has(CALENDAR)).toBe(false); + // Sorted by name. + expect(apps.map((app) => app.displayName)).toEqual( + [...apps.map((app) => app.displayName)].sort((a, b) => + a.localeCompare(b), + ), + ); + }); + + test("a usable connection is ready, and somebody else's is not mine", async () => { + await seedApp({ key: GMAIL, title: "Gmail" }); + await database.insert(brokeredConnections).values({ + provider: "composio", + app: slug("gmail"), + holder: "person", + userId: other, + vendorUserId: other, + verified: true, + }); + const mine = await json<{ apps: AppAccess[] }>( + await appAs(person).request(`/api/plugins/for/${bot}/apps`), + ); + expect(mine.apps[0]?.state).toBe("not_connected"); + const theirs = await json<{ apps: AppAccess[] }>( + await appAs(other).request(`/api/plugins/for/${bot}/apps`), + ); + expect(theirs.apps[0]?.state).toBe("ready"); + }); + + test("a Shared app with its team account connected is ready for everybody", async () => { + await seedApp({ key: SLACK, title: "Slack", accountMode: "shared" }); + await database.insert(brokeredConnections).values({ + provider: "composio", + app: slug("slack"), + holder: "deployment", + vendorUserId: `openbot-deployment:${suite}`, + verified: true, + }); + const { apps } = await json<{ apps: AppAccess[] }>( + await appAs(person).request(`/api/plugins/for/${bot}/apps`), + ); + expect(apps[0]?.state).toBe("ready"); + }); + + test("with no Composio key every Composio app is switched off", async () => { + await seedApp({ key: GMAIL, title: "Gmail" }); + const { apps } = await json<{ apps: AppAccess[] }>( + await appAs(person, "user", { composio: false }).request( + `/api/plugins/for/${bot}/apps`, + ), + ); + expect(apps[0]).toMatchObject({ state: "disabled", requestKind: "enable" }); + }); + + test("a Bot the person cannot use answers 404", async () => { + const response = await appAs(person).request( + `/api/plugins/for/${hidden}/apps`, + ); + expect(response.status).toBe(404); + }); + + test("a per-person catalogue app reads its credential, withdrawn or not", async () => { + const [client] = await database + .insert(credentials) + .values({ + kind: "mcp_oauth_client", + provider: `google-drive-${suite}`, + keyId: "oauth-client", + metadata: {}, + encryptedValue: "sealed", + }) + .returning({ id: credentials.id }); + await database.insert(mcpServers).values({ + id: "google-drive", + title: "Google Drive", + vendor: "Google", + url: "https://www.googleapis.com/drive/v3", + provenance: "first-party", + credentialId: client?.id ?? null, + }); + await database + .insert(pluginGrants) + .values({ kind: "mcp", ref: "google-drive/search", agentId: bot }); + const [credential] = await database + .insert(credentials) + .values({ + kind: "mcp_user_token", + provider: `google-drive-${suite}`, + keyId: person, + metadata: {}, + encryptedValue: "sealed", + revokedAt: new Date(), + }) + .returning({ id: credentials.id }); + if (!credential) throw new Error("credential not stored"); + await database.insert(mcpUserCredentials).values({ + serverId: "google-drive", + userId: person, + credentialId: credential.id, + scope: "drive.readonly", + }); + const { apps } = await json<{ apps: AppAccess[] }>( + await appAs(person).request(`/api/plugins/for/${bot}/apps`), + ); + expect(apps[0]).toMatchObject({ + key: "google-drive", + displayName: "Google Drive", + provider: "catalogue", + connect: "oauth", + state: "reconnect", + }); + }); +}); + +describe("one app, by key or by name, held or not", () => { + test("an enabled app the Bot does not hold is not_granted, found by its name", async () => { + await seedApp({ key: CALENDAR, title: "Google Calendar", grant: false }); + for (const asked of [CALENDAR, slug("calendar"), "google calendar"]) { + const response = await appAs(person).request( + `/api/plugins/for/${bot}/apps/${encodeURIComponent(asked)}`, + ); + expect(response.status).toBe(200); + expect((await json<{ app: AppAccess }>(response)).app).toMatchObject({ + key: CALENDAR, + state: "not_granted", + requestKind: "grant", + }); + } + }); + + test("an app only the directory knows is disabled, under the id enabling would mint", async () => { + const response = await appAs(person).request( + `/api/plugins/for/${bot}/apps/Dropbox`, + ); + expect((await json<{ app: AppAccess }>(response)).app).toMatchObject({ + key: DROPBOX, + displayName: "Dropbox", + provider: "composio", + state: "disabled", + requestKind: "enable", + }); + }); + + test("a catalogue entry nobody added is disabled under its key", async () => { + const response = await appAs(person).request( + `/api/plugins/for/${bot}/apps/notion`, + ); + expect((await json<{ app: AppAccess }>(response)).app).toMatchObject({ + key: "notion", + displayName: "Notion", + provider: "catalogue", + state: "disabled", + }); + }); + + test("a word that names no app is 404", async () => { + const response = await appAs(person).request( + `/api/plugins/for/${bot}/apps/${encodeURIComponent("Not An App")}`, + ); + expect(response.status).toBe(404); + }); +}); + +describe("asking for access", () => { + test("files the kind the state needs, once, and the status shows it", async () => { + await seedApp({ key: CALENDAR, title: "Google Calendar", grant: false }); + const first = await appAs(person).request( + "/api/plugins/access-requests", + post({ agentId: bot, app: "Google Calendar" }), + ); + expect(first.status).toBe(201); + const filed = await json<{ + request: AppAccessRequestRecord; + created: boolean; + }>(first); + expect(filed).toMatchObject({ + created: true, + request: { + appKey: CALENDAR, + appTitle: "Google Calendar", + kind: "grant", + status: "pending", + agentId: bot, + requestedBy: person, + }, + }); + + const again = await appAs(person).request( + "/api/plugins/access-requests", + post({ agentId: bot, app: CALENDAR }), + ); + expect(again.status).toBe(200); + expect( + await json<{ request: { id: string }; created: boolean }>(again), + ).toMatchObject({ + created: false, + request: { id: filed.request.id }, + }); + expect(eventsOf("app_access.requested")).toHaveLength(1); + + const status = await json<{ app: AppAccess }>( + await appAs(person).request(`/api/plugins/for/${bot}/apps/${CALENDAR}`), + ); + expect(status.app.request).toMatchObject({ + id: filed.request.id, + kind: "grant", + }); + // Somebody else asking files their own. + const theirs = await appAs(other).request( + "/api/plugins/access-requests", + post({ agentId: bot, app: CALENDAR }), + ); + expect(theirs.status).toBe(201); + }); + + test("two presses at once still leave one pending request", async () => { + await seedApp({ key: CALENDAR, title: "Google Calendar", grant: false }); + const responses = await Promise.all( + [1, 2, 3].map(() => + appAs(person).request( + "/api/plugins/access-requests", + post({ agentId: bot, app: CALENDAR }), + ), + ), + ); + const ids = new Set( + await Promise.all( + responses.map( + async (response) => + (await json<{ request: { id: string } }>(response)).request.id, + ), + ), + ); + expect(ids.size).toBe(1); + const rows = await database + .select() + .from(appAccessRequests) + .where( + and( + eq(appAccessRequests.requestedBy, person), + eq(appAccessRequests.status, "pending"), + ), + ); + expect(rows).toHaveLength(1); + }); + + test("a state the person can fix themselves is refused, and the refusal is in the trail", async () => { + await seedApp({ key: GMAIL, title: "Gmail" }); + const response = await appAs(person).request( + "/api/plugins/access-requests", + post({ agentId: bot, app: GMAIL }), + ); + expect(response.status).toBe(409); + expect(eventsOf("app_access.refused")[0]?.payload).toMatchObject({ + app: GMAIL, + reason: "nothing_to_request:not_connected", + }); + }); + + test("a Bot the person cannot use, and an unknown app, are refused and recorded", async () => { + const hiddenBot = await appAs(person).request( + "/api/plugins/access-requests", + post({ agentId: hidden, app: "Dropbox" }), + ); + expect(hiddenBot.status).toBe(404); + const unknown = await appAs(person).request( + "/api/plugins/access-requests", + post({ agentId: bot, app: "Not An App" }), + ); + expect(unknown.status).toBe(404); + expect( + eventsOf("app_access.refused").map((event) => event.payload.reason), + ).toEqual(["no_such_bot", "no_such_app"]); + const malformed = await appAs(person).request( + "/api/plugins/access-requests", + post({ agentId: 7 }), + ); + expect(malformed.status).toBe(400); + }); + + test("a person lists and cancels their own, and cannot touch anybody else's", async () => { + await seedApp({ key: CALENDAR, title: "Google Calendar", grant: false }); + const { request } = await json<{ request: AppAccessRequestRecord }>( + await appAs(person).request( + "/api/plugins/access-requests", + post({ agentId: bot, app: CALENDAR }), + ), + ); + const mine = await json<{ requests: AppAccessRequestRecord[] }>( + await appAs(person).request("/api/plugins/access-requests/mine"), + ); + expect(mine.requests.map((row) => row.id)).toEqual([request.id]); + expect( + ( + await json<{ requests: AppAccessRequestRecord[] }>( + await appAs(other).request("/api/plugins/access-requests/mine"), + ) + ).requests, + ).toEqual([]); + + const notTheirs = await appAs(other).request( + `/api/plugins/access-requests/${request.id}`, + { method: "DELETE" }, + ); + expect(notTheirs.status).toBe(404); + + const cancelled = await appAs(person).request( + `/api/plugins/access-requests/${request.id}`, + { method: "DELETE" }, + ); + expect(cancelled.status).toBe(200); + expect( + (await json<{ request: AppAccessRequestRecord }>(cancelled)).request, + ).toMatchObject({ status: "cancelled", resolvedBy: person }); + expect(eventsOf("app_access.cancelled")).toHaveLength(1); + + const twice = await appAs(person).request( + `/api/plugins/access-requests/${request.id}`, + { method: "DELETE" }, + ); + expect(twice.status).toBe(409); + }); +}); + +describe("answering a request", () => { + async function file(app: string, as = person) { + const response = await appAs(as).request( + "/api/plugins/access-requests", + post({ agentId: bot, app }), + ); + return (await json<{ request: AppAccessRequestRecord }>(response)).request; + } + + test("only an administrator sees the inbox and its counts", async () => { + await seedApp({ key: CALENDAR, title: "Google Calendar", grant: false }); + await file(CALENDAR); + await file(CALENDAR, other); + await file("Dropbox"); + + expect( + (await appAs(person).request("/api/plugins/access-requests")).status, + ).toBe(403); + const inbox = await json<{ + requests: AppAccessRequestRecord[]; + counts: { total: number; byApp: Record }; + }>( + await appAs(admin, "admin").request( + `/api/plugins/access-requests?app=${CALENDAR}`, + ), + ); + expect(inbox.requests).toHaveLength(2); + expect(inbox.requests[0]).toMatchObject({ + botName: `Bot ${bot}`, + requesterEmail: expect.stringContaining("@example.test"), + }); + expect(inbox.counts.byApp[CALENDAR]).toBe(2); + expect(inbox.counts.byApp[DROPBOX]).toBe(1); + expect(inbox.counts.total).toBeGreaterThanOrEqual(3); + }); + + test("approving a grant request grants the app's tools and records the Shared approval", async () => { + await seedApp({ + key: SLACK, + title: "Slack", + accountMode: "shared", + tools: ["SEND", "LIST"], + grant: false, + }); + const request = await file(SLACK); + expect(request.kind).toBe("grant"); + + expect( + ( + await appAs(person).request( + `/api/plugins/access-requests/${request.id}/approve`, + { method: "POST" }, + ) + ).status, + ).toBe(403); + + const response = await appAs(admin, "admin").request( + `/api/plugins/access-requests/${request.id}/approve`, + { method: "POST" }, + ); + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ + request: { status: "approved", resolvedBy: admin }, + enabled: false, + granted: [`${SLACK}/LIST`, `${SLACK}/SEND`], + }); + const grants = await database + .select({ ref: pluginGrants.ref }) + .from(pluginGrants) + .where(eq(pluginGrants.agentId, bot)); + expect(grants.map((grant) => grant.ref).sort()).toEqual([ + `${SLACK}/LIST`, + `${SLACK}/SEND`, + ]); + const [approval] = await database + .select() + .from(sharedUseApprovals) + .where(eq(sharedUseApprovals.agentId, bot)); + expect(approval?.serverId).toBe(SLACK); + expect(eventsOf("app_access.approved")[0]?.payload).toMatchObject({ + request: request.id, + kind: "grant", + granted: 2, + }); + + // The person now sees the next step, which is not theirs to ask for. + const { app } = await json<{ app: AppAccess }>( + await appAs(person).request(`/api/plugins/for/${bot}/apps/${SLACK}`), + ); + expect(app).toMatchObject({ state: "shared_not_connected", request: null }); + + const twice = await appAs(admin, "admin").request( + `/api/plugins/access-requests/${request.id}/approve`, + { method: "POST" }, + ); + expect(twice.status).toBe(409); + }); + + test("`refs` narrows the grant, and a ref outside the app is refused", async () => { + await seedApp({ + key: CALENDAR, + title: "Google Calendar", + tools: ["READ", "WRITE"], + grant: false, + }); + const request = await file(CALENDAR); + const outside = await appAs(admin, "admin").request( + `/api/plugins/access-requests/${request.id}/approve`, + post({ refs: [`${GMAIL}/READ`] }), + ); + expect(outside.status).toBe(400); + expect(eventsOf("app_access.refused").at(-1)?.payload).toMatchObject({ + reason: "refs_outside_app", + request: request.id, + }); + const narrowed = await appAs(admin, "admin").request( + `/api/plugins/access-requests/${request.id}/approve`, + post({ refs: [`${CALENDAR}/READ`] }), + ); + expect(narrowed.status).toBe(200); + expect((await json<{ granted: string[] }>(narrowed)).granted).toEqual([ + `${CALENDAR}/READ`, + ]); + }); + + test("approving an enable request switches the app on and grants it to the Bot", async () => { + const request = await file("Dropbox"); + expect(request).toMatchObject({ kind: "enable", appKey: DROPBOX }); + const response = await appAs(admin, "admin").request( + `/api/plugins/access-requests/${request.id}/approve`, + { method: "POST" }, + ); + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ + enabled: true, + granted: [`${DROPBOX}/LIST_FILES`], + }); + expect(enabled).toEqual([slug("dropbox")]); + const { app } = await json<{ app: AppAccess }>( + await appAs(person).request(`/api/plugins/for/${bot}/apps/${DROPBOX}`), + ); + expect(app).toMatchObject({ + state: "not_connected", + displayName: "Dropbox", + }); + }); + + test("an app that needs what only an administrator can type stays pending", async () => { + const request = await file("google-drive"); + expect(request.kind).toBe("enable"); + const response = await appAs(admin, "admin").request( + `/api/plugins/access-requests/${request.id}/approve`, + { method: "POST" }, + ); + expect(response.status).toBe(409); + expect((await json<{ error: string }>(response)).error).toContain( + "OAuth client", + ); + const [row] = await database + .select({ status: appAccessRequests.status }) + .from(appAccessRequests) + .where(eq(appAccessRequests.id, request.id)); + expect(row?.status).toBe("pending"); + expect(eventsOf("app_access.refused").at(-1)?.payload).toMatchObject({ + reason: "needs_admin_input", + }); + }); + + test("with no Composio key an enable request is refused with the setting to change", async () => { + await seedApp({ key: GMAIL, title: "Gmail" }); + const response = await appAs(person, "user", { composio: false }).request( + "/api/plugins/access-requests", + post({ agentId: bot, app: GMAIL }), + ); + const { request } = await json<{ request: AppAccessRequestRecord }>( + response, + ); + expect(request.kind).toBe("enable"); + const approve = await appAs(admin, "admin", { composio: false }).request( + `/api/plugins/access-requests/${request.id}/approve`, + { method: "POST" }, + ); + expect(approve.status).toBe(503); + expect((await json<{ error: string }>(approve)).error).toContain( + "COMPOSIO_API_KEY", + ); + }); + + test("a team-account request is approved only once the account is connected", async () => { + await seedApp({ key: SLACK, title: "Slack", accountMode: "shared" }); + const request = await file(SLACK); + expect(request.kind).toBe("connect_shared"); + const early = await appAs(admin, "admin").request( + `/api/plugins/access-requests/${request.id}/approve`, + { method: "POST" }, + ); + expect(early.status).toBe(409); + await database.insert(brokeredConnections).values({ + provider: "composio", + app: slug("slack"), + holder: "deployment", + vendorUserId: `openbot-deployment:${suite}`, + verified: true, + }); + const later = await appAs(admin, "admin").request( + `/api/plugins/access-requests/${request.id}/approve`, + { method: "POST" }, + ); + expect(later.status).toBe(200); + const { app } = await json<{ app: AppAccess }>( + await appAs(person).request(`/api/plugins/for/${bot}/apps/${SLACK}`), + ); + expect(app.state).toBe("ready"); + }); + + test("dismissing changes nothing but the request", async () => { + await seedApp({ key: CALENDAR, title: "Google Calendar", grant: false }); + const request = await file(CALENDAR); + const response = await appAs(admin, "admin").request( + `/api/plugins/access-requests/${request.id}/dismiss`, + { method: "POST" }, + ); + expect(response.status).toBe(200); + expect( + (await json<{ request: AppAccessRequestRecord }>(response)).request, + ).toMatchObject({ status: "dismissed", resolvedBy: admin }); + expect(eventsOf("app_access.dismissed")).toHaveLength(1); + const { app } = await json<{ app: AppAccess }>( + await appAs(person).request(`/api/plugins/for/${bot}/apps/${CALENDAR}`), + ); + expect(app).toMatchObject({ state: "not_granted", request: null }); + const again = await appAs(admin, "admin").request( + `/api/plugins/access-requests/${request.id}/dismiss`, + { method: "POST" }, + ); + expect(again.status).toBe(409); + }); +}); diff --git a/server/tests/app-access-state.test.ts b/server/tests/app-access-state.test.ts new file mode 100644 index 0000000000..e71adfacd0 --- /dev/null +++ b/server/tests/app-access-state.test.ts @@ -0,0 +1,177 @@ +// server/tests/app-access-state.test.ts +import { describe, expect, test } from "bun:test"; +import { + type AppAccessFacts, + accountOf, + appAccessState, + connectOf, + REQUEST_KIND_FOR, + unavailableReason, +} from "../src/plugins/app-access"; +import { catalogueEntry } from "../src/plugins/catalogue"; + +/** + * The rule one app's state is decided by, case by case, with every fact spelled out. + * + * The store gathers these facts from the tables; this file pins what the rule makes of them, so a + * change to the rule shows up as a changed expectation here rather than as a Bot page that quietly + * says something different. + */ + +const composio = (overrides: Partial = {}): AppAccessFacts => ({ + provider: "composio", + present: true, + composioConfigured: true, + authScheme: "OAUTH2", + accountMode: "personal", + catalogueAuth: null, + hasCredential: false, + granted: true, + personal: null, + shared: null, + ...overrides, +}); + +const drive = catalogueEntry("google-drive"); +const notion = catalogueEntry("notion"); +const parallel = catalogueEntry("parallel-authenticated"); +if (!drive || !notion || !parallel) + throw new Error("The catalogue entries these cases use are missing."); + +const catalogue = ( + auth: AppAccessFacts["catalogueAuth"], + overrides: Partial = {}, +): AppAccessFacts => ({ + provider: "catalogue", + present: true, + composioConfigured: false, + authScheme: null, + accountMode: null, + catalogueAuth: auth, + hasCredential: true, + granted: true, + personal: null, + shared: null, + ...overrides, +}); + +describe("a Composio app used under each person's own account", () => { + test("not connected, connected, and connected with a refused key", () => { + expect(appAccessState(composio())).toBe("not_connected"); + expect(appAccessState(composio({ personal: { usable: true } }))).toBe( + "ready", + ); + expect(appAccessState(composio({ personal: { usable: false } }))).toBe( + "reconnect", + ); + }); + + test("tells a key form from a consent screen", () => { + expect(connectOf(composio())).toBe("consent"); + expect(connectOf(composio({ authScheme: "API_KEY" }))).toBe("key"); + expect(accountOf(composio())).toBe("personal"); + }); + + test("the Bot holding none of it is not_granted, whatever the person has", () => { + expect( + appAccessState(composio({ granted: false, personal: { usable: true } })), + ).toBe("not_granted"); + }); + + test("off at the deployment is disabled before anything else is asked", () => { + for (const facts of [ + composio({ present: false }), + composio({ composioConfigured: false }), + composio({ authScheme: null }), + composio({ granted: false, present: false }), + ]) + expect(appAccessState(facts)).toBe("disabled"); + expect(unavailableReason(composio({ composioConfigured: false }))).toBe( + "composio_unconfigured", + ); + expect(unavailableReason(composio({ authScheme: "SOMETHING_NEW" }))).toBe( + "scheme_unreadable", + ); + }); +}); + +describe("a Shared Composio app", () => { + test("is ready only with a usable team account, whatever the person holds", () => { + const shared = (over: Partial) => + composio({ accountMode: "shared", ...over }); + expect(appAccessState(shared({}))).toBe("shared_not_connected"); + expect(appAccessState(shared({ personal: { usable: true } }))).toBe( + "shared_not_connected", + ); + expect(appAccessState(shared({ shared: { usable: false } }))).toBe( + "shared_not_connected", + ); + expect(appAccessState(shared({ shared: { usable: true } }))).toBe("ready"); + expect(accountOf(shared({}))).toBe("shared"); + }); +}); + +describe("an app with no account at all", () => { + test("a no-auth Composio app is ready once granted", () => { + const facts = composio({ authScheme: "NO_AUTH" }); + expect(appAccessState(facts)).toBe("ready"); + expect(accountOf(facts)).toBe("deployment"); + expect(connectOf(facts)).toBeNull(); + }); + + test("a server added by URL is ready once granted", () => { + expect( + appAccessState({ + ...catalogue(null), + provider: "custom", + hasCredential: false, + }), + ).toBe("ready"); + }); + + test("a deployment-token entry needs its token first", () => { + expect(appAccessState(catalogue(parallel.auth))).toBe("ready"); + expect( + appAccessState(catalogue(parallel.auth, { hasCredential: false })), + ).toBe("disabled"); + expect( + unavailableReason(catalogue(parallel.auth, { hasCredential: false })), + ).toBe("token_missing"); + }); +}); + +describe("a catalogue app each person signs in to", () => { + test("a manual OAuth client must be registered before anybody can connect", () => { + expect( + unavailableReason(catalogue(drive.auth, { hasCredential: false })), + ).toBe("oauth_client_missing"); + expect(appAccessState(catalogue(drive.auth))).toBe("not_connected"); + expect(connectOf(catalogue(drive.auth))).toBe("oauth"); + }); + + test("a self-registering client is ready as soon as the row is", () => { + expect( + appAccessState(catalogue(notion.auth, { hasCredential: false })), + ).toBe("not_connected"); + }); + + test("a withdrawn credential is reconnect", () => { + expect( + appAccessState(catalogue(drive.auth, { personal: { usable: false } })), + ).toBe("reconnect"); + expect( + appAccessState(catalogue(drive.auth, { personal: { usable: true } })), + ).toBe("ready"); + }); +}); + +test("only the three states an administrator can fix file a request", () => { + expect(REQUEST_KIND_FOR).toEqual({ + ready: null, + not_connected: null, + reconnect: null, + shared_not_connected: "connect_shared", + disabled: "enable", + not_granted: "grant", + }); +}); From d6b03d98a1fcea58cd33e188b7cc8dca8393d19d Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Tue, 20 Oct 2026 19:01:44 -0300 Subject: [PATCH 03/33] Browser queries and mutations for app access and access requests --- app/src/lib/app-access/mutations.ts | 100 +++++++++++++++++ app/src/lib/app-access/queries.ts | 148 +++++++++++++++++++++++++ app/tests/app-access-mutations.test.ts | 124 +++++++++++++++++++++ 3 files changed, 372 insertions(+) create mode 100644 app/src/lib/app-access/mutations.ts create mode 100644 app/src/lib/app-access/queries.ts create mode 100644 app/tests/app-access-mutations.test.ts diff --git a/app/src/lib/app-access/mutations.ts b/app/src/lib/app-access/mutations.ts new file mode 100644 index 0000000000..dfe59671f0 --- /dev/null +++ b/app/src/lib/app-access/mutations.ts @@ -0,0 +1,100 @@ +import { mutationOptions, type QueryClient } from "@tanstack/react-query"; +import { client } from "@/lib/client"; +import { pluginKeys } from "@/lib/plugins/queries"; +import type { AppAccessRequest } from "./queries"; + +const FALLBACK = "That request could not be sent."; + +/* + * EVERY PLUGIN SCREEN, AND ON `onSettled`, for the reason `invalidatePlugins` in + * `lib/plugins/mutations.ts` sets out: approving does several durable things in a row — switch the + * app on, grant its tools, record a Shared approval — and a refusal partway leaves the earlier ones + * standing. The app-access keys sit under `pluginKeys.all`, so one invalidation covers the Bot page, + * the card, the inbox and the plugin screens the approval just changed. + */ +const refresh = (queryClient: QueryClient) => + queryClient.invalidateQueries({ queryKey: pluginKeys.all }); + +/** + * Ask an administrator for access to one app on one Bot. The server decides what is asked for from + * the app's state; a second press answers with the request already pending (`created: false`). + */ +export function requestAppAccessMutationOptions(queryClient: QueryClient) { + return mutationOptions({ + mutationFn: async (variables: { + agentId: string; + app: string; + }): Promise<{ request: AppAccessRequest; created: boolean }> => { + const response = await client("/api/plugins/access-requests", { + method: "POST", + body: variables, + fallback: FALLBACK, + }); + return response.json(); + }, + onSettled: () => refresh(queryClient), + }); +} + +/** Withdraw one's own pending request. */ +export function cancelAppAccessRequestMutationOptions( + queryClient: QueryClient, +) { + return mutationOptions({ + mutationFn: (id: string): Promise => + client( + `/api/plugins/access-requests/${encodeURIComponent(id)}`, + "request", + { + method: "DELETE", + fallback: "That request could not be withdrawn.", + }, + ), + onSettled: () => refresh(queryClient), + }); +} + +/** + * Approve a request by doing what it asks. `refs` narrows a grant to those tools; absent, the Bot is + * granted every tool the app advertises. Answers with what changed. + */ +export function approveAppAccessRequestMutationOptions( + queryClient: QueryClient, +) { + return mutationOptions({ + mutationFn: async (variables: { + id: string; + refs?: string[]; + }): Promise<{ + request: AppAccessRequest; + enabled: boolean; + granted: string[]; + }> => { + const response = await client( + `/api/plugins/access-requests/${encodeURIComponent(variables.id)}/approve`, + { + method: "POST", + ...(variables.refs ? { body: { refs: variables.refs } } : {}), + fallback: "That request could not be approved.", + }, + ); + return response.json(); + }, + onSettled: () => refresh(queryClient), + }); +} + +/** Dismiss a request without changing anything else. */ +export function dismissAppAccessRequestMutationOptions( + queryClient: QueryClient, +) { + return mutationOptions({ + mutationFn: (id: string): Promise => + client( + `/api/plugins/access-requests/${encodeURIComponent(id)}/dismiss`, + "request", + { method: "POST", fallback: "That request could not be dismissed." }, + ), + onSettled: () => refresh(queryClient), + }); +} diff --git a/app/src/lib/app-access/queries.ts b/app/src/lib/app-access/queries.ts new file mode 100644 index 0000000000..4cda503b06 --- /dev/null +++ b/app/src/lib/app-access/queries.ts @@ -0,0 +1,148 @@ +import { queryOptions } from "@tanstack/react-query"; +import { client } from "@/lib/client"; + +/** + * What each work app is for the person looking, on one Bot, and the requests they file when only an + * administrator can change it. + * + * THE STATE IS THE SERVER'S. The browser draws `state` and `requestKind` as they arrive and never + * derives either from the other fields: whether an app is switched off, held by the Bot, connected + * by this person or waiting on a team account is read on the server from the same tables every + * call gate reads. + */ + +/** + * The six things a person can be told about one app on one Bot. + * + * `ready` — usable now. `not_connected` / `reconnect` — waiting on this person's own account, which + * they connect or reconnect themselves. `shared_not_connected` — a Shared app whose team account an + * administrator has not connected. `disabled` — switched off at the deployment. `not_granted` — + * enabled, and this Bot holds none of it. + */ +export type AppAccessState = + | "ready" + | "not_connected" + | "reconnect" + | "shared_not_connected" + | "disabled" + | "not_granted"; + +/** What a press of Request access asks an administrator for. */ +export type AppAccessRequestKind = "enable" | "grant" | "connect_shared"; + +export type AppAccess = { + /** The server id the app has, or the one enabling it would mint. What a request names. */ + key: string; + /** The app's own name — "Gmail", never a humanised server id. */ + displayName: string; + logo: string | null; + provider: "composio" | "catalogue" | "custom"; + /** Whose account calls land in: each person's own, one team account, or nobody's. */ + account: "personal" | "shared" | "deployment"; + /** How the account is connected: this deployment's OAuth flow, a Composio consent, or a key form. */ + connect: "oauth" | "consent" | "key" | null; + authScheme: string | null; + accountMode: "personal" | "shared" | null; + state: AppAccessState; + /** Whether this Bot holds any of the app's tools. */ + granted: boolean; + /** The request Request access files for this state; null where the next step is the person's. */ + requestKind: AppAccessRequestKind | null; + /** This person's pending request of exactly that kind — drawn as "Requested". */ + request: { id: string; kind: AppAccessRequestKind; createdAt: string } | null; +}; + +/** A request as the person who filed it and the administrator answering it both read it. */ +export type AppAccessRequest = { + id: string; + appKey: string; + appTitle: string; + kind: AppAccessRequestKind; + status: "pending" | "approved" | "dismissed" | "cancelled"; + agentId: string | null; + botName: string | null; + requestedBy: string; + requesterEmail: string | null; + requesterName: string | null; + createdAt: string; + resolvedAt: string | null; + resolvedBy: string | null; +}; + +/** The administrator's inbox: the requests asked for, and a count per app for the Plugins list. */ +export type AppAccessInbox = { + requests: AppAccessRequest[]; + counts: { total: number; byApp: Record }; +}; + +/* + * UNDER `["plugins"]`, so every plugin write that already refreshes the plugin screens — connecting + * an account, enabling an app, granting a tool — refreshes what these say too, without each of + * those writes having to know this view exists. + */ +export const appAccessKeys = { + all: ["plugins", "app-access"] as const, + bot: (agentId: string) => ["plugins", "app-access", "bot", agentId] as const, + app: (agentId: string, app: string) => + ["plugins", "app-access", "bot", agentId, "app", app] as const, + mine: () => ["plugins", "app-access", "mine"] as const, + inbox: (app?: string) => + ["plugins", "app-access", "inbox", { app: app ?? null }] as const, +}; + +/** Every work app this Bot holds, with what each is for the person asking. */ +export function botAppsQueryOptions(agentId: string) { + return queryOptions({ + queryKey: appAccessKeys.bot(agentId), + queryFn: (): Promise => + client(`/api/plugins/for/${encodeURIComponent(agentId)}/apps`, "apps", { + fallback: "This Bot's apps could not be loaded.", + }), + }); +} + +/** + * One app, by key or by name, whether or not this Bot holds it — what a card in a conversation + * asks, so it never draws a stale state. + */ +export function botAppQueryOptions(agentId: string, app: string) { + return queryOptions({ + queryKey: appAccessKeys.app(agentId, app), + queryFn: (): Promise => + client( + `/api/plugins/for/${encodeURIComponent(agentId)}/apps/${encodeURIComponent(app)}`, + "app", + { fallback: `What ${app} is for this Bot could not be loaded.` }, + ), + }); +} + +/** The person's own pending requests. */ +export function myAppAccessRequestsQueryOptions() { + return queryOptions({ + queryKey: appAccessKeys.mine(), + queryFn: (): Promise => + client("/api/plugins/access-requests/mine", "requests", { + fallback: "Your access requests could not be loaded.", + }), + }); +} + +/** + * The pending requests an administrator answers: one app's, or all of them. The counts are always + * the whole deployment's. `enabled` is false for anybody who is not an administrator, who would be + * answered 403. + */ +export function appAccessInboxQueryOptions(enabled: boolean, app?: string) { + return queryOptions({ + queryKey: appAccessKeys.inbox(app), + queryFn: async (): Promise => { + const query = app ? `?app=${encodeURIComponent(app)}` : ""; + const response = await client(`/api/plugins/access-requests${query}`, { + fallback: "Access requests could not be loaded.", + }); + return response.json(); + }, + enabled, + }); +} diff --git a/app/tests/app-access-mutations.test.ts b/app/tests/app-access-mutations.test.ts new file mode 100644 index 0000000000..771366abd4 --- /dev/null +++ b/app/tests/app-access-mutations.test.ts @@ -0,0 +1,124 @@ +import { afterEach, expect, test } from "bun:test"; +import { MutationObserver, QueryClient } from "@tanstack/react-query"; +import { + approveAppAccessRequestMutationOptions, + cancelAppAccessRequestMutationOptions, + dismissAppAccessRequestMutationOptions, + requestAppAccessMutationOptions, +} from "../src/lib/app-access/mutations"; +import { appAccessKeys } from "../src/lib/app-access/queries"; +import { pluginKeys } from "../src/lib/plugins/queries"; + +/** + * The access-request writes, as the browser makes them: what each sends, and that each refreshes + * every plugin screen on the way out — refused or not, for the reason `invalidatePlugins` gives. + */ + +const realFetch = globalThis.fetch; +afterEach(() => { + globalThis.fetch = realFetch; +}); + +function answering(status: number, body: unknown) { + const sent: { url: string; method: string; body: unknown }[] = []; + globalThis.fetch = (async (url: string, init?: RequestInit) => { + sent.push({ + url, + method: init?.method ?? "GET", + body: init?.body ? JSON.parse(String(init.body)) : undefined, + }); + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); + }) as unknown as typeof fetch; + return sent; +} + +async function run( + build: (queryClient: QueryClient) => unknown, + variables: unknown, +) { + const queryClient = new QueryClient({ + defaultOptions: { mutations: { retry: false } }, + }); + const asked: unknown[] = []; + const invalidate = queryClient.invalidateQueries.bind(queryClient); + queryClient.invalidateQueries = ((filters: never) => { + asked.push(filters); + return invalidate(filters); + }) as typeof queryClient.invalidateQueries; + const observer = new MutationObserver( + queryClient, + build(queryClient) as never, + ); + const result = await observer + .mutate(variables as never) + .catch((error: Error) => error); + return { asked, result }; +} + +test("every app-access key sits under the plugin root, so plugin writes refresh it", () => { + for (const key of [ + appAccessKeys.all, + appAccessKeys.bot("b"), + appAccessKeys.app("b", "Gmail"), + appAccessKeys.mine(), + appAccessKeys.inbox("composio-gmail"), + ]) + expect(key.slice(0, 1)).toEqual([...pluginKeys.all]); +}); + +test("asking names the Bot and the app and nothing else", async () => { + const sent = answering(201, { request: { id: "r1" }, created: true }); + const { asked, result } = await run(requestAppAccessMutationOptions, { + agentId: "bot-1", + app: "Gmail", + }); + expect(sent).toEqual([ + { + url: "/api/plugins/access-requests", + method: "POST", + body: { agentId: "bot-1", app: "Gmail" }, + }, + ]); + expect(result).toEqual({ request: { id: "r1" }, created: true }); + expect(asked).toEqual([{ queryKey: pluginKeys.all }]); +}); + +test("approving sends refs only when given", async () => { + const sent = answering(200, { request: {}, enabled: false, granted: [] }); + await run(approveAppAccessRequestMutationOptions, { id: "r1" }); + await run(approveAppAccessRequestMutationOptions, { + id: "r2", + refs: ["composio-gmail/SEND"], + }); + expect(sent).toEqual([ + { + url: "/api/plugins/access-requests/r1/approve", + method: "POST", + body: undefined, + }, + { + url: "/api/plugins/access-requests/r2/approve", + method: "POST", + body: { refs: ["composio-gmail/SEND"] }, + }, + ]); +}); + +test("a refused write still refreshes every plugin screen", async () => { + for (const build of [ + requestAppAccessMutationOptions, + cancelAppAccessRequestMutationOptions, + approveAppAccessRequestMutationOptions, + dismissAppAccessRequestMutationOptions, + ]) { + answering(409, { error: "That request has already been answered." }); + const { asked, result } = await run(build, "r1"); + expect((result as Error).message).toBe( + "That request has already been answered.", + ); + expect(asked).toEqual([{ queryKey: pluginKeys.all }]); + } +}); From bc89bc801e7e81d5042e294408132fff94d3e994 Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Tue, 20 Oct 2026 19:01:44 -0300 Subject: [PATCH 04/33] Document per-person app states and access requests --- CHANGELOG.md | 5 +++- docs/architecture.md | 2 ++ docs/plugins/composio.md | 56 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 62 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ef3f64d9a6..515f00da48 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged. ## Unreleased -**Before upgrading.** Five things change for an existing deployment: +**Before upgrading.** Six things change for an existing deployment: - A hosted computer's browser is now headed by default. It uses a little more memory than headless did; set `COMPUTER_BROWSER_MODE=headless` (Helm: `computers.browserMode: headless`) to keep the old behaviour. @@ -31,6 +31,9 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged. - Migration `0056_sign_in_code_step` widens the check on `sign_in_requests.status` to allow `code_required` (below). A server and a computer must be upgraded together: an older computer has no `/sign-in/code`. +- Migration `0057_app_access_requests` adds one table, `app_access_requests`, where a person's + request for an administrator to switch on a work app, grant it to a Bot, or connect a Shared app's + team account is kept, with who answered it and when. It starts empty and nothing is backfilled. ### A new Bot is made from a sheet that asks where it runs first diff --git a/docs/architecture.md b/docs/architecture.md index 68248991e8..fa5c727431 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -313,6 +313,8 @@ A catalogue entry says whose credential a Bot reaches it with, which is a differ Every MCP call checks the grant first, then evaluates the same action policy engine with MCP context, then audits the result. +Whether a person can use an app through a Bot is answered once, on the server, from those same facts — the app's row, the Bot's grants, the person's own connection, a Shared app's team account — as one of six states: `ready`, `not_connected`, `reconnect`, `shared_not_connected`, `disabled` or `not_granted` (`server/src/plugins/app-access.ts`). The last three are an administrator's to fix, so a person can file a request (`app_access_requests`) for exactly what the state is missing, and approving one performs it — switching the app on, granting it to the Bot — through the same code paths the admin screens use. See [Composio](plugins/composio.md#what-an-app-is-for-one-person-on-one-bot). + ### Writing a skill in a conversation A skill can be written from the composer as well as from `/skills`. The deployment ships a skill called `skill-creator` whose instruction is how to interview somebody about the skill they want; a Bot holding it is also offered four tools the app registers — `list_skills`, `read_skill`, `list_skill_tools`, and `save_skill`, which suspends the run on a card showing the command, the title and the whole instruction. Nothing is written until the person presses the button. diff --git a/docs/plugins/composio.md b/docs/plugins/composio.md index 7b31b1a600..a6ac79ca5b 100644 --- a/docs/plugins/composio.md +++ b/docs/plugins/composio.md @@ -17,6 +17,8 @@ Setting it up takes three hands, and none of them can do another's: | An administrator | Grants its actions to a Bot | `/admin/plugins/composio-`, then that page's per-Bot screen | | Each person | Connects their own account to that app | `/settings/connected-accounts` | | An administrator | Chooses Personal or Shared for an app, and connects a Shared app's account | `/admin/plugins/composio-` | +| Each person | Asks for access to an app a Bot needs and only an administrator can fix | The Bot's page, or the card in a conversation | +| An administrator | Approves or dismisses that request — approving does the change | `/admin/plugins/composio-` | The key is the row that is easiest to misread, so it is stated twice: it is an environment variable and nothing else. No administrator, however permissioned, can turn Composio on from a page, and @@ -495,6 +497,60 @@ the ordering everywhere in this connector, so a failure between the two leaves a than live and unreachable; pressing Disconnect again is the whole recovery. Audited as `mcp.account_disconnected`. +## What an app is for one person on one Bot + +Every app a Bot holds is in one of six states for the person looking, and the server decides which +— from the app's row, the grants, the person's own connection and the team account — so the Bot's +page and a card in a conversation say the same thing a call would find: + +| State | Means | Next step | +| ---------------------- | -------------------------------------------------------------------------- | --------------------------------- | +| `ready` | Usable now: their own account, a connected team account, or no account | None | +| `not_connected` | Enabled and granted; used under each person's account, and theirs is not connected | **Connect**, the Settings flow | +| `reconnect` | Connected once; the credential was withdrawn, or the vendor refused the key at the last check | **Reconnect** | +| `shared_not_connected` | A Shared app whose team account is not connected, or whose key was refused | **Request access** | +| `disabled` | Switched off: no row, no `COMPOSIO_API_KEY`, or something only an administrator can supply is missing | **Request access** | +| `not_granted` | Enabled, and this Bot holds none of its actions | **Request access** | + +"Connected" is what this deployment last recorded — the same `verified` and probe columns the +Connected accounts page reads. Nothing asks the vendor per page load, so an account ended in +Composio's dashboard reads as `ready` until the next call or re-check finds out. + +`GET /api/plugins/for/:agentId/apps` lists every app the Bot holds, including a grant whose app is +gone; `GET /api/plugins/for/:agentId/apps/:app` answers for one app by key or by name, held or not. +Both answer only for a Bot the person may use. Settings still lists only enabled apps, and the +directory is never listed to anybody but an administrator: an app nobody enabled appears only where +a Bot needs it. + +### Requesting access + +Where only an administrator can fix it, **Request access** files a request. The person names the Bot +and the app; what is asked for comes from the state — `enable` for `disabled`, `grant` for +`not_granted`, `connect_shared` for `shared_not_connected` — so a request cannot ask for something +the app does not need. Asking twice answers with the request already waiting: one pending request +per person, app, Bot and kind, held by a unique index rather than a check, so two replicas cannot +both file one. A person can withdraw their own. + +Requests wait on the app's own page under `/admin/plugins`, with a count on the Plugins list. They +show who asked and for which Bot — they asked — and nothing about anybody's connections. +**Approve** does what was asked in the same press: + +- **enable** switches the app on through the same path as the directory's Add, then grants it to + the Bot; +- **grant** grants the Bot every action the app advertises — or the ones the administrator picked — + with the Shared approval a grant from the app's page would record; +- **connect_shared** is approved once the team account is connected, which only the consent screen + on the app's page can do. + +What only an administrator can type — a missing `COMPOSIO_API_KEY`, a curated connector's OAuth +client or token — is refused with the step to take, and the request stays pending. **Dismiss** +changes nothing but the request. Filing, withdrawing, approving, dismissing and every refusal are +audited as `app_access.*`. + +This is a different thing from the Shared account requests above: those ask an administrator to +approve who may use a team account that is already connected; these ask for the app, the grant or +the account itself. + ## The two paths that end somebody else's access Both of these used to stop at this deployment's own tables, which was the only thing they could do From a7199e0725f4cb1ce41377207fd7021cd042d7a2 Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Tue, 20 Oct 2026 19:39:39 -0300 Subject: [PATCH 05/33] Show each app's state and next step for you on a Bot's page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Bot's Granted list now names each app by its own name and says what it is for the person looking — ready, not connected, needs reconnecting, waiting on the team account, turned off, or not given to this Bot — with Connect, Reconnect, Request access or Requested beside it. One piece draws the state and the action so the chat card can reuse it. Connect starts the same connect route Settings uses, in a new tab, lifted out of ConnectAppCard; key apps and Composio reconnects go to the app's Settings page. AppAccess gains connectedAt, the time the account the app runs in was connected. --- app/src/components/bot-profile/access.tsx | 172 ++++++++++--- .../components/channels/connect-app-card.tsx | 80 ++---- .../components/plugins/app-access-action.tsx | 236 +++++++++++++++++ .../components/plugins/connect-in-new-tab.ts | 89 +++++++ app/src/lib/app-access/queries.ts | 5 + app/tests/app-access-action.test.tsx | 242 ++++++++++++++++++ app/tests/app-access-fixture.ts | 24 ++ app/tests/bot-access-apps.test.tsx | 235 +++++++++++++++++ app/tests/bot-access-skills.test.tsx | 3 + server/src/plugins/app-access.ts | 10 + server/src/plugins/plugin-store/app-access.ts | 44 +++- .../app-access-routes.integration.test.ts | 8 + 12 files changed, 1048 insertions(+), 100 deletions(-) create mode 100644 app/src/components/plugins/app-access-action.tsx create mode 100644 app/src/components/plugins/connect-in-new-tab.ts create mode 100644 app/tests/app-access-action.test.tsx create mode 100644 app/tests/app-access-fixture.ts create mode 100644 app/tests/bot-access-apps.test.tsx diff --git a/app/src/components/bot-profile/access.tsx b/app/src/components/bot-profile/access.tsx index 14a8b8bed9..dfd3a069cb 100644 --- a/app/src/components/bot-profile/access.tsx +++ b/app/src/components/bot-profile/access.tsx @@ -3,6 +3,12 @@ import { useMutation, useQuery } from "@tanstack/react-query"; import { Link } from "@tanstack/react-router"; import { Fragment } from "react"; import { HandoffPanel } from "@/components/agents/handoff-panel"; +import { + AppAccessAction, + appAccessSentence, +} from "@/components/plugins/app-access-action"; +import { PluginLogo } from "@/components/plugins/plugin-logo"; +import { Button } from "@/components/ui/button"; import { PageEmpty, PageRows, @@ -19,6 +25,7 @@ import { import { Separator } from "@/components/ui/separator"; import { Switch } from "@/components/ui/switch"; import type { AgentProfile } from "@/lib/agents/queries"; +import { botAppsQueryOptions } from "@/lib/app-access/queries"; import { currentUserQueryOptions } from "@/lib/auth/queries"; import { setPluginGrantMutationOptions } from "@/lib/plugins/mutations"; import { @@ -29,7 +36,12 @@ import { import { readToolName } from "@/lib/plugins/tool-name"; import { queryClient } from "@/query-client"; -/** "google-drive" as "Google Drive": the connector key, said the way a person would. */ +/** + * "google-drive" as "Google Drive": the connector key, said the way a person would. + * + * ONLY FOR A CONNECTOR THE APP-ACCESS READ DID NOT NAME. Every app it names is drawn by its own + * display name — "Gmail", where this would have said "Composio Gmail". + */ function connectorName(key: string): string { return key .split(/[-_]/) @@ -147,13 +159,26 @@ function YourSkills({ agentId }: { agentId: string }) { } /** - * Everything this Bot holds, read from the same snapshot a run is offered. Read-only: apps are - * granted by an administrator, who gets a link to where that is decided. + * Everything this Bot holds, read from the same snapshot a run is offered, and what each app is for + * the person looking. + * + * READ-ONLY FOR GRANTS, NOT FOR THE PERSON'S OWN PART. Apps are granted by an administrator, who gets + * a link to where that is decided. But a Bot that holds Gmail is no use to somebody who has not + * connected their Gmail, and this list used to say "Gmail · 4 tools" to everybody alike — so each + * app now says what it is for the person reading (`GET /api/plugins/for/:agentId/apps`) with the one + * thing they can do about it: Connect, Reconnect, Request access, or Requested. The state is the + * server's and is drawn as it comes; see `AppAccessAction`. + * + * ADMINISTRATORS SEE THEIR OWN STATE HERE TOO, AND NOBODY ELSE'S. The app-access read answers for + * whoever is asking, exactly as the connections read does, so this cannot become a list of who has + * connected what. */ function Granted({ agentId }: { agentId: string }) { const me = useQuery(currentUserQueryOptions()).data; const plugins = useQuery(agentPluginsQueryOptions(agentId)); - if (plugins.isPending) return null; + const apps = useQuery(botAppsQueryOptions(agentId)); + // Both, so no row is drawn without its state and then redrawn with one a moment later. + if (plugins.isPending || apps.isPending) return null; if (!plugins.data) { return (

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

); } - const connectors = [...connectorTools(plugins.data).entries()]; + const admin = me?.role === "admin"; + const tools = connectorTools(plugins.data); + const states = apps.data ?? []; + /* + * Every app the Bot holds a grant for, by the app's own name, and then any connector the grant + * snapshot names that the app-access read did not — which is none on a healthy deployment, and + * keeps a row from vanishing on one where the second read failed. + */ + const described = new Set(states.map((app) => app.key)); + const rest = [...tools.entries()].filter(([key]) => !described.has(key)); const skills = plugins.data.skills; + const count = states.length + rest.length + skills.length; return ( - {connectors.length === 0 && skills.length === 0 ? ( + {apps.error ? ( +

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

+ ) : null} + {count === 0 ? ( Nothing granted yet. An administrator grants apps from the Plugins screens; until then this Bot can converse, and nothing more. ) : ( - {connectors.map(([key, labels], index) => ( + {states.map((app, index) => { + const labels = tools.get(app.key) ?? []; + return ( + + {index > 0 ? : null} + + + + + + {app.displayName} + + {app.state === "ready" + ? [appAccessSentence(app.state, app.displayName)] + .concat(labels.length > 0 ? [toolList(labels)] : []) + .join(" ") + : appAccessSentence(app.state, app.displayName)} + + + + {app.state === "ready" ? ( + + ) : ( + + )} + {admin ? ( + + ) : null} + + + + ); + })} + {rest.map(([key, labels], index) => ( - {index > 0 ? : null} - - ) : undefined - } - size="sm" - > + {states.length + index > 0 ? : null} + {connectorName(key)} - - {labels.slice(0, 4).join(", ")} - {labels.length > 4 ? ` and ${labels.length - 4} more` : ""} - + {toolList(labels)} - - {labels.length} {labels.length === 1 ? "tool" : "tools"} - - {me?.role === "admin" ? ( - + + {admin ? ( + ) : null} @@ -212,7 +278,7 @@ function Granted({ agentId }: { agentId: string }) { ))} {skills.map((skill, index) => ( - {connectors.length + index > 0 ? : null} + {states.length + rest.length + index > 0 ? : null} @@ -232,3 +298,49 @@ function Granted({ agentId }: { agentId: string }) {
); } + +/** The first few tool labels, and how many more. */ +function toolList(labels: string[]): string { + return `${labels.slice(0, 4).join(", ")}${labels.length > 4 ? ` and ${labels.length - 4} more` : ""}`; +} + +function ToolCount({ count }: { count: number }) { + return ( + + {count} {count === 1 ? "tool" : "tools"} + + ); +} + +/** + * Where an administrator decides which of the app's tools this Bot holds. + * + * A CHEVRON OF ITS OWN RATHER THAN THE WHOLE ROW. The row used to be the link, which was right while + * it held nothing else; now it can hold Connect or Request access, and a button inside a link is two + * controls answering one click. The destination is unchanged. + */ +function ManageLink({ + agentId, + appKey, + title, +}: { + agentId: string; + appKey: string; + title: string; +}) { + return ( + + ); +} diff --git a/app/src/components/channels/connect-app-card.tsx b/app/src/components/channels/connect-app-card.tsx index 89f712d54d..56361a9e84 100644 --- a/app/src/components/channels/connect-app-card.tsx +++ b/app/src/components/channels/connect-app-card.tsx @@ -1,8 +1,8 @@ -import { useMutation, useQuery } from "@tanstack/react-query"; +import { useQuery } from "@tanstack/react-query"; import { Link } from "@tanstack/react-router"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useRef } from "react"; +import { useConnectInNewTab } from "@/components/plugins/connect-in-new-tab"; import { Button } from "@/components/ui/button"; -import { connectAccountMutationOptions } from "@/lib/plugins/mutations"; import { connectionsQueryOptions, personalConnections, @@ -21,6 +21,11 @@ import { queryClient } from "@/query-client"; * WATCHING, NOT TRUSTING. The return trip proves nothing — it is an ordinary redirect in a tab this * one cannot read — so nothing here treats "they came back" as "it worked". The card polls this * person's own connections while it waits and only says connected when the server lists one. + * + * NOW THE FALLBACK RATHER THAN THE CARD. A refusal names the Bot it came from and `AppAccessCard` + * draws it from the app's live state for this person on that Bot. This one is drawn only where no + * Bot can be named — a refusal written before refusals carried one, on a surface that declared none + * — and it can only offer Connect, which is all such a refusal ever meant. */ export function ConnectAppCard({ serverId, @@ -31,12 +36,15 @@ export function ConnectAppCard({ title: string; message: string; }) { - const [waiting, setWaiting] = useState(false); - /** This app takes a key somebody types, so there is no page to send them to. */ - const [needsKey, setNeedsKey] = useState(false); - /** The authorization url, when the browser refused to open the tab holding it. */ - const [blockedUrl, setBlockedUrl] = useState(null); - const connect = useMutation(connectAccountMutationOptions()); + const { + open: openTab, + waiting, + stopWaiting, + needsKey, + blockedUrl, + pending, + error, + } = useConnectInNewTab(); const connections = useQuery({ ...connectionsQueryOptions(), // Only while a consent is open in the other tab; otherwise this is an ordinary cached read. @@ -59,53 +67,13 @@ export function ConnectAppCard({ const was = useRef(connected); useEffect(() => { if (connected && !was.current) { - setWaiting(false); + stopWaiting(); void queryClient.invalidateQueries(); } was.current = connected; - }, [connected]); - - /** - * The tab, opened on the click itself rather than on the answer to it. - * - * A BROWSER ONLY LETS A PAGE OPEN A TAB WHILE A CLICK IS STILL RECENT. Chrome's transient - * activation lasts about five seconds, and the request that mints the authorization url has to - * finish first — so on a slow deployment `window.open` was called with the activation already - * expired, the tab was blocked, and the card cheerfully said to finish in a tab that did not - * exist. The blank tab is claimed first and navigated once the url arrives. - * - * `noopener` CANNOT BE PASSED HERE: it makes `window.open` answer null, and null is a handle - * nothing can navigate later. The opener is severed on the handle instead, which is the same - * guarantee by a different route. - */ - const claimTab = (): Window | null => { - const tab = window.open("", "_blank"); - if (tab) tab.opener = null; - return tab; - }; + }, [connected, stopWaiting]); - const open = () => { - const tab = claimTab(); - connect.mutate(serverId, { - onSuccess: (authorizationUrl) => { - /* - * No url means this app is connected by typing a key rather than by visiting a page — the - * route answers `{ fields }` and the mutation unwraps nothing. Saying so, with the page - * that has the form, beats a button that silently does nothing. - */ - if (!authorizationUrl) { - tab?.close(); - setNeedsKey(true); - return; - } - setWaiting(true); - if (tab) tab.location.replace(authorizationUrl); - // The tab was blocked. The url is still good, so offer it as something to click. - else setBlockedUrl(authorizationUrl); - }, - onError: () => tab?.close(), - }); - }; + const open = () => openTab(serverId); return (
{/* The heading above already names the app; the button says what pressing it does. */} - {waiting ? ( @@ -158,11 +126,9 @@ export function ConnectAppCard({ .

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

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

) : null}
diff --git a/app/src/components/plugins/app-access-action.tsx b/app/src/components/plugins/app-access-action.tsx new file mode 100644 index 0000000000..25d1fa9eb9 --- /dev/null +++ b/app/src/components/plugins/app-access-action.tsx @@ -0,0 +1,236 @@ +import { useMutation } from "@tanstack/react-query"; +import { Link } from "@tanstack/react-router"; +import { useEffect, useRef } from "react"; +import { useConnectInNewTab } from "@/components/plugins/connect-in-new-tab"; +import { Button } from "@/components/ui/button"; +import { + cancelAppAccessRequestMutationOptions, + requestAppAccessMutationOptions, +} from "@/lib/app-access/mutations"; +import { + type AppAccess, + type AppAccessState, + appAccessKeys, +} from "@/lib/app-access/queries"; +import { queryClient } from "@/query-client"; + +/** + * One work app's state for the person looking, and the one thing they can do about it. + * + * ONE PIECE, DRAWN IN TWO PLACES. A Bot's page lists every app the Bot holds with a row each, and a + * conversation draws a card when the Bot needed an app it could not use. Both say the same thing — + * whether this person can use this app through this Bot, whose move it is if not, and the button + * for that move — so both draw it from here, and a sentence corrected for one is corrected for both. + * + * WHO CAN FIX IT IS THE WHOLE SENTENCE. Six states reduce to three answers: nothing to do, a step + * that is yours (connect, reconnect), and a step only an administrator can take (switch it on, + * grant it, connect the team account). The copy names which, because "Gmail is unavailable" sends + * somebody hunting through their own settings for a switch that is not there. + * + * THE STATE IS THE SERVER'S. It arrives on `access` and is drawn as it is; nothing here works out + * from `granted` or `accountMode` what the state ought to be. The one exception is the caller's: a + * card may hand in an `access` whose state it overrode from the refusal it was drawn for — see + * `AppAccessCard` — and that is the caller's decision, made where the refusal is in hand. + */ + +/** What the person is told about one app, in words that say whose move it is. */ +export function appAccessSentence( + state: AppAccessState, + displayName: string, +): string { + switch (state) { + case "ready": + return "Ready to use."; + case "not_connected": + return `You haven't connected your ${displayName} account yet.`; + case "reconnect": + return `Your ${displayName} connection needs reconnecting.`; + case "shared_not_connected": + return `Your organisation's ${displayName} account isn't connected yet. Only an administrator can connect it.`; + case "disabled": + return `Turned off by an administrator. Only they can turn ${displayName} on.`; + case "not_granted": + return `This Bot hasn't been given ${displayName}. Only an administrator can give it.`; + } +} + +/** + * The button for the person's next move, and what it says while it happens. + * + * - `ready` — nothing. + * - `not_connected` / `reconnect` — the person's own connection. Connect starts it in a new tab + * with the connect route Settings presses; an app connected with a typed key, and a Composio app + * that already has a row (which the connect route refuses until it is disconnected), go to the + * app's own page in Settings instead, where the form and Disconnect are. + * - the three an administrator fixes — Request access, which files the request the server decides + * the kind of, and then Requested with a way to withdraw it. + */ +export function AppAccessAction({ + agentId, + access, +}: { + agentId: string; + access: AppAccess; +}) { + if (access.state === "ready") return null; + if (access.state === "not_connected" || access.state === "reconnect") + return ; + return ; +} + +/** The person's own account: connect it, or reconnect it. */ +function ConnectAction({ access }: { access: AppAccess }) { + const label = access.state === "reconnect" ? "Reconnect" : "Connect"; + const tab = useConnectInNewTab(); + const { waiting, stopWaiting } = tab; + + /* + * WATCHING THE SERVER WHILE A CONSENT IS OPEN in the other tab, because the return trip proves + * nothing. The whole app-access view is refreshed every two seconds until this app stops being + * this person's to connect — which is what unmounts this component — and then everything once, + * because the grants a Bot is offered are read per conversation and the turn after this has to + * be asked against a store that knows about the new account. + */ + useEffect(() => { + if (!waiting) return; + const timer = setInterval(() => { + void queryClient.invalidateQueries({ queryKey: appAccessKeys.all }); + }, 2_000); + return () => clearInterval(timer); + }, [waiting]); + const waited = useRef(false); + waited.current ||= waiting; + useEffect( + () => () => { + if (waited.current) void queryClient.invalidateQueries(); + }, + [], + ); + + /* + * A FORM, OR A CONNECTION THAT ALREADY HAS A ROW, IS SETTINGS' JOB. A typed key has nowhere to be + * typed but the app's page, and a Composio app whose row still stands — a refused key, an account + * the vendor lost — is refused by the connect route until it is disconnected there. Only this + * deployment's own OAuth flow takes a fresh consent over a withdrawn one. + */ + const viaSettings = + access.connect === "key" || + (access.state === "reconnect" && access.connect !== "oauth") || + tab.needsKey; + if (viaSettings) + return ( + + ); + + return ( +
+ {tab.error ? ( +

+ {tab.error} +

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

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

+ ) : waiting ? ( +

+ Finish in the tab that opened. +

+ ) : null} + +
+ ); +} + +/** A step only an administrator can take: ask for it, and withdraw the ask. */ +function RequestAction({ + agentId, + access, +}: { + agentId: string; + access: AppAccess; +}) { + const request = useMutation(requestAppAccessMutationOptions(queryClient)); + const cancel = useMutation( + cancelAppAccessRequestMutationOptions(queryClient), + ); + const error = request.error ?? cancel.error; + + /* + * Nothing to ask for where the server names no request: a state a caller overrode from a refusal + * the server does not know about (the team account lost at the vendor), which the server would + * answer 409 because as far as it knows nothing is wrong. The sentence still says whose move it is. + */ + if (!access.request && !access.requestKind) return null; + + return ( +
+ {error ? ( +

+ {error.message} +

+ ) : null} + {access.request ? ( + <> + + Requested + + + + ) : ( + + )} +
+ ); +} diff --git a/app/src/components/plugins/connect-in-new-tab.ts b/app/src/components/plugins/connect-in-new-tab.ts new file mode 100644 index 0000000000..a96ac0120a --- /dev/null +++ b/app/src/components/plugins/connect-in-new-tab.ts @@ -0,0 +1,89 @@ +import { useMutation } from "@tanstack/react-query"; +import { useCallback, useState } from "react"; +import { connectAccountMutationOptions } from "@/lib/plugins/mutations"; + +/** + * Start this person's own connection to one app in a new tab, the way Settings starts it. + * + * THE SAME WRITE AS SETTINGS, A DIFFERENT TAB. `connectAccountMutationOptions` is the connect route + * the connected-accounts page presses, returning to that page when the vendor is done, so the + * confirm that writes the connection down is the one that page already runs. What differs is where + * the consent screen opens: Settings navigates its own tab, because the person went there to + * connect; a card in a conversation or a row on a Bot's page opens a new one, because the person is + * in the middle of something and a full navigation several redirects long would take them out of + * it. This tab stays where it was, and whoever drew the button watches the server for the + * connection to appear — the return trip proves nothing, being an ordinary redirect in a tab this + * one cannot read. + * + * Lifted out of `ConnectAppCard`, which had it inline, so the Bot page and the app-access card start + * a connection with the same code rather than a second copy of the popup rules below. + */ +export function useConnectInNewTab() { + const [waiting, setWaiting] = useState(false); + /** This app takes a key somebody types, so there is no page to send them to. */ + const [needsKey, setNeedsKey] = useState(false); + /** The authorization url, when the browser refused to open the tab holding it. */ + const [blockedUrl, setBlockedUrl] = useState(null); + const connect = useMutation(connectAccountMutationOptions()); + // Stable, so a caller can list it in an effect's dependencies without re-running it every render. + const stopWaiting = useCallback(() => setWaiting(false), []); + + /** + * The tab, opened on the click itself rather than on the answer to it. + * + * A BROWSER ONLY LETS A PAGE OPEN A TAB WHILE A CLICK IS STILL RECENT. Chrome's transient + * activation lasts about five seconds, and the request that mints the authorization url has to + * finish first — so on a slow deployment `window.open` was called with the activation already + * expired, the tab was blocked, and the card cheerfully said to finish in a tab that did not + * exist. The blank tab is claimed first and navigated once the url arrives. + * + * `noopener` CANNOT BE PASSED HERE: it makes `window.open` answer null, and null is a handle + * nothing can navigate later. The opener is severed on the handle instead, which is the same + * guarantee by a different route. + */ + const claimTab = (): Window | null => { + const tab = window.open("", "_blank"); + if (tab) tab.opener = null; + return tab; + }; + + const open = (serverId: string) => { + const tab = claimTab(); + connect.mutate(serverId, { + onSuccess: (authorizationUrl) => { + /* + * No url means this app is connected by typing a key rather than by visiting a page — the + * route answers `{ fields }` and the mutation unwraps nothing. Saying so, with the page + * that has the form, beats a button that silently does nothing. + */ + if (!authorizationUrl) { + tab?.close(); + setNeedsKey(true); + return; + } + setWaiting(true); + if (tab) tab.location.replace(authorizationUrl); + // The tab was blocked. The url is still good, so offer it as something to click. + else setBlockedUrl(authorizationUrl); + }, + onError: () => tab?.close(), + }); + }; + + return { + open, + /** A consent is open in the other tab; whoever drew the button should be watching. */ + waiting, + /** Called once the server lists the connection, so the button stops saying it is waiting. */ + stopWaiting, + needsKey, + blockedUrl, + pending: connect.isPending, + /** The connect route's own sentence, when it refused. */ + error: connect.isError + ? connect.error instanceof Error + ? connect.error.message + : "That account could not be connected." + : null, + }; +} diff --git a/app/src/lib/app-access/queries.ts b/app/src/lib/app-access/queries.ts index 4cda503b06..d8366366ce 100644 --- a/app/src/lib/app-access/queries.ts +++ b/app/src/lib/app-access/queries.ts @@ -46,6 +46,11 @@ export type AppAccess = { state: AppAccessState; /** Whether this Bot holds any of the app's tools. */ granted: boolean; + /** + * When the account this app runs in for this person was connected, or null. Read by the chat card + * to tell an account lost at the vendor from one connected again since. See `AppAccessCard`. + */ + connectedAt: string | null; /** The request Request access files for this state; null where the next step is the person's. */ requestKind: AppAccessRequestKind | null; /** This person's pending request of exactly that kind — drawn as "Requested". */ diff --git a/app/tests/app-access-action.test.tsx b/app/tests/app-access-action.test.tsx new file mode 100644 index 0000000000..155b8d1e6e --- /dev/null +++ b/app/tests/app-access-action.test.tsx @@ -0,0 +1,242 @@ +import { afterAll, afterEach, beforeAll, expect, test } from "bun:test"; +import { GlobalRegistrator } from "@happy-dom/global-registrator"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, + Outlet, + RouterProvider, +} from "@tanstack/react-router"; +import { cleanup, fireEvent, render, waitFor } from "@testing-library/react"; +import { + AppAccessAction, + appAccessSentence, +} from "@/components/plugins/app-access-action"; +import type { AppAccess, AppAccessState } from "@/lib/app-access/queries"; +import { appAccess } from "./app-access-fixture"; +import { settleReactWork } from "./settle-react-work"; + +/** + * The one piece the Bot page and the chat card both draw an app's state with. + * + * Each of the six states must say whose move it is and offer exactly that move — and nothing for a + * state that needs none. The two moves that write are asserted against the request they send: the + * browser names a Bot and an app and never a kind, which is the server's to decide. + */ + +let originalOpen: typeof window.open; +beforeAll(() => { + GlobalRegistrator.register(); + originalOpen = window.open; +}); +afterEach(cleanup); +afterAll(async () => { + await settleReactWork(); + GlobalRegistrator.unregister(); +}); + +const originalFetch = global.fetch; +afterEach(() => { + global.fetch = originalFetch; + window.open = originalOpen; +}); + +type Sent = { method: string; url: string; body: unknown }; + +function mounted( + access: AppAccess, + answer?: { status: number; body: unknown }, +) { + const sent: Sent[] = []; + const opened: string[] = []; + window.open = (() => { + const tab = { + opener: {} as unknown, + location: { replace: (next: string) => opened.push(next) }, + close: () => {}, + }; + return tab as unknown as Window; + }) as typeof window.open; + global.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + const method = init?.method ?? "GET"; + sent.push({ + method, + url, + body: init?.body ? JSON.parse(String(init.body)) : undefined, + }); + if (answer && method !== "GET") + return Response.json(answer.body, { status: answer.status }); + if (url.endsWith("/connect?returnTo=settings")) + return Response.json({ authorizationUrl: "https://gmail.test/consent" }); + return Response.json({ request: { id: "req_1" }, created: true }); + }) as unknown as typeof fetch; + + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + const root = createRootRoute({ component: Outlet }); + const page = createRoute({ + getParentRoute: () => root, + path: "/", + component: () => ( +
+

{appAccessSentence(access.state, access.displayName)}

+ +
+ ), + }); + const settings = createRoute({ + getParentRoute: () => root, + path: "/settings/connected-accounts/$key", + component: () => null, + }); + const router = createRouter({ + routeTree: root.addChildren([page, settings]), + history: createMemoryHistory({ initialEntries: ["/"] }), + }); + const view = render( + + + , + ); + return { view, sent, opened }; +} + +const COPY: Record = { + ready: /^Ready to use\.$/, + not_connected: /You haven't connected your Gmail account yet\./, + reconnect: /Your Gmail connection needs reconnecting\./, + shared_not_connected: + /Your organisation's Gmail account isn't connected yet\. Only an administrator can connect it\./, + disabled: /Turned off by an administrator\. Only they can turn Gmail on\./, + not_granted: + /This Bot hasn't been given Gmail\. Only an administrator can give it\./, +}; + +test.each(Object.entries(COPY))( + "says whose move it is: %s", + (state, sentence) => { + expect(appAccessSentence(state as AppAccessState, "Gmail")).toMatch( + sentence, + ); + }, +); + +test("ready offers nothing to press", async () => { + const { view } = mounted(appAccess()); + await view.findByText("Ready to use."); + expect(view.queryByRole("button")).toBeNull(); + expect(view.queryByRole("link")).toBeNull(); +}); + +test("not connected offers Connect, which starts the person's own consent in a new tab", async () => { + const { view, sent, opened } = mounted(appAccess({ state: "not_connected" })); + fireEvent.click(await view.findByRole("button", { name: "Connect" })); + await waitFor(() => expect(opened).toEqual(["https://gmail.test/consent"])); + expect(sent.map((call) => `${call.method} ${call.url}`)).toContain( + "POST /api/plugins/servers/composio-gmail/connect?returnTo=settings", + ); + await view.findByText("Finish in the tab that opened."); +}); + +test("an app connected with a typed key goes to its page in Settings, where the form is", async () => { + const { view } = mounted( + appAccess({ state: "not_connected", connect: "key" }), + ); + // Drawn as a button (`Button` over a `Link`), and an anchor underneath with the address. + const link = (await view.findByText("Connect")).closest("a"); + expect(link?.getAttribute("href")).toBe( + "/settings/connected-accounts/composio-gmail", + ); +}); + +test("a Composio app to reconnect goes to Settings, because the connect route refuses a row that stands", async () => { + const { view } = mounted(appAccess({ state: "reconnect" })); + const link = (await view.findByText("Reconnect")).closest("a"); + expect(link?.getAttribute("href")).toBe( + "/settings/connected-accounts/composio-gmail", + ); +}); + +test("this deployment's own OAuth app reconnects in place, with a fresh consent", async () => { + const { view, opened } = mounted( + appAccess({ + key: "notion", + displayName: "Notion", + provider: "catalogue", + connect: "oauth", + state: "reconnect", + }), + ); + fireEvent.click(await view.findByRole("button", { name: "Reconnect" })); + await waitFor(() => expect(opened).toHaveLength(1)); +}); + +test.each([ + ["disabled", "enable"], + ["not_granted", "grant"], + ["shared_not_connected", "connect_shared"], +] as const)( + "%s offers Request access, naming the Bot and the app and never the kind", + async (state, kind) => { + const { view, sent } = mounted(appAccess({ state, requestKind: kind })); + fireEvent.click( + await view.findByRole("button", { name: "Request access" }), + ); + await waitFor(() => + expect(sent.find((call) => call.method === "POST")).toEqual({ + method: "POST", + url: "/api/plugins/access-requests", + body: { agentId: "expenses", app: "composio-gmail" }, + }), + ); + }, +); + +test("a pending request reads Requested, and Withdraw cancels it", async () => { + const { view, sent } = mounted( + appAccess({ + state: "disabled", + requestKind: "enable", + request: { + id: "req_9", + kind: "enable", + createdAt: "2026-10-20T10:00:00.000Z", + }, + }), + ); + await view.findByText("Requested"); + expect(view.queryByRole("button", { name: "Request access" })).toBeNull(); + fireEvent.click(view.getByRole("button", { name: "Withdraw" })); + await waitFor(() => + expect( + sent.some( + (call) => + call.method === "DELETE" && + call.url === "/api/plugins/access-requests/req_9", + ), + ).toBe(true), + ); +}); + +test("the server's refusal of a request is drawn beside the button", async () => { + const { view } = mounted( + appAccess({ state: "not_granted", requestKind: "grant" }), + { status: 409, body: { error: "Gmail is ready to use." } }, + ); + fireEvent.click(await view.findByRole("button", { name: "Request access" })); + expect((await view.findByRole("alert")).textContent).toBe( + "Gmail is ready to use.", + ); +}); + +test("a state with no request to file offers none, rather than a button the server would refuse", async () => { + const { view } = mounted( + appAccess({ state: "shared_not_connected", requestKind: null }), + ); + await view.findByText(/organisation's Gmail account/); + expect(view.queryByRole("button")).toBeNull(); +}); diff --git a/app/tests/app-access-fixture.ts b/app/tests/app-access-fixture.ts new file mode 100644 index 0000000000..10930a6732 --- /dev/null +++ b/app/tests/app-access-fixture.ts @@ -0,0 +1,24 @@ +import type { AppAccess } from "@/lib/app-access/queries"; + +/** + * One app as `GET /api/plugins/for/:agentId/apps` answers it: Gmail, through Composio, under each + * person's own account, ready. Each test overrides only the fields its case is about. + */ +export function appAccess(overrides: Partial = {}): AppAccess { + return { + key: "composio-gmail", + displayName: "Gmail", + logo: null, + provider: "composio", + account: "personal", + connect: "consent", + authScheme: "OAUTH2", + accountMode: "personal", + state: "ready", + granted: true, + connectedAt: null, + requestKind: null, + request: null, + ...overrides, + }; +} diff --git a/app/tests/bot-access-apps.test.tsx b/app/tests/bot-access-apps.test.tsx new file mode 100644 index 0000000000..0efee53595 --- /dev/null +++ b/app/tests/bot-access-apps.test.tsx @@ -0,0 +1,235 @@ +import { afterAll, afterEach, beforeAll, expect, test } from "bun:test"; +import { GlobalRegistrator } from "@happy-dom/global-registrator"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, + Outlet, + RouterProvider, +} from "@tanstack/react-router"; +import { cleanup, render, within } from "@testing-library/react"; +import { AccessSections } from "@/components/bot-profile/access"; +import type { AgentProfile } from "@/lib/agents/queries"; +import type { AppAccess } from "@/lib/app-access/queries"; +import { appAccess } from "./app-access-fixture"; +import { settleReactWork } from "./settle-react-work"; + +/** + * The Granted section of a Bot's page, which used to list what a Bot holds and say nothing about + * whether the person reading could use any of it. + * + * Each app is named by its own name — never "Composio Gmail" — and says what it is for the person + * looking, with the one move they have. An administrator keeps the way to the Bot's grants for each + * app; nobody gets anybody else's connection state, because the read answers for the asker alone. + */ + +beforeAll(() => GlobalRegistrator.register()); +afterEach(cleanup); +afterAll(async () => { + await settleReactWork(); + GlobalRegistrator.unregister(); +}); + +const originalFetch = global.fetch; +afterEach(() => { + global.fetch = originalFetch; +}); + +const BOT: AgentProfile = { + id: "expenses", + name: "Expenses", + title: "Finance", + roleDescription: "Receipts.", + avatarSeed: "e", + visibility: "public", + endpoint: null, + builtIn: true, + hasAuth: false, + hasCallbackToken: false, + hidden: false, + pinned: false, + systemOwned: false, + canManage: false, + mine: false, +}; + +const tool = (ref: string, toolName: string) => ({ + ref, + toolName, + description: "", + inputSchema: {}, +}); + +function serving(options: { + role?: "admin" | "user"; + apps?: AppAccess[] | "fail"; + tools?: ReturnType[]; +}) { + const { role = "user", apps = [], tools = [] } = options; + global.fetch = (async (input: RequestInfo | URL) => { + const url = String(input); + if (url.endsWith("/api/plugins/for/expenses/apps")) + return apps === "fail" + ? Response.json({ error: "nope" }, { status: 500 }) + : Response.json({ apps }); + if (url.endsWith("/api/plugins/for/expenses")) + return Response.json({ tools, skills: [] }); + if (url.includes("/handoff")) + return Response.json({ + handoff: { + enabled: false, + canGrant: false, + reachable: [], + grantable: false, + }, + }); + if (url.includes("/api/agents")) return Response.json({ agents: [] }); + return Response.json({ + user: { id: "me", role, email: "me@example.test", name: "Me" }, + }); + }) as unknown as typeof fetch; +} + +function draw() { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + const root = createRootRoute({ component: Outlet }); + const page = createRoute({ + getParentRoute: () => root, + path: "/", + component: () => , + }); + const grants = createRoute({ + getParentRoute: () => root, + path: "/admin/plugins/$key/bots/$agentId", + component: () => null, + }); + const settings = createRoute({ + getParentRoute: () => root, + path: "/settings/connected-accounts/$key", + component: () => null, + }); + const router = createRouter({ + routeTree: root.addChildren([page, grants, settings]), + history: createMemoryHistory({ initialEntries: ["/"] }), + }); + return render( + + + , + ); +} + +const row = async (view: ReturnType, key: string) => { + await view.findByText("Granted"); + const found = await view.findByText((_, element) => + Boolean(element?.getAttribute("data-app") === key), + ); + return within(found); +}; + +test("each app is named by its own name and says it is ready, with its tools", async () => { + serving({ + apps: [appAccess()], + tools: [ + tool( + "composio-gmail/GMAIL_SEND_EMAIL", + "mcp__composio-gmail__GMAIL_SEND_EMAIL", + ), + tool( + "composio-gmail/GMAIL_FETCH_EMAILS", + "mcp__composio-gmail__GMAIL_FETCH_EMAILS", + ), + ], + }); + const view = draw(); + const gmail = await row(view, "composio-gmail"); + expect(gmail.getByText("Gmail")).toBeTruthy(); + expect(gmail.getByText(/^Ready to use\./)).toBeTruthy(); + expect(gmail.getByText("2 tools")).toBeTruthy(); + expect(view.queryByText(/Composio Gmail/)).toBeNull(); + expect(gmail.queryByRole("button")).toBeNull(); +}); + +test("an app this person has not connected says so and offers Connect", async () => { + serving({ apps: [appAccess({ state: "not_connected" })] }); + const gmail = await row(draw(), "composio-gmail"); + expect( + gmail.getByText("You haven't connected your Gmail account yet."), + ).toBeTruthy(); + expect(gmail.getByRole("button", { name: "Connect" })).toBeTruthy(); +}); + +test("an app only an administrator can fix offers Request access, and a pending one reads Requested", async () => { + serving({ + apps: [ + appAccess({ + key: "composio-slack", + displayName: "Slack", + state: "shared_not_connected", + account: "shared", + accountMode: "shared", + requestKind: "connect_shared", + }), + appAccess({ + key: "notion", + displayName: "Notion", + provider: "catalogue", + state: "disabled", + requestKind: "enable", + request: { + id: "req_1", + kind: "enable", + createdAt: "2026-10-20T10:00:00.000Z", + }, + }), + ], + }); + const view = draw(); + const slack = await row(view, "composio-slack"); + expect( + slack.getByText( + "Your organisation's Slack account isn't connected yet. Only an administrator can connect it.", + ), + ).toBeTruthy(); + expect(slack.getByRole("button", { name: "Request access" })).toBeTruthy(); + const notion = await row(view, "notion"); + expect(notion.getByText(/Turned off by an administrator/)).toBeTruthy(); + expect(notion.getByText("Requested")).toBeTruthy(); + expect(notion.getByRole("button", { name: "Withdraw" })).toBeTruthy(); +}); + +test("an administrator keeps the way to this Bot's grants on each app, beside the action", async () => { + serving({ role: "admin", apps: [appAccess({ state: "not_connected" })] }); + const gmail = await row(draw(), "composio-gmail"); + const manage = gmail.getByRole("button", { + name: "Manage Gmail on this Bot", + }); + expect(manage.getAttribute("href")).toBe( + "/admin/plugins/composio-gmail/bots/expenses", + ); + expect(gmail.getByRole("button", { name: "Connect" })).toBeTruthy(); +}); + +test("a person who is not an administrator gets no way to the grants", async () => { + serving({ apps: [appAccess()] }); + const gmail = await row(draw(), "composio-gmail"); + expect(gmail.queryByRole("button", { name: /Manage/ })).toBeNull(); +}); + +test("when the states cannot be read, the apps are still listed and the failure is said", async () => { + serving({ + apps: "fail", + tools: [ + tool("google-drive/search_files", "mcp__google-drive__search_files"), + ], + }); + const view = draw(); + expect((await view.findByRole("alert")).textContent).toContain( + "Whether each app is ready for you could not be loaded.", + ); + expect(await view.findByText("Google Drive")).toBeTruthy(); +}); diff --git a/app/tests/bot-access-skills.test.tsx b/app/tests/bot-access-skills.test.tsx index f8bab06846..fc7d4eee84 100644 --- a/app/tests/bot-access-skills.test.tsx +++ b/app/tests/bot-access-skills.test.tsx @@ -73,6 +73,9 @@ function serving() { grants.push(`${init?.method ?? "GET"} ${url} ${init?.body ?? ""}`); return Response.json({ ok: true }); } + if (url.includes("/api/plugins/for/") && url.endsWith("/apps")) { + return Response.json({ apps: [] }); + } if (url.includes("/api/plugins/for/")) { return Response.json({ tools: [], skills: [] }); } diff --git a/server/src/plugins/app-access.ts b/server/src/plugins/app-access.ts index cf8d6f7948..caa50ac949 100644 --- a/server/src/plugins/app-access.ts +++ b/server/src/plugins/app-access.ts @@ -108,6 +108,16 @@ export type AppAccess = { state: AppAccessState; /** Whether this Bot holds any of the app's tools. */ granted: boolean; + /** + * When the account this app runs in for this person was connected — their own, or the team's for + * a Shared app — and null where none is, or where no account is involved. + * + * FOR THE ONE FACT THE STATE CANNOT CARRY. An account the vendor has lost still has its row here, + * so the state reads `ready`; a card drawn over the refusal that found out says Reconnect anyway. + * This is how that card tells "lost since the refusal" from "connected again since the refusal": + * a connection newer than the refusal is the reconnection, and the live state wins. + */ + connectedAt: string | null; /** The request a press of Request access files for this state, and null where there is none. */ requestKind: AppAccessRequestKind | null; /** diff --git a/server/src/plugins/plugin-store/app-access.ts b/server/src/plugins/plugin-store/app-access.ts index 18beb5334b..615dddfc72 100644 --- a/server/src/plugins/plugin-store/app-access.ts +++ b/server/src/plugins/plugin-store/app-access.ts @@ -348,6 +348,11 @@ export function appAccessMethods(context: StoreContext) { .flatMap((app) => app.rowIds); const usable = (row: { verified: boolean; probeAction: string | null }) => !(row.probeAction !== null && !row.verified); + const fact = (row: { + verified: boolean; + probeAction: string | null; + connectedAt: Date | null; + }) => ({ usable: usable(row), connectedAt: iso(row.connectedAt) }); const [personal, deployment, held] = await Promise.all([ userId && toolkits.length > 0 @@ -356,6 +361,7 @@ export function appAccessMethods(context: StoreContext) { app: brokeredConnections.app, verified: brokeredConnections.verified, probeAction: brokeredConnections.probeAction, + connectedAt: brokeredConnections.connectedAt, }) .from(brokeredConnections) .where( @@ -373,6 +379,7 @@ export function appAccessMethods(context: StoreContext) { app: brokeredConnections.app, verified: brokeredConnections.verified, probeAction: brokeredConnections.probeAction, + connectedAt: brokeredConnections.connectedAt, }) .from(brokeredConnections) .where( @@ -388,6 +395,7 @@ export function appAccessMethods(context: StoreContext) { .select({ serverId: mcpUserCredentials.serverId, revokedAt: credentialRows.revokedAt, + connectedAt: mcpUserCredentials.connectedAt, }) .from(mcpUserCredentials) .innerJoin( @@ -403,12 +411,18 @@ export function appAccessMethods(context: StoreContext) { : [], ]); return { - personalBrokered: new Map(personal.map((row) => [row.app, usable(row)])), + personalBrokered: new Map(personal.map((row) => [row.app, fact(row)])), deploymentBrokered: new Map( - deployment.map((row) => [row.app, usable(row)]), + deployment.map((row) => [row.app, fact(row)]), ), personalHeld: new Map( - held.map((row) => [row.serverId, row.revokedAt === null]), + held.map((row) => [ + row.serverId, + { + usable: row.revokedAt === null, + connectedAt: iso(row.connectedAt), + }, + ]), ), }; } @@ -463,9 +477,10 @@ export function appAccessMethods(context: StoreContext) { : [], ]); return apps.map((app) => { - const sharedUsable = app.toolkit - ? connections.deploymentBrokered.get(app.toolkit) === true - : false; + const teamAccount = app.toolkit + ? connections.deploymentBrokered.get(app.toolkit) + : undefined; + const sharedUsable = teamAccount?.usable === true; const personal = app.provider === "composio" && app.toolkit ? connections.personalBrokered.get(app.toolkit) @@ -483,13 +498,10 @@ export function appAccessMethods(context: StoreContext) { catalogueAuth: app.entry?.auth ?? null, hasCredential: app.hasCredential, granted: [app.key, ...app.rowIds].some((id) => granted.has(id)), - personal: personal === undefined ? null : { usable: personal }, - shared: app.toolkit - ? connections.deploymentBrokered.has(app.toolkit) - ? { usable: sharedUsable } - : null - : null, + personal: personal === undefined ? null : { usable: personal.usable }, + shared: teamAccount ? { usable: sharedUsable } : null, }; + const account = accountOf(facts); const state = appAccessState(facts); const requestKind = REQUEST_KIND_FOR[state]; const request = @@ -504,12 +516,18 @@ export function appAccessMethods(context: StoreContext) { displayName: app.title, logo: app.logo, provider: app.provider, - account: accountOf(facts), + account, connect: connectOf(facts), authScheme: app.provider === "composio" ? app.authScheme : null, accountMode: app.accountMode, state, granted: facts.granted, + connectedAt: + account === "shared" + ? (teamAccount?.connectedAt ?? null) + : account === "personal" + ? (personal?.connectedAt ?? null) + : null, requestKind, request: request ? { diff --git a/server/tests/app-access-routes.integration.test.ts b/server/tests/app-access-routes.integration.test.ts index 1ff23e2aa3..eeb0e17a4b 100644 --- a/server/tests/app-access-routes.integration.test.ts +++ b/server/tests/app-access-routes.integration.test.ts @@ -369,15 +369,20 @@ describe("what each app a Bot holds is, for the person asking", () => { userId: other, vendorUserId: other, verified: true, + connectedAt: new Date("2026-10-20T12:00:00.000Z"), }); const mine = await json<{ apps: AppAccess[] }>( await appAs(person).request(`/api/plugins/for/${bot}/apps`), ); expect(mine.apps[0]?.state).toBe("not_connected"); + // Nothing of theirs leaks into mine, not even when they connected. + expect(mine.apps[0]?.connectedAt).toBeNull(); const theirs = await json<{ apps: AppAccess[] }>( await appAs(other).request(`/api/plugins/for/${bot}/apps`), ); expect(theirs.apps[0]?.state).toBe("ready"); + // When, so a chat card over a lost account can tell a reconnection from the loss. + expect(theirs.apps[0]?.connectedAt).toBe("2026-10-20T12:00:00.000Z"); }); test("a Shared app with its team account connected is ready for everybody", async () => { @@ -388,11 +393,14 @@ describe("what each app a Bot holds is, for the person asking", () => { holder: "deployment", vendorUserId: `openbot-deployment:${suite}`, verified: true, + connectedAt: new Date("2026-10-20T09:30:00.000Z"), }); const { apps } = await json<{ apps: AppAccess[] }>( await appAs(person).request(`/api/plugins/for/${bot}/apps`), ); expect(apps[0]?.state).toBe("ready"); + // The team account's, since that is the account the app runs in for everybody. + expect(apps[0]?.connectedAt).toBe("2026-10-20T09:30:00.000Z"); }); test("with no Composio key every Composio app is switched off", async () => { From 444fb182007785964ad2628c1fc9340a9d61fb71 Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Tue, 20 Oct 2026 19:39:39 -0300 Subject: [PATCH 06/33] List access requests for administrators to approve or dismiss An app's admin page has a Requests section, and the Plugins list shows every pending request at the top with a count on each app's row, so a request for an app nobody has switched on is still seen. Approve does what was asked; when the server cannot finish (a token to add, a team account to connect) its reason is shown on the request, which stays pending. Requests name who asked; nobody's connections are shown. --- .../components/plugins/access-requests.tsx | 162 ++++++++++++ app/src/routes/_authed/admin/plugins/$key.tsx | 8 + .../routes/_authed/admin/plugins/index.tsx | 34 +++ app/tests/access-requests.test.tsx | 250 ++++++++++++++++++ 4 files changed, 454 insertions(+) create mode 100644 app/src/components/plugins/access-requests.tsx create mode 100644 app/tests/access-requests.test.tsx diff --git a/app/src/components/plugins/access-requests.tsx b/app/src/components/plugins/access-requests.tsx new file mode 100644 index 0000000000..f1df877710 --- /dev/null +++ b/app/src/components/plugins/access-requests.tsx @@ -0,0 +1,162 @@ +import { IconInbox } from "@tabler/icons-react"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { Fragment } from "react"; +import { PageRows, PageSection } from "@/components/layout/page-shell"; +import { Button } from "@/components/ui/button"; +import { + Item, + ItemActions, + ItemContent, + ItemDescription, + ItemFooter, + ItemMedia, + ItemTitle, +} from "@/components/ui/item"; +import { Separator } from "@/components/ui/separator"; +import { + approveAppAccessRequestMutationOptions, + dismissAppAccessRequestMutationOptions, +} from "@/lib/app-access/mutations"; +import { + type AppAccessRequest, + appAccessInboxQueryOptions, +} from "@/lib/app-access/queries"; +import { relativeTime } from "@/lib/relative-time"; +import { queryClient } from "@/query-client"; + +/** + * The requests people have filed for an administrator to answer: switch an app on, give it to a + * Bot, or connect its shared account. + * + * ONE SECTION, ON TWO SCREENS. An app's own admin page lists the requests about that app, because + * that is where an administrator is already deciding about it. The Plugins list carries all of them, + * because an app nobody has switched on has no page of its own to list them on — a Composio app + * enabled by approving the request is exactly that case — and because a request nobody sees is a + * person waiting for nothing. + * + * APPROVE DOES THE THING. The server switches the app on and grants it to the Bot in the same press, + * or refuses with the step only an administrator can take first — a token to paste, a team account + * to connect — and the request stays pending. That sentence is drawn on the row it is about, because + * the screen's own banner is a long way from the button that was pressed. + * + * REQUESTS, NOT CONNECTIONS. Each row names who asked, because they asked; nothing here says who has + * connected what, which stays private to each person (see the note on `admin/plugins/$key.tsx`). + * + * Nothing at all while the read is open or when nothing is waiting: an empty section on every app's + * page would teach an administrator to scroll past the heading. + */ +export function AccessRequestsSection({ app }: { app?: string }) { + const inbox = useQuery(appAccessInboxQueryOptions(true, app)); + if (inbox.isPending) return null; + if (inbox.error) + return ( + +

+ Access requests could not be loaded. +

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

+ {error.message} +

+
+ ) : null} +
+ ); +} + +/** + * How many requests are waiting, for a list row that wants to say so: one app's, or all of them. + * Null while nothing is waiting or nothing has been read, so a row draws nothing rather than "0". + */ +export function usePendingRequestCounts() { + const inbox = useQuery(appAccessInboxQueryOptions(true)); + return inbox.data?.counts ?? null; +} diff --git a/app/src/routes/_authed/admin/plugins/$key.tsx b/app/src/routes/_authed/admin/plugins/$key.tsx index 69cab47f2a..c46fa8020d 100644 --- a/app/src/routes/_authed/admin/plugins/$key.tsx +++ b/app/src/routes/_authed/admin/plugins/$key.tsx @@ -13,6 +13,7 @@ import { PageSection, PageShell, } from "@/components/layout/page-shell"; +import { AccessRequestsSection } from "@/components/plugins/access-requests"; import { AccountModeDialog } from "@/components/plugins/account-mode-dialog"; import { BrokeredAccountRow, @@ -612,6 +613,13 @@ function RouteComponent() { + {/* + * People asking for this app: to switch it on, give it to a Bot, or connect its shared + * account. Only when somebody is; the section draws nothing otherwise. Requests name who + * asked — connections still name nobody. + */} + + !open && setModeTarget(null)} diff --git a/app/src/routes/_authed/admin/plugins/index.tsx b/app/src/routes/_authed/admin/plugins/index.tsx index b90b6994ba..1681d62c75 100644 --- a/app/src/routes/_authed/admin/plugins/index.tsx +++ b/app/src/routes/_authed/admin/plugins/index.tsx @@ -14,6 +14,10 @@ import { PageShell, } from "@/components/layout/page-shell"; import { RowMark } from "@/components/layout/row-mark"; +import { + AccessRequestsSection, + usePendingRequestCounts, +} from "@/components/plugins/access-requests"; import { Item, ItemActions, @@ -96,6 +100,11 @@ function summaryFor( function RouteComponent() { const plugins = useQuery(pluginsPageQueryOptions()); const connections = useQuery(connectionsQueryOptions()); + /* + * Requests waiting per app, so an administrator sees which app somebody is asking about from the + * list, without opening each one. Every request is also listed in full above the lists. + */ + const requestCounts = usePendingRequestCounts(); /* * PERSONAL ONLY: a Shared app's connection is the deployment's standing account, not something @@ -128,6 +137,12 @@ function RouteComponent() {

) : ( <> + {/* + * First, because each one is somebody waiting. All of them, not only those about an app + * that is connected: an app nobody has switched on has no page of its own to list them on. + */} + + + {summaryFor( server, @@ -226,6 +244,9 @@ function RouteComponent() { {entry.summary} + Not added @@ -295,3 +316,16 @@ function RouteComponent() { ); } + +/** + * "2 requests", beside a row's summary, in the foreground colour so it is the first thing on the + * row that reads as something to do. Nothing at zero: a count of nothing on every row is noise. + */ +function PendingRequests({ count }: { count: number }) { + if (count === 0) return null; + return ( + + {count} {count === 1 ? "request" : "requests"} + + ); +} diff --git a/app/tests/access-requests.test.tsx b/app/tests/access-requests.test.tsx new file mode 100644 index 0000000000..133ae74da8 --- /dev/null +++ b/app/tests/access-requests.test.tsx @@ -0,0 +1,250 @@ +import { afterAll, afterEach, beforeAll, expect, test } from "bun:test"; +import { GlobalRegistrator } from "@happy-dom/global-registrator"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, + Outlet, + RouterProvider, +} from "@tanstack/react-router"; +import { + cleanup, + fireEvent, + render, + waitFor, + within, +} from "@testing-library/react"; +import type { ReactNode } from "react"; +import { AccessRequestsSection } from "@/components/plugins/access-requests"; +import type { AppAccessRequest } from "@/lib/app-access/queries"; +import { Route as PluginsIndexRoute } from "@/routes/_authed/admin/plugins/index"; +import { settleReactWork } from "./settle-react-work"; + +/** + * The administrator's side of an access request: the Requests section on an app's admin page and + * at the top of the Plugins list, and the count on each row of that list. + * + * Approve and Dismiss go to the routes that do the work; a refusal from Approve — the step only an + * administrator can take first, with the request left pending — is drawn under the request it is + * about. Who asked is shown, because they asked; nobody's connection is. + */ + +beforeAll(() => GlobalRegistrator.register()); +afterEach(cleanup); +afterAll(async () => { + await settleReactWork(); + GlobalRegistrator.unregister(); +}); + +const originalFetch = global.fetch; +afterEach(() => { + global.fetch = originalFetch; +}); + +const request = ( + overrides: Partial = {}, +): AppAccessRequest => ({ + id: "req_1", + appKey: "composio-gmail", + appTitle: "Gmail", + kind: "grant", + status: "pending", + agentId: "expenses", + botName: "Expenses", + requestedBy: "u_ada", + requesterEmail: "ada@example.test", + requesterName: "Ada", + createdAt: new Date(Date.now() - 5 * 60_000).toISOString(), + resolvedAt: null, + resolvedBy: null, + ...overrides, +}); + +function serving(options: { + requests: AppAccessRequest[]; + approve?: { status: number; body: unknown }; +}) { + const sent: string[] = []; + global.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + const method = init?.method ?? "GET"; + sent.push(`${method} ${url}`); + if (method === "POST" && url.endsWith("/approve")) + return Response.json(options.approve?.body ?? { request: {} }, { + status: options.approve?.status ?? 200, + }); + if (method === "POST" && url.endsWith("/dismiss")) + return Response.json({ request: {} }); + if (url.startsWith("/api/plugins/access-requests")) { + const app = new URL(url, "http://x").searchParams.get("app"); + const byApp: Record = {}; + for (const each of options.requests) + byApp[each.appKey] = (byApp[each.appKey] ?? 0) + 1; + return Response.json({ + requests: app + ? options.requests.filter((each) => each.appKey === app) + : options.requests, + counts: { total: options.requests.length, byApp }, + }); + } + if (url === "/api/plugins/connections") + return Response.json({ connections: [], redirectUri: null }); + if (url === "/api/plugins") + return Response.json({ + catalogue: [], + servers: [ + { + id: "composio-gmail", + title: "Gmail", + summary: "Mail.", + tools: [], + lastError: null, + }, + { + id: "notion", + title: "Notion", + summary: "Docs.", + tools: [], + lastError: null, + }, + ], + composioConfigured: true, + botsMayCallBack: true, + redirectUri: null, + skills: [], + }); + return Response.json({}); + }) as unknown as typeof fetch; + return sent; +} + +function draw(Page: () => ReactNode) { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + const root = createRootRoute({ component: Outlet }); + const page = createRoute({ + getParentRoute: () => root, + path: "/", + component: Page, + }); + const app = createRoute({ + getParentRoute: () => root, + path: "/admin/plugins/$key", + component: () => null, + }); + const composio = createRoute({ + getParentRoute: () => root, + path: "/admin/plugins/composio", + component: () => null, + }); + const router = createRouter({ + routeTree: root.addChildren([page, app, composio]), + history: createMemoryHistory({ initialEntries: ["/"] }), + }); + return render( + + + , + ); +} + +test("lists who asked for what, for which Bot, and when — and nothing about anybody's connection", async () => { + serving({ + requests: [ + request(), + request({ id: "req_2", kind: "enable", appTitle: "Gmail" }), + request({ + id: "req_3", + kind: "connect_shared", + requesterName: null, + }), + ], + }); + const view = draw(() => ); + expect(await view.findByText("Give Gmail to Expenses")).toBeTruthy(); + expect(view.getByText("Turn on Gmail for Expenses")).toBeTruthy(); + expect(view.getByText("Connect the shared Gmail account")).toBeTruthy(); + expect(view.getAllByText(/Ada asked 5 minutes ago/)).toHaveLength(2); + // No name on the request falls back to the address they asked from. + expect(view.getByText(/ada@example\.test asked/)).toBeTruthy(); + expect(view.queryByText(/connected/i)).toBeNull(); +}); + +test("approve does what was asked through the approve route", async () => { + const sent = serving({ requests: [request()] }); + const view = draw(() => ); + fireEvent.click(await view.findByRole("button", { name: "Approve" })); + await waitFor(() => + expect(sent).toContain("POST /api/plugins/access-requests/req_1/approve"), + ); +}); + +test("an approval the server cannot complete says why, on the request, and leaves it there", async () => { + serving({ + requests: [request({ kind: "connect_shared" })], + approve: { + status: 409, + body: { + error: + "The shared Gmail account is not connected yet. Connect it on Gmail's page under Plugins, then approve this request.", + }, + }, + }); + const view = draw(() => ); + fireEvent.click(await view.findByRole("button", { name: "Approve" })); + const row = within( + (await view.findByRole("alert")).closest("[data-request]") as HTMLElement, + ); + expect(row.getByRole("alert").textContent).toMatch( + /^The shared Gmail account is not connected yet\./, + ); + expect(row.getByRole("button", { name: "Approve" })).toBeTruthy(); +}); + +test("dismiss goes to the dismiss route", async () => { + const sent = serving({ requests: [request()] }); + const view = draw(() => ); + fireEvent.click(await view.findByRole("button", { name: "Dismiss" })); + await waitFor(() => + expect(sent).toContain("POST /api/plugins/access-requests/req_1/dismiss"), + ); +}); + +test("nothing waiting draws no section at all", async () => { + const sent = serving({ requests: [] }); + const view = draw(() => ); + await waitFor(() => + expect(sent).toContain( + "GET /api/plugins/access-requests?app=composio-gmail", + ), + ); + await settleReactWork(); + expect(view.queryByText("Requests")).toBeNull(); +}); + +test("the Plugins list puts every request first and counts them on each app's row", async () => { + serving({ + requests: [ + request(), + request({ id: "req_2", kind: "enable" }), + // An app with no row of its own: its request is still listed, at the top. + request({ + id: "req_3", + appKey: "composio-slack", + appTitle: "Slack", + kind: "enable", + }), + ], + }); + const Page = PluginsIndexRoute.options.component as () => ReactNode; + const view = draw(Page); + expect(await view.findByText("Requests")).toBeTruthy(); + expect(view.getByText("Turn on Slack for Expenses")).toBeTruthy(); + const gmail = within(await view.findByTestId("plugin-composio-gmail")); + expect(gmail.getByText("2 requests")).toBeTruthy(); + const notion = within(view.getByTestId("plugin-notion")); + expect(notion.queryByText(/request/)).toBeNull(); +}); From 322dce2b7ddfb816f6981bf4837a3ae4539a6f1a Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Tue, 20 Oct 2026 19:39:45 -0300 Subject: [PATCH 07/33] Draw an app card in chat for every way a Bot cannot use an app Refusals for a Shared app with no team account and for an account Composio no longer holds now carry the same structured prompt as the connect refusal, with a reason, the refusing Bot and (for a lost account) when. A new show_app_access tool, held by every Bot, answers with that prompt for an app that is switched off or not granted, and the guidance tells the Bot to call it instead of only naming an administrator. The transcript draws all of them as one card that reads the app's live state for this person on that Bot, using the shared state piece; a lost account is shown as Reconnect until a connection newer than the refusal appears. The card is never folded into the tool group. --- .../components/channels/app-access-card.tsx | 141 +++++++++ .../components/channels/chat-transcript.tsx | 32 +- app/src/components/channels/tool-activity.tsx | 13 +- app/tests/app-access-card.test.tsx | 289 ++++++++++++++++++ app/tests/tool-activity.test.tsx | 32 ++ server/src/index.ts | 12 + server/src/plugins/app-access-tool.ts | 105 +++++++ server/src/plugins/broker.ts | 20 ++ server/src/plugins/composio-vendor.ts | 8 +- server/src/plugins/composio.ts | 14 +- server/src/plugins/mcp.ts | 6 + server/src/plugins/plugin-store/calls.ts | 30 +- server/src/plugins/plugin-store/context.ts | 17 +- server/src/plugins/plugin-store/model.ts | 3 +- server/src/plugins/tools.ts | 63 +++- server/tests/app-access-tool.test.ts | 156 ++++++++++ server/tests/composio-adapter.test.ts | 15 + server/tests/composio-transport.test.ts | 49 ++- server/tests/copilot.test.ts | 47 +++ .../shared-brokered-store.integration.test.ts | 103 ++++++- shared/connect-prompt.test.ts | 63 ++++ shared/connect-prompt.ts | 103 ++++++- 22 files changed, 1286 insertions(+), 35 deletions(-) create mode 100644 app/src/components/channels/app-access-card.tsx create mode 100644 app/tests/app-access-card.test.tsx create mode 100644 server/src/plugins/app-access-tool.ts create mode 100644 server/tests/app-access-tool.test.ts diff --git a/app/src/components/channels/app-access-card.tsx b/app/src/components/channels/app-access-card.tsx new file mode 100644 index 0000000000..5f851dd903 --- /dev/null +++ b/app/src/components/channels/app-access-card.tsx @@ -0,0 +1,141 @@ +import { useQuery } from "@tanstack/react-query"; +import { + AppAccessAction, + appAccessSentence, +} from "@/components/plugins/app-access-action"; +import { PluginLogo } from "@/components/plugins/plugin-logo"; +import { type AppAccess, botAppQueryOptions } from "@/lib/app-access/queries"; +import { useDeclaredBotId } from "@/lib/copilot/active-bot"; +import type { ConnectPrompt } from "@openbot/shared/connect-prompt"; +import { ConnectAppCard } from "./connect-app-card"; + +/** + * A work app a Bot could not use, in front of the person, with the one thing that would let it. + * + * WHAT DRAWS ONE. Every way an app is out of reach for a call now reaches the transcript carrying + * the same marker (`@openbot/shared/connect-prompt`): a person who has not connected, a Shared app + * with no team account, an account the vendor has lost, and — with no call refused at all — the + * `show_app_access` tool a Bot calls for an app it holds nothing for, switched off or not granted. + * They used to end in three different places: a card with Connect, a sentence telling the person to + * ask an administrator, and a sentence naming a settings page. + * + * LIVE, NEVER WHAT THE REFUSAL SAID. The marker names an app and a Bot; the state is asked of the + * server for this person on that Bot when the card is drawn, and the shared app-access piece draws + * that with the button that goes with it. A card scrolled back to an hour later says what is true + * now — connected since, granted since, the request approved — and never offers a step that has + * already been taken. Every write those buttons make refreshes the same key, so the card follows. + * + * THE ONE PLACE THE REFUSAL WINS. An account the vendor no longer holds is not recorded anywhere on + * this deployment: its row still stands, so the server reads `ready` until a call fails — and this + * card is drawn over exactly the call that failed. So a `reconnect` or `shared` refusal over a live + * `ready` is drawn as the refusal says — unless the account was connected after the refusal, which + * is the reconnection. Anything else live is newer than the refusal and is drawn as it is. + */ +export function AppAccessCard({ prompt }: { prompt: ConnectPrompt }) { + const declared = useDeclaredBotId(); + const agentId = prompt.agentId ?? declared; + /* + * A refusal from before refusals named their Bot, on a surface that declared none: there is no Bot + * to ask about, so the card it was drawn as then is the card it is drawn as now. + */ + if (!agentId) return ; + return ; +} + +function LiveAppAccessCard({ + agentId, + prompt, +}: { + agentId: string; + prompt: ConnectPrompt; +}) { + const live = useQuery(botAppQueryOptions(agentId, prompt.serverId)); + /* + * A read that failed falls back to what this card was before it read anything, where that was a + * card at all: a person's own missing connection still gets its Connect button. The other reasons + * had only ever been a sentence, and the sentence is what is left. + */ + if (live.error && (prompt.reason ?? "connect") === "connect") + return ; + const access = live.data ? asRefused(live.data, prompt) : null; + const name = access?.displayName ?? prompt.title; + return ( +
+
+
+ +
+
+

{name}

+ {/* + * Nothing while the read is open — no placeholder, per the data-access rule — and the + * Bot's own sentence if it failed. Otherwise the state, which is the server's. + */} + {live.isPending ? null : access ? ( +

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

+ ) : ( +

+ {prompt.message} +

+ )} +
+
+ {access && access.state !== "ready" ? ( +
+ +
+ ) : null} +
+ ); +} + +/** + * The live state, or the refusal's where only the refusal can know: an account the vendor lost, + * which the server still reads as `ready`. See the card's own comment. + * + * A lost team account is drawn as not connected with NO request to file — the server would answer + * any request 409, since as far as it knows the account is fine — so the sentence says whose move it + * is and the button is left out rather than offered and refused. + */ +function asRefused(live: AppAccess, prompt: ConnectPrompt): AppAccess { + if (live.state !== "ready") return live; + /* + * Connected again since the refusal: that is the reconnection, and the live `ready` is true. + * Compared as instants rather than as strings, because the two come from different clocks' + * formatters and only the instant is the same. + */ + if ( + prompt.at && + live.connectedAt && + Date.parse(live.connectedAt) > Date.parse(prompt.at) + ) + return live; + if (prompt.reason === "reconnect") return { ...live, state: "reconnect" }; + if (prompt.reason === "shared") + return { + ...live, + state: "shared_not_connected", + requestKind: null, + request: null, + }; + return live; +} + +/** As the shared sentence, except a lost team account, which needs reconnecting rather than connecting. */ +function refusedSentence(access: AppAccess, prompt: ConnectPrompt): string { + if ( + access.state === "shared_not_connected" && + prompt.reason === "shared" && + !access.requestKind + ) + return `Your organisation's ${access.displayName} account needs reconnecting. Only an administrator can reconnect it.`; + return appAccessSentence(access.state, access.displayName); +} diff --git a/app/src/components/channels/chat-transcript.tsx b/app/src/components/channels/chat-transcript.tsx index 94a8972d20..1fb109f7b2 100644 --- a/app/src/components/channels/chat-transcript.tsx +++ b/app/src/components/channels/chat-transcript.tsx @@ -59,7 +59,10 @@ import { toolResultFailed, } from "@/lib/plugins/tool-result"; import { readTeamBotConsent } from "@/lib/team-bots"; -import { readConnectPrompt } from "@openbot/shared/connect-prompt"; +import { + readConnectPrompt, + SHOW_APP_ACCESS_TOOL, +} from "@openbot/shared/connect-prompt"; import { cn } from "@/lib/utils"; import { VOICE_CHAT_ACTIVITY, type VoiceChatEntry } from "@/lib/voice/archive"; import { @@ -74,7 +77,7 @@ import { toVisibleChatItems, } from "./chat-messages"; import type { QueuedMessage } from "./composer"; -import { ConnectAppCard } from "./connect-app-card"; +import { AppAccessCard } from "./app-access-card"; import { TeamBotConsentCard } from "./team-bot-consent"; import { ToolRenderBoundary } from "./tool-boundary"; import { ToolLine } from "./tool-line"; @@ -1351,8 +1354,16 @@ const TranscriptToolCall = memo(function TranscriptToolCall({ * * Named from the reader's side: what was done, against which server, with the server's own words * behind a disclosure. The identifier the model was offered never reaches the screen. + * + * Exported for its tests, which draw it without the CopilotKit provider `TranscriptToolCall` needs. */ -function ServerToolLine({ name, result }: { name: string; result?: string }) { +export function ServerToolLine({ + name, + result, +}: { + name: string; + result?: string; +}) { const { label, detail } = readToolName(name); /* * A refusal is not a result, and must not read like one. @@ -1374,9 +1385,18 @@ function ServerToolLine({ name, result }: { name: string; result?: string }) { // A Team Bot asking to use this person's own account draws its consent card, not the sentence. const consent = refused ? readTeamBotConsent(body) : null; if (consent) return ; - // An app this person has not connected draws the connect button, not the name of a screen. - const connect = refused ? readConnectPrompt(body) : null; - if (connect) return ; + /* + * An app this call could not use draws a card with the step that fixes it, not the name of a + * screen: a refusal carrying the marker, or the answer of the one tool whose whole job is to put + * that card on screen. Only that tool's ordinary answer is read for a marker — any other result + * is somebody else's text, and a card must not be conjured out of it. + */ + const card = refused + ? readConnectPrompt(body) + : name === SHOW_APP_ACCESS_TOOL + ? readConnectPrompt(answer) + : null; + if (card) return ; return ( boolean): boolean { if (isBrowserStep(item)) return true; const name = item.toolCall.function.name; if (!name.startsWith("mcp__") && isDrawn(name)) return false; + /* + * The app-access card is drawn from a server tool's answer, so no renderer registers it and the + * registry cannot say so. Never folded — not even while its answer is still on the way, or the + * card would land inside a group and then jump out of it. + */ + if (name === SHOW_APP_ACCESS_TOOL) return false; return !asksThePerson(item.result); } @@ -116,7 +125,7 @@ export function useDrawnTool(): (name: string) => boolean { * Whether this refusal is a question for the person rather than a line about what a Bot did. * * Both of these draw a card with something to press — a Team Bot asking to act as them, and an app - * they have not connected — and neither may sit behind a disclosure for the same reason: a question + * they cannot use yet (not connected, lost at the vendor, or a team account nobody connected) — and neither may sit behind a disclosure for the same reason: a question * nobody can see is a conversation that has stopped without saying so. */ function asksThePerson(result: string | undefined): boolean { diff --git a/app/tests/app-access-card.test.tsx b/app/tests/app-access-card.test.tsx new file mode 100644 index 0000000000..b4ead7add4 --- /dev/null +++ b/app/tests/app-access-card.test.tsx @@ -0,0 +1,289 @@ +import { afterAll, afterEach, beforeAll, expect, test } from "bun:test"; +import { GlobalRegistrator } from "@happy-dom/global-registrator"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, + Outlet, + RouterProvider, +} from "@tanstack/react-router"; +import { cleanup, render, waitFor } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { AppAccessCard } from "@/components/channels/app-access-card"; +import { ServerToolLine } from "@/components/channels/chat-transcript"; +import type { AppAccess } from "@/lib/app-access/queries"; +import { REFUSAL_MARKER } from "@/lib/plugins/tool-result"; +import { + type ConnectPrompt, + connectAppRefusal, + SHOW_APP_ACCESS_TOOL, +} from "@openbot/shared/connect-prompt"; +import { appAccess } from "./app-access-fixture"; +import { settleReactWork } from "./settle-react-work"; + +/** + * The card a conversation draws when a Bot could not use an app, for every way that happens: the + * person has not connected, a Shared app has no team account, the vendor lost the account, and the + * `show_app_access` tool a Bot calls for an app it holds nothing for (switched off, not granted). + * + * The card never draws what the refusal said: it asks the server what the app is for this person + * on the Bot that refused, and draws that. The one exception is an account the vendor lost, which + * the server cannot know about — drawn as Reconnect over a live `ready`, until a connection newer + * than the refusal says it has been put back. + */ + +beforeAll(() => GlobalRegistrator.register()); +afterEach(cleanup); +afterAll(async () => { + await settleReactWork(); + GlobalRegistrator.unregister(); +}); + +const originalFetch = global.fetch; +afterEach(() => { + global.fetch = originalFetch; +}); + +/** Answers the live read with `live`, and records which Bot and app it was asked about. */ +function serving(live: AppAccess | null) { + const asked: string[] = []; + global.fetch = (async (input: RequestInfo | URL) => { + const url = String(input); + asked.push(url); + if (url.includes("/apps/")) + return live + ? Response.json({ app: live }) + : Response.json({ error: "nope" }, { status: 500 }); + if (url.includes("/api/plugins/connections")) + return Response.json({ connections: [], redirectUri: null }); + return Response.json({}); + }) as unknown as typeof fetch; + return asked; +} + +function mount(children: ReactNode) { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + const root = createRootRoute({ component: Outlet }); + const page = createRoute({ + getParentRoute: () => root, + path: "/", + component: () => <>{children}, + }); + const settings = createRoute({ + getParentRoute: () => root, + path: "/settings/connected-accounts/$key", + component: () => null, + }); + const router = createRouter({ + routeTree: root.addChildren([page, settings]), + history: createMemoryHistory({ initialEntries: ["/"] }), + }); + return render( + + + , + ); +} + +const prompt = (overrides: Partial = {}): ConnectPrompt => ({ + serverId: "composio-gmail", + title: "Gmail", + message: + "You have not connected your Gmail account. Connect it and ask again.", + agentId: "expenses", + ...overrides, +}); + +const card = (view: ReturnType) => + waitFor(() => { + const found = view.container.querySelector("[data-app-access]"); + if (!found?.getAttribute("data-state")) throw new Error("not drawn yet"); + return found as HTMLElement; + }); + +test("asks about the app on the Bot that refused, not on another", async () => { + const asked = serving(appAccess({ state: "not_connected" })); + mount(); + await waitFor(() => + expect(asked).toContain("/api/plugins/for/expenses/apps/composio-gmail"), + ); +}); + +test("not connected: names the app and offers Connect", async () => { + serving(appAccess({ state: "not_connected" })); + const view = mount(); + const drawn = await card(view); + expect(drawn.textContent).toContain("Gmail"); + expect(drawn.textContent).toContain( + "You haven't connected your Gmail account yet.", + ); + expect(view.getByRole("button", { name: "Connect" })).toBeTruthy(); +}); + +test("connected since the refusal: says so and offers nothing stale", async () => { + serving(appAccess({ state: "ready" })); + const view = mount(); + const drawn = await card(view); + expect(drawn.textContent).toContain( + "Gmail is ready now. Ask again and the Bot can use it.", + ); + expect(view.queryByRole("button")).toBeNull(); +}); + +test("a Shared app with no team account: Request access, not a sentence", async () => { + serving( + appAccess({ + state: "shared_not_connected", + account: "shared", + accountMode: "shared", + requestKind: "connect_shared", + }), + ); + const view = mount( + , + ); + const drawn = await card(view); + expect(drawn.textContent).toContain( + "Your organisation's Gmail account isn't connected yet.", + ); + expect(view.getByRole("button", { name: "Request access" })).toBeTruthy(); +}); + +test("an account the vendor lost: Reconnect, though the server still reads ready", async () => { + serving( + appAccess({ state: "ready", connectedAt: "2026-10-01T00:00:00.000Z" }), + ); + const view = mount( + , + ); + const drawn = await card(view); + expect(drawn.getAttribute("data-state")).toBe("reconnect"); + expect(drawn.textContent).toContain( + "Your Gmail connection needs reconnecting.", + ); + expect( + (await view.findByText("Reconnect")).closest("a")?.getAttribute("href"), + ).toBe("/settings/connected-accounts/composio-gmail"); +}); + +test("an account lost and then connected again: the newer connection wins", async () => { + serving( + appAccess({ state: "ready", connectedAt: "2026-10-20T12:05:00.000Z" }), + ); + const view = mount( + , + ); + const drawn = await card(view); + expect(drawn.getAttribute("data-state")).toBe("ready"); + expect(view.queryByText("Reconnect")).toBeNull(); +}); + +test("a team account the vendor lost: says an administrator has to reconnect it, with nothing to press", async () => { + serving(appAccess({ state: "ready", account: "shared" })); + const view = mount(); + const drawn = await card(view); + expect(drawn.textContent).toContain( + "Your organisation's Gmail account needs reconnecting. Only an administrator can reconnect it.", + ); + expect(view.queryByRole("button")).toBeNull(); +}); + +test.each([ + ["disabled", "enable", /Turned off by an administrator/], + ["not_granted", "grant", /This Bot hasn't been given Gmail/], +] as const)( + "the Bot asked about an app that is %s: Request access", + async (state, kind, sentence) => { + serving(appAccess({ state, requestKind: kind, granted: false })); + const view = mount( + , + ); + const drawn = await card(view); + expect(drawn.textContent).toMatch(sentence); + expect(view.getByRole("button", { name: "Request access" })).toBeTruthy(); + }, +); + +test("asked about an app that is ready, it says so without claiming it was just connected", async () => { + serving(appAccess()); + const view = mount(); + const drawn = await card(view); + expect(drawn.textContent).toContain("Ready to use."); +}); + +/* + * The fallback to the Bot the surface declared is not asserted here: `bot-route-default-agent`'s + * fixture replaces `useActiveBot` with a no-op for the whole process, so a declaration made in this + * file is a no-op whenever bun has loaded that one first. + */ +test("with no Bot to ask about at all, it is the Connect card it always was", async () => { + const asked = serving(null); + const view = mount(); + expect(await view.findByText("Connect Gmail")).toBeTruthy(); + expect(asked.some((url) => url.includes("/apps/"))).toBe(false); +}); + +test("a live read that fails falls back to the Connect card for a connect refusal", async () => { + serving(null); + const view = mount(); + expect(await view.findByText("Connect Gmail")).toBeTruthy(); +}); + +/* + * THROUGH THE TRANSCRIPT'S OWN LINE. The card is drawn from a server tool's answer — a refusal, or + * the `show_app_access` result — by `ServerToolLine`, and only those two are read for a marker. + * That it is drawn rather than folded into "Used N tools" is `tool-activity.test.tsx`'s. + */ +test("the transcript draws a refusal's card", async () => { + serving(appAccess({ state: "not_connected" })); + const view = mount( + , + ); + const drawn = await card(view); + expect(drawn.getAttribute("data-state")).toBe("not_connected"); +}); + +test("the transcript draws show_app_access's card from its ordinary answer", async () => { + serving( + appAccess({ state: "disabled", requestKind: "enable", granted: false }), + ); + const view = mount( + , + ); + const drawn = await card(view); + expect(drawn.getAttribute("data-state")).toBe("disabled"); +}); + +test("an ordinary tool's answer is never read for a card, whatever it starts with", async () => { + serving(appAccess({ state: "not_connected" })); + const view = mount( + , + ); + await settleReactWork(); + expect(view.container.querySelector("[data-app-access]")).toBeNull(); +}); diff --git a/app/tests/tool-activity.test.tsx b/app/tests/tool-activity.test.tsx index f395a81126..1f27221068 100644 --- a/app/tests/tool-activity.test.tsx +++ b/app/tests/tool-activity.test.tsx @@ -169,6 +169,38 @@ test("a request to connect an app at all is never folded away either", () => { expect(grouped.map((item) => item.id)).toContain("connect"); }); +test("a refusal that needs an administrator, or a lost account, is never folded away either", () => { + for (const reason of ["shared", "reconnect"]) { + const prompt = call( + reason, + "mcp__composio-gmail__GMAIL_FETCH_EMAILS", + {}, + JSON.stringify( + `Refused. [connect-app]{"serverId":"composio-gmail","title":"Gmail","agentId":"expenses","reason":"${reason}"} Gmail needs an account.`, + ), + ); + const grouped = groupToolSteps([search("a"), prompt, search("b")], drawn); + expect(grouped.map((item) => item.id)).toContain(reason); + } +}); + +test("the app-access card stays out of a group, even before its answer arrives", () => { + /* + * A server tool, so no renderer registers it and the registry cannot say it is drawn. Folded + * while it ran, the card would land inside "Used N tools" and then jump out once answered. + */ + for (const result of [ + undefined, + JSON.stringify( + '[connect-app]{"serverId":"composio-gmail","title":"Gmail","agentId":"expenses","reason":"access"} Gmail is switched off on this deployment.', + ), + ]) { + const card = call("card", "show_app_access", { app: "gmail" }, result); + const grouped = groupToolSteps([search("a"), card, search("b")], drawn); + expect(grouped.map((item) => item.id)).toEqual(["a", "card", "b"]); + } +}); + test("the summary says what was done, never which provider searched", () => { const steps = [ search(), diff --git a/server/src/index.ts b/server/src/index.ts index 1a519b93c4..20ba5853a6 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -179,6 +179,7 @@ import { createIntelligenceMemory } from "./memory/intelligence"; import { createMemoryStore } from "./memory/store"; import { reportModelAvailability } from "./model-availability-check"; import { memoryTools } from "./memory/tools"; +import { appAccessTools } from "./plugins/app-access-tool"; import { createSignInNotifier, createSignInResumer } from "./passwords/resume"; import { createSignInService } from "./passwords/service"; import { createPasswordStore } from "./passwords/store"; @@ -1283,6 +1284,17 @@ const loadToolsForActor = ownerUserId: actorId, agentId: botId, }), + /* + * Held by every Bot, granted or not: how a Bot puts an app it cannot reach in front of the + * person with the step that fixes it, instead of naming an administrator. A read; see the file. + */ + ...appAccessTools({ + store: pluginStore, + botId, + actorId, + composioConfigured: Boolean(composio), + ...(composio ? { directory: () => composio.broker.listApps() } : {}), + }), ...(await grantedTools({ store: pluginStore, botId, diff --git a/server/src/plugins/app-access-tool.ts b/server/src/plugins/app-access-tool.ts new file mode 100644 index 0000000000..be48070488 --- /dev/null +++ b/server/src/plugins/app-access-tool.ts @@ -0,0 +1,105 @@ +import { + connectAppRefusal, + SHOW_APP_ACCESS_TOOL, +} from "@openbot/shared/connect-prompt"; +import type { GrantedTool } from "@openbot/toolkit/granted-tool"; +import { z } from "zod"; +import type { AppAccess, AppAccessState } from "./app-access"; +import type { AppDirectory } from "./plugin-store/app-access"; +import type { PluginStore } from "./store"; + +/** + * The tool every Bot holds for putting a work app's state in front of the person asking. + * + * WHY A TOOL AND NOT A SENTENCE. A Bot that met an app it does not hold used to be told to "say an + * administrator can grant it", and did — correctly, and uselessly: the person was handed the name of + * a role and no way to reach it, about an app the Bot could have had all along. The other ways an + * app is out of reach already drew a card, because each of them is a refused call and the refusal + * carries one (`@openbot/shared/connect-prompt`). An app the Bot holds nothing for is never called, + * so nothing was refused and there was nothing to draw. This is the call. + * + * THE ANSWER IS THE SAME MARKER A REFUSAL CARRIES, with `reason: "access"`, so the transcript draws + * it with the same card, and the card asks the server for the app's state for this person and this + * Bot when it is drawn — never trusting what this answer said, which is only what was true when the + * model asked. The sentence after the marker is what the model reads: the state in words, and what + * the card offers, so it does not go on to repeat the card at length. + * + * A READ, AND ONLY EVER ONE. Nothing here enables, grants or files anything — pressing Request + * access on the card does that, as the person, through the same route the Bot page uses, where the + * server decides what is asked for. So the tool is safe to hand every Bot: the worst a model can do + * with it is show somebody an accurate card about an app they did not ask about. + */ +export function appAccessTools(options: { + store: Pick; + botId: string; + actorId: string; + composioConfigured: boolean; + /** Names an app this deployment has no row for. Absent without a Composio key. */ + directory?: AppDirectory; +}): GrantedTool[] { + const parameters = z + .object({ + app: z + .string() + .trim() + .min(1) + .max(200) + .describe( + "The app's name (Gmail, Slack, Google Drive) or its id as your instructions list it.", + ), + }) + .strict(); + return [ + { + name: SHOW_APP_ACCESS_TOOL, + ref: `app-access/${SHOW_APP_ACCESS_TOOL}`, + description: + "Show the person a card saying whether they can use one work app through you, and the one thing that would let them: connecting their own account, or asking an administrator to switch the app on, grant it to you, or connect its shared account. Call it whenever a question needs an app you hold no tools for, instead of only saying it is missing.", + parameters, + async execute(args) { + const parsed = parameters.safeParse(args); + if (!parsed.success) return "Name the app to show."; + const { app } = parsed.data; + const inspected = await options.store.inspectApp({ + app, + agentId: options.botId, + userId: options.actorId || null, + composioConfigured: options.composioConfigured, + ...(options.directory ? { directory: options.directory } : {}), + }); + /* + * No card for an app nobody can name. A card about it would have nothing to ask the server + * and nothing to offer, and the model is better told plainly so it can say so. + */ + if (!inspected) + return `This deployment has no app called ${app}, so there is nothing to show. Say so plainly; do not browse to it instead.`; + const { access } = inspected; + return connectAppRefusal({ + serverId: access.key, + title: access.displayName, + agentId: options.botId, + reason: "access", + message: toldTheModel(access), + }); + }, + }, + ]; +} + +/** + * What the model reads about the card it just put on screen: the state in words, and that the card + * already offers the next step, so the reply around it is a sentence rather than a set of + * instructions the person can already see. + */ +function toldTheModel(access: AppAccess): string { + const name = access.displayName; + const said: Record = { + ready: `${name} is ready for this person to use through you. Use its tools rather than the card.`, + not_connected: `${name} is granted to you, and this person has not connected their own account. The card in front of them offers Connect; ask them to try again once they have.`, + reconnect: `${name} is granted to you, and this person's connection needs reconnecting. The card in front of them offers Reconnect; ask them to try again once they have.`, + shared_not_connected: `${name} uses one shared account for this deployment, and an administrator has not connected it yet. The card in front of the person lets them ask an administrator for it.`, + disabled: `${name} is switched off on this deployment. The card in front of the person lets them ask an administrator to switch it on.`, + not_granted: `${name} is switched on, and you have not been granted it. The card in front of the person lets them ask an administrator to grant it to you.`, + }; + return `${said[access.state]} Do not browse to its website or ask them to sign in there.`; +} diff --git a/server/src/plugins/broker.ts b/server/src/plugins/broker.ts index 8595a10805..b151287545 100644 --- a/server/src/plugins/broker.ts +++ b/server/src/plugins/broker.ts @@ -625,6 +625,26 @@ export class BrokerUnconfiguredError extends BrokerRefusalError { } } +/** + * An account this deployment still has a row for and the vendor no longer holds. + * + * ITS OWN CLASS BECAUSE IT IS THE ONE VENDOR CONDITION A PERSON CAN FIX FROM THE CONVERSATION. A + * grant withdrawn at the provider and an account removed in Composio's dashboard both arrive like + * this, nothing on this deployment records either — the `brokered_connections` row still stands and + * the app still reads as ready — and connecting again is the whole remedy. Telling it apart by type + * is what lets the tool call that met it hand the transcript a card with Reconnect on it instead of + * a sentence naming a settings page, without anything matching on the sentence. + * + * A {@link BrokerRefusalError} because it keeps that class's promise about the message, and because + * every reader that already prefers an authored sentence goes on preferring this one. + */ +export class BrokerAccountGoneError extends BrokerRefusalError { + constructor(message: string, options?: ErrorOptions) { + super(message, options); + this.name = "BrokerAccountGoneError"; + } +} + /** * A return address that could not bring anybody back, refused before a consent is spent on it. * diff --git a/server/src/plugins/composio-vendor.ts b/server/src/plugins/composio-vendor.ts index cc8f4e9d41..bbbf8964cb 100644 --- a/server/src/plugins/composio-vendor.ts +++ b/server/src/plugins/composio-vendor.ts @@ -1,4 +1,5 @@ import { + BrokerAccountGoneError, type BrokerApp, type BrokerConnection, BrokerRefusalError, @@ -1676,8 +1677,13 @@ function vendorRefusal( * neither; connecting again is what puts an account back under the row. */ case "ComposioConnectedAccountNotFoundError": - return refusal( + /* + * Raised as its own class so the tool call that met it can put Reconnect in front of the + * person rather than this sentence alone. See {@link BrokerAccountGoneError}. + */ + return new BrokerAccountGoneError( `Composio holds no connected account for this person and ${app}, so ${outcome}. A grant withdrawn at the provider and an account removed in Composio's own dashboard both read exactly like this, and a retry reaches neither: connecting ${app} again on this deployment's Connected accounts page is what restores it.`, + { cause: error }, ); /* diff --git a/server/src/plugins/composio.ts b/server/src/plugins/composio.ts index 240a926ffd..7a8dc07271 100644 --- a/server/src/plugins/composio.ts +++ b/server/src/plugins/composio.ts @@ -1,5 +1,5 @@ import { cutAtCodeUnits } from "../channels/text"; -import { brokerSentence, flagOf } from "./broker"; +import { BrokerAccountGoneError, brokerSentence, flagOf } from "./broker"; import { type ListedTool, MAX_RESULT_CHARS, type McpCallResult } from "./mcp"; /** @@ -1767,6 +1767,18 @@ export async function askAction( * vendor's own words below, exactly as before. */ const authored = brokerSentence(error); + /* + * THE ONE AUTHORED CONDITION THAT TRAVELS AS A FACT AS WELL AS A SENTENCE. An account the + * vendor no longer holds is something the person can fix themselves, from the conversation, + * and nothing on this deployment's rows knows it happened — so the result says so in a field + * the store reads, rather than leaving it to be recognised from the wording. See + * {@link BrokerAccountGoneError} and `McpCallResult.accountGone`. + */ + if (authored !== null && error instanceof BrokerAccountGoneError) + return { + result: { ...failure(authored), accountGone: true }, + answered: false, + }; if (authored !== null) return unreached(authored); /* diff --git a/server/src/plugins/mcp.ts b/server/src/plugins/mcp.ts index d2d310def2..055352a973 100644 --- a/server/src/plugins/mcp.ts +++ b/server/src/plugins/mcp.ts @@ -432,6 +432,12 @@ export type McpCallResult = { /** True when the server itself reported the call as an error rather than failing to answer. */ isError: boolean; truncated: boolean; + /** + * The vendor no longer holds the account this call was made in, though this deployment still has + * a row for it. Only the Composio transport sets it, and only on an `isError` result; the store + * turns it into a refusal that offers Reconnect. Absent everywhere else. + */ + accountGone?: true; }; /** diff --git a/server/src/plugins/plugin-store/calls.ts b/server/src/plugins/plugin-store/calls.ts index 70f9e9482e..12f76655cc 100644 --- a/server/src/plugins/plugin-store/calls.ts +++ b/server/src/plugins/plugin-store/calls.ts @@ -1,3 +1,4 @@ +import { connectAppRefusal } from "@openbot/shared/connect-prompt"; import { and, eq } from "drizzle-orm"; import { currentApprovalContext } from "../../approvals/types"; import { type AuditInitiator, recordAuditEvent } from "../../audit"; @@ -510,7 +511,11 @@ export function callMethods(context: StoreContext) { }); const answer = { text: result.text, isError: result.isError }; await approval?.complete(answer); - return answer; + /* + * The one answer that leaves this block without returning: see below the catch. Thrown + * from in here, the catch would write a second `mcp.call_failed` row about the same call. + */ + if (!result.accountGone) return answer; } catch (error) { /* * Recorded, then rethrown unchanged. The caller's behaviour is unaffected — what changes is @@ -546,6 +551,29 @@ export function callMethods(context: StoreContext) { }); throw error; } + /* + * THE ACCOUNT IS GONE AT THE VENDOR, AND THE PERSON CAN PUT IT BACK FROM WHERE THEY ARE. The + * only way here: every other answer returned inside the block and every throw left the catch. + * + * Recorded above as the failure it is, with the vendor's condition in the trail. Handed on as + * a refusal carrying an app card rather than as the vendor's error, because nothing on this + * deployment's rows knows the account is gone — the app reads as ready on every screen — and + * the card is the one place the person is told, with Reconnect beside it. A Shared app's + * account is the deployment's, so there the card says an administrator has to reconnect it. + */ + throw new PluginRefusedError( + connectAppRefusal({ + serverId: row.id, + title: row.title, + message: + gatedMode === "shared" + ? `The shared ${row.title} account is no longer connected at the vendor, so this was not done. An administrator has to connect it again.` + : `Your ${row.title} account is no longer connected at the vendor, so this was not done. Reconnect it and ask again.`, + reason: gatedMode === "shared" ? "shared" : "reconnect", + at: new Date().toISOString(), + }), + null, + ); }, }; } diff --git a/server/src/plugins/plugin-store/context.ts b/server/src/plugins/plugin-store/context.ts index b877d5bf4f..e6c38481a7 100644 --- a/server/src/plugins/plugin-store/context.ts +++ b/server/src/plugins/plugin-store/context.ts @@ -528,13 +528,22 @@ export function createStoreContext(options: PluginStoreOptions) { .limit(1); if (!connected) { /* - * Only the personal half carries a connect prompt. A Shared app's account belongs to the - * deployment, so the person reading this cannot connect it however clearly they are asked — - * a card offering them the button would be an invitation to a refusal. + * BOTH HALVES CARRY A PROMPT, AND ONLY ONE OF THEM OFFERS A CONNECT BUTTON. A Shared app's + * account belongs to the deployment, so the person reading this cannot connect it however + * clearly they are asked — a card offering them that button would be an invitation to a + * refusal. What the card draws is decided by the app's state for this person, read when it + * is drawn: for a missing team account that is Request access, which files the request an + * administrator answers by connecting it. The sentence stays for the model and for a reader + * with no card. `reason: "shared"` is only a hint; see `ConnectReason`. */ throw new PluginRefusedError( resolved.ref.holder === "deployment" - ? `${row.title} is shared across this deployment and no account is connected to it yet. Ask an administrator to connect it.` + ? connectAppRefusal({ + serverId: row.id, + title: row.title, + message: `${row.title} is shared across this deployment and no account is connected to it yet. Ask an administrator to connect it.`, + reason: "shared", + }) : connectAppRefusal({ serverId: row.id, title: row.title, diff --git a/server/src/plugins/plugin-store/model.ts b/server/src/plugins/plugin-store/model.ts index 69ef78b109..0eec70dafb 100644 --- a/server/src/plugins/plugin-store/model.ts +++ b/server/src/plugins/plugin-store/model.ts @@ -1104,7 +1104,8 @@ export type PluginStoreOptions = { }, toolName: string, args: Record, - ) => Promise<{ text: string; isError: boolean }>; + /** `accountGone` as `McpCallResult` carries it: the vendor lost the account this call ran in. */ + ) => Promise<{ text: string; isError: boolean; accountGone?: true }>; /** Trading a refresh token for a short-lived access token. Defaults to a real HTTP exchange. */ exchangeRefreshToken?: (input: { tokenUrl: string; diff --git a/server/src/plugins/tools.ts b/server/src/plugins/tools.ts index baa73f39af..9496f2f6a9 100644 --- a/server/src/plugins/tools.ts +++ b/server/src/plugins/tools.ts @@ -8,6 +8,10 @@ import { catalogueEntry, researchRoleFor, } from "./catalogue"; +import { + addressedTo, + SHOW_APP_ACCESS_TOOL, +} from "@openbot/shared/connect-prompt"; import { type ResearchRole, RESEARCH_TOOLS } from "@openbot/shared/research"; import type { SelectableSkill } from "./selection"; import { @@ -100,10 +104,19 @@ export function grantedToolGuidance( */ connectedButNotHeld: readonly string[] = [], ): string { - if (tools.length === 0 && connectedButNotHeld.length === 0) return ""; + /* + * THE CARD TOOL IS NOT A SYSTEM. It is offered to every Bot so a missing app can be put in front of + * the person with the step that fixes it, and it reaches nothing — listed beside the vendors it + * would read as one more system this Bot "can reach directly, as the person asking". It is named + * below instead, at exactly the sentences that used to end on "an administrator can grant it". + */ + const showsCards = tools.some((tool) => tool.name === SHOW_APP_ACCESS_TOOL); + const reaching = tools.filter((tool) => tool.name !== SHOW_APP_ACCESS_TOOL); + if (reaching.length === 0 && connectedButNotHeld.length === 0 && !showsCards) + return ""; const bySystem = new Map(); - for (const tool of tools) { + for (const tool of reaching) { // `mcp__server__tool`, which is the shape the model is offered. const parts = tool.name.replace(/^mcp__/, "").split("__"); const system = parts.length > 1 ? (parts[0] as string) : "this deployment"; @@ -116,10 +129,10 @@ export function grantedToolGuidance( (system) => !held.includes(system), ); - const research = researchProviders(tools); + const research = researchProviders(reaching); return [ - ...(tools.length > 0 + ...(reaching.length > 0 ? [ "You can reach these systems directly, as the person asking, with their own access:", ] @@ -127,7 +140,7 @@ export function grantedToolGuidance( ...[...bySystem.entries()].map( ([system, names]) => `- ${system}: ${names.join(", ")}`, ), - ...(tools.length > 0 + ...(reaching.length > 0 ? [ "Use them for anything about those systems. Do NOT browse to one of their websites instead: your", "browser is signed in as nobody, so it sees less than these tools do and will meet a sign-in wall", @@ -151,12 +164,35 @@ export function grantedToolGuidance( */ ...(missing.length > 0 ? [ - ...(tools.length > 0 ? [""] : []), + ...(reaching.length > 0 ? [""] : []), `This deployment also connects to: ${missing.join(", ")}. You hold none of their tools.`, - "If a question needs one of them, say plainly that you have not been granted it and that an", - "administrator can grant it on that connector. Do NOT browse to its website: that is not the", - "same thing, your browser is signed in as nobody, and it will meet a sign-in wall that the", - "connector exists to avoid. Do not ask the person to sign in there either.", + ...(showsCards + ? [ + `If a question needs one of them, call ${SHOW_APP_ACCESS_TOOL} with its name: that puts a card in`, + "front of the person saying you have not been granted it, with a button to ask an administrator", + "for it. Then say so in a sentence; the card already carries the rest.", + ] + : [ + "If a question needs one of them, say plainly that you have not been granted it and that an", + "administrator can grant it on that connector.", + ]), + "Do NOT browse to its website: that is not the same thing, your browser is signed in as nobody,", + "and it will meet a sign-in wall that the connector exists to avoid. Do not ask the person to", + "sign in there either.", + ] + : []), + /* + * AN APP THIS DEPLOYMENT DOES NOT CONNECT AT ALL is the one case nothing above names, and the + * one a Bot met most bluntly: asked about Gmail on a deployment that has not switched it on, it + * had no word for Gmail anywhere in its instructions and either browsed or said it could not. + * The card says whether it can be switched on and lets the person ask for it. + */ + ...(showsCards + ? [ + ...(reaching.length > 0 || missing.length > 0 ? [""] : []), + `If the person needs a work app that is not named here at all (Gmail, Slack, Salesforce), call`, + `${SHOW_APP_ACCESS_TOOL} with its name before saying you cannot reach it: the card tells them whether it`, + "can be switched on and lets them ask an administrator. Do not browse to its website instead.", ] : []), ].join("\n"); @@ -281,7 +317,12 @@ export async function grantedTools(options: { error instanceof PluginRefusedError || error instanceof ApprovalRefusedError ) { - return `${REFUSAL_MARKER} ${error.message}`; + /* + * A refusal that carries an app card names the Bot here, the one place both are known: + * in a channel several Bots answer in, the card has to ask what the app is for this + * person on THIS Bot, not on whichever one the surface happens to have declared. + */ + return `${REFUSAL_MARKER} ${addressedTo(error.message, botId)}`; } /* * A contradiction in this deployment's own tables says nothing to a model. diff --git a/server/tests/app-access-tool.test.ts b/server/tests/app-access-tool.test.ts new file mode 100644 index 0000000000..1496510f2a --- /dev/null +++ b/server/tests/app-access-tool.test.ts @@ -0,0 +1,156 @@ +import { describe, expect, test } from "bun:test"; +import { + readConnectPrompt, + SHOW_APP_ACCESS_TOOL, +} from "@openbot/shared/connect-prompt"; +import type { AppAccess, AppAccessState } from "../src/plugins/app-access"; +import { appAccessTools } from "../src/plugins/app-access-tool"; +import type { AppInspection } from "../src/plugins/plugin-store/app-access"; +import { PluginRefusedError, type PluginStore } from "../src/plugins/store"; +import { grantedTools, REFUSAL_MARKER } from "../src/plugins/tools"; + +/** + * The tool every Bot holds for putting an app it cannot use in front of the person, and the Bot's + * name on the refusals that already carried a card. + * + * The tool is a read: it asks the store what the app is for this person on this Bot and answers + * with the card marker — the app, the Bot, `reason: "access"` — and a sentence for the model. It + * never files a request; pressing the card does that, as the person. + */ + +function access(state: AppAccessState): AppAccess { + return { + key: "composio-gmail", + displayName: "Gmail", + logo: null, + provider: "composio", + account: "personal", + connect: "consent", + authScheme: "OAUTH2", + accountMode: "personal", + state, + granted: state !== "not_granted" && state !== "disabled", + connectedAt: null, + requestKind: null, + request: null, + }; +} + +function toolOver(answer: AppAccess | null) { + const asked: unknown[] = []; + const [tool] = appAccessTools({ + store: { + inspectApp: async (input) => { + asked.push(input); + return answer ? ({ access: answer } as unknown as AppInspection) : null; + }, + }, + botId: "expenses", + actorId: "usr_7", + composioConfigured: true, + }); + if (!tool) throw new Error("no tool"); + return { tool, asked }; +} + +describe("show_app_access", () => { + test("is offered under its shared name, as a read anybody may hold", () => { + const { tool } = toolOver(null); + expect(tool.name).toBe(SHOW_APP_ACCESS_TOOL); + expect(tool.description).toContain("instead of only saying it is missing"); + }); + + test("asks about the app for this person on this Bot", async () => { + const { tool, asked } = toolOver(access("disabled")); + await tool.execute({ app: "Gmail" }); + expect(asked).toEqual([ + { + app: "Gmail", + agentId: "expenses", + userId: "usr_7", + composioConfigured: true, + }, + ]); + }); + + test.each([ + ["ready", /ready for this person to use through you/], + ["not_connected", /offers Connect/], + ["reconnect", /offers Reconnect/], + ["shared_not_connected", /ask an administrator for it/], + ["disabled", /switched off on this deployment/], + ["not_granted", /you have not been granted it/], + ] as const)( + "answers %s with the card marker and the state in words", + async (state, said) => { + const { tool } = toolOver(access(state)); + const answer = await tool.execute({ app: "gmail" }); + // Not a refusal: the transcript reads this tool's ordinary answer for its card. + expect(answer.startsWith(REFUSAL_MARKER)).toBe(false); + const prompt = readConnectPrompt(answer); + expect(prompt).toMatchObject({ + serverId: "composio-gmail", + title: "Gmail", + agentId: "expenses", + reason: "access", + }); + expect(prompt?.message).toMatch(said); + expect(prompt?.message).toContain("Do not browse to its website"); + }, + ); + + test("an app nobody can name gets a plain sentence and no card", async () => { + const { tool } = toolOver(null); + const answer = await tool.execute({ app: "Frobnicator" }); + expect(readConnectPrompt(answer)).toBeNull(); + expect(answer).toContain("no app called Frobnicator"); + }); + + test("arguments that name no app are refused before the store is asked", async () => { + const { tool, asked } = toolOver(access("ready")); + expect(await tool.execute({})).toBe("Name the app to show."); + expect(await tool.execute({ app: " " })).toBe("Name the app to show."); + expect(asked).toEqual([]); + }); +}); + +describe("a refusal carrying a card, on its way to the model", () => { + async function refusedWith(message: string) { + const [tool] = await grantedTools({ + store: { + callTool: async () => { + throw new PluginRefusedError(message, null); + }, + listForAgent: async () => ({ + tools: [ + { + toolName: "mcp__composio-gmail__GMAIL_FETCH_EMAILS", + ref: "composio-gmail/GMAIL_FETCH_EMAILS", + description: "Fetch mail.", + inputSchema: { type: "object" }, + }, + ], + }), + } as unknown as PluginStore, + botId: "expenses", + actorId: "usr_7", + }); + return (await tool?.execute({})) ?? ""; + } + + test("names the Bot that refused, so the card asks about the right one", async () => { + const answer = await refusedWith( + '[connect-app]{"serverId":"composio-gmail","title":"Gmail","reason":"shared"} Gmail is shared across this deployment and no account is connected to it yet. Ask an administrator to connect it.', + ); + expect(answer.startsWith(`${REFUSAL_MARKER} `)).toBe(true); + expect( + readConnectPrompt(answer.slice(REFUSAL_MARKER.length).trim()), + ).toMatchObject({ agentId: "expenses", reason: "shared" }); + }); + + test("leaves an ordinary refusal exactly as it was", async () => { + expect(await refusedWith("That tool is not granted.")).toBe( + `${REFUSAL_MARKER} That tool is not granted.`, + ); + }); +}); diff --git a/server/tests/composio-adapter.test.ts b/server/tests/composio-adapter.test.ts index e80987a9c7..fd95336d87 100644 --- a/server/tests/composio-adapter.test.ts +++ b/server/tests/composio-adapter.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "bun:test"; import { Composio, telemetry } from "@composio/core"; import { + BrokerAccountGoneError, type BrokerConnection, BrokerRefusalError, BrokerUnconfiguredError, @@ -3502,6 +3503,20 @@ describe("each vendor condition reaches the reader as its own remedy", () => { }); } + test("an account Composio no longer holds is raised as its own class, so a call can offer Reconnect", async () => { + const failure = await failureOf( + whileRunning(raising("ComposioConnectedAccountNotFoundError")), + ); + expect(failure).toBeInstanceOf(BrokerAccountGoneError); + // Still an authored refusal, so every reader that prefers this deployment's sentence still does. + expect(failure).toBeInstanceOf(BrokerRefusalError); + // And nothing else is: the class is what the store acts on. + const other = await failureOf( + whileRunning(raising("ComposioRequestCancelledError")), + ); + expect(other).not.toBeInstanceOf(BrokerAccountGoneError); + }); + /** * ONE VENDOR CLASS OVER EVERY WAY A FETCH CAN FAIL, which is a fact about the SDK rather than a * reading of its name. `getRawComposioToolBySlug` wraps its retrieve in a try whose catch diff --git a/server/tests/composio-transport.test.ts b/server/tests/composio-transport.test.ts index 9c1086a55e..022d542fa9 100644 --- a/server/tests/composio-transport.test.ts +++ b/server/tests/composio-transport.test.ts @@ -1,5 +1,8 @@ import { afterEach, describe, expect, test } from "bun:test"; -import { BrokerRefusalError } from "../src/plugins/broker"; +import { + BrokerAccountGoneError, + BrokerRefusalError, +} from "../src/plugins/broker"; import { askAction, type ComposioAction, @@ -3223,6 +3226,50 @@ describe("calling one action", () => { expect(result.text).not.toContain("Invalid request"); }); + test("an account the vendor lost travels as a fact on the result, beside its sentence", async () => { + /* + * The one authored condition the store acts on: it turns this result into a refusal that + * offers Reconnect. Read off a field rather than off the wording, so rephrasing the sentence + * cannot quietly stop the card being drawn — and set on nothing else. + */ + const authored = + "Composio holds no connected account for this person and gmail, so GMAIL_FETCH_EMAILS was not run."; + useComposioClient( + recording({ + execute: async () => { + throw new BrokerAccountGoneError(authored); + }, + }).client, + ); + + const gone = await askAction( + { url: "composio://gmail", actorId: "user_asker" }, + "GMAIL_FETCH_EMAILS", + { __version: "20260903_00" }, + ); + expect(gone.result).toMatchObject({ + isError: true, + text: authored, + accountGone: true, + }); + // Not a verdict on the key: a probe meeting this keeps its "could not find out". + expect(gone.answered).toBe(false); + + useComposioClient( + recording({ + execute: async () => { + throw new BrokerRefusalError("Some other authored refusal."); + }, + }).client, + ); + const other = await callTool( + { url: "composio://gmail", actorId: "user_asker" }, + "GMAIL_FETCH_EMAILS", + { __version: "20260903_00" }, + ); + expect(other.accountGone).toBeUndefined(); + }); + test("a failure this deployment cannot explain still reaches for the vendor's words", async () => { // The other half of the precedence, and the reason it is `brokerSentence` rather than // `error.message`. `./broker` raises its class only where the sentence names the step that diff --git a/server/tests/copilot.test.ts b/server/tests/copilot.test.ts index 48b43149e6..978a0811d9 100644 --- a/server/tests/copilot.test.ts +++ b/server/tests/copilot.test.ts @@ -1069,6 +1069,53 @@ describe("what a Bot is told it holds", () => { ); }); + /* + * THE CARD TOOL. Every Bot holds `show_app_access`, and the sentences that used to end on "an + * administrator can grant it" now tell the Bot to call it — which puts a card with Request access + * in front of the person, where the sentence put the name of a role. + */ + const card = { name: "show_app_access" } as never; + + test("tells a Bot to show the card for a connected vendor it holds nothing for", () => { + const guidance = grantedToolGuidance([card], ["composio-gmail"]) + .toLowerCase() + .replace(/\s+/g, " "); + expect(guidance).toContain("composio-gmail"); + expect(guidance).toContain("you hold none of their tools"); + expect(guidance).toContain("call show_app_access with its name"); + expect(guidance).not.toContain( + "administrator can grant it on that connector", + ); + expect(guidance).toContain("do not browse to its website"); + }); + + test("tells a Bot to show the card for an app this deployment does not connect at all", () => { + const guidance = grantedToolGuidance([card]) + .toLowerCase() + .replace(/\s+/g, " "); + expect(guidance).toContain("not named here at all"); + expect(guidance).toContain("show_app_access"); + expect(guidance).toContain("ask an administrator"); + }); + + test("never lists the card tool as a system the Bot can reach", () => { + // Listed beside the vendors, it would read as one more system reached "as the person asking". + const guidance = grantedToolGuidance([...drive, card]); + expect(guidance).not.toMatch(/^- .*show_app_access/m); + expect(guidance).toContain("search_files"); + expect(grantedToolGuidance([card])).not.toContain( + "You can reach these systems directly", + ); + }); + + test("a Bot not offered the card is still told to name the administrator, as before", () => { + const guidance = grantedToolGuidance([], ["google-drive"]) + .toLowerCase() + .replace(/\s+/g, " "); + expect(guidance).toContain("administrator can grant it on that connector"); + expect(guidance).not.toContain("show_app_access"); + }); + test("says nothing at all when the Bot holds nothing", () => { // A deployment with no connectors must not be told about connectors it does not have. expect(grantedToolGuidance([])).toBe(""); diff --git a/server/tests/shared-brokered-store.integration.test.ts b/server/tests/shared-brokered-store.integration.test.ts index 59d2e912ee..1abeb1fc92 100644 --- a/server/tests/shared-brokered-store.integration.test.ts +++ b/server/tests/shared-brokered-store.integration.test.ts @@ -10,7 +10,12 @@ import { mcpServers, mcpTools, } from "../src/db/schema"; -import type { ConnectedAppBroker } from "../src/plugins/broker"; +import { readConnectPrompt } from "@openbot/shared/connect-prompt"; +import { auditEvents } from "../src/db/schema"; +import { + BrokerAccountGoneError, + type ConnectedAppBroker, +} from "../src/plugins/broker"; import { useComposioClient } from "../src/plugins/composio"; import { createPluginStore } from "../src/plugins/store"; import { expectOnlyRefusal } from "./helpers/refusals"; @@ -181,6 +186,12 @@ describe("a shared app", () => { (error: Error) => error.message, ); expectOnlyRefusal(said, "sharedNotConnected", "Team GitHub"); + // And it carries the card, which asks an administrator rather than offering Connect. + expect(readConnectPrompt(said)).toMatchObject({ + serverId: app, + title: "Team GitHub", + reason: "shared", + }); }); test("offboarding the admin who connected it leaves it in place", async () => { @@ -539,3 +550,93 @@ describe("a duplicate row at an app's url", () => { ]); }); }); + +/* + * THE ACCOUNT GONE AT THE VENDOR. Nothing on this deployment's rows knows it happened, so the call + * that meets it is the only place the person can be told: refused with the app card, `reconnect` + * for their own account and `shared` for the team's, stamped with when — and audited once, as the + * failure it is. + */ +describe("an account the vendor no longer holds", () => { + const gone = () => + useComposioClient({ + listActions: async () => [], + execute: async () => { + throw new BrokerAccountGoneError( + "Composio holds no connected account for this person and team-gh, so GITHUB_LIST_ISSUES was not run.", + ); + }, + }); + + /* Counted before and after: the trail is not cleaned between tests, which share the app id. */ + const failures = async () => + ( + await database + .select({ eventType: auditEvents.eventType }) + .from(auditEvents) + .where(eq(auditEvents.targetId, `${app}/GITHUB_LIST_ISSUES`)) + ).filter((row) => row.eventType === "mcp.call_failed").length; + + test("on a Shared app: refused with a card saying an administrator has to connect it again", async () => { + const store = freshStore(); + await seedShared(store, true); + gone(); + const already = await failures(); + const before = Date.now(); + const said = await store + .callTool({ + ref: `${app}/GITHUB_LIST_ISSUES`, + args: {}, + botId: bot, + actorId: asker, + }) + .then( + () => "called", + (error: Error) => error.message, + ); + const prompt = readConnectPrompt(said); + expect(prompt).toMatchObject({ + serverId: app, + title: "Team GitHub", + reason: "shared", + }); + expect(prompt?.message).toContain( + "An administrator has to connect it again", + ); + expect(Date.parse(prompt?.at ?? "")).toBeGreaterThanOrEqual(before - 1000); + expect(await failures()).toBe(already + 1); + }); + + test("on a personal app: refused with a card offering Reconnect", async () => { + const store = freshStore(); + await seedShared(store, false); + await database + .update(mcpServers) + .set({ accountMode: "personal", sharedVendorUserId: null }) + .where(eq(mcpServers.id, app)); + await database.insert(brokeredConnections).values({ + provider: "composio", + app, + holder: "person", + userId: asker, + vendorUserId: asker, + }); + gone(); + const said = await store + .callTool({ + ref: `${app}/GITHUB_LIST_ISSUES`, + args: {}, + botId: bot, + actorId: asker, + }) + .then( + () => "called", + (error: Error) => error.message, + ); + expect(readConnectPrompt(said)).toMatchObject({ + serverId: app, + reason: "reconnect", + }); + expect(said).toContain("Reconnect it and ask again"); + }); +}); diff --git a/shared/connect-prompt.test.ts b/shared/connect-prompt.test.ts index 630b76437c..336c53058a 100644 --- a/shared/connect-prompt.test.ts +++ b/shared/connect-prompt.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test"; import { + addressedTo, CONNECT_APP_MARKER, connectAppRefusal, readConnectPrompt, @@ -58,3 +59,65 @@ describe("text that is not one", () => { expect(readConnectPrompt(undefined)).toBeNull(); }); }); + +describe("the Bot, the reason and the moment it carries", () => { + test("survive the round trip, and are left out when absent", () => { + const text = connectAppRefusal({ + serverId: "composio-gmail", + title: "Gmail", + message: "Your Gmail account is no longer connected at the vendor.", + agentId: "expenses", + reason: "reconnect", + at: "2026-10-20T12:00:00.000Z", + }); + expect(readConnectPrompt(text)).toEqual({ + serverId: "composio-gmail", + title: "Gmail", + message: "Your Gmail account is no longer connected at the vendor.", + agentId: "expenses", + reason: "reconnect", + at: "2026-10-20T12:00:00.000Z", + }); + expect(connectAppRefusal({ serverId: "s", title: "T", message: "m" })).toBe( + `${CONNECT_APP_MARKER}{"serverId":"s","title":"T"} m`, + ); + }); + + test("a field of the wrong shape is dropped, and the prompt still names its app", () => { + const prompt = readConnectPrompt( + `${CONNECT_APP_MARKER}{"serverId":"composio-gmail","title":"Gmail","agentId":7,"reason":"grant me","at":"yesterday"} hello`, + ); + expect(prompt).toEqual({ + serverId: "composio-gmail", + title: "Gmail", + message: "hello", + }); + }); + + test("addressedTo names the Bot on a prompt that names none, and leaves everything else alone", () => { + const bare = connectAppRefusal({ + serverId: "notion", + title: "Notion", + message: "You have not connected your Notion account.", + reason: "shared", + }); + expect(readConnectPrompt(addressedTo(bare, "expenses"))).toEqual({ + serverId: "notion", + title: "Notion", + message: "You have not connected your Notion account.", + reason: "shared", + agentId: "expenses", + }); + + const named = connectAppRefusal({ + serverId: "notion", + title: "Notion", + message: "m", + agentId: "travel", + }); + expect(addressedTo(named, "expenses")).toBe(named); + expect(addressedTo("That tool is not granted.", "expenses")).toBe( + "That tool is not granted.", + ); + }); +}); diff --git a/shared/connect-prompt.ts b/shared/connect-prompt.ts index 499ea32787..e1042b5936 100644 --- a/shared/connect-prompt.ts +++ b/shared/connect-prompt.ts @@ -1,5 +1,7 @@ /** - * The refusal a person can act on: an app this call needed, that they have not connected. + * The refusal a person can act on: an app this call needed, that they have not connected — or + * whose account the vendor has lost, or whose team account nobody has connected — and the answer of + * the one tool that says what an app is for them without a call having been refused at all. * * ONE DECLARATION, READ FROM BOTH SIDES, for the reason `./handoff-markers.ts` sets out at length. * A tool that runs on the server reaches the transcript as text meant for a model, so a renderer @@ -16,15 +18,65 @@ /** The first characters of the refusal, immediately followed by its JSON. */ export const CONNECT_APP_MARKER = "[connect-app]"; +/** + * Why the card was drawn, as the server saw it at the moment of the call. + * + * `connect` — this person has no account at the app. `reconnect` — they had one, and the vendor no + * longer holds it: a grant withdrawn at the provider, an account removed in Composio's dashboard. + * `shared` — a Shared app whose one team account is missing, which only an administrator connects. + * `access` — the Bot asked what the app is for this person (`show_app_access`), with no refusal + * behind it: switched off, not granted, or any of the above. + * + * A HINT, NOT THE STATE. The card asks the server for the app's state for this person and this Bot + * when it is drawn, and draws that. The one place the hint wins is an account the vendor has lost: + * nothing on this deployment records that, so the live state still reads `ready` there, and only the + * refusal knows otherwise. See `AppAccessCard`. + */ +export type ConnectReason = "connect" | "reconnect" | "shared" | "access"; + +const REASONS: readonly ConnectReason[] = [ + "connect", + "reconnect", + "shared", + "access", +]; + export type ConnectPrompt = { - /** The plugin server to connect, as `POST /api/plugins/servers/:serverId/connect` names it. */ + /** + * The app, as the server names it: the plugin server to connect, as + * `POST /api/plugins/servers/:serverId/connect` names it, or the key an app nobody enabled would + * have. What the card asks `GET /api/plugins/for/:agentId/apps/:app` about. + */ serverId: string; /** The app's own name, for a card that says what it is asking for. */ title: string; /** What the Bot would have said, kept intact for a reader with no card to draw. */ message: string; + /** + * The Bot whose call this was, so the card asks about the right one in a channel several Bots + * answer in. Absent on a refusal written before it carried one, where the card falls back to the + * Bot the surface declared. + */ + agentId?: string; + /** See {@link ConnectReason}. Absent reads as `connect`, which is all a refusal used to mean. */ + reason?: ConnectReason; + /** + * When the server refused, as an ISO instant — on a `reconnect` or `shared` refusal, the only + * ones whose hint can outrank the live state. A connection made after this is the reconnection, + * and the card stops saying Reconnect. See `AppAccess.connectedAt`. + */ + at?: string; }; +/** + * The name of the tool every Bot holds for saying what an app is for the person asking. + * + * Here rather than beside the tool, because the transcript reads it too: that tool's answer is the + * one ordinary result (not a refusal) whose marker is drawn as a card, and a renderer may only trust + * a marker on a result it knows this deployment wrote. + */ +export const SHOW_APP_ACCESS_TOOL = "show_app_access"; + /** * The refusal text a `PluginRefusedError` carries for an app the asker has not connected. * @@ -32,8 +84,28 @@ export type ConnectPrompt = { * the MODEL is told, and because a transcript that cannot parse the marker still reads correctly. */ export function connectAppRefusal(prompt: ConnectPrompt): string { - const { serverId, title, message } = prompt; - return `${CONNECT_APP_MARKER}${JSON.stringify({ serverId, title })} ${message}`; + const { serverId, title, message, agentId, reason, at } = prompt; + return `${CONNECT_APP_MARKER}${JSON.stringify({ + serverId, + title, + ...(agentId ? { agentId } : {}), + ...(reason ? { reason } : {}), + ...(at ? { at } : {}), + })} ${message}`; +} + +/** + * The same text with the Bot named on it, for the one caller that knows which Bot asked. + * + * WHY AFTERWARDS RATHER THAN AT THE SITE THAT WROTE IT. The refusals are raised deep in resolving a + * credential, which is a question about a person and an app and never about a Bot — threading a Bot + * id down there would be a parameter nothing else reads. The tool's own wrapper knows the Bot, so it + * adds it on the way out. Text that is not a prompt, or already names a Bot, is returned unchanged. + */ +export function addressedTo(text: string, agentId: string): string { + const prompt = readConnectPrompt(text); + if (!prompt || prompt.agentId || !agentId) return text; + return connectAppRefusal({ ...prompt, agentId }); } /** @@ -53,13 +125,32 @@ export function readConnectPrompt( try { const parsed: unknown = JSON.parse(rest.slice(0, end + 1)); if (!parsed || typeof parsed !== "object") return null; - const { serverId, title } = parsed as { + const { serverId, title, agentId, reason, at } = parsed as { serverId?: unknown; title?: unknown; + agentId?: unknown; + reason?: unknown; + at?: unknown; }; if (typeof serverId !== "string" || typeof title !== "string") return null; if (!serverId || !title) return null; - return { serverId, title, message: rest.slice(end + 1).trim() }; + /* + * The optional fields are dropped rather than failing the whole prompt when they are the + * wrong shape: without them the card still names the right app and asks about the declared Bot, + * which is what every refusal did before they existed. + */ + return { + serverId, + title, + message: rest.slice(end + 1).trim(), + ...(typeof agentId === "string" && agentId ? { agentId } : {}), + ...(REASONS.includes(reason as ConnectReason) + ? { reason: reason as ConnectReason } + : {}), + ...(typeof at === "string" && !Number.isNaN(Date.parse(at)) + ? { at } + : {}), + }; } catch { return null; } From 6cbe79a9762ffe93a898e84229265bbe06e14ed4 Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Tue, 20 Oct 2026 19:39:45 -0300 Subject: [PATCH 08/33] Describe app states on the Bot page, the chat card and access requests --- CHANGELOG.md | 16 ++++++++++++++++ docs/plugins/composio.md | 29 +++++++++++++++++++++++++++-- 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 515f00da48..78e4f0d5e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,22 @@ 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. +### Each app on a Bot says whether you can use it, and you can ask an administrator for the rest + +A Bot's page now says, for every work app the Bot holds, what that app is for the person looking: +ready, not connected yet, needing reconnecting, waiting on your organisation's shared account, turned +off by an administrator, or not given to this Bot. Each comes with the one thing you can do about it — +Connect or Reconnect your own account, or Request access where only an administrator can help — and +apps are named by their own name ("Gmail", not "Composio Gmail"). In a conversation, a Bot that needs +an app it cannot use now puts the same card in front of you instead of a sentence: an account you have +not connected, a Shared app with no team account, an account the vendor no longer holds, and — through +a new `show_app_access` tool every Bot holds — an app that is switched off or not granted to it. The +card reads the app's current state when it is drawn, so it never offers a step already taken. +Administrators see waiting requests at the top of Plugins, a count on each app's row, and a Requests +section on the app's own page, where Approve switches the app on and grants it to the Bot in one press +(or says what has to be done first) and Dismiss closes the request. Requests name who asked; nobody's +connection status is shown to administrators. + ### A new Bot is made from a sheet that asks where it runs first New Bot opens a sheet instead of the four-screen wizard. It asks where the Bot will run, with Run it diff --git a/docs/plugins/composio.md b/docs/plugins/composio.md index a6ac79ca5b..0e5afe5144 100644 --- a/docs/plugins/composio.md +++ b/docs/plugins/composio.md @@ -514,7 +514,31 @@ page and a card in a conversation say the same thing a call would find: "Connected" is what this deployment last recorded — the same `verified` and probe columns the Connected accounts page reads. Nothing asks the vendor per page load, so an account ended in -Composio's dashboard reads as `ready` until the next call or re-check finds out. +Composio's dashboard reads as `ready` until the next call or re-check finds out — and the call that +finds out is refused with a card offering **Reconnect** (below). + +On the Bot's page, Connect opens the app's consent screen in a new tab through the same connect route +Settings uses, and the row turns ready once the connection is recorded. An app connected with a typed +key, and Reconnect on a Composio app (whose row has to be disconnected before a new account can be +attached), go to the app's own page under `/settings/connected-accounts` instead. + +### In a conversation + +A Bot that needs an app it cannot use puts a card in the transcript naming the app, its state and the +next step, drawn from the same state as the Bot's page: + +- a call refused because the person has not connected the app; +- a call refused because a Shared app's team account is not connected — **Request access** rather + than the sentence "Ask an administrator to connect it."; +- a call Composio answered with "no connected account" — **Reconnect**, or for a Shared app a line + saying an administrator has to connect it again; +- the `show_app_access` tool, which every Bot holds and is told to call for an app it holds nothing + for, switched off or not granted — **Request access**. + +The card asks `GET /api/plugins/for/:agentId/apps/:app` for the Bot that made the call when it is +drawn, so a card scrolled back to later shows what is true now. The one case it takes the refusal's +word over the live state is the lost account, which the server cannot see; a connection made after +the refusal ends that. The card is drawn in the transcript, never folded into the "Used N tools" line. `GET /api/plugins/for/:agentId/apps` lists every app the Bot holds, including a grant whose app is gone; `GET /api/plugins/for/:agentId/apps/:app` answers for one app by key or by name, held or not. @@ -531,7 +555,8 @@ the app does not need. Asking twice answers with the request already waiting: on per person, app, Bot and kind, held by a unique index rather than a check, so two replicas cannot both file one. A person can withdraw their own. -Requests wait on the app's own page under `/admin/plugins`, with a count on the Plugins list. They +Requests wait on the app's own page under `/admin/plugins`, and all of them at the top of the +Plugins list, with a count on each app's row — an app nobody has switched on has no page of its own. They show who asked and for which Bot — they asked — and nothing about anybody's connections. **Approve** does what was asked in the same press: From 969d2795703c8fb17c9521f24c43772797ca6c0e Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Wed, 21 Oct 2026 09:30:35 -0300 Subject: [PATCH 09/33] Leave Composio apps off Connected accounts when the deployment has no Composio key --- .../settings/connected-accounts/index.tsx | 24 ++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/app/src/routes/_authed/settings/connected-accounts/index.tsx b/app/src/routes/_authed/settings/connected-accounts/index.tsx index 9eaeea4d2a..b55508bdc8 100644 --- a/app/src/routes/_authed/settings/connected-accounts/index.tsx +++ b/app/src/routes/_authed/settings/connected-accounts/index.tsx @@ -100,6 +100,23 @@ export function brokeredAccountsListedOn( ); } +/** + * The servers this page may list at all: none reached through Composio when the deployment has no + * Composio key. + * + * An app an administrator enabled while a key was set keeps its row after the key is removed, and + * listing it offered a person an account they cannot connect, under a sentence about an environment + * variable only an administrator can set. Without the broker there is nothing for them to decide. + */ +export function serversListedFor(page: { + servers: PluginServer[]; + composioConfigured: boolean; +}): PluginServer[] { + return page.composioConfigured + ? page.servers + : page.servers.filter((server) => server.provenance !== "composio"); +} + /** Apps whose account is the organisation's, shown so a person knows a Bot does not act as them there. */ export function sharedAccountsListedOn( servers: PluginServer[], @@ -198,16 +215,17 @@ function RouteComponent() { * unrecorded scheme included: a row whose column was never written is far likelier to be a key or * consent app, and dropping it here would hide a connection somebody does have. */ - const brokered = brokeredAccountsListedOn(plugins.data?.servers ?? []); + const listed = plugins.data ? serversListedFor(plugins.data) : []; + const brokered = brokeredAccountsListedOn(listed); /* * Listed, never connected here. A Shared app's account is the organisation's, not this person's to * make or to break, so this list draws no Connect action and links nowhere — it exists only so a * person can see that a Bot calling this app acts as the team, not as them. */ - const shared = sharedAccountsListedOn(plugins.data?.servers ?? []); + const shared = sharedAccountsListedOn(listed); const sectionsToShow = connectedAccountSections( yours.length + brokered.length, - plugins.data?.servers ?? [], + listed, ); const accounts = [ ...yours.map((entry) => { From b95bbde74acb2efb821ab1719dd1251bee0ecb4b Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Wed, 21 Oct 2026 09:30:35 -0300 Subject: [PATCH 10/33] Say a Composio app is not available yet, rather than naming the key, on its account page --- .../settings/connected-accounts/$key.tsx | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/app/src/routes/_authed/settings/connected-accounts/$key.tsx b/app/src/routes/_authed/settings/connected-accounts/$key.tsx index b507c54cc0..8b7f37aa9a 100644 --- a/app/src/routes/_authed/settings/connected-accounts/$key.tsx +++ b/app/src/routes/_authed/settings/connected-accounts/$key.tsx @@ -206,6 +206,24 @@ function RouteComponent() { ); } + /* + * A COMPOSIO APP ON A DEPLOYMENT WITH NO COMPOSIO KEY, BEFORE EITHER BRANCH BELOW DRAWS A ROW. + * + * The list on the way in no longer offers one, but a link from elsewhere still lands here, and the + * brokered row's own sentence for this case names an environment variable — an instruction for + * whoever runs the deployment, read by somebody who cannot act on it. + */ + if (server?.provenance === "composio" && !plugins.data.composioConfigured) { + return ( + + + {server.title} is not available on this OpenBot yet. An administrator + has to finish setting it up before anybody can connect it. + + + ); + } + /* * A SHARED APP, BEFORE THE BROKERED BRANCH BELOW GETS A LOOK AT IT. * From 1df882a9c17a8f7fda2fd17832f7a28db257c298 Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Wed, 21 Oct 2026 09:30:35 -0300 Subject: [PATCH 11/33] Test Connected accounts on a deployment with no Composio key --- app/tests/brokered-account-row.test.tsx | 12 +- .../composio-unconfigured-account.test.tsx | 181 ++++++++++++++++++ 2 files changed, 190 insertions(+), 3 deletions(-) create mode 100644 app/tests/composio-unconfigured-account.test.tsx diff --git a/app/tests/brokered-account-row.test.tsx b/app/tests/brokered-account-row.test.tsx index 1c1811fd89..6ac33d89a9 100644 --- a/app/tests/brokered-account-row.test.tsx +++ b/app/tests/brokered-account-row.test.tsx @@ -808,7 +808,11 @@ test("a disconnect that lands stops reading Connected and offers Connect again", expect(server.deletes).toBe(1); }); -test("a deployment with no Composio key names the setting and offers neither action", async () => { +/* + * The setting is named on the admin page below, to somebody who can set it. Here the reader is the + * person who would connect the account, so the page says only that the app is not available yet. + */ +test("a deployment with no Composio key says the app is not available yet and offers neither action", async () => { installDeployment({ composioConfigured: false, // A row left over from when a key WAS set: the app keeps its row and its grants, which is the @@ -819,8 +823,10 @@ test("a deployment with no Composio key names the setting and offers neither act const view = renderAccountScreen(queryClient()); - expect(await view.findByText(NAMES_THE_SETTING)).toBeTruthy(); - expect(view.queryByText("Key missing")).toBeTruthy(); + expect( + await view.findByText(/is not available on this OpenBot yet/), + ).toBeTruthy(); + expect(view.queryByText(NAMES_THE_SETTING)).toBeNull(); expect(view.queryByText("Connected")).toBeNull(); expect(view.queryByRole("button", { name: "Disconnect" })).toBeNull(); expect(view.queryByRole("button", { name: "Connect" })).toBeNull(); diff --git a/app/tests/composio-unconfigured-account.test.tsx b/app/tests/composio-unconfigured-account.test.tsx new file mode 100644 index 0000000000..cb5920dbd8 --- /dev/null +++ b/app/tests/composio-unconfigured-account.test.tsx @@ -0,0 +1,181 @@ +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + expect, + test, +} from "bun:test"; +import { GlobalRegistrator } from "@happy-dom/global-registrator"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, + Outlet, + RouterProvider, +} from "@tanstack/react-router"; +import { cleanup, render } from "@testing-library/react"; +import { + type PluginConnections, + type PluginServer, + type PluginsPage, + pluginKeys, +} from "@/lib/plugins/queries"; +import { Route as ConnectedAccountRoute } from "@/routes/_authed/settings/connected-accounts/$key"; +import { serversListedFor } from "@/routes/_authed/settings/connected-accounts/index"; + +/** + * A Composio app on a deployment with no Composio key, as the person who would connect it sees it. + * + * An app an administrator enabled while a key was set keeps its row after the key goes. The + * Connected accounts list offered it, and its page drew "Set COMPOSIO_API_KEY on this deployment" — + * an instruction for whoever runs the deployment, shown to somebody who cannot act on it. The list + * now leaves it off, and the page says plainly that it is not available yet. + * + * The harness is `plugin-read-failure.test.tsx`'s: the exported `Route` singleton is captured and + * restored around its `.update()`. + */ + +beforeAll(() => GlobalRegistrator.register()); +afterEach(cleanup); +afterAll(() => GlobalRegistrator.unregister()); + +const originalFetch = global.fetch; + +beforeEach(() => { + // Every read this page makes is seeded below; anything else is a request the test did not expect. + global.fetch = Object.assign( + async () => new Response(null, { status: 404 }), + { preconnect: originalFetch.preconnect }, + ); +}); + +afterEach(() => { + global.fetch = originalFetch; +}); + +function server(overrides: Partial & { id: string }) { + return { + title: "App", + summary: "", + vendor: "Composio", + url: `composio://${overrides.id}`, + provenance: "composio", + authScheme: "OAUTH2", + tools: [], + ...overrides, + } as PluginServer; +} + +const gmail = server({ id: "composio-gmail", title: "Gmail" }); +const drive = server({ + id: "google-drive", + title: "Google Drive", + provenance: "catalogue", + vendor: "Google", +} as Partial & { id: string }); + +test("lists no Composio app when the deployment has no Composio key", () => { + expect( + serversListedFor({ + servers: [gmail, drive], + composioConfigured: false, + }).map((row) => row.id), + ).toEqual(["google-drive"]); +}); + +test("lists Composio apps as before once the key is set", () => { + expect( + serversListedFor({ + servers: [gmail, drive], + composioConfigured: true, + }).map((row) => row.id), + ).toEqual(["composio-gmail", "google-drive"]); +}); + +type Updatable = { update: (options: unknown) => unknown }; + +const pristine = { + ...ConnectedAccountRoute, + options: { ...ConnectedAccountRoute.options }, +} as Record; + +afterEach(() => { + for (const key of Object.keys(ConnectedAccountRoute)) { + if (!(key in pristine)) { + delete (ConnectedAccountRoute as unknown as Record)[key]; + } + } + Object.assign(ConnectedAccountRoute, pristine); +}); + +function renderAccount(composioConfigured: boolean) { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + const page: PluginsPage = { + catalogue: [], + servers: [gmail], + skills: [], + botsMayCallBack: true, + redirectUri: null, + composioConfigured, + }; + const connections: PluginConnections = { connections: [], redirectUri: null }; + client.setQueryData(pluginKeys.page(), page); + client.setQueryData(pluginKeys.connections(), connections); + + const rootRoute = createRootRoute({ component: Outlet }); + const authedRoute = createRoute({ + id: "/_authed", + getParentRoute: () => rootRoute, + component: Outlet, + }); + const settingsRoute = createRoute({ + path: "/settings", + getParentRoute: () => authedRoute, + component: Outlet, + }); + const indexRoute = createRoute({ + path: "/connected-accounts/", + getParentRoute: () => settingsRoute, + component: () => null, + }); + const account = (ConnectedAccountRoute as unknown as Updatable).update({ + id: "/connected-accounts/$key", + path: "/connected-accounts/$key", + getParentRoute: () => settingsRoute, + }); + const router = createRouter({ + routeTree: rootRoute.addChildren([ + authedRoute.addChildren([ + settingsRoute.addChildren([indexRoute, account as never]), + ]), + ]), + history: createMemoryHistory({ + initialEntries: ["/settings/connected-accounts/composio-gmail"], + }), + }); + return render( + + + , + ); +} + +test("the app's page says it is not available yet, and names no setting", async () => { + const view = renderAccount(false); + expect( + await view.findByText(/Gmail is not available on this OpenBot yet/), + ).toBeTruthy(); + expect(view.queryByText(/COMPOSIO_API_KEY/)).toBeNull(); + expect(view.queryByRole("button", { name: "Connect" })).toBeNull(); +}); + +test("with the key set, the app's page offers the account as before", async () => { + const view = renderAccount(true); + expect(await view.findByText("Your account")).toBeTruthy(); + expect(view.queryByText(/not available on this OpenBot yet/)).toBeNull(); +}); From fd09ac986019041fc0ca43c5d239dbd4cfe38a5c Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Wed, 21 Oct 2026 09:57:57 -0300 Subject: [PATCH 12/33] Note Connected accounts on a deployment with no Composio key in the changelog --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 78e4f0d5e3..f7fa72c2dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,6 +51,11 @@ section on the app's own page, where Approve switches the app on and grants it t (or says what has to be done first) and Dismiss closes the request. Requests name who asked; nobody's connection status is shown to administrators. +On a deployment with no `COMPOSIO_API_KEY`, Settings → Connected accounts no longer lists Composio +apps, which nobody could connect there. An app's own page, reached from an older link, says it is not +available yet and that an administrator has to finish setting it up, instead of naming the setting. +The administrator's page for the app still names `COMPOSIO_API_KEY`. + ### A new Bot is made from a sheet that asks where it runs first New Bot opens a sheet instead of the four-screen wizard. It asks where the Bot will run, with Run it From fc368b13d5be79d14aab29bb519b057f83bd611f Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Wed, 21 Oct 2026 14:07:40 -0300 Subject: [PATCH 13/33] Let React finish before tearing down the Composio account test's browser --- app/tests/composio-unconfigured-account.test.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/app/tests/composio-unconfigured-account.test.tsx b/app/tests/composio-unconfigured-account.test.tsx index cb5920dbd8..bcdc7046e8 100644 --- a/app/tests/composio-unconfigured-account.test.tsx +++ b/app/tests/composio-unconfigured-account.test.tsx @@ -25,6 +25,7 @@ import { } from "@/lib/plugins/queries"; import { Route as ConnectedAccountRoute } from "@/routes/_authed/settings/connected-accounts/$key"; import { serversListedFor } from "@/routes/_authed/settings/connected-accounts/index"; +import { settleReactWork } from "./settle-react-work"; /** * A Composio app on a deployment with no Composio key, as the person who would connect it sees it. @@ -40,7 +41,12 @@ import { serversListedFor } from "@/routes/_authed/settings/connected-accounts/i beforeAll(() => GlobalRegistrator.register()); afterEach(cleanup); -afterAll(() => GlobalRegistrator.unregister()); +// React may still have a passive effect queued when the last test ends; unregistering under it +// throws "window is not defined" between tests, which fails the run though every test passed. +afterAll(async () => { + await settleReactWork(); + GlobalRegistrator.unregister(); +}); const originalFetch = global.fetch; From dc6f40a51401fabb06594b5a23b6836c1536347d Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Wed, 21 Oct 2026 14:21:50 -0300 Subject: [PATCH 14/33] Keep the Pydantic AI harness on anthropic 1.x --- agent-pydantic-ai/requirements.txt | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/agent-pydantic-ai/requirements.txt b/agent-pydantic-ai/requirements.txt index 1ef30a6424..bed8980de1 100644 --- a/agent-pydantic-ai/requirements.txt +++ b/agent-pydantic-ai/requirements.txt @@ -1,4 +1,9 @@ pydantic-ai-slim[openai,anthropic,google,ag-ui] +# anthropic 2.0.0 fails every Anthropic run in this harness: the stream ends in RUN_ERROR +# `Cannot instantiate typing.Union` before a request leaves the process. pydantic-ai-slim +# 2.59.0 asks only for anthropic>=1.12.1, so a fresh install picks up 2.0.0. 1.17.0 works. +# Lift this when pydantic-ai supports anthropic 2. +anthropic<2 fastapi python-multipart uvicorn[standard] From 4c62cbc3227cc98991279c0db9ecf517aab2eedf Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Wed, 21 Oct 2026 15:59:02 -0300 Subject: [PATCH 15/33] Hand the active broker to enableComposioApp from both of its callers The merge with main left enableComposioApp needing the active provider and vendor. The Plugins route and the access-request approve route now both pass them, and the app-access route test builds its store and shared-use store for Composio explicitly. --- server/src/plugins/route-groups/app-access.ts | 4 +++- server/src/plugins/route-groups/composio-apps.ts | 12 ++++++++++-- server/tests/app-access-routes.integration.test.ts | 7 +++++-- 3 files changed, 18 insertions(+), 5 deletions(-) diff --git a/server/src/plugins/route-groups/app-access.ts b/server/src/plugins/route-groups/app-access.ts index f1f98c19ac..bbdb5b19b2 100644 --- a/server/src/plugins/route-groups/app-access.ts +++ b/server/src/plugins/route-groups/app-access.ts @@ -77,6 +77,8 @@ export function mountAppAccessRoutes({ composio, shared, actorEmail, + activeProvider, + activeVendor, }: PluginRouteContext) { const composioConfigured = Boolean(composio); /* @@ -375,7 +377,7 @@ export function mountAppAccessRoutes({ 503, ); const outcome = await enableComposioApp( - { store, composio }, + { store, composio, activeProvider, activeVendor }, inspected.toolkit, actorEmail(context), ); diff --git a/server/src/plugins/route-groups/composio-apps.ts b/server/src/plugins/route-groups/composio-apps.ts index a11a2805f8..7750ada3e0 100644 --- a/server/src/plugins/route-groups/composio-apps.ts +++ b/server/src/plugins/route-groups/composio-apps.ts @@ -175,7 +175,7 @@ export function mountComposioAppRoutes({ */ const slug = typeof body?.slug === "string" ? body.slug.trim() : undefined; const outcome = await enableComposioApp( - { store, composio }, + { store, composio, activeProvider, activeVendor }, slug, actorEmail(context), ); @@ -200,7 +200,15 @@ export type EnableOutcome = * are administrator-only. */ export async function enableComposioApp( - { store, composio }: Pick, + { + store, + composio, + activeProvider, + activeVendor, + }: Pick< + PluginRouteContext, + "store" | "composio" | "activeProvider" | "activeVendor" + >, slug: string | undefined, by: string, ): Promise { diff --git a/server/tests/app-access-routes.integration.test.ts b/server/tests/app-access-routes.integration.test.ts index eeb0e17a4b..5f7e420f9b 100644 --- a/server/tests/app-access-routes.integration.test.ts +++ b/server/tests/app-access-routes.integration.test.ts @@ -117,6 +117,7 @@ const enabled: string[] = []; function storeFor(): PluginStore { const real = createPluginStore({ + brokerProvider: "composio", database, auditStore: audit, credentials: {} as never, @@ -166,7 +167,7 @@ function appAs( } as never); await next(); }; - const sharedUse = createSharedUseStore(database); + const sharedUse = createSharedUseStore(database, "composio"); return new Hono().route( "/api/plugins", createPluginRoutes( @@ -175,7 +176,9 @@ function appAs( // Everybody may use the suite's Bot; nobody but an administrator may use the hidden one. async (actor, agentId) => agentId !== hidden || actor.role === "admin", undefined, - options.composio === false ? undefined : { broker }, + options.composio === false + ? undefined + : { broker, provider: "composio" }, { modes: {} as never, use: sharedUse }, ), ); From dc55298fc0f87dc64afc53b5c58a091cb350315d Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Wed, 21 Oct 2026 16:00:33 -0300 Subject: [PATCH 16/33] Build the app-access route test's broker with stubBroker The hand-written broker answered revokeAccount with a silent no-op, which the broker-stub ratchet refuses. It is now built by stubBroker with Composio's own declarations, revokeAccount is left unprovided, and every test checks that no unprovided member was reached. The personal connections it seeds also carry a vendor id distinct from the local one, as the fixture-identity guard asks. --- .../app-access-routes.integration.test.ts | 30 +++++++++++++------ 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/server/tests/app-access-routes.integration.test.ts b/server/tests/app-access-routes.integration.test.ts index 5f7e420f9b..4c66f823b8 100644 --- a/server/tests/app-access-routes.integration.test.ts +++ b/server/tests/app-access-routes.integration.test.ts @@ -1,6 +1,7 @@ // server/tests/app-access-routes.integration.test.ts import { afterAll, + afterEach, beforeAll, beforeEach, describe, @@ -27,7 +28,7 @@ import { users, } from "../src/db/schema"; import type { AppAccess } from "../src/plugins/app-access"; -import type { BrokerApp, ConnectedAppBroker } from "../src/plugins/broker"; +import type { BrokerApp } from "../src/plugins/broker"; import type { AppAccessRequestRecord } from "../src/plugins/plugin-store/app-access"; import { createPluginRoutes } from "../src/plugins/routes"; import { createSharedUseStore } from "../src/plugins/shared-use-store"; @@ -37,6 +38,7 @@ import { type ServerRecord, } from "../src/plugins/store"; import { TEST_POOL, testDatabaseUrl } from "./support/database"; +import { stubBroker } from "./support/stub-broker"; /** * What a work app is for one person on one Bot, the requests they file when only an administrator @@ -100,7 +102,16 @@ const directory: BrokerApp[] = [ }, ]; -const broker: ConnectedAppBroker = { +/** + * The broker, built by `stubBroker`: every member this file does not provide THROWS AND RECORDS, + * and the check after each test reads the record, so a path that reaches past what is provided is + * a red even where production catches the throw. Nothing here undoes one account, so + * `revokeAccount` is not provided rather than stubbed to answer "done" for a revoke that never ran. + */ +const stub = stubBroker({ + // Composio's own declarations: the grant is held here, and there is no authorization flow. + grantIsLocal: true, + authorizations: null, listApps: async () => directory, ensureAuthConfig: async () => "created", deleteAuthConfig: async () => {}, @@ -109,9 +120,12 @@ const broker: ConnectedAppBroker = { revoke: async () => true, connectionFields: async () => [], connectWithFields: async () => ({ accountId: "ca_1" }), - revokeAccount: async () => {}, accountName: async () => "team", -}; +}); +const broker = stub.broker; +afterEach(() => { + stub.expectNothingUnprovidedReached(); +}); const enabled: string[] = []; @@ -176,9 +190,7 @@ function appAs( // Everybody may use the suite's Bot; nobody but an administrator may use the hidden one. async (actor, agentId) => agentId !== hidden || actor.role === "admin", undefined, - options.composio === false - ? undefined - : { broker, provider: "composio" }, + options.composio === false ? undefined : { broker, provider: "composio" }, { modes: {} as never, use: sharedUse }, ), ); @@ -315,7 +327,7 @@ describe("what each app a Bot holds is, for the person asking", () => { app: slug("linear"), holder: "person", userId: person, - vendorUserId: person, + vendorUserId: `vendor-${person}`, verified: false, probeAction: "LINEAR_LIST", }); @@ -370,7 +382,7 @@ describe("what each app a Bot holds is, for the person asking", () => { app: slug("gmail"), holder: "person", userId: other, - vendorUserId: other, + vendorUserId: `vendor-${other}`, verified: true, connectedAt: new Date("2026-10-20T12:00:00.000Z"), }); From e406e7b0528e33f5f3a38b06a4b9434aba693bef Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Wed, 21 Oct 2026 16:00:33 -0300 Subject: [PATCH 17/33] Give the vendor-gone personal fixture its own vendor id The account-gone test OB-05 added seeded a personal connection with the same value as local and vendor id, one more collapse than the fixture-identity guard allows for this file. --- server/tests/shared-brokered-store.integration.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/tests/shared-brokered-store.integration.test.ts b/server/tests/shared-brokered-store.integration.test.ts index 386cdb6ffd..01716fa486 100644 --- a/server/tests/shared-brokered-store.integration.test.ts +++ b/server/tests/shared-brokered-store.integration.test.ts @@ -734,7 +734,7 @@ describe("an account the vendor no longer holds", () => { app, holder: "person", userId: asker, - vendorUserId: asker, + vendorUserId: `vendor-${asker}`, }); gone(); const said = await store From 94769aa5ca42b6c5a941fecce98b64d85b49fa09 Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Wed, 21 Oct 2026 16:01:53 -0300 Subject: [PATCH 18/33] Assert the missing approve body on its own, not as an undefined key toEqual passes over an expected key whose value is undefined, so body: undefined asserted nothing. The fetch recorder now leaves body out when none was sent, and the test checks that the first approval has no body property. --- app/tests/app-access-mutations.test.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/app/tests/app-access-mutations.test.ts b/app/tests/app-access-mutations.test.ts index 771366abd4..0e9fa3a32f 100644 --- a/app/tests/app-access-mutations.test.ts +++ b/app/tests/app-access-mutations.test.ts @@ -20,12 +20,13 @@ afterEach(() => { }); function answering(status: number, body: unknown) { - const sent: { url: string; method: string; body: unknown }[] = []; + const sent: { url: string; method: string; body?: unknown }[] = []; globalThis.fetch = (async (url: string, init?: RequestInit) => { + // A request with no body is recorded with no `body` key, so its absence can be asserted. sent.push({ url, method: init?.method ?? "GET", - body: init?.body ? JSON.parse(String(init.body)) : undefined, + ...(init?.body ? { body: JSON.parse(String(init.body)) } : {}), }); return new Response(JSON.stringify(body), { status, @@ -94,17 +95,16 @@ test("approving sends refs only when given", async () => { refs: ["composio-gmail/SEND"], }); expect(sent).toEqual([ - { - url: "/api/plugins/access-requests/r1/approve", - method: "POST", - body: undefined, - }, + { url: "/api/plugins/access-requests/r1/approve", method: "POST" }, { url: "/api/plugins/access-requests/r2/approve", method: "POST", body: { refs: ["composio-gmail/SEND"] }, }, ]); + // `toEqual` passes over a key it is not given, so the first approval's missing body is its own + // assertion: no refs means no body at all, not an empty one. + expect(sent[0]).not.toHaveProperty("body"); }); test("a refused write still refreshes every plugin screen", async () => { From 08a7da10d504818ea6e501b8e4ba35587c974b3f Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Wed, 21 Oct 2026 16:01:54 -0300 Subject: [PATCH 19/33] Re-key two allow-listed composio-adapter assertions after the merge The two listActions assertions main allow-lists at composio-adapter lines 5412 and 5458 now sit at 5427 and 5473, because OB-05 added fifteen lines of tests above them. They are the same offences, still owned by the fix cycle. --- server/tests/assertion-shape.test.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/server/tests/assertion-shape.test.ts b/server/tests/assertion-shape.test.ts index 2c1f398017..d8e055952c 100644 --- a/server/tests/assertion-shape.test.ts +++ b/server/tests/assertion-shape.test.ts @@ -835,14 +835,18 @@ const ALLOW: Allowed[] = [ }, { file: "server/tests/composio-adapter.test.ts", - line: 5412, + // Moved from 5412 by OB-05, which added fifteen lines of tests above it; the offence is the same + // one and is re-keyed rather than re-found. + line: 5427, shape: "c", finding: "NEW (round 2 did not find this)", why: 'expect( await actions.listActions("gmail", { limit: WHOLE_LISTING }), ).toEqual([ { slug: "GMAIL_FETCH_EMAILS"', }, { file: "server/tests/composio-adapter.test.ts", - line: 5458, + // Moved from 5458 by OB-05, which added fifteen lines of tests above it; the offence is the same + // one and is re-keyed rather than re-found. + line: 5473, shape: "c", finding: "NEW (round 2 did not find this)", why: 'expect( await actions.listActions("gmail", { limit: WHOLE_LISTING }), ).toEqual([ { slug: "GMAIL_FETCH_EMAILS"', From fb84008d1b787940e324d5b5bcc03760a3ec2bd8 Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Wed, 21 Oct 2026 16:04:40 -0300 Subject: [PATCH 20/33] Format the enableComposioApp refusal the merge resolution left long --- server/src/plugins/route-groups/composio-apps.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/server/src/plugins/route-groups/composio-apps.ts b/server/src/plugins/route-groups/composio-apps.ts index 7750ada3e0..2636afa8b5 100644 --- a/server/src/plugins/route-groups/composio-apps.ts +++ b/server/src/plugins/route-groups/composio-apps.ts @@ -230,7 +230,10 @@ export async function enableComposioApp( * deployment whose key is wrong was told nothing at all, on the one screen where the key * had just been set. */ - return { ok: false, ...brokerRefusal(error, directoryUnavailable(activeProvider)) }; + return { + ok: false, + ...brokerRefusal(error, directoryUnavailable(activeProvider)), + }; } } const app = directory.find((candidate) => candidate.slug === slug); From dbef2f57ad05c0bfff69ec2bc32fa8fb0d9c116c Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Wed, 21 Oct 2026 16:04:51 -0300 Subject: [PATCH 21/33] Read app access through the store's broker, not Composio by name The app-access store compared provenance and the connection provider to "composio", built composio:// urls and composio- ids by hand, and called Composio's own url parser, so on an Arcade deployment every Arcade app read as a custom server that was always ready. It now asks the store's broker provider, BROKERED_PROFILE for the scheme and id prefix, and toolkitOfFor(provider, url), and the state rule asks brokeredProvenance(). A personal Arcade app is therefore not_connected until that person connects it, switched off without an Arcade key, and an app only the directory knows gets the arcade- id enabling would mint. Slugs are matched as typed before being lowercased, so Arcade's case-sensitive toolkit names can be found. An app's provider is now composio or arcade on the wire, and the approve refusal names the active vendor and its key. Composio deployments read exactly as before. --- app/src/lib/app-access/queries.ts | 5 +- server/src/plugins/app-access-tool.ts | 2 +- server/src/plugins/app-access.ts | 26 +-- server/src/plugins/plugin-store/app-access.ts | 105 +++++++----- server/src/plugins/route-groups/app-access.ts | 31 +++- .../app-access-routes.integration.test.ts | 156 ++++++++++++++++++ 6 files changed, 265 insertions(+), 60 deletions(-) diff --git a/app/src/lib/app-access/queries.ts b/app/src/lib/app-access/queries.ts index d8366366ce..ef6430230a 100644 --- a/app/src/lib/app-access/queries.ts +++ b/app/src/lib/app-access/queries.ts @@ -36,10 +36,11 @@ export type AppAccess = { /** The app's own name — "Gmail", never a humanised server id. */ displayName: string; logo: string | null; - provider: "composio" | "catalogue" | "custom"; + /** A brokered app names its broker; a catalogue or custom app is reached directly. */ + provider: "composio" | "arcade" | "catalogue" | "custom"; /** Whose account calls land in: each person's own, one team account, or nobody's. */ account: "personal" | "shared" | "deployment"; - /** How the account is connected: this deployment's OAuth flow, a Composio consent, or a key form. */ + /** How the account is connected: this deployment's OAuth flow, a broker's consent, or a key form. */ connect: "oauth" | "consent" | "key" | null; authScheme: string | null; accountMode: "personal" | "shared" | null; diff --git a/server/src/plugins/app-access-tool.ts b/server/src/plugins/app-access-tool.ts index be48070488..70d0f55b7d 100644 --- a/server/src/plugins/app-access-tool.ts +++ b/server/src/plugins/app-access-tool.ts @@ -34,7 +34,7 @@ export function appAccessTools(options: { botId: string; actorId: string; composioConfigured: boolean; - /** Names an app this deployment has no row for. Absent without a Composio key. */ + /** Names an app this deployment has no row for. Absent without the broker's key. */ directory?: AppDirectory; }): GrantedTool[] { const parameters = z diff --git a/server/src/plugins/app-access.ts b/server/src/plugins/app-access.ts index caa50ac949..82594a3e31 100644 --- a/server/src/plugins/app-access.ts +++ b/server/src/plugins/app-access.ts @@ -1,3 +1,4 @@ +import { type BrokerProvider, brokeredProvenance } from "./access"; import { schemeKind } from "./broker"; import type { CatalogueEntry } from "./catalogue"; @@ -6,7 +7,7 @@ import type { CatalogueEntry } from "./catalogue"; * whose it is. * * WHY THIS EXISTS AS ONE ANSWER. Every fact it reads already had a home: whether the app is - * enabled is a row in `mcp_servers`, whether Composio is configured is a key in the environment, + * enabled is a row in `mcp_servers`, whether the broker is configured is a key in the environment, * whether the Bot holds the app is `plugin_grants`, whether the person connected is * `brokered_connections` or `mcp_user_credentials`, and whether a Shared app has its team account * is a deployment row in `brokered_connections`. Each screen that wanted to say "you can use this" @@ -33,7 +34,7 @@ import type { CatalogueEntry } from "./catalogue"; * to fix. * `shared_not_connected` — a Shared app whose one team account is not connected, or whose key was * refused. Only an administrator can connect it. - * `disabled` — switched off at the deployment: no row, Composio not configured, or an app that + * `disabled` — switched off at the deployment: no row, the broker not configured, or an app that * still needs something only an administrator can supply. * `not_granted` — enabled, and this Bot holds none of its tools. Only an administrator can grant. */ @@ -81,12 +82,17 @@ export type AppAccount = /** * How the account is connected, for the screen that starts it: the deployment's own OAuth flow, a - * Composio consent screen, a Composio key form, or nothing to connect. + * broker's consent screen, a broker's key form, or nothing to connect. */ export type AppConnect = "oauth" | "consent" | "key" | null; -/** Where the app comes from, which decides what enabling it takes. */ -export type AppProvider = "composio" | "catalogue" | "custom"; +/** + * Where the app comes from, which decides what enabling it takes. + * + * A BROKERED APP IS NAMED BY ITS BROKER, `composio` or `arcade`, and every rule below asks + * {@link brokeredProvenance} rather than comparing to one of them, so the two are read alike. + */ +export type AppProvider = BrokerProvider | "catalogue" | "custom"; /** One app as the Bot page and the chat card draw it. */ export type AppAccess = { @@ -94,7 +100,7 @@ export type AppAccess = { key: string; /** * The app's own name — "Gmail", never "Composio Gmail" and never a humanised `composio-gmail`. - * A Composio row's title is the directory's name for the app; a catalogue row's is the entry's. + * A brokered row's title is the directory's name for the app; a catalogue row's is the entry's. */ displayName: string; logo: string | null; @@ -135,7 +141,7 @@ export type AppAccessFacts = { provider: AppProvider; /** Whether a row stands for the app at all. */ present: boolean; - /** Whether this deployment has a Composio key, for a brokered app. */ + /** Whether this deployment has its broker's key, for a brokered app. */ composioConfigured: boolean; /** The scheme the app's answering row records, for a brokered app. */ authScheme: string | null; @@ -153,7 +159,7 @@ export type AppAccessFacts = { /** Whose account, from the same facts `accessFor` resolves a call with. */ export function accountOf(facts: AppAccessFacts): AppAccount { - if (facts.provider === "composio") { + if (brokeredProvenance(facts.provider)) { if (schemeKind(facts.authScheme) === "none") return "deployment"; return facts.accountMode === "shared" ? "shared" : "personal"; } @@ -165,7 +171,7 @@ export function accountOf(facts: AppAccessFacts): AppAccount { } export function connectOf(facts: AppAccessFacts): AppConnect { - if (facts.provider === "composio") { + if (brokeredProvenance(facts.provider)) { const kind = schemeKind(facts.authScheme); return kind === "consent" || kind === "key" ? kind : null; } @@ -189,7 +195,7 @@ export type Unavailable = export function unavailableReason(facts: AppAccessFacts): Unavailable | null { if (!facts.present) return "absent"; - if (facts.provider === "composio") { + if (brokeredProvenance(facts.provider)) { if (!facts.composioConfigured) return "composio_unconfigured"; if (schemeKind(facts.authScheme) === "unreadable") return "scheme_unreadable"; diff --git a/server/src/plugins/plugin-store/app-access.ts b/server/src/plugins/plugin-store/app-access.ts index 615dddfc72..14b11ea675 100644 --- a/server/src/plugins/plugin-store/app-access.ts +++ b/server/src/plugins/plugin-store/app-access.ts @@ -12,6 +12,7 @@ import { pluginGrants, users, } from "../../db/schema"; +import { BROKERED_PROFILE, brokeredProvenance, toolkitOfFor } from "../access"; import { type AppAccess, type AppAccessFacts, @@ -26,7 +27,6 @@ import { } from "../app-access"; import type { BrokerApp } from "../broker"; import { CATALOGUE, type CatalogueEntry, catalogueEntry } from "../catalogue"; -import { toolkitOf } from "../composio"; import type { StoreContext } from "./context"; import { iso } from "./model"; @@ -34,7 +34,7 @@ import { iso } from "./model"; * One app as this module resolves it before anybody's connection is asked about: which rows are * it, which of them answers for it, and what the deployment holds for it. * - * `rowIds` IS EVERY ROW THAT IS THIS APP, so a grant made through a duplicate row at a Composio + * `rowIds` IS EVERY ROW THAT IS THIS APP, so a grant made through a duplicate row at a brokered * app's url counts as holding the app — the same "the apps, not the rows" rule * `sharedAppsHeldBy` follows. `answeringId` is the one row whose columns decide, and the one a * grant made by approving a request is written against. @@ -75,7 +75,7 @@ export type AppInspection = { unavailable: Unavailable | null; /** The row a grant is written against, and null for an app with no row. */ answeringId: string | null; - /** The Composio app, for a brokered one. */ + /** The app at the broker, for a brokered one. */ toolkit: string | null; /** The catalogue entry, for an app this deployment ships. */ entry: CatalogueEntry | null; @@ -108,14 +108,24 @@ export type AppAccessRequestRecord = { resolvedBy: string | null; }; -const SLUG_OF_ID = /^composio-/; - /** * Per-person, per-Bot status of the work apps a Bot holds, and the requests a person files when * only an administrator can change it. See `../app-access.ts` for the rule itself. + * + * ONE BROKER, THE STORE'S. Which rows are brokered apps, which connections are theirs, the scheme + * an app's url is minted at and the prefix its row id takes all come from `brokerProvider` and + * {@link BROKERED_PROFILE}, so a personal Arcade app is read exactly as a personal Composio app is + * — and a row left by the broker this deployment does not run is no brokered app here, as it is + * no brokered app to any other reader in the store. */ export function appAccessMethods(context: StoreContext) { - const { database, auditStore, brokeredAppRowsAt } = context; + const { database, auditStore, brokeredAppRowsAt, brokerProvider } = context; + const { scheme, idPrefix } = BROKERED_PROFILE[brokerProvider]; + /** Whether an id is in the namespace `addBrokeredApp` mints this broker's rows into. */ + const isBrokeredId = (id: string) => id.startsWith(idPrefix); + /** An id with that namespace taken off, and any other word as it came. */ + const slugOfId = (id: string) => + isBrokeredId(id) ? id.slice(idPrefix.length) : id; /* * EVERY ROW, ONCE. `mcp_servers` is the deployment's list of apps — tens of rows, not thousands — @@ -151,11 +161,14 @@ export function appAccessMethods(context: StoreContext) { async function appsByRow(rows: ServerRow[]) { const toolkitOfRow = new Map(); for (const row of rows) { - const toolkit = row.provenance === "composio" ? toolkitOf(row.url) : null; + const toolkit = + row.provenance === brokerProvider + ? toolkitOfFor(brokerProvider, row.url) + : null; if (toolkit && !catalogueEntry(row.id)) toolkitOfRow.set(row.id, toolkit); } const answering = await brokeredAppRowsAt([ - ...new Set([...toolkitOfRow.values()].map((t) => `composio://${t}`)), + ...new Set([...toolkitOfRow.values()].map((t) => `${scheme}${t}`)), ]); const byId = new Map(rows.map((row) => [row.id, row])); const apps = new Map(); @@ -163,7 +176,7 @@ export function appAccessMethods(context: StoreContext) { for (const row of rows) { const toolkit = toolkitOfRow.get(row.id) ?? null; - const head = toolkit ? answering.get(`composio://${toolkit}`) : undefined; + const head = toolkit ? answering.get(`${scheme}${toolkit}`) : undefined; const key = head?.id ?? row.id; let app = apps.get(key); if (!app) { @@ -173,7 +186,7 @@ export function appAccessMethods(context: StoreContext) { key, title: entry?.title ?? source.title, logo: source.logo, - provider: toolkit ? "composio" : entry ? "catalogue" : "custom", + provider: toolkit ? brokerProvider : entry ? "catalogue" : "custom", present: true, answeringId: key, rowIds: [], @@ -192,10 +205,11 @@ export function appAccessMethods(context: StoreContext) { } /** - * An app with no row: a catalogue entry nobody added, or a Composio app nobody enabled. + * An app with no row: a catalogue entry nobody added, or a brokered app nobody enabled. * - * `key` is the id enabling it would mint — the entry's key, or `composio-` as - * `addBrokeredApp` names a row — so a request filed now names the row the approval creates. + * `key` is the id enabling it would mint — the entry's key, or the broker's prefix and the slug + * (`composio-`, `arcade-`) as `addBrokeredApp` names a row — so a request filed + * now names the row the approval creates. */ function absent(input: { key: string; @@ -250,37 +264,41 @@ export function appAccessMethods(context: StoreContext) { provider: "catalogue", entry, }); - const slug = key.replace(SLUG_OF_ID, ""); - if (toolkitOf(`composio://${slug}`) !== slug) return null; + const slug = slugOfId(key); + if (toolkitOfFor(brokerProvider, `${scheme}${slug}`) !== slug) return null; const apps = listed === undefined ? await readDirectory(directory) : listed; const app = apps?.find((candidate) => candidate.slug === slug); if (app) return absent({ - key: `composio-${app.slug}`, + key: `${idPrefix}${app.slug}`, title: app.name, logo: app.logo, - provider: "composio", + provider: brokerProvider, toolkit: app.slug, }); /* - * A `composio-` id the directory cannot vouch for is still an app somebody once enabled — a - * grant can outlive its row — so it is named by its slug rather than dropped. A bare word that - * matches nothing is not an app at all. + * An id in the broker's namespace that the directory cannot vouch for is still an app somebody + * once enabled — a grant can outlive its row — so it is named by its slug rather than dropped. + * A bare word that matches nothing is not an app at all. */ - if (SLUG_OF_ID.test(key)) + if (isBrokeredId(key)) return absent({ key, title: slug, - provider: "composio", + provider: brokerProvider, toolkit: slug, }); return null; } /** - * One app by whatever a caller called it: a server id, a catalogue key, a Composio slug with or - * without its `composio-` prefix, or the app's name. Null for a word that names no app here or - * in the directory. + * One app by whatever a caller called it: a server id, a catalogue key, a brokered app's slug + * with or without its id prefix (`composio-`, `arcade-`), or the app's name. Null for a word that + * names no app here or in the directory. + * + * A SLUG IS MATCHED AS TYPED FIRST, THEN LOWERCASED. Arcade's toolkit names are case-sensitive + * (`Gmail`), so the word as the caller typed it is the one that can reach them; Composio's slugs + * are lowercase, so the lowercased word is the one that always reached those, and still does. */ async function candidateFor( app: string, @@ -295,9 +313,12 @@ export function appAccessMethods(context: StoreContext) { const byId = byRow.get(asked); if (byId) return byId; - const slug = lower.replace(SLUG_OF_ID, ""); - for (const candidate of new Set(byRow.values())) - if (candidate.toolkit === slug) return candidate; + const typed = slugOfId(asked); + const slug = slugOfId(lower); + const apps = new Set(byRow.values()); + for (const wanted of new Set([typed, slug])) + for (const candidate of apps) + if (candidate.toolkit === wanted) return candidate; const entry = catalogueEntry(lower) ?? @@ -306,16 +327,20 @@ export function appAccessMethods(context: StoreContext) { if (entry) return byRow.get(entry.key) ?? absentFor(entry.key, undefined, null); - for (const candidate of new Set(byRow.values())) + for (const candidate of apps) if (candidate.title.toLowerCase() === lower) return candidate; const listed = await readDirectory(directory); - const named = listed?.find( - (candidate) => - candidate.slug === slug || candidate.name.toLowerCase() === lower, - ); - if (named) return absentFor(`composio-${named.slug}`, undefined, listed); - return absentFor(SLUG_OF_ID.test(lower) ? lower : asked, undefined, listed); + const named = + (typed !== slug + ? listed?.find((candidate) => candidate.slug === typed) + : undefined) ?? + listed?.find( + (candidate) => + candidate.slug === slug || candidate.name.toLowerCase() === lower, + ); + if (named) return absentFor(`${idPrefix}${named.slug}`, undefined, listed); + return absentFor(isBrokeredId(lower) ? lower : asked, undefined, listed); } /** The server halves of a Bot's MCP grants. */ @@ -366,7 +391,7 @@ export function appAccessMethods(context: StoreContext) { .from(brokeredConnections) .where( and( - eq(brokeredConnections.provider, "composio"), + eq(brokeredConnections.provider, brokerProvider), eq(brokeredConnections.holder, "person"), eq(brokeredConnections.userId, userId), inArray(brokeredConnections.app, toolkits), @@ -384,7 +409,7 @@ export function appAccessMethods(context: StoreContext) { .from(brokeredConnections) .where( and( - eq(brokeredConnections.provider, "composio"), + eq(brokeredConnections.provider, brokerProvider), eq(brokeredConnections.holder, "deployment"), inArray(brokeredConnections.app, toolkits), ), @@ -482,7 +507,7 @@ export function appAccessMethods(context: StoreContext) { : undefined; const sharedUsable = teamAccount?.usable === true; const personal = - app.provider === "composio" && app.toolkit + brokeredProvenance(app.provider) && app.toolkit ? connections.personalBrokered.get(app.toolkit) : app.provider === "catalogue" ? app.rowIds @@ -518,7 +543,7 @@ export function appAccessMethods(context: StoreContext) { provider: app.provider, account, connect: connectOf(facts), - authScheme: app.provider === "composio" ? app.authScheme : null, + authScheme: brokeredProvenance(app.provider) ? app.authScheme : null, accountMode: app.accountMode, state, granted: facts.granted, @@ -615,7 +640,7 @@ export function appAccessMethods(context: StoreContext) { if (app) apps.set(app.key, app); else missing.push(serverId); } - const listed = missing.some((id) => SLUG_OF_ID.test(id)) + const listed = missing.some(isBrokeredId) ? await readDirectory(input.directory) : null; for (const serverId of missing) { diff --git a/server/src/plugins/route-groups/app-access.ts b/server/src/plugins/route-groups/app-access.ts index bbdb5b19b2..1ba6af4443 100644 --- a/server/src/plugins/route-groups/app-access.ts +++ b/server/src/plugins/route-groups/app-access.ts @@ -1,5 +1,6 @@ import type { Context } from "hono"; import { type AppVariables, requireAdmin } from "../../auth/guards"; +import { BROKERED_PROFILE, type BrokerProvider } from "../access"; import type { Unavailable } from "../app-access"; import type { CatalogueEntry } from "../catalogue"; import type { @@ -38,13 +39,25 @@ function adminInputFor(entry: CatalogueEntry): string | null { return null; } -/** The sentence an approval is refused with when the app is there and still cannot be used. */ -function unavailableSentence(reason: Unavailable, title: string): string { +/** + * The sentence an approval is refused with when the app is there and still cannot be used. + * + * The broker's name and key come from {@link BROKERED_PROFILE}, so an Arcade deployment is told + * to set ARCADE_API_KEY in the words a Composio one is told to set COMPOSIO_API_KEY. + */ +function unavailableSentence( + reason: Unavailable, + title: string, + provider: BrokerProvider, +): string { switch (reason) { case "absent": return `${title} is not an app this deployment can switch on from here.`; - case "composio_unconfigured": - return `${title} is a Composio app, and this deployment has no Composio key. Set COMPOSIO_API_KEY, then approve this request again.`; + case "composio_unconfigured": { + const { vendor, envVar } = BROKERED_PROFILE[provider]; + const article = /^[aeiou]/i.test(vendor) ? "an" : "a"; + return `${title} is ${article} ${vendor} app, and this deployment has no ${vendor} key. Set ${envVar}, then approve this request again.`; + } case "scheme_unreadable": return `${title} is enabled, but how it connects is not recorded in a way this deployment can read. Remove it and enable it again on its page under Plugins, then approve this request again.`; case "oauth_client_missing": @@ -366,14 +379,14 @@ export function mountAppAccessRoutes({ if (inspected.unavailable !== "absent") return deny( inspected.unavailable, - unavailableSentence(inspected.unavailable, title), + unavailableSentence(inspected.unavailable, title, activeProvider), inspected.unavailable === "composio_unconfigured" ? 503 : 409, ); if (inspected.toolkit) { if (!composio) return deny( "composio_unconfigured", - unavailableSentence("composio_unconfigured", title), + unavailableSentence("composio_unconfigured", title, activeProvider), 503, ); const outcome = await enableComposioApp( @@ -407,7 +420,11 @@ export function mountAppAccessRoutes({ throw error; } } else { - return deny("absent", unavailableSentence("absent", title), 409); + return deny( + "absent", + unavailableSentence("absent", title, activeProvider), + 409, + ); } enabled = true; inspected = await inspect(); diff --git a/server/tests/app-access-routes.integration.test.ts b/server/tests/app-access-routes.integration.test.ts index 4c66f823b8..182f21939d 100644 --- a/server/tests/app-access-routes.integration.test.ts +++ b/server/tests/app-access-routes.integration.test.ts @@ -936,3 +936,159 @@ describe("answering a request", () => { expect(again.status).toBe(409); }); }); + +/* + * THE SAME RULE ON AN ARCADE DEPLOYMENT. Which rows are brokered apps, which connections count and + * which id an app nobody enabled would get all come from the store's broker, so a personal Arcade + * app reads exactly as a personal Composio app does — under Arcade's case-sensitive toolkit name. + */ +describe("on an Arcade deployment", () => { + const toolkit = `ArcMail_${suite}`; + const ARC_MAIL = `arcade-${toolkit}`; + const docs = `ArcDocs_${suite}`; + + function arcadeStore(): PluginStore { + return createPluginStore({ + brokerProvider: "arcade", + database, + auditStore: audit, + credentials: {} as never, + encryptionKey: "x".repeat(44), + policy: () => ({ mode: "enforce", deny: [], allow: ["true"] }), + broker, + deploymentId: `acme-${suite}`, + }); + } + + async function seedArcadeApp() { + await database.insert(mcpServers).values({ + id: ARC_MAIL, + title: "Arc Mail", + vendor: "Arcade", + url: `arcade://${toolkit}`, + provenance: "arcade", + authScheme: "OAUTH2", + accountMode: "personal", + }); + await database.insert(mcpTools).values({ + serverId: ARC_MAIL, + name: "READ", + description: "READ", + inputSchema: {}, + }); + await database + .insert(pluginGrants) + .values({ kind: "mcp", ref: `${ARC_MAIL}/READ`, agentId: bot }); + } + + const forBot = (composioConfigured = true) => + arcadeStore().appAccessForBot({ + userId: person, + agentId: bot, + composioConfigured, + }); + + test("a personal app is not_connected until the person connects it, then ready", async () => { + await seedArcadeApp(); + expect(await forBot()).toMatchObject([ + { + key: ARC_MAIL, + provider: "arcade", + account: "personal", + connect: "consent", + authScheme: "OAUTH2", + state: "not_connected", + }, + ]); + // Somebody else's connection, and a Composio one at the same app name, are not this person's. + await database.insert(brokeredConnections).values([ + { + provider: "arcade", + app: toolkit, + holder: "person", + userId: other, + vendorUserId: `vendor-${other}`, + verified: true, + }, + { + provider: "composio", + app: toolkit, + holder: "person", + userId: person, + vendorUserId: `vendor-${person}`, + verified: true, + }, + ]); + expect((await forBot())[0]?.state).toBe("not_connected"); + await database.insert(brokeredConnections).values({ + provider: "arcade", + app: toolkit, + holder: "person", + userId: person, + vendorUserId: `vendor-${person}`, + verified: true, + connectedAt: new Date("2026-10-21T08:00:00.000Z"), + }); + expect(await forBot()).toMatchObject([ + { + key: ARC_MAIL, + state: "ready", + connectedAt: "2026-10-21T08:00:00.000Z", + }, + ]); + }); + + test("with no Arcade key the app is switched off at the deployment", async () => { + await seedArcadeApp(); + const inspected = await arcadeStore().inspectApp({ + app: ARC_MAIL, + agentId: bot, + userId: person, + composioConfigured: false, + }); + expect(inspected?.unavailable).toBe("composio_unconfigured"); + expect(inspected?.access.state).toBe("disabled"); + }); + + test("an app is found by its id or its toolkit name as typed", async () => { + await seedArcadeApp(); + for (const asked of [ARC_MAIL, toolkit, "arc mail"]) { + const inspected = await arcadeStore().inspectApp({ + app: asked, + agentId: bot, + userId: person, + composioConfigured: true, + }); + expect(inspected?.access.key).toBe(ARC_MAIL); + expect(inspected?.toolkit).toBe(toolkit); + } + }); + + test("an app only the directory knows is disabled, under the arcade- id enabling would mint", async () => { + const inspected = await arcadeStore().inspectApp({ + app: "Arc Docs", + agentId: bot, + userId: person, + composioConfigured: true, + directory: async () => [ + { + slug: docs, + name: "Arc Docs", + description: "", + logo: null, + categories: [], + actionCount: 1, + connection: { kind: "consent" }, + }, + ], + }); + expect(inspected?.access).toMatchObject({ + key: `arcade-${docs}`, + displayName: "Arc Docs", + provider: "arcade", + state: "disabled", + requestKind: "enable", + }); + expect(inspected?.toolkit).toBe(docs); + }); +}); From c1e5acee27717a4f5c955fcff634f68caf1cd2fd Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Wed, 21 Oct 2026 16:06:23 -0300 Subject: [PATCH 22/33] Declare authorizesPerAction in the unconfigured-Composio page fixture Main made authorizesPerAction a required field of PluginsPage. The fixture OB-05 added gives Composio's answer, false. --- app/tests/composio-unconfigured-account.test.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/tests/composio-unconfigured-account.test.tsx b/app/tests/composio-unconfigured-account.test.tsx index bcdc7046e8..f9252f7e8e 100644 --- a/app/tests/composio-unconfigured-account.test.tsx +++ b/app/tests/composio-unconfigured-account.test.tsx @@ -128,6 +128,8 @@ function renderAccount(composioConfigured: boolean) { botsMayCallBack: true, redirectUri: null, composioConfigured, + // Composio's answer: its auth config fixes one state for a whole toolkit. + authorizesPerAction: false, }; const connections: PluginConnections = { connections: [], redirectUri: null }; client.setQueryData(pluginKeys.page(), page); From 87da73685951956e29cf6b3a4a25c33bcb4943d7 Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Wed, 21 Oct 2026 16:07:44 -0300 Subject: [PATCH 23/33] Approve with read-only actions, claim a request before acting, and work an app's state out for the account its calls use Approving with no refs now grants only the app's read-only actions, and refuses an app that has none. The request is claimed by the approving administrator before anything changes, so a dismissal or withdrawal meanwhile loses rather than leaving a grant under a dismissed request; an approval that cannot be carried out puts it back. On somebody else's published Team Bot the state follows the owner's account where the owner has one, and says when the first call will ask the person to allow their own. A Shared app whose audience the gate would refuse reads shared_not_approved. A name matches enabled apps before catalogue entries. Requests cap their names at 200 characters, refuse to enable an id nothing vouches for, and stop at 20 waiting per person. --- server/src/index.ts | 1 + server/src/plugins/app-access-tool.ts | 20 +- server/src/plugins/app-access.ts | 46 +- server/src/plugins/plugin-store/app-access.ts | 500 +++++++++++++----- server/src/plugins/route-groups/app-access.ts | 442 +++++++++------- server/src/plugins/shared-use-store.ts | 25 + .../app-access-routes.integration.test.ts | 466 +++++++++++++++- server/tests/app-access-state.test.ts | 19 + 8 files changed, 1199 insertions(+), 320 deletions(-) diff --git a/server/src/index.ts b/server/src/index.ts index 20ba5853a6..78b23719a3 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -1294,6 +1294,7 @@ const loadToolsForActor = actorId, composioConfigured: Boolean(composio), ...(composio ? { directory: () => composio.broker.listApps() } : {}), + sharedAudience: sharedUseStore.admitsPerson, }), ...(await grantedTools({ store: pluginStore, diff --git a/server/src/plugins/app-access-tool.ts b/server/src/plugins/app-access-tool.ts index be48070488..47bafbe89a 100644 --- a/server/src/plugins/app-access-tool.ts +++ b/server/src/plugins/app-access-tool.ts @@ -5,7 +5,10 @@ import { import type { GrantedTool } from "@openbot/toolkit/granted-tool"; import { z } from "zod"; import type { AppAccess, AppAccessState } from "./app-access"; -import type { AppDirectory } from "./plugin-store/app-access"; +import type { + AppDirectory, + SharedAudienceCheck, +} from "./plugin-store/app-access"; import type { PluginStore } from "./store"; /** @@ -36,6 +39,8 @@ export function appAccessTools(options: { composioConfigured: boolean; /** Names an app this deployment has no row for. Absent without a Composio key. */ directory?: AppDirectory; + /** The shared-use gate's answer for this person, so a Shared app it would refuse says so. */ + sharedAudience?: SharedAudienceCheck; }): GrantedTool[] { const parameters = z .object({ @@ -66,6 +71,9 @@ export function appAccessTools(options: { userId: options.actorId || null, composioConfigured: options.composioConfigured, ...(options.directory ? { directory: options.directory } : {}), + ...(options.sharedAudience + ? { sharedAudience: options.sharedAudience } + : {}), }); /* * No card for an app nobody can name. A card about it would have nothing to ask the server @@ -93,11 +101,17 @@ export function appAccessTools(options: { */ function toldTheModel(access: AppAccess): string { const name = access.displayName; + const owners = access.accountHolder === "owner"; const said: Record = { - ready: `${name} is ready for this person to use through you. Use its tools rather than the card.`, + ready: access.asksFirst + ? `${name} is ready for this person to use through you, on their own account; the first call will ask them to allow that. Use its tools rather than the card.` + : `${name} is ready for this person to use through you. Use its tools rather than the card.`, not_connected: `${name} is granted to you, and this person has not connected their own account. The card in front of them offers Connect; ask them to try again once they have.`, - reconnect: `${name} is granted to you, and this person's connection needs reconnecting. The card in front of them offers Reconnect; ask them to try again once they have.`, + reconnect: owners + ? `${name} is granted to you and runs on your owner's account, whose connection needs reconnecting. Only your owner can reconnect it; the card in front of the person says so.` + : `${name} is granted to you, and this person's connection needs reconnecting. The card in front of them offers Reconnect; ask them to try again once they have.`, shared_not_connected: `${name} uses one shared account for this deployment, and an administrator has not connected it yet. The card in front of the person lets them ask an administrator for it.`, + shared_not_approved: `${name} uses one shared account for this deployment, and you reach more people than an administrator approved for it, so its calls are refused. Only an administrator can approve that; the card in front of the person says so.`, disabled: `${name} is switched off on this deployment. The card in front of the person lets them ask an administrator to switch it on.`, not_granted: `${name} is switched on, and you have not been granted it. The card in front of the person lets them ask an administrator to grant it to you.`, }; diff --git a/server/src/plugins/app-access.ts b/server/src/plugins/app-access.ts index caa50ac949..d174c081ee 100644 --- a/server/src/plugins/app-access.ts +++ b/server/src/plugins/app-access.ts @@ -24,7 +24,7 @@ import type { CatalogueEntry } from "./catalogue"; */ /** - * The six things a person can be told about one app on one Bot. + * The seven things a person can be told about one app on one Bot. * * `ready` — usable now, as far as this deployment knows. * `not_connected` — enabled and held, and this app is used under each person's own account, which @@ -33,6 +33,9 @@ import type { CatalogueEntry } from "./catalogue"; * to fix. * `shared_not_connected` — a Shared app whose one team account is not connected, or whose key was * refused. Only an administrator can connect it. + * `shared_not_approved` — a Shared app whose team account is connected, on a Bot that reaches more + * people than an administrator approved for that account. Every call is refused by the shared-use + * gate, which files the administrator's request itself; nothing is left for a press to ask. * `disabled` — switched off at the deployment: no row, Composio not configured, or an app that * still needs something only an administrator can supply. * `not_granted` — enabled, and this Bot holds none of its tools. Only an administrator can grant. @@ -42,6 +45,7 @@ export const APP_ACCESS_STATES = [ "not_connected", "reconnect", "shared_not_connected", + "shared_not_approved", "disabled", "not_granted", ] as const; @@ -66,6 +70,7 @@ export const REQUEST_KIND_FOR: Record< not_connected: null, reconnect: null, shared_not_connected: "connect_shared", + shared_not_approved: null, disabled: "enable", not_granted: "grant", }; @@ -106,16 +111,34 @@ export type AppAccess = { /** The app's mode, off its answering row; null for an app that is not brokered. */ accountMode: "personal" | "shared" | null; state: AppAccessState; + /** Why nobody can use the app, when `state` is `disabled`; null otherwise. See {@link Unavailable}. */ + unavailable: Unavailable | null; + /** + * Whose own account a personal app's calls go out on for this person: theirs, or — on somebody + * else's published Team Bot whose owner connected the app — the owner's, which only the owner can + * reconnect. Null where the app is not used under a person's own account. + * + * THE SAME ANSWER `credentialActorFor` GIVES A CALL, read without its side effects: the owner's + * account wherever the owner has one, and otherwise the person's own. + */ + accountHolder: "you" | "owner" | null; + /** + * Whether the first call on the person's own account through somebody else's Team Bot will stop + * to ask them to allow it, because they have given that Bot no standing consent. The state is + * still `ready`: the question is asked in the conversation, and answering it is the next step. + */ + asksFirst: boolean; /** Whether this Bot holds any of the app's tools. */ granted: boolean; /** * When the account this app runs in for this person was connected — their own, or the team's for * a Shared app — and null where none is, or where no account is involved. * - * FOR THE ONE FACT THE STATE CANNOT CARRY. An account the vendor has lost still has its row here, - * so the state reads `ready`; a card drawn over the refusal that found out says Reconnect anyway. - * This is how that card tells "lost since the refusal" from "connected again since the refusal": - * a connection newer than the refusal is the reconnection, and the live state wins. + * FOR THE ONE FACT THE STATE CANNOT ALWAYS CARRY. The call that finds an account lost at the + * vendor now deletes its row, so the state says so itself from then on; a refusal written before + * that, over a row that still stands, reads `ready` here and its card says Reconnect anyway. This + * is how that card tells "lost since the refusal" from "connected again since the refusal": a + * connection newer than the refusal is the reconnection, and the live state wins. */ connectedAt: string | null; /** The request a press of Request access files for this state, and null where there is none. */ @@ -149,6 +172,11 @@ export type AppAccessFacts = { personal: { usable: boolean } | null; /** The deployment's account at a Shared app, or null where none is connected. */ shared: { usable: boolean } | null; + /** + * Whether the shared-use gate would let this person's calls through this Bot reach the team + * account, or null where nobody asked — no person named, or not a Shared app. + */ + sharedAdmitted: boolean | null; }; /** Whose account, from the same facts `accessFor` resolves a call with. */ @@ -213,7 +241,7 @@ export function unavailableReason(facts: AppAccessFacts): Unavailable | null { /** * The rule, in the order a person meets the obstacles: the deployment first, then the Bot, then - * their own account. + * the account — and, for a team account, whether this Bot's audience was approved for it. * * A KEY THE VENDOR REFUSED READS AS `reconnect`, AND AN UNCHECKED ONE AS `ready`. The store * reduces each connection to `usable` the way `brokeredConnectionsFor` sets out its four states: a @@ -225,8 +253,10 @@ export function appAccessState(facts: AppAccessFacts): AppAccessState { if (!facts.granted) return "not_granted"; const account = accountOf(facts); if (account === "deployment") return "ready"; - if (account === "shared") - return facts.shared?.usable ? "ready" : "shared_not_connected"; + if (account === "shared") { + if (!facts.shared?.usable) return "shared_not_connected"; + return facts.sharedAdmitted === false ? "shared_not_approved" : "ready"; + } if (!facts.personal) return "not_connected"; return facts.personal.usable ? "ready" : "reconnect"; } diff --git a/server/src/plugins/plugin-store/app-access.ts b/server/src/plugins/plugin-store/app-access.ts index 615dddfc72..4863914611 100644 --- a/server/src/plugins/plugin-store/app-access.ts +++ b/server/src/plugins/plugin-store/app-access.ts @@ -1,7 +1,18 @@ import { randomUUID } from "node:crypto"; -import { and, asc, desc, eq, inArray, isNull, or, type SQL } from "drizzle-orm"; +import { + and, + asc, + count, + desc, + eq, + inArray, + isNull, + or, + type SQL, +} from "drizzle-orm"; import { type AuditEventInput, recordAuditEvent } from "../../audit"; import { + agentProfiles, agents, appAccessRequests, brokeredConnections, @@ -10,6 +21,9 @@ import { mcpTools, mcpUserCredentials, pluginGrants, + teamBotConsentDefaults, + teamBotConsents, + teamBotPublications, users, } from "../../db/schema"; import { @@ -52,6 +66,12 @@ type AppCandidate = { authScheme: string | null; accountMode: "personal" | "shared" | null; hasCredential: boolean; + /** + * Whether anything but the caller's own word says this app exists: a row, a catalogue entry, or + * the vendor's directory. False only for a `composio-` id the directory did not list — named so a + * grant that outlived its row still reads, and never something a request may be filed to enable. + */ + vouched: boolean; }; type ServerRow = { @@ -81,8 +101,20 @@ export type AppInspection = { entry: CatalogueEntry | null; /** Whether the Shared team account is connected and its key was not refused. */ sharedUsable: boolean; + /** See {@link AppCandidate.vouched}. */ + vouched: boolean; }; +/** + * Whether the shared-use gate would admit this person's calls through this Bot to a Shared app's + * team account — the gate's own rule, without the request it files when it refuses. + */ +export type SharedAudienceCheck = (input: { + botId: string; + serverId: string; + actorId: string; +}) => Promise; + export type AppAccessRequestStatus = | "pending" | "approved" @@ -110,6 +142,18 @@ export type AppAccessRequestRecord = { const SLUG_OF_ID = /^composio-/; +/** + * Whether `error` is a Postgres unique_violation (SQLSTATE 23505). Bun's driver puts the SQLSTATE in + * `errno`, and drizzle wraps the driver error as `cause` — the same reading `identity/store.ts` makes. + */ +function isUniqueViolation(error: unknown): boolean { + type Driver = { errno?: unknown }; + return [ + error as Driver | null | undefined, + (error as { cause?: Driver } | null | undefined)?.cause, + ].some((candidate) => candidate?.errno === "23505"); +} + /** * Per-person, per-Bot status of the work apps a Bot holds, and the requests a person files when * only an administrator can change it. See `../app-access.ts` for the rule itself. @@ -182,6 +226,7 @@ export function appAccessMethods(context: StoreContext) { authScheme: toolkit ? (head?.authScheme ?? null) : source.authScheme, accountMode: toolkit ? (head?.accountMode ?? null) : null, hasCredential: source.credentialId !== null, + vouched: true, }; apps.set(key, app); } @@ -204,6 +249,7 @@ export function appAccessMethods(context: StoreContext) { provider: AppProvider; toolkit?: string | null; entry?: CatalogueEntry | null; + vouched?: boolean; }): AppCandidate { return { key: input.key, @@ -218,6 +264,7 @@ export function appAccessMethods(context: StoreContext) { authScheme: null, accountMode: null, hasCredential: false, + vouched: input.vouched ?? true, }; } @@ -273,6 +320,7 @@ export function appAccessMethods(context: StoreContext) { title: slug, provider: "composio", toolkit: slug, + vouched: false, }); return null; } @@ -281,33 +329,47 @@ export function appAccessMethods(context: StoreContext) { * One app by whatever a caller called it: a server id, a catalogue key, a Composio slug with or * without its `composio-` prefix, or the app's name. Null for a word that names no app here or * in the directory. + * + * A NAME FINDS WHAT IS ENABLED BEFORE WHAT COULD BE. "Google Drive" is the title of the built-in + * `google-drive` entry and of a Composio row alike; on a deployment that enabled the Composio one + * and never added the built-in, the name is about the app that is there, and answering with the + * entry instead would call it switched off and file a request to enable something nobody needs. + * So an exact id or key is taken as it was given, and a bare name is matched against the rows + * first — the one this Bot holds, where two rows share it — and only then against the catalogue. */ async function candidateFor( app: string, directory: AppDirectory | undefined, + granted: Set, ): Promise { const asked = app.trim(); if (!asked) return null; const lower = asked.toLowerCase(); const rows = await allRows(); const byRow = await appsByRow(rows); + const enabled = [...new Set(byRow.values())]; const byId = byRow.get(asked); if (byId) return byId; const slug = lower.replace(SLUG_OF_ID, ""); - for (const candidate of new Set(byRow.values())) + for (const candidate of enabled) if (candidate.toolkit === slug) return candidate; - const entry = - catalogueEntry(lower) ?? - CATALOGUE.find((item) => item.title.toLowerCase() === lower) ?? - null; - if (entry) - return byRow.get(entry.key) ?? absentFor(entry.key, undefined, null); + const keyed = catalogueEntry(lower); + if (keyed) + return byRow.get(keyed.key) ?? absentFor(keyed.key, undefined, null); - for (const candidate of new Set(byRow.values())) - if (candidate.title.toLowerCase() === lower) return candidate; + const titled = enabled.filter( + (candidate) => candidate.title.toLowerCase() === lower, + ); + const held = titled.find((candidate) => + [candidate.key, ...candidate.rowIds].some((id) => granted.has(id)), + ); + if (held ?? titled[0]) return held ?? titled[0] ?? null; + + const entry = CATALOGUE.find((item) => item.title.toLowerCase() === lower); + if (entry) return absentFor(entry.key, undefined, null); const listed = await readDirectory(directory); const named = listed?.find( @@ -456,94 +518,207 @@ export function appAccessMethods(context: StoreContext) { .orderBy(asc(appAccessRequests.createdAt)); } - /** Every candidate described for one person and one Bot. */ + /** + * The owner of a published Team Bot, when the person looking is somebody else; null for any other + * Bot, or for its owner. The same publication `credentialActorFor` reads before it decides whose + * account a call goes out on. + */ + async function teamBotOwnerFor( + agentId: string, + userId: string, + ): Promise { + const [row] = await database + .select({ ownerUserId: agentProfiles.ownerUserId }) + .from(teamBotPublications) + .innerJoin( + agentProfiles, + eq(agentProfiles.agentId, teamBotPublications.agentId), + ) + .where( + and( + eq(teamBotPublications.agentId, agentId), + isNull(agentProfiles.deletedAt), + ), + ) + .limit(1); + const owner = row?.ownerUserId ?? null; + return owner && owner !== userId ? owner : null; + } + + /** + * The servers this person lets one Team Bot use their own account on without asking: an + * "always" or a not-yet-spent "once" for this Bot, or a default for every Bot. + */ + async function standingConsents( + userId: string, + agentId: string, + ): Promise> { + const [forBot, forEvery] = await Promise.all([ + database + .select({ serverId: teamBotConsents.serverId }) + .from(teamBotConsents) + .where( + and( + eq(teamBotConsents.userId, userId), + eq(teamBotConsents.agentId, agentId), + ), + ), + database + .select({ serverId: teamBotConsentDefaults.serverId }) + .from(teamBotConsentDefaults) + .where(eq(teamBotConsentDefaults.userId, userId)), + ]); + return new Set([...forBot, ...forEvery].map((row) => row.serverId)); + } + + /** + * Every candidate described for one person and one Bot. + * + * THE ACCOUNT IS THE ONE THE CALL WILL USE. On somebody else's published Team Bot, a personal + * app's calls go out on the owner's account wherever the owner has one — connected, withdrawn or + * refused alike, exactly as `credentialActorFor` decides from `connectedServers` — and only + * otherwise on the person's own, after they allow it. So the owner's connection decides the state + * there, and a teammate is never offered a Connect that changes nothing, nor told `ready` over a + * call that will stop to ask them first without saying so. + */ async function describe( apps: AppCandidate[], input: { userId: string | null; agentId: string; composioConfigured: boolean; + sharedAudience?: SharedAudienceCheck; }, ): Promise { - const [granted, connections, pending] = await Promise.all([ - grantedServerIds(input.agentId), - connectionFacts(apps, input.userId), - input.userId - ? pendingRequestsFor( - input.userId, - input.agentId, - apps.map((app) => app.key), - ) - : [], - ]); - return apps.map((app) => { - const teamAccount = app.toolkit - ? connections.deploymentBrokered.get(app.toolkit) - : undefined; - const sharedUsable = teamAccount?.usable === true; - const personal = - app.provider === "composio" && app.toolkit - ? connections.personalBrokered.get(app.toolkit) - : app.provider === "catalogue" - ? app.rowIds - .map((id) => connections.personalHeld.get(id)) - .find((value) => value !== undefined) - : undefined; - const facts: AppAccessFacts = { - provider: app.provider, - present: app.present, - composioConfigured: input.composioConfigured, - authScheme: app.authScheme, - accountMode: app.accountMode, - catalogueAuth: app.entry?.auth ?? null, - hasCredential: app.hasCredential, - granted: [app.key, ...app.rowIds].some((id) => granted.has(id)), - personal: personal === undefined ? null : { usable: personal.usable }, - shared: teamAccount ? { usable: sharedUsable } : null, - }; - const account = accountOf(facts); - const state = appAccessState(facts); - const requestKind = REQUEST_KIND_FOR[state]; - const request = - requestKind === null - ? undefined - : pending.find( - (row) => row.appKey === app.key && row.kind === requestKind, - ); - return { - access: { - key: app.key, - displayName: app.title, - logo: app.logo, + const { userId } = input; + const owner = userId ? await teamBotOwnerFor(input.agentId, userId) : null; + const [granted, connections, ownerConnections, consented, pending] = + await Promise.all([ + grantedServerIds(input.agentId), + connectionFacts(apps, userId), + owner ? connectionFacts(apps, owner) : null, + owner && userId + ? standingConsents(userId, input.agentId) + : new Set(), + userId + ? pendingRequestsFor( + userId, + input.agentId, + apps.map((app) => app.key), + ) + : [], + ]); + const personalIn = ( + found: Awaited>, + app: AppCandidate, + ) => + app.provider === "composio" && app.toolkit + ? found.personalBrokered.get(app.toolkit) + : app.provider === "catalogue" + ? app.rowIds + .map((id) => found.personalHeld.get(id)) + .find((value) => value !== undefined) + : undefined; + return Promise.all( + apps.map(async (app) => { + const ids = [app.key, ...app.rowIds]; + const teamAccount = app.toolkit + ? connections.deploymentBrokered.get(app.toolkit) + : undefined; + const sharedUsable = teamAccount?.usable === true; + const own = personalIn(connections, app); + const owners = ownerConnections + ? personalIn(ownerConnections, app) + : undefined; + const personal = owners ?? own; + const known: AppAccessFacts = { provider: app.provider, - account, - connect: connectOf(facts), - authScheme: app.provider === "composio" ? app.authScheme : null, + present: app.present, + composioConfigured: input.composioConfigured, + authScheme: app.authScheme, accountMode: app.accountMode, - state, - granted: facts.granted, - connectedAt: - account === "shared" - ? (teamAccount?.connectedAt ?? null) - : account === "personal" - ? (personal?.connectedAt ?? null) - : null, - requestKind, - request: request + catalogueAuth: app.entry?.auth ?? null, + hasCredential: app.hasCredential, + granted: ids.some((id) => granted.has(id)), + personal: personal === undefined ? null : { usable: personal.usable }, + shared: teamAccount ? { usable: sharedUsable } : null, + sharedAdmitted: null, + }; + const account = accountOf(known); + /* + * Asked only where it can change the answer: a Shared app this Bot holds, whose team account + * is there, for a named person. The gate is asked again on every call; this is what it would + * say now. + */ + const facts: AppAccessFacts = + account === "shared" && + sharedUsable && + known.granted && + userId && + input.sharedAudience && + app.answeringId ? { - id: request.id, - kind: request.kind, - createdAt: iso(request.createdAt) ?? "", + ...known, + sharedAdmitted: await input.sharedAudience({ + botId: input.agentId, + serverId: app.answeringId, + actorId: userId, + }), } - : null, - }, - unavailable: unavailableReason(facts), - answeringId: app.answeringId, - toolkit: app.toolkit, - entry: app.entry, - sharedUsable, - }; - }); + : known; + const state = appAccessState(facts); + const holder = owners !== undefined ? "owner" : "you"; + const requestKind = REQUEST_KIND_FOR[state]; + const request = + requestKind === null + ? undefined + : pending.find( + (row) => row.appKey === app.key && row.kind === requestKind, + ); + return { + access: { + key: app.key, + displayName: app.title, + logo: app.logo, + provider: app.provider, + account, + connect: connectOf(facts), + authScheme: app.provider === "composio" ? app.authScheme : null, + accountMode: app.accountMode, + state, + unavailable: unavailableReason(facts), + accountHolder: account === "personal" ? holder : null, + asksFirst: + state === "ready" && + account === "personal" && + holder === "you" && + owner !== null && + !ids.some((id) => consented.has(id)), + granted: facts.granted, + connectedAt: + account === "shared" + ? (teamAccount?.connectedAt ?? null) + : account === "personal" + ? (personal?.connectedAt ?? null) + : null, + requestKind, + request: request + ? { + id: request.id, + kind: request.kind, + createdAt: iso(request.createdAt) ?? "", + } + : null, + }, + unavailable: unavailableReason(facts), + answeringId: app.answeringId, + toolkit: app.toolkit, + entry: app.entry, + sharedUsable, + vouched: app.vouched, + }; + }), + ); } const requestColumns = { @@ -587,6 +762,35 @@ export function appAccessMethods(context: StoreContext) { return rows.map(toRecord); } + /** See `closeAppAccessRequest` below. */ + async function closeRequest(input: { + id: string; + status: Exclude; + by: string; + requestedBy?: string; + }): Promise { + const [closed] = await database + .update(appAccessRequests) + .set({ + status: input.status, + resolvedAt: new Date(), + resolvedBy: input.by, + }) + .where( + and( + eq(appAccessRequests.id, input.id), + eq(appAccessRequests.status, "pending"), + input.requestedBy + ? eq(appAccessRequests.requestedBy, input.requestedBy) + : undefined, + ), + ) + .returning({ id: appAccessRequests.id }); + if (!closed) return null; + const [record] = await requestRows(eq(appAccessRequests.id, closed.id)); + return record ?? null; + } + return { /** * Every work app one Bot holds, each with what it is for this person. @@ -604,6 +808,7 @@ export function appAccessMethods(context: StoreContext) { agentId: string; composioConfigured: boolean; directory?: AppDirectory; + sharedAudience?: SharedAudienceCheck; }): Promise { const granted = await grantedServerIds(input.agentId); if (granted.size === 0) return []; @@ -645,21 +850,36 @@ export function appAccessMethods(context: StoreContext) { userId: string | null; composioConfigured: boolean; directory?: AppDirectory; + sharedAudience?: SharedAudienceCheck; }): Promise { - const candidate = await candidateFor(input.app, input.directory); + const candidate = await candidateFor( + input.app, + input.directory, + await grantedServerIds(input.agentId), + ); if (!candidate) return null; const [described] = await describe([candidate], input); return described ?? null; }, - /** Every tool the app's answering row advertises, as grant refs. */ - async appToolRefs(serverId: string): Promise { + /** + * Every tool the app's answering row advertises, as grant refs, and whether each is a read. + * + * `read` IS THE EXACT WORD AND NOTHING ELSE, as `classifyTool` reads `effect`: a null or an + * unrecognised value is a write, so a tool nobody classified is never handed out as a read. + */ + async appTools( + serverId: string, + ): Promise<{ ref: string; read: boolean }[]> { const rows = await database - .select({ name: mcpTools.name }) + .select({ name: mcpTools.name, effect: mcpTools.effect }) .from(mcpTools) .where(eq(mcpTools.serverId, serverId)) .orderBy(asc(mcpTools.name)); - return rows.map((row) => `${serverId}/${row.name}`); + return rows.map((row) => ({ + ref: `${serverId}/${row.name}`, + read: row.effect === "read", + })); }, /** @@ -718,6 +938,76 @@ export function appAccessMethods(context: StoreContext) { ); }, + /** How many requests this person has waiting, across every app and Bot. */ + async countPendingAppAccessRequests(requestedBy: string): Promise { + const [row] = await database + .select({ count: count() }) + .from(appAccessRequests) + .where( + and( + eq(appAccessRequests.requestedBy, requestedBy), + eq(appAccessRequests.status, "pending"), + ), + ); + return row?.count ?? 0; + }, + + /** + * Claim a pending request for an approval about to be carried out: closed as approved, by this + * administrator, before anything is changed — or null when somebody else answered it first. + * + * THE CLAIM COMES FIRST SO NOTHING CAN ANSWER IT UNDERNEATH THE CHANGE. Approving switches an + * app on and grants a Bot its tools, and used to close the request only afterwards; a dismiss or + * a withdrawal landing in between closed it first, the approve's close found nothing, and the + * Bot kept a grant under a request that read dismissed, with no `app_access.approved` in the + * trail. Claimed here by the same conditional update every close is, the approval is the answer + * the moment it starts, and anybody else closing meanwhile finds it answered and loses with a + * 409 — before the change, not after it. + * + * NOT A ROW LOCK HELD ACROSS THE CHANGE, which is the other way to get this and the wrong one + * here. The change is made through the store on connections of its own, and a transaction kept + * open around it is the shape `db/client.ts` warns about: every pooled connection inside such a + * transaction, each waiting for a second, is a hang under load and an immediate one on a pool + * of one. + * + * An approval that cannot be carried out puts the request back with + * {@link releaseAppAccessRequest}, so it waits again with the step that is missing. + */ + async claimAppAccessRequest(input: { + id: string; + by: string; + }): Promise { + return closeRequest({ id: input.id, status: "approved", by: input.by }); + }, + + /** + * Put back a request {@link claimAppAccessRequest} claimed and the approval then could not carry + * out. Only this administrator's claim, and only while it still reads approved. + * + * A REQUEST FILED AGAIN MEANWHILE IS THE ONE THAT WAITS. The person saw theirs answered, and the + * partial unique index lets them file the same request again; two cannot both be pending, so + * this one is closed as cancelled instead — the same request, already waiting under the other id. + */ + async releaseAppAccessRequest(input: { id: string; by: string }) { + const claimed = and( + eq(appAccessRequests.id, input.id), + eq(appAccessRequests.status, "approved"), + eq(appAccessRequests.resolvedBy, input.by), + ); + try { + await database + .update(appAccessRequests) + .set({ status: "pending", resolvedAt: null, resolvedBy: null }) + .where(claimed); + } catch (error) { + if (!isUniqueViolation(error)) throw error; + await database + .update(appAccessRequests) + .set({ status: "cancelled", resolvedAt: new Date() }) + .where(claimed); + } + }, + async appAccessRequest(id: string): Promise { const [request] = await requestRows(eq(appAccessRequests.id, id)); return request ?? null; @@ -750,33 +1040,7 @@ export function appAccessMethods(context: StoreContext) { * lost. `requestedBy`, when given, confines a cancel to the person's own requests in the same * statement. */ - async closeAppAccessRequest(input: { - id: string; - status: Exclude; - by: string; - requestedBy?: string; - }): Promise { - const [closed] = await database - .update(appAccessRequests) - .set({ - status: input.status, - resolvedAt: new Date(), - resolvedBy: input.by, - }) - .where( - and( - eq(appAccessRequests.id, input.id), - eq(appAccessRequests.status, "pending"), - input.requestedBy - ? eq(appAccessRequests.requestedBy, input.requestedBy) - : undefined, - ), - ) - .returning({ id: appAccessRequests.id }); - if (!closed) return null; - const [record] = await requestRows(eq(appAccessRequests.id, closed.id)); - return record ?? null; - }, + closeAppAccessRequest: closeRequest, /** * A row in the trail about a request, written here so the routes write through the same diff --git a/server/src/plugins/route-groups/app-access.ts b/server/src/plugins/route-groups/app-access.ts index f1f98c19ac..7ce1ddccad 100644 --- a/server/src/plugins/route-groups/app-access.ts +++ b/server/src/plugins/route-groups/app-access.ts @@ -18,6 +18,21 @@ import { sharedGrantApproval } from "./grants"; type RouteContext = Context<{ Variables: AppVariables }>; +/** + * The longest app name or Bot id a request may carry — the same cap the `show_app_access` tool puts + * on what a model may ask about. Both are written into the trail when a filing is refused, and an + * app's name becomes the title an administrator reads in the inbox, so neither is a field a caller + * may fill with whatever they like. + */ +const MAX_NAME = 200; + +/** + * How many requests one person may have waiting at once. A real person asks about the handful of + * apps a question needed; past this the inbox is being filled rather than asked, and each extra row + * is one an administrator has to answer by hand. + */ +export const MAX_PENDING_REQUESTS = 20; + /** * What enabling a catalogue entry would need that only an administrator can type, or null when * adding its row is the whole of it. @@ -87,6 +102,13 @@ export function mountAppAccessRoutes({ const directory: AppDirectory | undefined = composio ? () => composio.broker.listApps() : undefined; + /* + * What the shared-use gate would answer this person's chat through this Bot, so a Shared app + * whose audience was never approved reads as refused rather than ready. Without the store there + * is no gate to ask, and the call path refuses every Shared call anyway; the state says `ready` + * there exactly as it did before this was asked. + */ + const sharedAudience = shared?.use.admitsPerson; const refused = async ( context: RouteContext, @@ -94,7 +116,7 @@ export function mountAppAccessRoutes({ app: string; reason: string; error: string; - status: 400 | 403 | 404 | 409 | 502 | 503; + status: 400 | 403 | 404 | 409 | 429 | 502 | 503; request?: string; bot?: string | null; kind?: string; @@ -131,6 +153,7 @@ export function mountAppAccessRoutes({ agentId, composioConfigured, directory, + sharedAudience, }), }); }); @@ -144,13 +167,17 @@ export function mountAppAccessRoutes({ if (!(await canUseBot(context.var.actor, agentId))) return context.json({ error: "There is no such Bot." }, 404); const app = context.req.param("app"); - const inspected = await store.inspectApp({ - app, - agentId, - userId: context.var.actor.id, - composioConfigured, - directory, - }); + const inspected = + app.length > MAX_NAME + ? null + : await store.inspectApp({ + app, + agentId, + userId: context.var.actor.id, + composioConfigured, + directory, + sharedAudience, + }); if (!inspected) return context.json( { error: `This deployment has no app called ${app}.` }, @@ -165,6 +192,12 @@ export function mountAppAccessRoutes({ * A SECOND PRESS IS THE FIRST REQUEST. One pending request per person, app, Bot and kind, so * asking again answers with the one already waiting and `created: false`, and writes no second * trail row. + * + * ONLY FOR AN APP SOMETHING VOUCHES FOR, AND ONLY SO MANY. Asking to enable a `composio-` id the + * directory does not list would put whatever the caller typed in front of an administrator as an + * app's name, for an approval that can only fail; it is answered as no such app, unless the Bot + * already holds a grant for it and the request is about putting back something it was given. And + * a person with {@link MAX_PENDING_REQUESTS} waiting is told to wait for those to be answered. */ routes.post("/access-requests", requireUser, async (context) => { const body = (await context.req.json().catch(() => null)) as { @@ -176,6 +209,13 @@ export function mountAppAccessRoutes({ const app = typeof body?.app === "string" ? body.app.trim() : ""; if (!agentId || !app) return context.json({ error: "Name the Bot and the app." }, 400); + if (agentId.length > MAX_NAME || app.length > MAX_NAME) + return context.json( + { + error: `A Bot or an app is named in at most ${MAX_NAME} characters.`, + }, + 400, + ); if (!(await canUseBot(context.var.actor, agentId))) return refused(context, { app, @@ -184,13 +224,16 @@ export function mountAppAccessRoutes({ error: "There is no such Bot.", status: 404, }); - const inspected = await store.inspectApp({ + const found = await store.inspectApp({ app, agentId, userId: context.var.actor.id, composioConfigured, directory, + sharedAudience, }); + const inspected = + found && (found.vouched || found.access.granted) ? found : null; if (!inspected) return refused(context, { app, @@ -211,6 +254,23 @@ export function mountAppAccessRoutes({ : `${access.displayName} is waiting on your own account, not on an administrator. Connect it yourself.`, status: 409, }); + /* + * The one already waiting is answered as itself before the cap is counted, so a person at the + * cap pressing again on a request they filed is shown it, not refused. + */ + if ( + !access.request && + (await store.countPendingAppAccessRequests(context.var.actor.id)) >= + MAX_PENDING_REQUESTS + ) + return refused(context, { + app: access.key, + bot: agentId, + reason: "too_many_pending", + kind: access.requestKind, + error: `You already have ${MAX_PENDING_REQUESTS} requests waiting for an administrator. Withdraw one, or wait for them to be answered, then ask again.`, + status: 429, + }); const filed = await store.fileAppAccessRequest({ requestedBy: context.var.actor.id, agentId, @@ -291,30 +351,24 @@ export function mountAppAccessRoutes({ * Approve a request by doing what it asks: switch the app on, grant its tools to the Bot, or — * for a team account, which only a consent screen can connect — confirm it has been connected. * - * THE CHANGE FIRST, THEN THE CONDITIONAL CLOSE. Enabling and granting are both idempotent, so - * two administrators approving at once both arrive at the same deployment and one of them is - * told the request was already answered. Closing first would leave an approved request over a - * change that then failed. + * CLAIMED, THEN CHANGED. The request is closed as approved by this administrator before anything + * is changed, so a dismiss, a withdrawal or a second approval arriving meanwhile finds it answered + * and loses, and the change can never land under a request somebody else answered. A change that + * cannot be carried out puts the request back, and it waits again with the step that is missing; + * so does one that throws. See `claimAppAccessRequest` in the store, which says why this is not a + * row lock held across the change. * - * `refs` NARROWS THE GRANT. Absent, the Bot is granted every tool the app advertises — which is - * what "let this Bot use Gmail" means — and every call is still policy-checked and audited. A - * ref outside the app is refused rather than dropped. + * `refs` NARROWS THE GRANT, AND WITHOUT IT ONLY THE READS ARE GRANTED. One press in the inbox + * gives the Bot every action the app has marked read-only — what the Bot page's bulk switch + * offers, and nothing that sends, writes or deletes. Anything more is chosen action by action, on + * the Bot's page for the app or by naming them here. An app with no read-only action is refused + * with that sentence rather than granted everything. A ref outside the app is refused rather than + * dropped. */ routes.post("/access-requests/:id/approve", requireUser, async (context) => { const forbidden = requireAdmin(context); if (forbidden) return forbidden; const id = context.req.param("id"); - const request = await store.appAccessRequest(id); - if (request?.status !== "pending") - return refused(context, { - app: request?.appKey ?? "", - request: id, - reason: request ? "already_resolved" : "no_such_request", - error: request - ? "That request has already been answered." - : "There is no such request.", - status: request ? 409 : 404, - }); const body = (await context.req.json().catch(() => null)) as { refs?: unknown; } | null; @@ -326,177 +380,207 @@ export function mountAppAccessRoutes({ return context.json({ error: "Tools must be a list of refs." }, 400); const chosen = body?.refs as string[] | undefined; - const deny = ( - reason: string, - error: string, - status: 400 | 404 | 409 | 502 | 503, - ) => - refused(context, { - app: request.appKey, - request: request.id, - bot: request.agentId, - kind: request.kind, - reason, - error, - status, - }); - - const inspect = () => - store.inspectApp({ - app: request.appKey, - agentId: request.agentId ?? "", - userId: null, - composioConfigured, - directory, - }); + let carriedOut = false; + const approve = async (request: AppAccessRequestRecord) => { + const deny = ( + reason: string, + error: string, + status: 400 | 404 | 409 | 502 | 503, + ) => + refused(context, { + app: request.appKey, + request: request.id, + bot: request.agentId, + kind: request.kind, + reason, + error, + status, + }); - let inspected = await inspect(); - if (!inspected) - return deny( - "no_such_app", - `${request.appTitle} is no longer an app this deployment can find.`, - 409, - ); + const inspect = () => + store.inspectApp({ + app: request.appKey, + agentId: request.agentId ?? "", + userId: null, + composioConfigured, + directory, + }); - let enabled = false; - if (request.kind !== "connect_shared" && inspected.unavailable) { - const title = inspected.access.displayName; - if (inspected.unavailable !== "absent") + let inspected = await inspect(); + if (!inspected) return deny( - inspected.unavailable, - unavailableSentence(inspected.unavailable, title), - inspected.unavailable === "composio_unconfigured" ? 503 : 409, + "no_such_app", + `${request.appTitle} is no longer an app this deployment can find.`, + 409, ); - if (inspected.toolkit) { - if (!composio) + + let enabled = false; + if (request.kind !== "connect_shared" && inspected.unavailable) { + const title = inspected.access.displayName; + if (inspected.unavailable !== "absent") return deny( - "composio_unconfigured", - unavailableSentence("composio_unconfigured", title), - 503, + inspected.unavailable, + unavailableSentence(inspected.unavailable, title), + inspected.unavailable === "composio_unconfigured" ? 503 : 409, ); - const outcome = await enableComposioApp( - { store, composio }, - inspected.toolkit, - actorEmail(context), - ); - if (!outcome.ok) - return deny("enable_failed", outcome.error, outcome.status); - } else if (inspected.entry) { - const needed = adminInputFor(inspected.entry); - if (needed) + if (inspected.toolkit) { + if (!composio) + return deny( + "composio_unconfigured", + unavailableSentence("composio_unconfigured", title), + 503, + ); + const outcome = await enableComposioApp( + { store, composio }, + inspected.toolkit, + actorEmail(context), + ); + if (!outcome.ok) + return deny("enable_failed", outcome.error, outcome.status); + } else if (inspected.entry) { + const needed = adminInputFor(inspected.entry); + if (needed) + return deny( + "needs_admin_input", + `${title} needs ${needed}, which only an administrator can supply. Add it on its page under Plugins, then approve this request again.`, + 409, + ); + try { + await store.addServer({ + key: inspected.entry.key, + by: actorEmail(context), + }); + } catch (error) { + if ( + error instanceof CatalogueEntryUnknownError || + error instanceof CustomServerRefusedError + ) + return deny("enable_failed", error.message, 400); + if (isDeploymentFault(error)) + return deny("enable_failed", deploymentFaultSentence(error), 409); + throw error; + } + } else { + return deny("absent", unavailableSentence("absent", title), 409); + } + enabled = true; + inspected = await inspect(); + if (!inspected || inspected.unavailable) return deny( - "needs_admin_input", - `${title} needs ${needed}, which only an administrator can supply. Add it on its page under Plugins, then approve this request again.`, + "enable_failed", + `${title} was switched on and still cannot be used. Open its page under Plugins to see why.`, 409, ); - try { - await store.addServer({ - key: inspected.entry.key, - by: actorEmail(context), - }); - } catch (error) { - if ( - error instanceof CatalogueEntryUnknownError || - error instanceof CustomServerRefusedError - ) - return deny("enable_failed", error.message, 400); - if (isDeploymentFault(error)) - return deny("enable_failed", deploymentFaultSentence(error), 409); - throw error; - } - } else { - return deny("absent", unavailableSentence("absent", title), 409); } - enabled = true; - inspected = await inspect(); - if (!inspected || inspected.unavailable) - return deny( - "enable_failed", - `${title} was switched on and still cannot be used. Open its page under Plugins to see why.`, - 409, - ); - } - let granted: string[] = []; - if ( - request.kind !== "connect_shared" && - request.agentId && - !inspected.access.granted - ) { - const agentId = request.agentId; - const serverId = inspected.answeringId; - if (!(await store.agentIsRegistered(agentId)) || !serverId) - return deny("no_such_bot", "That Bot is no longer here.", 409); - const advertised = await store.appToolRefs(serverId); - const refs = chosen ?? advertised; - const outside = refs.filter((ref) => !advertised.includes(ref)); - if (outside.length > 0) - return deny( - "refs_outside_app", - `${outside.join(", ")} ${outside.length === 1 ? "is" : "are"} not among ${inspected.access.displayName}'s tools.`, - 400, + let granted: string[] = []; + if ( + request.kind !== "connect_shared" && + request.agentId && + !inspected.access.granted + ) { + const agentId = request.agentId; + const serverId = inspected.answeringId; + const name = inspected.access.displayName; + if (!(await store.agentIsRegistered(agentId)) || !serverId) + return deny("no_such_bot", "That Bot is no longer here.", 409); + const tools = await store.appTools(serverId); + const advertised = tools.map((tool) => tool.ref); + const refs = + chosen ?? tools.filter((tool) => tool.read).map((tool) => tool.ref); + const outside = refs.filter((ref) => !advertised.includes(ref)); + if (outside.length > 0) + return deny( + "refs_outside_app", + `${outside.join(", ")} ${outside.length === 1 ? "is" : "are"} not among ${name}'s tools.`, + 400, + ); + if (advertised.length === 0) + return deny( + "no_tools", + `${name} advertises no tools to grant yet. Refresh its tools on its page under Plugins, then approve this request again.`, + 409, + ); + if (refs.length === 0) + return deny( + "no_read_only_tools", + `None of ${name}'s actions is marked read-only, so approving here would grant nothing. Choose which actions this Bot may use on ${name}'s page for it under Plugins, then approve this request again.`, + 409, + ); + for (const ref of refs) { + await store.grant("mcp", ref, agentId, actorEmail(context)); + } + const sharedDecision = await sharedGrantApproval( + { store, shared }, + refs[0] ?? "", + agentId, + undefined, ); - if (refs.length === 0) + if ( + sharedDecision !== "unreadable" && + sharedDecision.approval && + shared + ) + await shared.use.setApproval({ + botId: agentId, + serverId: sharedDecision.appId, + approval: sharedDecision.approval, + by: context.var.actor.id, + }); + granted = refs; + } + + if ( + request.kind === "connect_shared" && + inspected.access.account === "shared" && + !inspected.sharedUsable + ) return deny( - "no_tools", - `${inspected.access.displayName} advertises no tools to grant yet. Refresh its tools on its page under Plugins, then approve this request again.`, + "shared_not_connected", + `The shared ${inspected.access.displayName} account is not connected yet. Connect it on ${inspected.access.displayName}'s page under Plugins, then approve this request.`, 409, ); - for (const ref of refs) { - await store.grant("mcp", ref, agentId, actorEmail(context)); - } - const decided = await sharedGrantApproval( - { store, shared }, - refs[0] ?? "", - agentId, - undefined, - ); - if (decided !== "unreadable" && decided.approval && shared) - await shared.use.setApproval({ - botId: agentId, - serverId: decided.appId, - approval: decided.approval, - by: context.var.actor.id, - }); - granted = refs; - } - if ( - request.kind === "connect_shared" && - inspected.access.account === "shared" && - !inspected.sharedUsable - ) - return deny( - "shared_not_connected", - `The shared ${inspected.access.displayName} account is not connected yet. Connect it on ${inspected.access.displayName}'s page under Plugins, then approve this request.`, - 409, - ); + carriedOut = true; + const closed = request; + await store.recordAppAccessEvent({ + eventType: "app_access.approved", + targetId: closed.appKey, + actorUserId: context.var.actor.id, + payload: { + request: closed.id, + app: closed.appKey, + bot: closed.agentId, + kind: closed.kind, + enabled, + granted: granted.length, + }, + }); + return context.json({ request: closed, enabled, granted }); + }; - const closed = await store.closeAppAccessRequest({ - id: request.id, - status: "approved", + const claimed = await store.claimAppAccessRequest({ + id, by: context.var.actor.id, }); - if (!closed) - return deny( - "already_resolved", - "That request has already been answered.", - 409, - ); - await store.recordAppAccessEvent({ - eventType: "app_access.approved", - targetId: closed.appKey, - actorUserId: context.var.actor.id, - payload: { - request: closed.id, - app: closed.appKey, - bot: closed.agentId, - kind: closed.kind, - enabled, - granted: granted.length, - }, - }); - return context.json({ request: closed, enabled, granted }); + if (!claimed) { + const existing = await store.appAccessRequest(id); + return refused(context, { + app: existing?.appKey ?? "", + request: id, + reason: existing ? "already_resolved" : "no_such_request", + error: existing + ? "That request has already been answered." + : "There is no such request.", + status: existing ? 409 : 404, + }); + } + try { + return await approve(claimed); + } finally { + if (!carriedOut) + await store.releaseAppAccessRequest({ id, by: context.var.actor.id }); + } }); /** Dismiss a request: nothing changes but the request, which stops waiting. */ diff --git a/server/src/plugins/shared-use-store.ts b/server/src/plugins/shared-use-store.ts index c63c345ad3..1472423b34 100644 --- a/server/src/plugins/shared-use-store.ts +++ b/server/src/plugins/shared-use-store.ts @@ -650,9 +650,34 @@ export function createSharedUseStore(database: Database) { return row; } + /** + * Whether the gate would let this person's own chat with this Bot reach the app's team account — + * the same approval, owner and audience test the gate makes for a person-started run, and nothing + * it does after refusing. For a screen saying what a call would meet; the gate still decides + * every call. + */ + async function admitsPerson(input: { + botId: string; + serverId: string; + actorId: string; + }): Promise { + const approval = await approvalFor(input.botId, input.serverId); + if (!approval) return false; + const facts = await botFacts(input.botId); + return admits( + approval, + facts.ownerUserId, + await actorFacts(input.actorId), + { + outside: false, + }, + ); + } + return { appIdOf, approvalFor, + admitsPerson, setApproval, deleteApprovalsFor, botFacts, diff --git a/server/tests/app-access-routes.integration.test.ts b/server/tests/app-access-routes.integration.test.ts index eeb0e17a4b..9fbc9f313c 100644 --- a/server/tests/app-access-routes.integration.test.ts +++ b/server/tests/app-access-routes.integration.test.ts @@ -24,12 +24,16 @@ import { mcpUserCredentials, pluginGrants, sharedUseApprovals, + teamBotConsents, + teamBotPublications, users, } from "../src/db/schema"; import type { AppAccess } from "../src/plugins/app-access"; import type { BrokerApp, ConnectedAppBroker } from "../src/plugins/broker"; import type { AppAccessRequestRecord } from "../src/plugins/plugin-store/app-access"; +import { MAX_PENDING_REQUESTS } from "../src/plugins/route-groups/app-access"; import { createPluginRoutes } from "../src/plugins/routes"; +import { exposureOf } from "../src/plugins/shared-use"; import { createSharedUseStore } from "../src/plugins/shared-use-store"; import { createPluginStore, @@ -115,9 +119,15 @@ const broker: ConnectedAppBroker = { const enabled: string[] = []; -function storeFor(): PluginStore { +/** + * Run before every grant the routes make, when set — how a test holds an approval partway through + * its change while something else answers the same request. + */ +let beforeGrant: (() => Promise) | null = null; + +function storeFor(db = database): PluginStore { const real = createPluginStore({ - database, + database: db, auditStore: audit, credentials: {} as never, encryptionKey: "x".repeat(44), @@ -127,6 +137,10 @@ function storeFor(): PluginStore { }); return { ...real, + async grant(...args) { + await beforeGrant?.(); + return real.grant(...args); + }, async addBrokeredApp(input) { enabled.push(input.slug); await database.insert(mcpServers).values({ @@ -143,6 +157,7 @@ function storeFor(): PluginStore { name: "LIST_FILES", description: "List files", inputSchema: {}, + effect: "read", }); return { id: `composio-${input.slug}` } as ServerRecord; }, @@ -152,9 +167,9 @@ function storeFor(): PluginStore { function appAs( actorId: string, role: "admin" | "user" = "user", - options: { composio?: boolean } = {}, + options: { composio?: boolean; database?: typeof database } = {}, ) { - const store = storeFor(); + const store = storeFor(options.database); const signedIn: MiddlewareHandler<{ Variables: AppVariables }> = async ( context, next, @@ -166,7 +181,7 @@ function appAs( } as never); await next(); }; - const sharedUse = createSharedUseStore(database); + const sharedUse = createSharedUseStore(options.database ?? database); return new Hono().route( "/api/plugins", createPluginRoutes( @@ -198,6 +213,9 @@ async function seedApp(input: { authScheme?: string | null; accountMode?: "personal" | "shared" | null; tools?: string[]; + /** Each tool's `effect`, by name; a tool left out is unclassified, which reads as a write. */ + effects?: Record; + destructive?: string[]; grant?: boolean; }) { await database.insert(mcpServers).values({ @@ -218,6 +236,8 @@ async function seedApp(input: { name: tool, description: tool, inputSchema: {}, + effect: input.effects?.[tool] ?? null, + destructive: input.destructive?.includes(tool) ?? false, }); if (input.grant !== false) await database.insert(pluginGrants).values({ @@ -229,7 +249,34 @@ async function seedApp(input: { const ourServers = like(mcpServers.id, `%${suite}%`); +/** An administrator's approval of the suite Bot's audience for a Shared app, as granting writes. */ +async function approveAudience(serverId: string) { + const sharedUse = createSharedUseStore(database); + await sharedUse.setApproval({ + botId: bot, + serverId, + approval: exposureOf(await sharedUse.botFacts(bot)), + by: admin, + }); +} + +async function connectTeamAccount(name: string) { + await database.insert(brokeredConnections).values({ + provider: "composio", + app: slug(name), + holder: "deployment", + vendorUserId: `openbot-deployment:${suite}`, + verified: true, + }); +} + async function clearApps() { + await database + .delete(teamBotPublications) + .where(inArray(teamBotPublications.agentId, [bot, hidden])); + await database + .delete(teamBotConsents) + .where(inArray(teamBotConsents.userId, [person, other, admin])); await database .delete(appAccessRequests) .where(inArray(appAccessRequests.requestedBy, [person, other, admin])); @@ -281,6 +328,7 @@ beforeAll(async () => { beforeEach(async () => { events.length = 0; enabled.length = 0; + beforeGrant = null; await clearApps(); }); @@ -385,8 +433,9 @@ describe("what each app a Bot holds is, for the person asking", () => { expect(theirs.apps[0]?.connectedAt).toBe("2026-10-20T12:00:00.000Z"); }); - test("a Shared app with its team account connected is ready for everybody", async () => { + test("a Shared app with its team account connected and its audience approved is ready", async () => { await seedApp({ key: SLACK, title: "Slack", accountMode: "shared" }); + await approveAudience(SLACK); await database.insert(brokeredConnections).values({ provider: "composio", app: slug("slack"), @@ -725,6 +774,7 @@ describe("answering a request", () => { title: "Slack", accountMode: "shared", tools: ["SEND", "LIST"], + effects: { LIST: "read", SEND: "write" }, grant: false, }); const request = await file(SLACK); @@ -744,19 +794,17 @@ describe("answering a request", () => { { method: "POST" }, ); expect(response.status).toBe(200); + // The read only: sending is chosen on the Bot's page, never handed out by one press. expect(await response.json()).toMatchObject({ request: { status: "approved", resolvedBy: admin }, enabled: false, - granted: [`${SLACK}/LIST`, `${SLACK}/SEND`], + granted: [`${SLACK}/LIST`], }); const grants = await database .select({ ref: pluginGrants.ref }) .from(pluginGrants) .where(eq(pluginGrants.agentId, bot)); - expect(grants.map((grant) => grant.ref).sort()).toEqual([ - `${SLACK}/LIST`, - `${SLACK}/SEND`, - ]); + expect(grants.map((grant) => grant.ref).sort()).toEqual([`${SLACK}/LIST`]); const [approval] = await database .select() .from(sharedUseApprovals) @@ -765,7 +813,7 @@ describe("answering a request", () => { expect(eventsOf("app_access.approved")[0]?.payload).toMatchObject({ request: request.id, kind: "grant", - granted: 2, + granted: 1, }); // The person now sees the next step, which is not theirs to ask for. @@ -873,6 +921,7 @@ describe("answering a request", () => { test("a team-account request is approved only once the account is connected", async () => { await seedApp({ key: SLACK, title: "Slack", accountMode: "shared" }); + await approveAudience(SLACK); const request = await file(SLACK); expect(request.kind).toBe("connect_shared"); const early = await appAs(admin, "admin").request( @@ -921,3 +970,396 @@ describe("answering a request", () => { expect(again.status).toBe(409); }); }); + +describe("what approving grants, and when it can be answered", () => { + async function file(app: string, as = person) { + const response = await appAs(as).request( + "/api/plugins/access-requests", + post({ agentId: bot, app }), + ); + return (await json<{ request: AppAccessRequestRecord }>(response)).request; + } + const statusOf = async (id: string) => + ( + await database + .select({ status: appAccessRequests.status }) + .from(appAccessRequests) + .where(eq(appAccessRequests.id, id)) + )[0]?.status; + + test("without refs only the read-only actions are granted, never a send or a delete", async () => { + await seedApp({ + key: GMAIL, + title: "Gmail", + tools: ["READ_MAIL", "SEND_MAIL", "DELETE_ALL", "UNLABELLED"], + effects: { READ_MAIL: "read", SEND_MAIL: "write", DELETE_ALL: "write" }, + destructive: ["DELETE_ALL"], + grant: false, + }); + const request = await file("Gmail"); + expect(request.kind).toBe("grant"); + const response = await appAs(admin, "admin").request( + `/api/plugins/access-requests/${request.id}/approve`, + { method: "POST" }, + ); + expect(response.status).toBe(200); + expect((await json<{ granted: string[] }>(response)).granted).toEqual([ + `${GMAIL}/READ_MAIL`, + ]); + const grants = await database + .select({ ref: pluginGrants.ref }) + .from(pluginGrants) + .where(eq(pluginGrants.agentId, bot)); + expect(grants.map((grant) => grant.ref)).toEqual([`${GMAIL}/READ_MAIL`]); + }); + + test("an app with no read-only action is refused with where to choose, and stays pending", async () => { + await seedApp({ + key: GMAIL, + title: "Gmail", + tools: ["SEND_MAIL"], + effects: { SEND_MAIL: "write" }, + grant: false, + }); + const request = await file("Gmail"); + const response = await appAs(admin, "admin").request( + `/api/plugins/access-requests/${request.id}/approve`, + { method: "POST" }, + ); + expect(response.status).toBe(409); + expect((await json<{ error: string }>(response)).error).toContain( + "read-only", + ); + expect(await statusOf(request.id)).toBe("pending"); + expect( + await database + .select() + .from(pluginGrants) + .where(eq(pluginGrants.agentId, bot)), + ).toEqual([]); + // Naming the actions is how an administrator grants more than the reads. + const chosen = await appAs(admin, "admin").request( + `/api/plugins/access-requests/${request.id}/approve`, + post({ refs: [`${GMAIL}/SEND_MAIL`] }), + ); + expect(chosen.status).toBe(200); + }); + + for (const [loser, answer] of [ + [ + "a dismissal", + (id: string) => + appAs(admin, "admin").request( + `/api/plugins/access-requests/${id}/dismiss`, + { method: "POST" }, + ), + ], + [ + "a withdrawal", + (id: string) => + appAs(person).request(`/api/plugins/access-requests/${id}`, { + method: "DELETE", + }), + ], + ] as const) { + test(`${loser} arriving while an approval is granting loses to it`, async () => { + await seedApp({ + key: CALENDAR, + title: "Google Calendar", + tools: ["READ"], + effects: { READ: "read" }, + grant: false, + }); + const request = await file(CALENDAR); + + let release = () => {}; + let granting = () => {}; + const held = new Promise((resolve) => { + release = resolve; + }); + const reached = new Promise((resolve) => { + granting = resolve; + }); + beforeGrant = async () => { + granting(); + await held; + }; + try { + const approving = appAs(admin, "admin").request( + `/api/plugins/access-requests/${request.id}/approve`, + { method: "POST" }, + ); + await reached; + // Answered while the grant is still being written: the approval already claimed it. + const lost = await answer(request.id); + expect(lost.status).toBe(409); + release(); + const approved = await approving; + expect(approved.status).toBe(200); + expect(await statusOf(request.id)).toBe("approved"); + expect(eventsOf("app_access.approved")).toHaveLength(1); + expect(eventsOf("app_access.dismissed")).toHaveLength(0); + expect(eventsOf("app_access.cancelled")).toHaveLength(0); + } finally { + release(); + beforeGrant = null; + } + }); + } + + test("an approval that throws partway puts the request back", async () => { + await seedApp({ + key: CALENDAR, + title: "Google Calendar", + tools: ["READ"], + effects: { READ: "read" }, + grant: false, + }); + const request = await file(CALENDAR); + beforeGrant = async () => { + throw new Error("the grant failed"); + }; + const response = await appAs(admin, "admin").request( + `/api/plugins/access-requests/${request.id}/approve`, + { method: "POST" }, + ); + expect(response.status).toBe(500); + expect(await statusOf(request.id)).toBe("pending"); + expect(eventsOf("app_access.approved")).toHaveLength(0); + }); + + test("a request filed again while the first was claimed is the one left waiting", async () => { + await seedApp({ + key: GMAIL, + title: "Gmail", + tools: ["SEND_MAIL"], + effects: { SEND_MAIL: "write" }, + grant: false, + }); + const first = await file("Gmail"); + const store = storeFor(); + expect( + await store.claimAppAccessRequest({ id: first.id, by: admin }), + ).not.toBeNull(); + const second = await file("Gmail"); + expect(second.id).not.toBe(first.id); + await store.releaseAppAccessRequest({ id: first.id, by: admin }); + expect(await statusOf(first.id)).toBe("cancelled"); + expect(await statusOf(second.id)).toBe("pending"); + }); +}); + +describe("what a request may carry", () => { + const ask = (app: string, agentId = bot) => + appAs(person).request( + "/api/plugins/access-requests", + post({ agentId, app }), + ); + + test("an app or Bot name past the cap is refused before anything is read or written", async () => { + const long = "x".repeat(201); + expect((await ask(`composio-${long}`)).status).toBe(400); + expect((await ask(GMAIL, long)).status).toBe(400); + expect(eventsOf("app_access.refused")).toEqual([]); + expect( + (await appAs(person).request(`/api/plugins/for/${bot}/apps/${long}`)) + .status, + ).toBe(404); + }); + + test("enabling a composio- id the directory does not list is no such app", async () => { + const response = await ask(row("invented")); + expect(response.status).toBe(404); + const filed = await database + .select() + .from(appAccessRequests) + .where(eq(appAccessRequests.requestedBy, person)); + expect(filed).toEqual([]); + }); + + test("a grant that outlived its app can still be asked about", async () => { + await database + .insert(pluginGrants) + .values({ kind: "mcp", ref: `${GONE}/SEND`, agentId: bot }); + const response = await appAs(person, "user", { composio: false }).request( + "/api/plugins/access-requests", + post({ agentId: bot, app: GONE }), + ); + expect(response.status).toBe(201); + }); + + test(`at ${MAX_PENDING_REQUESTS} waiting a new one is refused, and one already waiting is still shown`, async () => { + await database.insert(appAccessRequests).values( + Array.from({ length: MAX_PENDING_REQUESTS - 1 }, (_, index) => ({ + id: randomUUID(), + requestedBy: person, + agentId: bot, + appKey: `composio-filler${index}_${suite}`, + appTitle: `Filler ${index}`, + kind: "enable" as const, + })), + ); + await seedApp({ key: CALENDAR, title: "Google Calendar", grant: false }); + const last = await ask(CALENDAR); + expect(last.status).toBe(201); + const over = await ask("Dropbox"); + expect(over.status).toBe(429); + expect(eventsOf("app_access.refused").at(-1)?.payload).toMatchObject({ + reason: "too_many_pending", + }); + const again = await ask(CALENDAR); + expect(again.status).toBe(200); + expect((await json<{ created: boolean }>(again)).created).toBe(false); + }); +}); + +describe("finding an app by its name", () => { + test("a name finds the enabled app before a catalogue entry nobody added", async () => { + const DRIVE = row("googledrive"); + await seedApp({ key: DRIVE, title: "Google Drive", grant: false }); + const { app } = await json<{ app: AppAccess }>( + await appAs(person).request( + `/api/plugins/for/${bot}/apps/Google%20Drive`, + ), + ); + expect(app).toMatchObject({ + key: DRIVE, + state: "not_granted", + requestKind: "grant", + }); + // The entry is still what its own key names. + const { app: entry } = await json<{ app: AppAccess }>( + await appAs(person).request(`/api/plugins/for/${bot}/apps/google-drive`), + ); + expect(entry).toMatchObject({ key: "google-drive", state: "disabled" }); + }); + + test("of two enabled apps with one name, the one this Bot holds", async () => { + await seedApp({ key: row("drive_a"), title: "Drive", grant: false }); + await seedApp({ key: row("drive_b"), title: "Drive" }); + const { app } = await json<{ app: AppAccess }>( + await appAs(person).request(`/api/plugins/for/${bot}/apps/Drive`), + ); + expect(app.key).toBe(row("drive_b")); + }); +}); + +describe("why an app is off, and a Shared app's audience", () => { + test("a switched-off app says why", async () => { + await seedApp({ key: GMAIL, title: "Gmail" }); + const { app } = await json<{ app: AppAccess }>( + await appAs(person, "user", { composio: false }).request( + `/api/plugins/for/${bot}/apps/${GMAIL}`, + ), + ); + expect(app).toMatchObject({ + state: "disabled", + unavailable: "composio_unconfigured", + }); + const { app: ready } = await json<{ app: AppAccess }>( + await appAs(person).request(`/api/plugins/for/${bot}/apps/${GMAIL}`), + ); + expect(ready.unavailable).toBeNull(); + }); + + test("a Shared app whose audience nobody approved is refused, with nothing to file", async () => { + await seedApp({ key: SLACK, title: "Slack", accountMode: "shared" }); + await connectTeamAccount("slack"); + const { app } = await json<{ app: AppAccess }>( + await appAs(person).request(`/api/plugins/for/${bot}/apps/${SLACK}`), + ); + expect(app).toMatchObject({ + state: "shared_not_approved", + requestKind: null, + }); + const filed = await appAs(person).request( + "/api/plugins/access-requests", + post({ agentId: bot, app: SLACK }), + ); + expect(filed.status).toBe(409); + await approveAudience(SLACK); + const { app: approved } = await json<{ app: AppAccess }>( + await appAs(person).request(`/api/plugins/for/${bot}/apps/${SLACK}`), + ); + expect(approved.state).toBe("ready"); + }); +}); + +describe("a teammate on somebody else's Team Bot", () => { + beforeEach(async () => { + await database + .insert(teamBotPublications) + .values({ agentId: bot, publishedBy: person, audience: "team" }); + await seedApp({ key: GMAIL, title: "Gmail" }); + }); + const connect = ( + userId: string, + over: { verified?: boolean; probeAction?: string } = {}, + ) => + database.insert(brokeredConnections).values({ + provider: "composio", + app: slug("gmail"), + holder: "person", + userId, + vendorUserId: userId, + verified: over.verified ?? true, + ...(over.probeAction ? { probeAction: over.probeAction } : {}), + }); + const asTeammate = async () => + ( + await json<{ app: AppAccess }>( + await appAs(other).request(`/api/plugins/for/${bot}/apps/${GMAIL}`), + ) + ).app; + + test("the owner's connection is the one their calls use", async () => { + await connect(person); + expect(await asTeammate()).toMatchObject({ + state: "ready", + accountHolder: "owner", + asksFirst: false, + }); + }); + + test("the owner's refused connection is theirs to reconnect, not the teammate's", async () => { + await connect(person, { verified: false, probeAction: "GMAIL_LIST" }); + await connect(other); + expect(await asTeammate()).toMatchObject({ + state: "reconnect", + accountHolder: "owner", + requestKind: null, + }); + }); + + test("with no owner account, the teammate's own, asked first until they always allow it", async () => { + expect(await asTeammate()).toMatchObject({ + state: "not_connected", + accountHolder: "you", + }); + await connect(other); + expect(await asTeammate()).toMatchObject({ + state: "ready", + accountHolder: "you", + asksFirst: true, + }); + await database.insert(teamBotConsents).values({ + userId: other, + agentId: bot, + serverId: GMAIL, + decision: "always", + }); + expect((await asTeammate()).asksFirst).toBe(false); + }); + + test("the owner reads their own account, and is never asked", async () => { + await connect(person); + const { app } = await json<{ app: AppAccess }>( + await appAs(person).request(`/api/plugins/for/${bot}/apps/${GMAIL}`), + ); + expect(app).toMatchObject({ + state: "ready", + accountHolder: "you", + asksFirst: false, + }); + }); +}); diff --git a/server/tests/app-access-state.test.ts b/server/tests/app-access-state.test.ts index e71adfacd0..e2e4428b7a 100644 --- a/server/tests/app-access-state.test.ts +++ b/server/tests/app-access-state.test.ts @@ -29,6 +29,7 @@ const composio = (overrides: Partial = {}): AppAccessFacts => ({ granted: true, personal: null, shared: null, + sharedAdmitted: null, ...overrides, }); @@ -52,6 +53,7 @@ const catalogue = ( granted: true, personal: null, shared: null, + sharedAdmitted: null, ...overrides, }); @@ -107,6 +109,21 @@ describe("a Shared Composio app", () => { "shared_not_connected", ); expect(appAccessState(shared({ shared: { usable: true } }))).toBe("ready"); + // Connected, and the gate would refuse this person's calls through this Bot. + expect( + appAccessState( + shared({ shared: { usable: true }, sharedAdmitted: false }), + ), + ).toBe("shared_not_approved"); + expect( + appAccessState( + shared({ shared: { usable: true }, sharedAdmitted: true }), + ), + ).toBe("ready"); + // The audience matters only once there is an account to reach. + expect(appAccessState(shared({ sharedAdmitted: false }))).toBe( + "shared_not_connected", + ); expect(accountOf(shared({}))).toBe("shared"); }); }); @@ -171,6 +188,8 @@ test("only the three states an administrator can fix file a request", () => { not_connected: null, reconnect: null, shared_not_connected: "connect_shared", + // The refused call files the administrator's request itself. + shared_not_approved: null, disabled: "enable", not_granted: "grant", }); From 505551e86e40724a947c769e6a68dcfb029e1f0b Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Wed, 21 Oct 2026 16:07:44 -0300 Subject: [PATCH 24/33] Forget a Composio account once a call finds the vendor no longer holds it --- server/src/plugins/plugin-store/calls.ts | 30 ++++++++++++- .../shared-brokered-store.integration.test.ts | 42 +++++++++++++++---- shared/connect-prompt.ts | 6 +-- 3 files changed, 67 insertions(+), 11 deletions(-) diff --git a/server/src/plugins/plugin-store/calls.ts b/server/src/plugins/plugin-store/calls.ts index 12f76655cc..19f7442795 100644 --- a/server/src/plugins/plugin-store/calls.ts +++ b/server/src/plugins/plugin-store/calls.ts @@ -8,7 +8,7 @@ import { policyInitiator, } from "../../computer/policy"; import { withoutStatement } from "../../db/query-failure"; -import { mcpTools, pluginGrants } from "../../db/schema"; +import { brokeredConnections, mcpTools, pluginGrants } from "../../db/schema"; import { classifyTool, tokenHeaderFor } from "../catalogue"; import { VERSION_ARG } from "../composio"; import { inspectToolArguments } from "../content-governance"; @@ -560,7 +560,35 @@ export function callMethods(context: StoreContext) { * deployment's rows knows the account is gone — the app reads as ready on every screen — and * the card is the one place the person is told, with Reconnect beside it. A Shared app's * account is the deployment's, so there the card says an administrator has to reconnect it. + * + * AND THE ROW GOES, as it does when a confirm hears the same no from the vendor: the row is + * only the vendor's last answer, and one that outlived the account would go on reading + * `ready` on every screen — `show_app_access` telling the model to use the app straight after + * this card — and would make the connect route refuse a fresh connection until somebody + * found Disconnect. Deleted, the app reads not connected for the person, or the team account + * not connected for everybody, which is the truth and the state whose button fixes it. Like + * the confirm's, the delete files no trail entry: nobody disconnected anything here. */ + if (access.credential === "brokered" && access.toolkit) + await database + .delete(brokeredConnections) + .where( + gatedMode === "shared" + ? and( + eq(brokeredConnections.provider, "composio"), + eq(brokeredConnections.app, access.toolkit), + eq(brokeredConnections.holder, "deployment"), + ) + : and( + eq(brokeredConnections.provider, "composio"), + eq(brokeredConnections.app, access.toolkit), + eq(brokeredConnections.holder, "person"), + eq( + brokeredConnections.userId, + input.credentialActorId ?? input.actorId, + ), + ), + ); throw new PluginRefusedError( connectAppRefusal({ serverId: row.id, diff --git a/server/tests/shared-brokered-store.integration.test.ts b/server/tests/shared-brokered-store.integration.test.ts index 1abeb1fc92..e2600e8397 100644 --- a/server/tests/shared-brokered-store.integration.test.ts +++ b/server/tests/shared-brokered-store.integration.test.ts @@ -568,6 +568,20 @@ describe("an account the vendor no longer holds", () => { }, }); + /** Who still holds a row at the app, by holder. */ + const rowsFor = async (holder: "person" | "deployment") => + ( + await database + .select({ userId: brokeredConnections.userId }) + .from(brokeredConnections) + .where( + and( + eq(brokeredConnections.app, app), + eq(brokeredConnections.holder, holder), + ), + ) + ).map((row) => row.userId); + /* Counted before and after: the trail is not cleaned between tests, which share the app id. */ const failures = async () => ( @@ -605,6 +619,8 @@ describe("an account the vendor no longer holds", () => { ); expect(Date.parse(prompt?.at ?? "")).toBeGreaterThanOrEqual(before - 1000); expect(await failures()).toBe(already + 1); + // The team account's row is gone with the account, so every screen reads it as not connected. + expect(await rowsFor("deployment")).toEqual([]); }); test("on a personal app: refused with a card offering Reconnect", async () => { @@ -614,13 +630,23 @@ describe("an account the vendor no longer holds", () => { .update(mcpServers) .set({ accountMode: "personal", sharedVendorUserId: null }) .where(eq(mcpServers.id, app)); - await database.insert(brokeredConnections).values({ - provider: "composio", - app, - holder: "person", - userId: asker, - vendorUserId: asker, - }); + await database.insert(brokeredConnections).values([ + { + provider: "composio", + app, + holder: "person", + userId: asker, + vendorUserId: asker, + }, + // Somebody else's account at the same app, which the vendor still holds. + { + provider: "composio", + app, + holder: "person", + userId: admin, + vendorUserId: admin, + }, + ]); gone(); const said = await store .callTool({ @@ -638,5 +664,7 @@ describe("an account the vendor no longer holds", () => { reason: "reconnect", }); expect(said).toContain("Reconnect it and ask again"); + // Only the asker's row goes, so Connect on their card starts a fresh connection. + expect(await rowsFor("person")).toEqual([admin]); }); }); diff --git a/shared/connect-prompt.ts b/shared/connect-prompt.ts index e1042b5936..95bafbedb4 100644 --- a/shared/connect-prompt.ts +++ b/shared/connect-prompt.ts @@ -28,9 +28,9 @@ export const CONNECT_APP_MARKER = "[connect-app]"; * behind it: switched off, not granted, or any of the above. * * A HINT, NOT THE STATE. The card asks the server for the app's state for this person and this Bot - * when it is drawn, and draws that. The one place the hint wins is an account the vendor has lost: - * nothing on this deployment records that, so the live state still reads `ready` there, and only the - * refusal knows otherwise. See `AppAccessCard`. + * when it is drawn, and draws that. The one place the hint wins is an account the vendor has lost + * whose row still stands — a refusal from before the call that found out started deleting the row — + * where the live state reads `ready` and only the refusal knows otherwise. See `AppAccessCard`. */ export type ConnectReason = "connect" | "reconnect" | "shared" | "access"; From 5e25a76c7ce352bdccdb2b302a32ac99e46f8004 Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Wed, 21 Oct 2026 16:07:44 -0300 Subject: [PATCH 25/33] Mark a connection withdrawn when the vendor refuses its refresh token --- server/src/plugins/plugin-store/context.ts | 29 +++++++++++++++++++ server/src/plugins/plugin-store/model.ts | 8 +++++ server/tests/plugin-store.integration.test.ts | 23 +++++++++++++-- 3 files changed, 57 insertions(+), 3 deletions(-) diff --git a/server/src/plugins/plugin-store/context.ts b/server/src/plugins/plugin-store/context.ts index e6c38481a7..40b669d5ec 100644 --- a/server/src/plugins/plugin-store/context.ts +++ b/server/src/plugins/plugin-store/context.ts @@ -34,6 +34,7 @@ import { CustomServerRefusedError, exchangeRefreshTokenOverHttp, INVALID_CLIENT, + INVALID_GRANT, isUsableClient, type OAuthClient, PluginInvariantError, @@ -804,6 +805,8 @@ export function createStoreContext(options: PluginStoreOptions) { * token it had read on the way in would present the one the first replica just spent, which is * the very double-spend this exists to prevent. */ + /** The credential whose refresh token went to the vendor, once one has. */ + let presented: string | null = null; try { return await database.transaction(async (transaction) => { const [current] = await transaction @@ -861,6 +864,7 @@ export function createStoreContext(options: PluginStoreOptions) { locked.encryptedValue, ); + presented = current.credentialId; const minted = await exchangeRefreshToken({ tokenUrl, client: stored.client, @@ -894,6 +898,31 @@ export function createStoreContext(options: PluginStoreOptions) { return { token: minted.accessToken }; }); } catch (error) { + /* + * THE VENDOR DISOWNED THE GRANT, NOT THE CLIENT. A refresh token answered `invalid_grant` + * will never be honoured again, and until now nothing here wrote that down: the connection + * read as connected on every screen, and every call came back as the same folded tool + * error. Revoked here — after the transaction, for the reason the recovery below gives — the + * connection reads as withdrawn, which is Reconnect on the Bot page and on the card this + * refusal draws, and the consent that fixes it replaces the credential as any reconnect does. + */ + if ( + error instanceof TokenRefusedError && + error.code === INVALID_GRANT && + presented + ) { + await credentials.revoke(presented); + throw new PluginRefusedError( + connectAppRefusal({ + serverId: row.id, + title, + message: `Your ${title} access was withdrawn at the vendor, so this was not done. Reconnect it and ask again.`, + reason: "reconnect", + at: new Date().toISOString(), + }), + null, + ); + } /* * Outside the transaction, so the row lock is already released and the vault read this does * is not a second connection held behind this one's. diff --git a/server/src/plugins/plugin-store/model.ts b/server/src/plugins/plugin-store/model.ts index 0eec70dafb..686d407555 100644 --- a/server/src/plugins/plugin-store/model.ts +++ b/server/src/plugins/plugin-store/model.ts @@ -705,6 +705,14 @@ export class TokenRefusedError extends McpServerError { */ export const INVALID_CLIENT = "invalid_client"; +/** + * The OAuth error code for a refresh token the vendor will no longer honour (RFC 6749 §5.2): it was + * revoked, it expired, or the person withdrew the grant. Nothing this deployment can do brings it + * back — only the person consenting again — so the connection is recorded as withdrawn, the state + * every screen already reads as Reconnect. + */ +export const INVALID_GRANT = "invalid_grant"; + /** * A transaction, as the writes in this module hand one to each other. * diff --git a/server/tests/plugin-store.integration.test.ts b/server/tests/plugin-store.integration.test.ts index 4950c8de7c..7d70e72ed7 100644 --- a/server/tests/plugin-store.integration.test.ts +++ b/server/tests/plugin-store.integration.test.ts @@ -2795,12 +2795,29 @@ describe("a dynamic client the vendor has evicted", () => { "invalid_grant", ); - await expect(call()).rejects.toThrow("invalid_grant"); + // A withdrawn grant is the person's to fix by connecting again: the refusal draws the card that + // says so, rather than folding the vendor's code into a tool error. + const refusal = await call().catch((error: unknown) => error); + expect(refusal).toBeInstanceOf(PluginRefusedError); + expect(readConnectPrompt((refusal as Error).message)).toMatchObject({ + serverId: dynamicServerId, + reason: "reconnect", + }); - // A withdrawn grant is the person's to fix by connecting again. Minting a client for it would - // leave a spare client behind and still refuse. + // Minting a client for it would leave a spare client behind and still refuse. expect(registrations).toEqual([]); expect(offered).toEqual([EVICTED.clientId]); + + // And the connection now reads as withdrawn, which is Reconnect on every screen. + const [connection] = await dynamicStore.connectionsFor(dynamicUserId); + expect(connection).toMatchObject({ + serverId: dynamicServerId, + revoked: true, + }); + // The next call is refused before anything is presented to the vendor again. + offered.length = 0; + await expect(call()).rejects.toBeInstanceOf(PluginRefusedError); + expect(offered).toEqual([]); }); /** From 44385684d63193d30b57356f6eac2cd372a6f6ca Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Wed, 21 Oct 2026 16:07:44 -0300 Subject: [PATCH 26/33] Say a Memory source's app refusal in words instead of storing the card's marker --- server/src/memory/ingestion.ts | 30 ++++++++++++++++-- server/tests/memory-store.integration.test.ts | 31 ++++++++++++++++++- 2 files changed, 57 insertions(+), 4 deletions(-) diff --git a/server/src/memory/ingestion.ts b/server/src/memory/ingestion.ts index f6cc574f0a..2ad0cf932d 100644 --- a/server/src/memory/ingestion.ts +++ b/server/src/memory/ingestion.ts @@ -1,4 +1,5 @@ import { createHash } from "node:crypto"; +import { readConnectPrompt } from "@openbot/shared/connect-prompt"; import { accessFor } from "../plugins/access"; import type { Decides } from "../plugins/broker"; import { catalogueEntry } from "../plugins/catalogue"; @@ -280,14 +281,37 @@ export function createMemoryIngestion(options: { }; } catch (error) { const message = - error instanceof MemoryRefusedError || error instanceof PluginRefusedError - ? error.message - : "The source could not sync. Try again or check the connected app."; + ? syncRefusal(error.message) + : error instanceof MemoryRefusedError + ? error.message + : "The source could not sync. Try again or check the connected app."; await store.failSync(ownerUserId, id, message); throw error; } } + /** + * What the Memory sources page says about a refusal: the refusal's own sentence, except one that + * carries an app card. + * + * A CARD MARKER IS FOR A TRANSCRIPT, AND THIS IS NOT ONE. The marker is a JSON prefix the chat + * draws as a card with a Connect button; stored as the sync error it reached the sources page + * raw, and the sentence after it is written for a conversation ("ask again"). So a refusal with a + * marker is said for this page instead, naming where the account is connected. + */ + function syncRefusal(text: string): string { + const prompt = readConnectPrompt(text); + if (!prompt) return text; + switch (prompt.reason) { + case "shared": + return `The shared ${prompt.title} account is not connected. An administrator has to connect it before this source can sync.`; + case "reconnect": + return `Your ${prompt.title} account needs reconnecting. Reconnect it under Settings, Connected accounts, then sync again.`; + default: + return `Your ${prompt.title} account is not connected. Connect it under Settings, Connected accounts, then sync again.`; + } + } + async function eligibleSources(ownerUserId: string, botId: string) { const sources = (await store.sources(ownerUserId)).filter( (source) => diff --git a/server/tests/memory-store.integration.test.ts b/server/tests/memory-store.integration.test.ts index de733595b1..0b22f36b51 100644 --- a/server/tests/memory-store.integration.test.ts +++ b/server/tests/memory-store.integration.test.ts @@ -1,5 +1,6 @@ import { afterAll, beforeAll, expect, test } from "bun:test"; import { randomUUID } from "node:crypto"; +import { connectAppRefusal } from "@openbot/shared/connect-prompt"; import { and, eq, inArray } from "drizzle-orm"; import { createAuditStore } from "../src/audit"; import { createCredentialStore } from "../src/credentials"; @@ -11,7 +12,7 @@ import { mcpServers, mcpTools } from "../src/db/schema/plugins"; import { createMemoryIngestion } from "../src/memory/ingestion"; import { createMemoryStore } from "../src/memory/store"; import { MemoryNotFoundError, MemoryRefusedError } from "../src/memory/types"; -import { createPluginStore } from "../src/plugins/store"; +import { createPluginStore, PluginRefusedError } from "../src/plugins/store"; import { TEST_POOL, testDatabaseUrl } from "./support/database"; const database = createDatabase(testDatabaseUrl(), TEST_POOL); @@ -25,6 +26,8 @@ const ref = `${server}/read_documents`; let vendorText = '[{"id":"doc1","text":"Project launch is Tuesday","title":"Roadmap"}]'; let vendorError = false; +/** Thrown by the vendor instead of answering, when set: how a refused call reaches a sync. */ +let vendorThrows: Error | null = null; const calls: { actorId?: string; botId?: string }[] = []; const plugins = createPluginStore({ database, @@ -34,6 +37,7 @@ const plugins = createPluginStore({ policy: () => ({ mode: "enforce", deny: [], allow: ["true"] }), callVendor: async (connection) => { calls.push(connection); + if (vendorThrows) throw vendorThrows; return { text: vendorText, isError: vendorError }; }, }); @@ -249,6 +253,31 @@ test("vendor failure hides stale source facts and records a visible error", asyn expect((await store.getSource(owner, value.id)).syncStatus).toBe("error"); vendorError = false; }); +test("a refusal carrying an app card is said for the sources page, not stored raw", async () => { + const value = await source(); + vendorThrows = new PluginRefusedError( + connectAppRefusal({ + serverId: server, + title: "Docs", + message: + "Your Docs account is no longer connected at the vendor. Reconnect it and ask again.", + reason: "reconnect", + }), + null, + ); + try { + await expect(ingestion.sync(owner, value.id)).rejects.toBeInstanceOf( + PluginRefusedError, + ); + const { syncError } = await store.getSource(owner, value.id); + expect(syncError).toBe( + "Your Docs account needs reconnecting. Reconnect it under Settings, Connected accounts, then sync again.", + ); + expect(syncError).not.toContain("[connect-app]"); + } finally { + vendorThrows = null; + } +}); test("source leases prevent competing connector calls", async () => { const value = await source(); const results = await Promise.all([ From 1d2cdbde139dab092c043f160310ab63580cb5e0 Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Wed, 21 Oct 2026 16:07:44 -0300 Subject: [PATCH 27/33] Stop the Bot route fixture replacing the whole active-bot module for every file --- app/tests/bot-route-default-agent.fixture.tsx | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/app/tests/bot-route-default-agent.fixture.tsx b/app/tests/bot-route-default-agent.fixture.tsx index 2754e68b0a..dcab617581 100644 --- a/app/tests/bot-route-default-agent.fixture.tsx +++ b/app/tests/bot-route-default-agent.fixture.tsx @@ -19,9 +19,14 @@ mock.module("@copilotkit/react-core/v2", () => ({ ), })); -mock.module("@/lib/copilot/active-bot", () => ({ - useActiveBot: () => undefined, -})); +/* + * `@/lib/copilot/active-bot` IS NOT MOCKED, though the route calls `useActiveBot`. Drawn without an + * `ActiveBotProvider`, as these tests draw it, the real hook finds no holder and does nothing — which + * is all the mock that used to be here did. And a module mock is not this file's to keep: bun cannot + * take one back off, so a replacement exporting only `useActiveBot` stood in for the whole module in + * every file walked into the same process, and `useDeclaredBotId` — the app-access card's fallback + * for a refusal that names no Bot — stopped existing there. + */ mock.module("@/lib/copilot/bot-thread", () => ({ useBotThread: (agentId: string) => { From 08210baaa3b6a2228141cd4865097a422d07c063 Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Wed, 21 Oct 2026 16:07:44 -0300 Subject: [PATCH 28/33] Draw the new app-access states, keep model text off screen, and end the sign-in wait when its tab closes --- app/src/components/bot-profile/access.tsx | 10 +- .../components/channels/app-access-card.tsx | 26 +- .../components/channels/chat-transcript.tsx | 16 +- app/src/components/channels/tool-activity.tsx | 16 +- .../components/plugins/access-requests.tsx | 38 ++- .../components/plugins/app-access-action.tsx | 116 ++++++-- .../components/plugins/connect-in-new-tab.ts | 61 +++- app/src/lib/app-access/mutations.ts | 5 +- app/src/lib/app-access/queries.ts | 50 ++-- app/src/lib/plugins/tool-name.ts | 27 +- app/tests/access-requests.test.tsx | 67 ++++- app/tests/app-access-action.test.tsx | 272 +++++++++++++++--- app/tests/app-access-card.test.tsx | 119 +++++++- app/tests/app-access-fixture.ts | 3 + app/tests/app-access-mutations.test.ts | 1 - app/tests/bot-access-apps.test.tsx | 66 ++++- app/tests/tool-activity.test.tsx | 17 ++ app/tests/tool-name.test.ts | 15 + 18 files changed, 811 insertions(+), 114 deletions(-) diff --git a/app/src/components/bot-profile/access.tsx b/app/src/components/bot-profile/access.tsx index dfd3a069cb..ca4a5ed8c6 100644 --- a/app/src/components/bot-profile/access.tsx +++ b/app/src/components/bot-profile/access.tsx @@ -226,12 +226,16 @@ function Granted({ agentId }: { agentId: string }) { {app.displayName} - + {/* + * Unclamped: the sentence ends on who can fix it, and two lines at a phone's + * width cut exactly that off. + */} + {app.state === "ready" - ? [appAccessSentence(app.state, app.displayName)] + ? [appAccessSentence(app)] .concat(labels.length > 0 ? [toolList(labels)] : []) .join(" ") - : appAccessSentence(app.state, app.displayName)} + : appAccessSentence(app)} diff --git a/app/src/components/channels/app-access-card.tsx b/app/src/components/channels/app-access-card.tsx index 5f851dd903..d58164cdf9 100644 --- a/app/src/components/channels/app-access-card.tsx +++ b/app/src/components/channels/app-access-card.tsx @@ -25,11 +25,12 @@ import { ConnectAppCard } from "./connect-app-card"; * now — connected since, granted since, the request approved — and never offers a step that has * already been taken. Every write those buttons make refreshes the same key, so the card follows. * - * THE ONE PLACE THE REFUSAL WINS. An account the vendor no longer holds is not recorded anywhere on - * this deployment: its row still stands, so the server reads `ready` until a call fails — and this - * card is drawn over exactly the call that failed. So a `reconnect` or `shared` refusal over a live - * `ready` is drawn as the refusal says — unless the account was connected after the refusal, which - * is the reconnection. Anything else live is newer than the refusal and is drawn as it is. + * THE ONE PLACE THE REFUSAL WINS. The call that finds an account gone at the vendor deletes its row, + * so the live state normally says so itself. A refusal from before that, over a row that still + * stands, reads `ready` live — and this card is drawn over exactly the call that failed. So a + * `reconnect` or `shared` refusal over a live `ready` is drawn as the refusal says — unless the + * account was connected after the refusal, which is the reconnection. Anything else live is newer + * than the refusal and is drawn as it is. */ export function AppAccessCard({ prompt }: { prompt: ConnectPrompt }) { const declared = useDeclaredBotId(); @@ -72,8 +73,10 @@ function LiveAppAccessCard({

{name}

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

@@ -83,7 +86,7 @@ function LiveAppAccessCard({

) : (

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

)}
@@ -104,6 +107,11 @@ function LiveAppAccessCard({ * A lost team account is drawn as not connected with NO request to file — the server would answer * any request 409, since as far as it knows the account is fine — so the sentence says whose move it * is and the button is left out rather than offered and refused. + * + * A lost account on somebody else's Team Bot keeps the live `accountHolder`. Where that is the + * owner's account, the override reads as the owner's reconnection — the sentence says only the owner + * can, and `AppAccessAction` offers nothing — because a Reconnect pressed here would connect this + * person's own account, which the Bot does not run on. */ function asRefused(live: AppAccess, prompt: ConnectPrompt): AppAccess { if (live.state !== "ready") return live; @@ -137,5 +145,5 @@ function refusedSentence(access: AppAccess, prompt: ConnectPrompt): string { !access.requestKind ) return `Your organisation's ${access.displayName} account needs reconnecting. Only an administrator can reconnect it.`; - return appAccessSentence(access.state, access.displayName); + return appAccessSentence(access); } diff --git a/app/src/components/channels/chat-transcript.tsx b/app/src/components/channels/chat-transcript.tsx index 1fb109f7b2..6cf6cc805d 100644 --- a/app/src/components/channels/chat-transcript.tsx +++ b/app/src/components/channels/chat-transcript.tsx @@ -1364,7 +1364,8 @@ export function ServerToolLine({ name: string; result?: string; }) { - const { label, detail } = readToolName(name); + const running = result === undefined; + const { label, detail } = readToolName(name, running); /* * A refusal is not a result, and must not read like one. * @@ -1397,17 +1398,24 @@ export function ServerToolLine({ ? readConnectPrompt(answer) : null; if (card) return ; + /* + * THAT TOOL'S OTHER ANSWERS ARE FOR THE MODEL. An app nobody can name comes back as plain text + * telling the Bot to say so plainly and not to browse to it — an instruction, which read by the + * person looks like the Bot being told off in front of them. The line still says the check + * happened; the Bot's own reply says what it found. + */ + const shown = name === SHOW_APP_ACCESS_TOOL ? undefined : body; return ( - {body ? ( + {shown ? ( - {forDisplay(body)} + {forDisplay(shown)} ) : null} diff --git a/app/src/components/channels/tool-activity.tsx b/app/src/components/channels/tool-activity.tsx index 793a1708fd..c03e5d2870 100644 --- a/app/src/components/channels/tool-activity.tsx +++ b/app/src/components/channels/tool-activity.tsx @@ -10,7 +10,7 @@ import { useCallback, useContext, useMemo, useSyncExternalStore } from "react"; import { Streamdown } from "streamdown"; import { PendingApprovals } from "@/components/approvals/inline-approval"; import { markdownComponents } from "@/lib/markdown"; -import { humanise, readToolName } from "@/lib/plugins/tool-name"; +import { humanise, ownToolLabel, readToolName } from "@/lib/plugins/tool-name"; import { asText, forDisplay, @@ -237,17 +237,25 @@ export function stepDetails(step: ToolItem): StepDetails { const answer = step.result === undefined ? undefined : asText(step.result); const refused = answer?.startsWith(REFUSAL_MARKER) ?? false; const body = refused ? answer?.slice(REFUSAL_MARKER.length).trim() : answer; + // This deployment's own tools answer the model, not the person; see `ServerToolLine`. + const own = ownToolLabel(name) !== null; const outcome = { id: step.id, pending: step.result === undefined, failed: !refused && toolResultFailed(step.result), refused, inputs, - ...(body ? { output: body } : {}), + ...(body && !own ? { output: body } : {}), }; - // Not a connector: a tool an external agent runs itself. Named in words, never as an identifier. + // Not a connector: one of this deployment's own tools, or one an external agent runs itself. + // Named in words, never as an identifier. if (!name.startsWith("mcp__")) - return { ...outcome, kind: "tool", label: humanise(name), pages: 0 }; + return { + ...outcome, + kind: "tool", + label: ownToolLabel(name, outcome.pending) ?? humanise(name), + pages: 0, + }; const [, server = "", ...rest] = name.split("__"); const role = researchRoleOfTool(server, rest.join("__")); diff --git a/app/src/components/plugins/access-requests.tsx b/app/src/components/plugins/access-requests.tsx index f1df877710..973ba8f565 100644 --- a/app/src/components/plugins/access-requests.tsx +++ b/app/src/components/plugins/access-requests.tsx @@ -1,5 +1,6 @@ import { IconInbox } from "@tabler/icons-react"; import { useMutation, useQuery } from "@tanstack/react-query"; +import { Link } from "@tanstack/react-router"; import { Fragment } from "react"; import { PageRows, PageSection } from "@/components/layout/page-shell"; import { Button } from "@/components/ui/button"; @@ -39,6 +40,11 @@ import { queryClient } from "@/query-client"; * to connect — and the request stays pending. That sentence is drawn on the row it is about, because * the screen's own banner is a long way from the button that was pressed. * + * A GRANT IS READ-ONLY UNTIL SOMEBODY CHOOSES MORE. One press is not a decision about every action an + * app offers — Gmail's include sending and deleting mail — so approving gives the Bot the app's + * read-only actions and nothing else, and an app with none is refused with a sentence pointing at the + * Bot's page. The row says so before the press, and links to that page where it can. + * * REQUESTS, NOT CONNECTIONS. Each row names who asked, because they asked; nothing here says who has * connected what, which stays private to each person (see the note on `admin/plugins/$key.tsx`). * @@ -110,12 +116,12 @@ function AccessRequestRow({ request }: { request: AppAccessRequest }) { {askedFor(request)} - + {who} asked {relativeTime(request.createdAt)} {request.kind === "connect_shared" && request.botName ? `, for ${request.botName}` : ""} - . + . @@ -152,6 +158,34 @@ function AccessRequestRow({ request }: { request: AppAccessRequest }) { ); } +/** + * What approving gives a Bot, for a request that grants one: the app's read-only actions, with the + * rest chosen on the Bot's page under the app. Linked there for a grant, whose app is already on + * and so has that page; an app still to be switched on has none until the press that switches it on. + */ +function GrantNote({ request }: { request: AppAccessRequest }) { + if (request.kind === "connect_shared" || !request.agentId) return null; + const bot = request.botName ?? "the Bot"; + const where = `the Bot's page under ${request.appTitle}`; + const page = + request.kind === "grant" ? ( + + {where} + + ) : ( + where + ); + return ( + <> + Approving gives {bot} only {request.appTitle}'s read-only actions. Choose + any others on {page}. + + ); +} + /** * How many requests are waiting, for a list row that wants to say so: one app's, or all of them. * Null while nothing is waiting or nothing has been read, so a row draws nothing rather than "0". diff --git a/app/src/components/plugins/app-access-action.tsx b/app/src/components/plugins/app-access-action.tsx index 25d1fa9eb9..df9e2e9d37 100644 --- a/app/src/components/plugins/app-access-action.tsx +++ b/app/src/components/plugins/app-access-action.tsx @@ -7,11 +7,7 @@ import { cancelAppAccessRequestMutationOptions, requestAppAccessMutationOptions, } from "@/lib/app-access/mutations"; -import { - type AppAccess, - type AppAccessState, - appAccessKeys, -} from "@/lib/app-access/queries"; +import { type AppAccess, appAccessKeys } from "@/lib/app-access/queries"; import { queryClient } from "@/query-client"; /** @@ -22,10 +18,12 @@ import { queryClient } from "@/query-client"; * whether this person can use this app through this Bot, whose move it is if not, and the button * for that move — so both draw it from here, and a sentence corrected for one is corrected for both. * - * WHO CAN FIX IT IS THE WHOLE SENTENCE. Six states reduce to three answers: nothing to do, a step - * that is yours (connect, reconnect), and a step only an administrator can take (switch it on, - * grant it, connect the team account). The copy names which, because "Gmail is unavailable" sends - * somebody hunting through their own settings for a switch that is not there. + * WHO CAN FIX IT IS THE WHOLE SENTENCE. Seven states reduce to four answers: nothing to do, a step + * that is yours (connect, reconnect), a step only an administrator can take (switch it on, finish + * setting it up, grant it, connect or approve the team account), and on somebody else's Team Bot a + * step only its owner can take (reconnect the account the Bot runs on). The copy names which, + * because "Gmail is unavailable" sends somebody hunting through their own settings for a switch that + * is not there. * * THE STATE IS THE SERVER'S. It arrives on `access` and is drawn as it is; nothing here works out * from `granted` or `accountMode` what the state ought to be. The one exception is the caller's: a @@ -34,23 +32,50 @@ import { queryClient } from "@/query-client"; */ /** What the person is told about one app, in words that say whose move it is. */ -export function appAccessSentence( - state: AppAccessState, - displayName: string, -): string { - switch (state) { +export function appAccessSentence(access: AppAccess): string { + const name = access.displayName; + const owners = access.accountHolder === "owner"; + switch (access.state) { case "ready": + if (owners) + return `Ready to use. This Bot uses its owner's ${name} account.`; + if (access.asksFirst) + return `Ready to use with your own ${name} account. You'll be asked to allow it the first time.`; return "Ready to use."; case "not_connected": - return `You haven't connected your ${displayName} account yet.`; + return `You haven't connected your ${name} account yet.`; case "reconnect": - return `Your ${displayName} connection needs reconnecting.`; + return owners + ? `This Bot uses its owner's ${name} account, which needs reconnecting. Only the Bot's owner can reconnect it.` + : `Your ${name} connection needs reconnecting.`; case "shared_not_connected": - return `Your organisation's ${displayName} account isn't connected yet. Only an administrator can connect it.`; + return `Your organisation's ${name} account isn't connected yet. Only an administrator can connect it.`; + case "shared_not_approved": + return `This Bot reaches more people than an administrator has approved for your organisation's ${name} account. Only an administrator can approve it.`; case "disabled": - return `Turned off by an administrator. Only they can turn ${displayName} on.`; + return disabledSentence(access); case "not_granted": - return `This Bot hasn't been given ${displayName}. Only an administrator can give it.`; + return `This Bot hasn't been given ${name}. Only an administrator can give it.`; + } +} + +/** + * Switched off, in the words for why. "Turned off by an administrator" was true of one reason in + * five: an app nobody switched on, and a set-up somebody has not finished, are not a decision + * anybody took — and the Composio case says what Settings → Connected accounts says about the same + * app, so the two screens never disagree about it. + */ +function disabledSentence(access: AppAccess): string { + const name = access.displayName; + switch (access.unavailable) { + case "composio_unconfigured": + return `${name} is not available on this OpenBot yet. An administrator has to finish setting it up.`; + case "scheme_unreadable": + case "oauth_client_missing": + case "token_missing": + return `${name} isn't fully set up yet. Only an administrator can finish setting it up.`; + default: + return `${name} isn't switched on for this OpenBot. Only an administrator can switch it on.`; } } @@ -58,6 +83,10 @@ export function appAccessSentence( * The button for the person's next move, and what it says while it happens. * * - `ready` — nothing. + * - `shared_not_approved` — nothing either: the call that was refused already filed the + * administrator's request, so there is no second one to make. + * - `reconnect` on an account the Bot's owner holds — nothing. Only the owner can reconnect their + * own account, and nobody else's press could do it for them. * - `not_connected` / `reconnect` — the person's own connection. Connect starts it in a new tab * with the connect route Settings presses; an app connected with a typed key, and a Composio app * that already has a row (which the connect route refuses until it is disconnected), go to the @@ -72,7 +101,10 @@ export function AppAccessAction({ agentId: string; access: AppAccess; }) { - if (access.state === "ready") return null; + if (access.state === "ready" || access.state === "shared_not_approved") + return null; + if (access.state === "reconnect" && access.accountHolder === "owner") + return null; if (access.state === "not_connected" || access.state === "reconnect") return ; return ; @@ -126,6 +158,7 @@ function ConnectAction({ access }: { access: AppAccess }) { to="/settings/connected-accounts/$key" /> } + aria-label={`${label} ${access.displayName}`} size="sm" variant="outline" > @@ -159,6 +192,8 @@ function ConnectAction({ access }: { access: AppAccess }) {

) : null}